Home page styling

This commit is contained in:
Robert Long 2021-12-07 17:59:55 -08:00
parent 9c7006f239
commit 20350e66a2
13 changed files with 467 additions and 216 deletions

58
src/Avatar.jsx Normal file
View file

@ -0,0 +1,58 @@
import React, { useMemo } from "react";
import classNames from "classnames";
import styles from "./Avatar.module.css";
const backgroundColors = [
"#5C56F5",
"#03B381",
"#368BD6",
"#AC3BA8",
"#E64F7A",
"#FF812D",
"#2DC2C5",
"#74D12C",
];
function hashStringToArrIndex(str, arrLength) {
let sum = 0;
for (let i = 0; i < str.length; i++) {
sum += str.charCodeAt(i);
}
return sum % arrLength;
}
export function Avatar({
bgKey,
src,
fallback,
size,
className,
style,
...rest
}) {
const backgroundColor = useMemo(() => {
const index = hashStringToArrIndex(
bgKey || fallback || src,
backgroundColors.length
);
return backgroundColors[index];
}, [bgKey, src, fallback]);
return (
<div
className={classNames(styles.avatar, styles[size || "md"], className)}
style={{ backgroundColor, ...style }}
{...rest}
>
{src ? (
<img src={src} />
) : typeof fallback === "string" ? (
<span>{fallback}</span>
) : (
fallback
)}
</div>
);
}

46
src/Avatar.module.css Normal file
View file

