Browse Source

improve video preview component, add to storybook

Klaudiusz Dembler 4 years ago
parent
commit
1476d42e12

+ 2 - 2
packages/app/src/components/VideoGallery.tsx

@@ -1,7 +1,7 @@
-import React, { useCallback, useEffect, useState } from "react";
+import React, { useCallback, useState } from "react";
 import { css, SerializedStyles } from "@emotion/core";
 
-import { VideoPreview, Gallery, theme } from "@joystream/components";
+import { Gallery, VideoPreview } from "@joystream/components";
 
 type VideoGalleryProps = {
 	title: string;

+ 0 - 5
packages/components/.storybook/Container.js

@@ -1,5 +0,0 @@
-import React from "react";
-
-export default function Container({ children }) {
-	return <div style={{ backgroundColor: "black", padding: 15 }}>{children}</div>;
-}

+ 4 - 0
packages/components/.storybook/main.js

@@ -43,6 +43,10 @@ module.exports = {
 			],
 		});
 		config.resolve.extensions.push(".ts", ".tsx");
+		config.resolve.alias = {
+			...config.resolve.alias,
+			components: path.resolve(__dirname, "../src", "components"),
+		};
 		return config;
 	},
 };

+ 6 - 3
packages/components/.storybook/preview-head.html

@@ -7,8 +7,11 @@
 		src: url("https://ben.click/Optimo_11264_K0tfnA/Optimo-PxGrotesk/PxGroteskRegular-Regular.css");
 	}
 	@font-face {
-		font-family: "PxGroteskRegular";
-		font-weight: bold;
-		src: url("https://ben.click/Optimo_11264_K0tfnA/Optimo-PxGrotesk/PxGroteskBold-Regular.css");
+        font-family: "PxGroteskRegular";
+        font-weight: bold;
+        src: url("https://ben.click/Optimo_11264_K0tfnA/Optimo-PxGrotesk/PxGroteskBold-Regular.css");
+    }
+	body {
+		margin: 0;
 	}
 </style>

+ 0 - 15
packages/components/.storybook/preview.js

@@ -1,15 +0,0 @@
-import { addDecorator, addParameters } from "@storybook/react";
-import { withKnobs } from "@storybook/addon-knobs";
-import { jsxDecorator } from "storybook-addon-jsx";
-import theme from "./theme";
-import Container from "./Container";
-
-addDecorator(withKnobs);
-addDecorator(jsxDecorator);
-addDecorator((storyFn) => <Container>{storyFn()}</Container>);
-
-addParameters({
-	options: {
-		theme: theme,
-	},
-});

+ 27 - 0
packages/components/.storybook/preview.jsx

@@ -0,0 +1,27 @@
+import React from "react";
+import { css } from "@emotion/core";
+import { addDecorator, addParameters } from "@storybook/react";
+import { withKnobs } from "@storybook/addon-knobs";
+import { jsxDecorator } from "storybook-addon-jsx";
+import { Layout } from "app/src/components";
+import theme from "./theme";
+
+const wrapperStyle = css`
+	padding: 10px;
+`;
+
+const stylesWrapperDecorator = (styleFn) => (
+	<div css={wrapperStyle}>
+		<Layout>{styleFn()}</Layout>
+	</div>
+);
+
+addDecorator(withKnobs);
+addDecorator(jsxDecorator);
+addDecorator(stylesWrapperDecorator);
+
+addParameters({
+	options: {
+		theme: theme,
+	},
+});

+ 1 - 2
packages/components/.storybook/theme.js

