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

View file

@ -1,31 +1,11 @@
.facepile { .facepile {
display: flex; width: 100%;
margin: 0 16px; height: 24px;
position: relative;
} }
.facepile .avatar { .facepile .avatar {
position: relative;
width: 20px;
height: 20px;
border-radius: 20px;
background-color: var(--primaryColor);
}
.facepile .avatar > * {
position: absolute; position: absolute;
left: 0; top: 0;
color: #fff; border: 1px solid var(--bgColor2);
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;
} }

View file

@ -15,25 +15,21 @@ limitations under the License.
*/ */
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { useHistory, Link } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { import {
useGroupCallRooms, useGroupCallRooms,
usePublicRooms, usePublicRooms,
} from "./ConferenceCallManagerHooks"; } from "./ConferenceCallManagerHooks";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header"; import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import ColorHash from "color-hash";
import styles from "./Home.module.css"; import styles from "./Home.module.css";
import { FieldRow, InputField, ErrorMessage } from "./Input"; import { FieldRow, InputField, ErrorMessage } from "./Input";
import { Center, Content, Modal } from "./Layout";
import { import {
GroupCallIntent, GroupCallIntent,
GroupCallType, GroupCallType,
} from "matrix-js-sdk/src/browser-index"; } from "matrix-js-sdk/src/browser-index";
import { Facepile } from "./Facepile";
import { UserMenu } from "./UserMenu"; import { UserMenu } from "./UserMenu";
import { Button } from "./button"; import { Button } from "./button";
import { CallTile } from "./CallTile";
const colorHash = new ColorHash({ lightness: 0.3 });
function roomAliasFromRoomName(roomName) { function roomAliasFromRoomName(roomName) {
return roomName return roomName
@ -46,10 +42,8 @@ function roomAliasFromRoomName(roomName) {
export function Home({ client, onLogout }) { export function Home({ client, onLogout }) {
const history = useHistory(); const history = useHistory();
const [roomName, setRoomName] = useState(""); const [roomName, setRoomName] = useState("");
const [roomAlias, setRoomAlias] = useState("");
const [guestAccess, setGuestAccess] = useState(false); const [guestAccess, setGuestAccess] = useState(false);
const [createRoomError, setCreateRoomError] = useState(); const [createRoomError, setCreateRoomError] = useState();
const [showAdvanced, setShowAdvanced] = useState();
const rooms = useGroupCallRooms(client); const rooms = useGroupCallRooms(client);
const publicRooms = usePublicRooms( const publicRooms = usePublicRooms(
client, client,
@ -110,131 +104,142 @@ export function Home({ client, onLogout }) {
const data = new FormData(e.target); const data = new FormData(e.target);
const roomName = data.get("roomName"); const roomName = data.get("roomName");
const roomAlias = data.get("roomAlias");
const guestAccess = data.get("guestAccess"); const guestAccess = data.get("guestAccess");
createRoom(roomName, roomAlias, guestAccess).catch((error) => { createRoom(roomName, roomAliasFromRoomName(roomName), guestAccess).catch(
setCreateRoomError(error); (error) => {
setShowAdvanced(true); setCreateRoomError(error);
}); setShowAdvanced(true);
}
);
}, },
[client] [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 ( return (
<> <div class={styles.home}>
<Header> <div className={styles.left}>
<LeftNav> <Header>
<HeaderLogo /> <LeftNav>
</LeftNav> <HeaderLogo />
<RightNav> </LeftNav>
<UserMenu </Header>
signedIn <div className={styles.content}>
userName={client.getUserIdLocalpart()} <div className={styles.centered}>
onLogout={onLogout} <form onSubmit={onJoinRoom}>
/> <h1>Join a call</h1>
</RightNav> <FieldRow className={styles.fieldRow}>
</Header> <InputField
<Content> id="roomId"
<Center> name="roomId"
<Modal> label="Call ID"
<section> type="text"
<form onSubmit={onCreateRoom}> required
<h2>Create New Room</h2> autoComplete="off"
<FieldRow> placeholder="Call ID"
<InputField value={roomId}
id="roomName" onChange={(e) => setRoomId(e.target.value)}
name="roomName" />
label="Room Name" </FieldRow>
type="text" <FieldRow className={styles.fieldRow}>
required <Button className={styles.button} type="submit">
autoComplete="off" Join call
placeholder="Room Name" </Button>
value={roomName} </FieldRow>
onChange={(e) => setRoomName(e.target.value)} </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> </FieldRow>
<details open={showAdvanced}> )}
<summary>Advanced</summary> <FieldRow className={styles.fieldRow}>
<FieldRow> <Button className={styles.button} type="submit">
<InputField Create call
id="roomAlias" </Button>
name="roomAlias" </FieldRow>
label="Room Alias" </form>
type="text" </div>
autoComplete="off" </div>
placeholder="Room Alias" </div>
value={roomAlias || roomAliasFromRoomName(roomName)} <div className={styles.right}>
onChange={(e) => setRoomAlias(e.target.value)} <Header>
/> <LeftNav />
</FieldRow> <RightNav>
<FieldRow> <UserMenu
<InputField signedIn
id="guestAccess" userName={client.getUserIdLocalpart()}
name="guestAccess" onLogout={onLogout}
label="Allow Guest Access" />
type="checkbox" </RightNav>
checked={guestAccess} </Header>
onChange={(e) => setGuestAccess(e.target.checked)} <div className={styles.content}>
/> {publicRooms.length > 0 && (
</FieldRow> <>
</details> <h3>Public Calls</h3>
{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>
<div className={styles.roomList}> <div className={styles.roomList}>
{rooms.map(({ room, participants }) => ( {publicRooms.map((room) => (
<Link <CallTile
className={styles.roomListItem} key={room.room_id}
key={room.roomId} name={room.name}
to={`/room/${room.getCanonicalAlias() || room.roomId}`} avatarUrl={null}
> roomUrl={`/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>
<Facepile participants={participants} />
</Link>
))} ))}
</div> </div>
</section> </>
</Modal> )}
</Center> <h3>Recent Calls</h3>
</Content> <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 { .left,
margin-bottom: 4px; .right {
padding: 4px;
display: flex; display: flex;
cursor: pointer; flex-direction: column;
text-decoration: none; flex: 1;
color: var(--textColor1); }
.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; align-items: center;
} }
.roomListItem:hover { .left .content form > * {
background-color: rgba(141, 151, 165, 0.2); margin-top: 0;
border-radius: 8px; margin-bottom: 24px;
color: var(--textColor1);
} }
.roomAvatar { .left .content form > :last-child {
position: relative; margin-bottom: 0;
width: 32px;
height: 32px;
border-radius: 32px;
flex-shrink: 0;
} }
.roomAvatar > * { .left .content hr {
position: absolute; width: 100%;
left: 0; border: none;
color: #fff; border-top: 1px solid var(--bgColor4);
color: var(--textColor2);
overflow: visible;
text-align: center; text-align: center;
pointer-events: none; height: 5px;
font-weight: 400; font-weight: 600;
font-size: 15px;
line-height: 24px;
} }
.roomAvatar span { .left .content hr:after {
font-size: 20.8px; background-color: var(--bgColor2);
width: 32px; content: "OR";
line-height: 32px; padding: 0 12px;
position: relative;
top: -12px;
} }
.roomName { .left .content form {
margin-left: 8px; display: flex;
font-size: 14px; flex-direction: column;
line-height: 18px; 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 { .inputField input {
font-weight: 400; font-weight: 400;
font-size: 14px; font-size: 15px;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
padding: 8px 9px; padding: 11px 9px;
color: var(--textColor1); color: var(--textColor1);
background-color: var(--bgColor2); background-color: var(--bgColor1);
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
@ -60,11 +60,11 @@
top 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s; top 0.25s ease-out 0.1s, background-color 0.25s ease-out 0.1s;
color: var(--textColor1); color: var(--textColor1);
background-color: transparent; background-color: transparent;
font-size: 14px; font-size: 15px;
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
margin: 7px 8px; margin: 9px 8px;
padding: 2px; padding: 2px;
pointer-events: none; pointer-events: none;
overflow: hidden; overflow: hidden;

View file

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

View file

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

View file

@ -52,22 +52,22 @@ limitations under the License.
background-color: #ffffff; background-color: #ffffff;
} }
.iconButton svg * { .iconButton:not(.stroke) svg * {
fill: #8e99a4; fill: #8e99a4;
} }
.iconButton:hover svg * { .iconButton:not(.stroke):hover svg * {
fill: #8d97a5; fill: #8d97a5;
} }
.iconButton:hover svg * { .iconButton.on:not(.stroke) svg * {
fill: #8d97a5;
}
.iconButton.on svg * {
fill: #0dbd8b; fill: #0dbd8b;
} }
.iconButton.on.stroke svg * {
stroke: #0dbd8b;
}
.hangupButton, .hangupButton,
.hangupButton:hover { .hangupButton:hover {
background-color: #ff5b55; 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 { ReactComponent as CopyIcon } from "../icons/Copy.svg";
import { Button } from "./Button"; import { Button } from "./Button";
export function CopyButton({ value, children, ...rest }) { export function CopyButton({ value, children, variant, ...rest }) {
const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 }); const [isCopied, setCopied] = useClipboard(value, { successDuration: 3000 });
return ( return (
<Button {...rest} variant="copy" on={isCopied} onPress={setCopied}> <Button
{...rest}
variant={variant || "copy"}
on={isCopied}
onPress={setCopied}
iconStyle={isCopied ? "stroke" : "fill"}
>
{isCopied ? ( {isCopied ? (
<> <>
<span>Copied!</span> {variant !== "icon" && <span>Copied!</span>}
<CheckIcon /> <CheckIcon />
</> </>
) : ( ) : (
<> <>
<span>{children || value}</span> {variant !== "icon" && <span>{children || value}</span>}
<CopyIcon /> <CopyIcon />
</> </>
)} )}