@ -0,0 +1,46 @@
.avatar {
position: relative;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar svg * {
fill: #ffffff;
}
.sm {
width: 22px;
height: 22px;
border-radius: 22px;
font-size: 14px;
}
.md {
width: 36px;
height: 36px;
border-radius: 36px;
font-size: 20px;
}
.lg {
width: 42px;
height: 42px;
border-radius: 42px;
font-size: 36px;
}
.xl {
width: 90px;
height: 90px;
border-radius: 90px;
}

31
src/CallTile.jsx Normal file
View file

@ -0,0 +1,31 @@
import React from "react";
import { Link } from "react-router-dom";
import { CopyButton } from "./button";
import { Facepile } from "./Facepile";
import { Avatar } from "./Avatar";
import { ReactComponent as VideoIcon } from "./icons/Video.svg";
import styles from "./CallTile.module.css";
export function CallTile({ name, avatarUrl, roomUrl, participants }) {
return (
<Link to={roomUrl} className={styles.callTile}>
<Avatar
size="md"
bgKey={name}
src={avatarUrl}
fallback={<VideoIcon width={16} height={16} />}
className={styles.avatar}
/>
<div className={styles.callInfo}>
<h5>{name}</h5>
<p>{roomUrl}</p>
{participants && <Facepile participants={participants} />}
</div>
<CopyButton
className={styles.copyButton}
variant="icon"
value={roomUrl}
/>
</Link>
);
}

54
src/CallTile.module.css Normal file
View file

@ -0,0 +1,54 @@
.callTile {
display: flex;
width: 329px;
height: 94px;
padding: 12px;
text-decoration: none;
background-color: var(--bgColor2);
border-radius: 8px;
overflow: hidden;
}
.avatar,
.copyButton {
flex-shrink: 0;
}
.callInfo {
display: flex;
flex-direction: column;
flex: 1;
padding: 0 16px;
color: var(--textColor1);
min-width: 0;
}
.callInfo > * {
margin-top: 0;
margin-bottom: 8px;
}
.callInfo > :last-child {
margin-bottom: 0;
}
.callInfo h5 {
font-size: 15px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.callInfo p {
font-weight: 400;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.copyButton {
width: 16px;
height: 16px;
}

View file

@ -1,32 +1,32 @@
import React from "react";
import styles from "./Facepile.module.css";
import ColorHash from "color-hash";
import classNames from "classnames";
import { Avatar } from "./Avatar";
const colorHash = new ColorHash({ lightness: 0.3 });
export function Facepile({ participants }) {
export function Facepile({ className, participants, ...rest }) {
return (
<div
className={styles.facepile}
className={classNames(styles.facepile, className)}
title={participants.map((member) => member.name).join(", ")}
{...rest}
>
{participants.slice(0, 3).map((member) => (
<div
{participants.slice(0, 3).map((member, i) => (
<Avatar
key={member.userId}
size="sm"
fallback={member.name.slice(0, 1).toUpperCase()}
className={styles.avatar}
style={{ backgroundColor: colorHash.hex(member.name) }}
>
<span>{member.name.slice(0, 1).toUpperCase()}</span>
</div>
style={{ left: i * 22 }}
/>
))}
{participants.length > 3 && (
<div
<Avatar
key="additional"
className={classNames(styles.avatar, styles.additional)}
>
<span>{`+${participants.length - 3}`}</span>
</div>
size="sm"
fallback={`+${participants.length - 3}`}
className={styles.avatar}
style={{ left: 3 * 22 }}
/>
)}
</div>
);

View file

@ -1,31 +1,11 @@
.facepile {
display: flex;
margin: 0 16px;
width: 100%;
height: 24px;
position: relative;
}
.facepile .avatar {
position: relative;
width: 20px;
height: 20px;
border-radius: 20px;
background-color: var(--primaryColor);
}
.facepile .avatar > * {
position: absolute;
left: 0;
color: #fff;
text-align: center;
pointer-events: none;
font-weight: 600;
}
.facepile .avatar span {
font-size: 14px;
width: 20px;
line-height: 20px;
}
.facepile .avatar.additional span {
font-size: 12px;
top: 0;
border: 1px solid var(--bgColor2);
}

View file

@ -15,25 +15,21 @@ limitations under the License.
*/
import React, { useCallback, useState } from "react";
import { useHistory, Link } from "react-router-dom";
import { useHistory } from "react-router-dom";
import {
useGroupCallRooms,
usePublicRooms,
} from "./ConferenceCallManagerHooks";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import ColorHash from "color-hash";
import styles from "./Home.module.css";
import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Center, Content, Modal } from "./Layout";
import {
GroupCallIntent,
GroupCallType,
} from "matrix-js-sdk/src/browser-index";
import { Facepile } from "./Facepile";
import { UserMenu } from "./UserMenu";
import { Button } from "./button";
const colorHash = new ColorHash({ lightness: 0.3 });
import { CallTile } from "./CallTile";
function roomAliasFromRoomName(roomName) {
return roomName
@ -46,10 +42,8 @@ function roomAliasFromRoomName(roomName) {
export function Home({ client, onLogout }) {
const history = useHistory();
const [roomName, setRoomName] = useState("");
const [roomAlias, setRoomAlias] = useState("");
const [guestAccess, setGuestAccess] = useState(false);
const [createRoomError, setCreateRoomError] = useState();
const [showAdvanced, setShowAdvanced] = useState();
const rooms = useGroupCallRooms(client);
const publicRooms = usePublicRooms(
client,
@ -110,131 +104,142 @@ export function Home({ client, onLogout }) {
const data = new FormData(e.target);
const roomName = data.get("roomName");
const roomAlias = data.get("roomAlias");
const guestAccess = data.get("guestAccess");
createRoom(roomName, roomAlias, guestAccess).catch((error) => {
setCreateRoomError(error);
setShowAdvanced(true);
});
createRoom(roomName, roomAliasFromRoomName(roomName), guestAccess).catch(
(error) => {
setCreateRoomError(error);
setShowAdvanced(true);
}
);
},
[client]
);
const [roomId, setRoomId] = useState("");
const onJoinRoom = useCallback(
(e) => {
e.preventDefault();
const data = new FormData(e.target);
const roomId = data.get("roomId");
history.push(`/room/${roomId}`);
},
[history]
);
return (
<>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
<RightNav>
<UserMenu
signedIn
userName={client.getUserIdLocalpart()}
onLogout={onLogout}
/>
</RightNav>
</Header>
<Content>
<Center>
<Modal>
<section>
<form onSubmit={onCreateRoom}>
<h2>Create New Room</h2>
<FieldRow>
<InputField
id="roomName"
name="roomName"
label="Room Name"
type="text"
required
autoComplete="off"
placeholder="Room Name"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
/>
<div class={styles.home}>
<div className={styles.left}>
<Header>
<LeftNav>
<HeaderLogo />
</LeftNav>
</Header>
<div className={styles.content}>
<div className={styles.centered}>
<form onSubmit={onJoinRoom}>
<h1>Join a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomId"
name="roomId"
label="Call ID"
type="text"
required
autoComplete="off"
placeholder="Call ID"
value={roomId}
onChange={(e) => setRoomId(e.target.value)}
/>
</FieldRow>
<FieldRow className={styles.fieldRow}>
<Button className={styles.button} type="submit">
Join call
</Button>
</FieldRow>
</form>
<hr />
<form onSubmit={onCreateRoom}>
<h1>Create a call</h1>
<FieldRow className={styles.fieldRow}>
<InputField
id="roomName"
name="roomName"
label="Room Name"
type="text"
required
autoComplete="off"
placeholder="Room Name"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
/>
</FieldRow>
<FieldRow>
<InputField
id="guestAccess"
name="guestAccess"
label="Allow Guest Access"
type="checkbox"
checked={guestAccess}
onChange={(e) => setGuestAccess(e.target.checked)}
/>
</FieldRow>
{createRoomError && (
<FieldRow className={styles.fieldRow}>
<ErrorMessage>{createRoomError.message}</ErrorMessage>
</FieldRow>
<details open={showAdvanced}>
<summary>Advanced</summary>
<FieldRow>
<InputField
id="roomAlias"
name="roomAlias"
label="Room Alias"
type="text"
autoComplete="off"
placeholder="Room Alias"
value={roomAlias || roomAliasFromRoomName(roomName)}
onChange={(e) => setRoomAlias(e.target.value)}
/>
</FieldRow>
<FieldRow>
<InputField
id="guestAccess"
name="guestAccess"
label="Allow Guest Access"
type="checkbox"
checked={guestAccess}
onChange={(e) => setGuestAccess(e.target.checked)}
/>
</FieldRow>
</details>
{createRoomError && (
<FieldRow>
<ErrorMessage>{createRoomError.message}</ErrorMessage>
</FieldRow>
)}
<FieldRow rightAlign>
<Button type="submit">Create Room</Button>
</FieldRow>
</form>
</section>
{publicRooms.length > 0 && (
<section>
<h3>Public Rooms</h3>
<div className={styles.roomList}>
{publicRooms.map((room) => (
<Link
className={styles.roomListItem}
key={room.room_id}
to={`/room/${room.room_id}`}
>
<div
className={styles.roomAvatar}
style={{ backgroundColor: colorHash.hex(room.name) }}
>
<span>{room.name.slice(0, 1)}</span>
</div>
<div className={styles.roomName}>{room.name}</div>
</Link>
))}
</div>
</section>
)}
<section>
<h3>Recent Rooms</h3>
)}
<FieldRow className={styles.fieldRow}>
<Button className={styles.button} type="submit">
Create call
</Button>
</FieldRow>
</form>
</div>
</div>
</div>
<div className={styles.right}>
<Header>
<LeftNav />
<RightNav>
<UserMenu
signedIn
userName={client.getUserIdLocalpart()}
onLogout={onLogout}
/>
</RightNav>
</Header>
<div className={styles.content}>
{publicRooms.length > 0 && (
<>
<h3>Public Calls</h3>
<div className={styles.roomList}>
{rooms.map(({ room, participants }) => (
<Link
className={styles.roomListItem}
key={room.roomId}
to={`/room/${room.getCanonicalAlias() || room.roomId}`}
>
<div
className={styles.roomAvatar}
style={{ backgroundColor: colorHash.hex(room.name) }}
>
<span>{room.name.slice(0, 1)}</span>
</div>
<div className={styles.roomName}>{room.name}</div>
<Facepile participants={participants} />
</Link>
{publicRooms.map((room) => (
<CallTile
key={room.room_id}
name={room.name}
avatarUrl={null}
roomUrl={`/room/${room.room_id}`}
/>
))}
</div>
</section>
</Modal>
</Center>
</Content>
</>
</>
)}
<h3>Recent Calls</h3>
<div className={styles.roomList}>
{rooms.map(({ room, participants }) => (
<CallTile
key={room.roomId}
name={room.name}
avatarUrl={null}
roomUrl={`/room/${room.getCanonicalAlias() || room.roomId}`}
participants={participants}
/>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -1,47 +1,109 @@
.roomList {
.home {
display: flex;
flex: 1;
height: 100%;
}
.roomListItem {
margin-bottom: 4px;
padding: 4px;
.left,
.right {
display: flex;
cursor: pointer;
text-decoration: none;
color: var(--textColor1);
flex-direction: column;
flex: 1;
}
.left {
background-color: var(--bgColor2);
}
.centered {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
max-width: 512px;
min-width: 0;
}
.content {
flex: 1;
}
.left .content {
display: flex;
flex-direction: column;
padding-top: 113px;
align-items: center;
}
.roomListItem:hover {
background-color: rgba(141, 151, 165, 0.2);
border-radius: 8px;
color: var(--textColor1);
.left .content form > * {
margin-top: 0;
margin-bottom: 24px;
}
.roomAvatar {
position: relative;
width: 32px;
height: 32px;
border-radius: 32px;
flex-shrink: 0;
.left .content form > :last-child {
margin-bottom: 0;
}
.roomAvatar > * {
position: absolute;
left: 0;
color: #fff;
.left .content hr {
width: 100%;
border: none;
border-top: 1px solid var(--bgColor4);
color: var(--textColor2);
overflow: visible;
text-align: center;
pointer-events: none;
font-weight: 400;
height: 5px;
font-weight: 600;
font-size: 15px;
line-height: 24px;
}
.roomAvatar span {
font-size: 20.8px;
width: 32px;
line-height: 32px;
.left .content hr:after {
background-color: var(--bgColor2);
content: "OR";
padding: 0 12px;
position: relative;
top: -12px;
}
.roomName {
margin-left: 8px;
font-size: 14px;
line-height: 18px;
.left .content form {
display: flex;
flex-direction: column;
align-items: center;
padding: 92px;
}
.fieldRow {
width: 100%;
}
.button {
height: 40px;
width: 100%;
font-size: 15px;
font-weight: 600;
}
.left .content form:first-child {
padding-top: 0;
}
.left .content form:last-child {
padding-bottom: 0;
}
.right .content {
padding: 113px 40px 40px 40px;
overflow-y: auto;
}
.right .content h3:first-child {
margin-top: 0;
}
.roomList {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 24px;
flex: 1;
}

View file

@ -35,12 +35,12 @@
.inputField input {
font-weight: 400;
font-size: 14px;
font-size: 15px;
border: none;
border-radius: 4px;
padding: 8px 9px;
padding: 11px 9px;
color: var(--textColor1);
background-color: var(--bgColor2);
background-color: var(--bgColor1);
flex: 1;
min-width: 0;
}
@ -60,11 +60,11 @@
top 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s;
color: var(--textColor1);
background-color: transparent;
font-size: 14px;
font-size: 15px;
position: absolute;
left: 0;
top: 0;
margin: 7px 8px;
margin: 9px 8px;
padding: 2px;
pointer-events: none;
overflow: hidden;

View file

@ -31,6 +31,7 @@ import {
RightNav,
RoomHeaderInfo,
RoomSetupHeaderInfo,
HeaderLogo,
} from "./Header";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import VideoGrid, {

View file

@ -18,7 +18,10 @@ const variantToClassName = {
};
export const Button = forwardRef(
({ variant = "default", on, off, className, children, ...rest }, ref) => {
(
{ variant = "default", on, off, iconStyle, className, children, ...rest },
ref
) => {
const buttonRef = useObjectRef(ref);
const { buttonProps } = useButton(rest, buttonRef);
@ -33,10 +36,15 @@ export const Button = forwardRef(
return (
<button
className={classNames(variantToClassName[variant], className, {
[styles.on]: on,
[styles.off]: off,
})}
className={classNames(
variantToClassName[variant],
styles[iconStyle],
className,
{
[styles.on]: on,
[styles.off]: off,
}
)}
{...filteredButtonProps}
ref={buttonRef}
>

View file

@ -52,22 +52,22 @@ limitations under the License.
background-color: #ffffff;
}
.iconButton svg * {
.iconButton:not(.stroke) svg * {
fill: #8e99a4;
}
.iconButton:hover svg * {
.iconButton:not(.stroke):hover svg * {
fill: #8d97a5;
}
.iconButton:hover svg * {
fill: #8d97a5;
}
.iconButton.on svg * {
.iconButton.on:not(.stroke) svg * {
fill: #0dbd8b;
}
.iconButton.on.stroke svg * {
stroke: #0dbd8b;
}
.hangupButton,
.hangupButton:hover {
background-color: #ff5b55;

View file

@ -4,19 +4,25 @@ import { ReactComponent as CheckIcon } from "../icons/Check.svg";
import { ReactComponent as CopyIcon } from "../icons/Copy.svg";
import { Button } from "./Button";
export function CopyButton({ value, children, ...rest }) {
export function CopyButton({ value, children, variant, ...rest }) {
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
return (
<Button {...rest} variant="copy" on={isCopied} onPress={setCopied}>
<Button
{...rest}
variant={variant || "copy"}
on={isCopied}
onPress={setCopied}
iconStyle={isCopied ? "stroke" : "fill"}
>
{isCopied ? (
<>
<span>Copied!</span>
{variant !== "icon" && <span>Copied!</span>}
<CheckIcon />
</>
) : (
<>
<span>{children || value}</span>
{variant !== "icon" && <span>{children || value}</span>}
<CopyIcon />
</>
)}