@@ -11,7 +11,6 @@ export default create({
 	// UI
 	appBg: colors.black,
 	appContentBg: "#272D33",
-	inputBg: "black",
 	appBorderColor: "#424E57",
 	appBorderRadius: 4,
 
@@ -32,7 +31,7 @@ export default create({
 	// Form colors
 	inputBg: "white",
 	inputBorder: "#272D33",
-	inputTextColor: "white",
+	inputTextColor: "black",
 	inputBorderRadius: 4,
 
 	brandTitle: "@joystream/components",

+ 1 - 1
packages/components/assets/play.svg

@@ -1 +1 @@
-<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 14.5l6-4.5-6-4.5v9zM10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" fill="#7B8A95"/></svg>
+<svg width="64" height="64" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M28.166 23l12 9-12 9V23z" stroke="#fff" stroke-width="3"/><circle cx="32.001" cy="32" r="25.167" stroke="#fff" stroke-width="3"/></svg>

+ 12 - 4
packages/components/src/components/Avatar/Avatar.tsx

@@ -1,12 +1,13 @@
 import React from "react";
 import { SerializedStyles } from "@emotion/core";
-import { useCSS, AvatarStyleProps } from "./Avatar.style";
+import { AvatarStyleProps, useCSS } from "./Avatar.style";
 
 export type AvatarProps = {
 	onClick: (e: React.MouseEvent<HTMLElement>) => void;
-	outerStyles: SerializedStyles;
+	outerStyles?: SerializedStyles;
 	img: string;
 	name: string;
+	className?: string;
 } & AvatarStyleProps;
 
 function initialsFromName(name: string): string {
@@ -15,10 +16,17 @@ function initialsFromName(name: string): string {
 	return vowels.includes(second) ? first : `${first}${second}`;
 }
 
-const Avatar: React.FC<Partial<AvatarProps>> = ({ img, outerStyles, onClick = () => {}, name, ...styleProps }) => {
+const Avatar: React.FC<Partial<AvatarProps>> = ({
+	img,
+	outerStyles,
+	onClick = () => {},
+	name,
+	className,
+	...styleProps
+}) => {
 	const styles = useCSS({ ...styleProps });
 	return (
-		<div css={[styles.container, outerStyles]} onClick={onClick}>
+		<div css={[styles.container, outerStyles]} className={className} onClick={onClick}>
 			{img ? <img src={img} css={styles.img} /> : <span>{initialsFromName(name || "")}</span>}
 		</div>
 	);

+ 142 - 69
packages/components/src/components/VideoPreview/VideoPreview.styles.tsx

@@ -1,72 +1,145 @@
-import { css } from "@emotion/core";
-import { typography, colors, spacing } from "./../../theme";
-
-export type VideoPreviewStyleProps = {
-	showChannel: boolean;
-	poster: string;
-	darken: boolean;
-	fade: string;
+import styled from "@emotion/styled";
+import { colors, spacing, typography } from "./../../theme";
+import Avatar from "components/Avatar";
+
+const HOVER_BORDER_SIZE = "2px";
+
+type CoverImageProps = {
+	displayPosterPlaceholder?: boolean;
+};
+
+type ContainerProps = {
+	clickable: boolean;
 };
 
-export let makeStyles = ({ showChannel = false, poster = "", fade }: Partial<VideoPreviewStyleProps>) => {
-	const withPoster = poster ? fade : `linear-gradient(${colors.gray[300]}, ${colors.gray[700]})`;
-
-	return {
-		container: css`
-			color: ${colors.gray[300]};
-		`,
-		link: css`
-			text-decoration: none;
-		`,
-		coverContainer: css`
-			width: 320px;
-			height: 190px;
-		`,
-		cover: css`
-			width: 100%;
-			height: 100%;
-			background-image: ${withPoster};
-			object-fit: cover;
-		`,
-		infoContainer: css`
-			display: flex;
-			margin-top: ${spacing.s};
-		`,
-		avatar: css`
-			width: 40px;
-			height: 40px;
-		`,
-		textContainer: css`
-			margin-left: ${spacing.xs};
-			line-height: 1.25;
-			& > h3 {
-				font-size: 1rem;
-				font-family: ${typography.fonts.headers};
-				margin: 0;
-			}
-			& > span {
-				font-size: 0.875rem;
-				line-height: 1.43;
-				margin: 0;
-			}
-
-			span:first-of-type {
-				margin-bottom: ${spacing.xs};
-			}
-		`,
-		title: css`
-			margin: 0;
-			font-weight: ${typography.weights.bold};
-			text-transform: capitalize;
-			color: ${colors.white};
-		`,
-		channel: css`
-			margin: 0.5rem 0;
-			font-size: ${typography.sizes.subtitle2};
-			display: block;
-		`,
-		meta: css`
-			font-size: ${typography.sizes.subtitle2};
-		`,
-	};
+type ChannelProps = {
+	channelClickable: boolean;
 };
+
+export const CoverContainer = styled.div`
+	width: 320px;
+	height: 190px;
+
+	transition-property: box-shadow, transform;
+	transition-duration: 0.4s;
+	transition-timing-function: cubic-bezier(0.165, 0.84, 0.44, 1);
+
+	position: relative;
+`;
+
+export const CoverImage = styled.img<CoverImageProps>`
+	width: 100%;
+	height: 100%;
+	background-image: ${({ displayPosterPlaceholder }) =>
+		displayPosterPlaceholder ? `linear-gradient(${colors.gray[300]}, ${colors.gray[700]})` : "none"};
+	background-size: cover;
+	object-fit: cover;
+`;
+
+export const CoverHoverOverlay = styled.div`
+	position: absolute;
+	top: 0;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	opacity: 0;
+
+	transition: opacity 0.4s cubic-bezier(0.165, 0.84, 0.44, 1);
+
+	border: ${HOVER_BORDER_SIZE} solid ${colors.white};
+
+	display: flex;
+	justify-content: center;
+	align-items: center;
+`;
+
+export const ProgressOverlay = styled.div`
+	position: absolute;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	height: ${spacing.xxs};
+	background-color: ${colors.white};
+`;
+
+export const ProgressBar = styled.div`
+	position: absolute;
+	left: 0;
+	bottom: 0;
+	height: 100%;
+	width: 0;
+	background-color: ${colors.blue["500"]};
+`;
+
+export const Container = styled.div<ContainerProps>`
+	color: ${colors.gray[300]};
+	cursor: ${({ clickable }) => (clickable ? "pointer" : "auto")};
+	display: inline-block;
+	${({ clickable }) =>
+		clickable &&
+		`
+				&:hover {
+					${CoverContainer} {
+						transform: translate(-${spacing.xs}, -${spacing.xs});
+						box-shadow: ${spacing.xs} ${spacing.xs} 0 ${colors.blue["500"]};
+					}
+
+					${CoverHoverOverlay} {
+						opacity: 1;
+					}
+
+					${ProgressOverlay} {
+						bottom: ${HOVER_BORDER_SIZE};
+					}
+				}
+			`}
+`;
+
+export const CoverDurationOverlay = styled.div`
+	position: absolute;
+	bottom: ${spacing.xs};
+	right: ${spacing.xs};
+	padding: ${spacing.xxs} ${spacing.xs};
+	background-color: ${colors.black};
+	color: ${colors.white};
+	font-size: ${typography.sizes.body2};
+`;
+
+export const InfoContainer = styled.div`
+	display: flex;
+	margin-top: ${spacing.s};
+`;
+
+export const StyledAvatar = styled(Avatar)<ChannelProps>`
+	width: 40px;
+	height: 40px;
+	margin-right: ${spacing.xs};
+	cursor: ${({ channelClickable }) => (channelClickable ? "pointer" : "auto")};
+`;
+
+export const TextContainer = styled.div`
+	display: flex;
+	flex-direction: column;
+	align-items: start;
+`;
+
+export const TitleHeader = styled.h3`
+	margin: 0;
+	font-weight: ${typography.weights.bold};
+	font-size: ${typography.sizes.h6};
+	line-height: 1.25rem;
+	color: ${colors.white};
+	display: inline-block;
+`;
+
+export const ChannelName = styled.span<ChannelProps>`
+	font-size: ${typography.sizes.subtitle2};
+	line-height: 1.25rem;
+	display: inline-block;
+	cursor: ${({ channelClickable }) => (channelClickable ? "pointer" : "auto")};
+`;
+
+export const MetaText = styled.span`
+	margin-top: ${spacing.xs};
+	font-size: ${typography.sizes.subtitle2};
+`;

+ 74 - 28
packages/components/src/components/VideoPreview/VideoPreview.tsx

@@ -1,6 +1,20 @@
 import React from "react";
-import { makeStyles, VideoPreviewStyleProps } from "./VideoPreview.styles";
-import Avatar from "./../Avatar";
+import {
+	ChannelName,
+	Container,
+	CoverContainer,
+	CoverDurationOverlay,
+	CoverHoverOverlay,
+	CoverImage,
+	InfoContainer,
+	MetaText,
+	ProgressBar,
+	ProgressOverlay,
+	StyledAvatar,
+	TextContainer,
+	TitleHeader,
+} from "./VideoPreview.styles";
+import Play from "../../../assets/play.svg";
 
 type VideoPreviewProps = {
 	title: string;
@@ -9,58 +23,90 @@ type VideoPreviewProps = {
 	showChannel: boolean;
 	showMeta: boolean;
 	createdAt: string;
+	duration?: string;
+	// video watch progress in percent (0-100)
+	progress?: number;
 	views: string;
 	poster: string;
-	onClick: (e: React.MouseEvent<HTMLElement>) => void;
 	imgRef: React.Ref<HTMLImageElement>;
-	onChannelClick: (e: React.MouseEvent<HTMLElement>) => void;
-} & VideoPreviewStyleProps;
+	onClick?: (e: React.MouseEvent<HTMLElement>) => void;
+	onChannelClick?: (e: React.MouseEvent<HTMLElement>) => void;
+};
 
 const VideoPreview: React.FC<Partial<VideoPreviewProps>> = ({
 	title,
 	channel,
 	channelImg,
-	showChannel,
-	showMeta,
+	showChannel = true,
+	showMeta = true,
 	createdAt,
+	duration,
+	progress = 0,
 	views,
 	imgRef,
 	poster,
-	onClick = () => {},
-	onChannelClick = () => {},
-	...styleProps
+	onClick,
+	onChannelClick,
 }) => {
-	let styles = makeStyles({ showChannel, poster, ...styleProps });
+	const clickable = !!onClick;
+	const channelClickable = !!onChannelClick;
+
+	const handleChannelClick = (e: React.MouseEvent<HTMLElement>) => {
+		if (!onChannelClick) {
+			return;
+		}
+		e.stopPropagation();
+		onChannelClick(e);
+	};
+
+	const handleClick = (e: React.MouseEvent<HTMLElement>) => {
+		if (!onClick) {
+			return;
+		}
+		e.stopPropagation();
+		onClick(e);
+	};
+
 	return (
-		<div css={styles.container} onClick={onClick}>
-			<div css={styles.coverContainer}>
-				<img src={poster} ref={imgRef} css={styles.cover} alt={`${title} by ${channel} thumbnail`} />
-			</div>
-			<div css={styles.infoContainer}>
+		<Container onClick={handleClick} clickable={clickable}>
+			<CoverContainer>
+				<CoverImage src={poster} ref={imgRef} alt={`${title} by ${channel} thumbnail`} />
+				{duration && <CoverDurationOverlay>{duration}</CoverDurationOverlay>}
+				{!!progress && (
+					<ProgressOverlay>
+						<ProgressBar style={{ width: `${progress}%` }} />
+					</ProgressOverlay>
+				)}
+				<CoverHoverOverlay>
+					<Play />
+				</CoverHoverOverlay>
+			</CoverContainer>
+			<InfoContainer>
 				{showChannel && (
-					<Avatar
+					<StyledAvatar
 						size="small"
 						name={channel}
 						img={channelImg}
-						outerStyles={styles.avatar}
-						onClick={onChannelClick}
+						channelClickable={channelClickable}
+						onClick={handleChannelClick}
 					/>
 				)}
-				<div css={styles.textContainer}>
-					<h3 onClick={onClick}>{title}</h3>
+				<TextContainer>
+					<TitleHeader>{title}</TitleHeader>
 					{showChannel && (
-						<span css={styles.channel} onClick={onChannelClick}>
+						<ChannelName channelClickable={channelClickable} onClick={handleChannelClick}>
 							{channel}
-						</span>
+						</ChannelName>
 					)}
 					{showMeta && (
-						<span css={styles.meta}>
+						<MetaText>
 							{createdAt}・{views} views
-						</span>
+						</MetaText>
 					)}
-				</div>
-			</div>
-		</div>
+				</TextContainer>
+			</InfoContainer>
+		</Container>
 	);
 };
+
 export default VideoPreview;

+ 8 - 8
packages/components/src/theme/typography.ts

@@ -1,14 +1,14 @@
 export default {
 	fonts: {
 		base: "Inter, Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif",
-		headers: "PxGroteskRegular, Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif"
+		headers: "PxGroteskRegular, Lato, 'Helvetica Neue', Arial, Helvetica, sans-serif",
 	},
 	weights: {
 		thin: "100",
 		light: "300",
 		regular: "400",
 		medium: "500",
-		bold: "700"
+		bold: "700",
 	},
 	sizes: {
 		hero: "4rem",
@@ -17,7 +17,7 @@ export default {
 		h3: "2rem",
 		h4: "1.5rem",
 		h5: "1.1rem",
-		h6: "0.9rem",
+		h6: "1rem",
 		subtitle1: "1.1rem",
 		subtitle2: "0.9rem",
 		body1: "1.1rem",
@@ -27,14 +27,14 @@ export default {
 		button: {
 			large: "1rem",
 			medium: "0.8rem",
-			small: "0.7rem"
+			small: "0.7rem",
 		},
 		icon: {
 			xxlarge: "2rem",
 			xlarge: "1.5rem",
 			large: "1.2rem",
 			medium: "1rem",
-			small: "0.8rem"
-		}
-	}
-}
+			small: "0.8rem",
+		},
+	},
+};

+ 27 - 0
packages/components/stories/13-VideoPreview.stories.tsx

@@ -0,0 +1,27 @@
+import React from "react";
+import { VideoPreview } from "../src";
+import { boolean, number, text, withKnobs } from "@storybook/addon-knobs";
+import { action } from "@storybook/addon-actions";
+
+export default {
+	title: "VideoPreview",
+	component: VideoPreview,
+	decorators: [withKnobs],
+};
+
+export const Primary = () => (
+	<VideoPreview
+		title={text("Video title", "Test video")}
+		channel={text("Channel name", "Test channel")}
+		channelImg={text("Channel image", "")}
+		showChannel={boolean("Show channel", true)}
+		showMeta={boolean("Show meta", true)}
+		createdAt={text("Formatted time", "2 weeks ago")}
+		duration={text("Video duration", "1:23")}
+		progress={number("Watch progress percentage", 0, { range: true, min: 0, max: 100, step: 1 })}
+		views={text("Views", "30")}
+		poster={text("Poster image", "https://cdn.pixabay.com/photo/2020/01/31/07/26/japan-4807317_1280.jpg")}
+		onClick={boolean("Clickable", true) ? action("on click") : undefined}
+		onChannelClick={boolean("Channel clickable", true) ? action("on channel click") : undefined}
+	/>
+);