Merge pull request #864 from filecoin-project/@aminejv/preview-tweaks

Update: Object and Collection previews tweaks
This commit is contained in:
martinalong 2021-08-06 13:41:41 -07:00 committed by GitHub
commit d9c355cabe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 646 additions and 325 deletions

View File

@ -127,6 +127,7 @@ export const shadow = {
darkSmall: "0px 4px 16px 0 rgba(99, 101, 102, 0.1)", darkSmall: "0px 4px 16px 0 rgba(99, 101, 102, 0.1)",
darkMedium: "0px 8px 32px 0 rgba(99, 101, 102, 0.2)", darkMedium: "0px 8px 32px 0 rgba(99, 101, 102, 0.2)",
darkLarge: "0px 12px 64px 0 rgba(99, 101, 102, 0.3)", darkLarge: "0px 12px 64px 0 rgba(99, 101, 102, 0.3)",
card: "0px 0px 32px #E5E8EA;",
}; };
export const zindex = { export const zindex = {
@ -204,12 +205,12 @@ export const grids = {
}, },
}, },
object: { object: {
desktop: { width: 248, rowGap: 16 }, desktop: { width: 248, rowGap: 20 },
mobile: { width: 166, rowGap: 8 }, mobile: { width: 166, rowGap: 12 },
}, },
collection: { collection: {
desktop: { width: 432, rowGap: 16 }, desktop: { width: 382, rowGap: 16 },
mobile: { width: 300, rowGap: 8 }, mobile: { width: 280, rowGap: 8 },
}, },
profile: { profile: {
desktop: { width: 248, rowGap: 16 }, desktop: { width: 248, rowGap: 16 },

View File

@ -5,7 +5,7 @@ import * as Events from "~/common/custom-events";
export const useMounted = (callback, depedencies) => { export const useMounted = (callback, depedencies) => {
const mountedRef = React.useRef(false); const mountedRef = React.useRef(false);
React.useLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
if (mountedRef.current && callback) { if (mountedRef.current && callback) {
callback(); callback();
} }
@ -230,7 +230,7 @@ export const useIntersection = ({ onIntersect, ref }, dependencies = []) => {
const onIntersectRef = React.useRef(); const onIntersectRef = React.useRef();
onIntersectRef.current = onIntersect; onIntersectRef.current = onIntersect;
React.useLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
if (!ref.current) return; if (!ref.current) return;
const lazyObserver = new IntersectionObserver((entries) => { const lazyObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
@ -404,3 +404,10 @@ export function useMemoCompare(next, compare) {
return isEqual ? previous : next; return isEqual ? previous : next;
} }
/**
* NOTE(amine): use this hook to get rid of nextJs warnings
* source: https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a
*/
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;

View File

@ -0,0 +1,123 @@
import * as React from "react";
import * as Validations from "~/common/validations";
import * as Typography from "~/components/system/components/Typography";
import * as Strings from "~/common/strings";
import * as Constants from "~/common/constants";
import { Logo } from "~/common/logo";
import { useInView } from "~/common/hooks";
import { isBlurhashValid } from "blurhash";
import { Blurhash } from "react-blurhash";
import { css } from "@emotion/react";
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders";
const STYLES_PLACEHOLDER_CONTAINER = css`
height: 100%;
width: 100%;
`;
const STYLES_PREVIEW = css`
flex-grow: 1;
background-size: cover;
overflow: hidden;
img {
height: 100%;
width: 100%;
object-fit: cover;
}
`;
const STYLES_EMPTY_CONTAINER = css`
display: flex;
overflow: hidden;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
`;
export default function Preview({ collection, children, ...props }) {
const [isLoading, setLoading] = React.useState(true);
const handleOnLoaded = () => setLoading(false);
const previewerRef = React.useRef();
const { isInView } = useInView({
ref: previewerRef,
});
const object = React.useMemo(() => getObjectToPreview(collection.objects), [collection.objects]);
const isCollectionEmpty = collection.objects.length === 0;
if (isCollectionEmpty) {
return (
<div css={STYLES_EMPTY_CONTAINER} {...props}>
{children}
<Logo style={{ height: 24, marginBottom: 8, color: Constants.system.grayLight2 }} />
<Typography.P2 color="grayLight2">This collection is empty</Typography.P2>
</div>
);
}
if (object.isImage) {
const { coverImage } = object.data;
const blurhash = getFileBlurHash(object);
const previewImage = coverImage
? Strings.getURLfromCID(coverImage?.cid)
: Strings.getURLfromCID(object.cid);
return (
<div ref={previewerRef} css={STYLES_PREVIEW} {...props}>
{children}
{isInView && (
<>
{isLoading && blurhash && (
<Blurhash
hash={blurhash}
style={{ position: "absolute", top: 0, left: 0 }}
height="100%"
width="100%"
resolutionX={32}
resolutionY={32}
punch={1}
/>
)}
<img src={previewImage} alt="Collection preview" onLoad={handleOnLoaded} />
</>
)}
</div>
);
}
return (
<div css={STYLES_PREVIEW} {...props}>
{children}
<ObjectPlaceholder ratio={1} containerCss={STYLES_PLACEHOLDER_CONTAINER} file={object} />
</div>
);
}
const getFileBlurHash = (file) => {
const coverImage = file?.data?.coverImage;
const coverImageBlurHash = coverImage?.data?.blurhash;
if (coverImage && isBlurhashValid(coverImageBlurHash)) return coverImageBlurHash;
const blurhash = file?.data?.blurhash;
if (isBlurhashValid(blurhash)) return blurhash;
return null;
};
const getObjectToPreview = (objects = []) => {
let objectIdx = 0;
let isImage = false;
objects.some((object, i) => {
const isPreviewableImage = Validations.isPreviewableImage(object.data.type);
if (isPreviewableImage) (objectIdx = i), (isImage = true);
return isPreviewableImage;
});
return { ...objects[objectIdx], isImage };
};

View File

@ -1 +1,2 @@
export { default as FollowButton } from "./FollowButton"; export { default as FollowButton } from "./FollowButton";
export { default as Preview } from "./Preview";

View File

@ -1,22 +1,17 @@
import * as React from "react"; import * as React from "react";
import * as Validations from "~/common/validations";
import * as Typography from "~/components/system/components/Typography";
import * as Styles from "~/common/styles"; import * as Styles from "~/common/styles";
import * as Strings from "~/common/strings";
import * as Constants from "~/common/constants"; import * as Constants from "~/common/constants";
import * as SVG from "~/common/svg"; import * as SVG from "~/common/svg";
import { Logo } from "~/common/logo";
import { useInView } from "~/common/hooks";
import { isBlurhashValid } from "blurhash";
import { Blurhash } from "react-blurhash";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import { FollowButton } from "~/components/core/CollectionPreviewBlock/components"; import { FollowButton } from "~/components/core/CollectionPreviewBlock/components";
import { useFollowHandler } from "~/components/core/CollectionPreviewBlock/hooks"; import { useFollowHandler } from "~/components/core/CollectionPreviewBlock/hooks";
import { Link } from "~/components/core/Link"; import { Link } from "~/components/core/Link";
import { motion } from "framer-motion"; import { motion, useAnimation } from "framer-motion";
import { Preview } from "~/components/core/CollectionPreviewBlock/components";
import ObjectPlaceholder from "~/components/core/ObjectPreview/placeholders"; import { AspectRatio } from "~/components/system";
import { P3, H5, P2 } from "~/components/system/components/Typography";
import { useMounted } from "~/common/hooks";
const STYLES_CONTAINER = (theme) => css` const STYLES_CONTAINER = (theme) => css`
position: relative; position: relative;
@ -27,35 +22,25 @@ const STYLES_CONTAINER = (theme) => css`
border-radius: 16px; border-radius: 16px;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
height: 304px;
@media (max-width: ${theme.sizes.mobile}px) {
height: 281px;
}
`; `;
const STYLES_PREVIEW = css` const STYLES_DESCRIPTION = (theme) => css`
flex-grow: 1; position: relative;
background-size: cover;
overflow: hidden;
img {
height: 100%;
width: 100%;
object-fit: cover;
}
`;
const STYLES_DESCRIPTION_CONTAINER = (theme) => css`
background-color: ${theme.semantic.bgLight};
position: absolute;
bottom: 0%;
display: flex;
flex-direction: column;
padding: 9px 16px 12px;
border-radius: 0px 0px 16px 16px; border-radius: 0px 0px 16px 16px;
box-shadow: 0 -0.5px 0.5px ${theme.system.grayLight4}; box-sizing: border-box;
width: 100%; width: 100%;
margin-top: auto; background-color: ${theme.semantic.bgLight};
z-index: 1;
`;
const STYLES_INNER_DESCRIPTION = (theme) => css`
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: ${theme.semantic.bgLight};
padding: 9px 16px 0px;
box-shadow: 0 -0.5px 0.5px ${theme.system.grayLight4};
`; `;
const STYLES_SPACE_BETWEEN = css` const STYLES_SPACE_BETWEEN = css`
@ -71,26 +56,14 @@ const STYLES_PROFILE_IMAGE = (theme) => css`
`; `;
const STYLES_METRICS = css` const STYLES_METRICS = css`
position: relative;
z-index: 1;
margin-top: auto; margin-top: auto;
padding: 4px 16px 8px;
${Styles.CONTAINER_CENTERED}; ${Styles.CONTAINER_CENTERED};
${STYLES_SPACE_BETWEEN} ${STYLES_SPACE_BETWEEN}
`; `;
const STYLES_PLACEHOLDER_CONTAINER = css`
height: 100%;
width: 100%;
`;
const STYLES_EMPTY_CONTAINER = css`
display: flex;
overflow: hidden;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
`;
const STYLES_CONTROLS = css` const STYLES_CONTROLS = css`
position: absolute; position: absolute;
z-index: 1; z-index: 1;
@ -104,97 +77,24 @@ const STYLES_TEXT_GRAY = (theme) => css`
color: ${theme.semantic.textGray}; color: ${theme.semantic.textGray};
`; `;
const getFileBlurHash = (file) => {
const coverImage = file?.data?.coverImage;
const coverImageBlurHash = coverImage?.data?.blurhash;
if (coverImage && isBlurhashValid(coverImageBlurHash)) return coverImageBlurHash;
const blurhash = file?.data?.blurhash;
if (isBlurhashValid(blurhash)) return blurhash;
return null;
};
const getObjectToPreview = (objects = []) => {
let objectIdx = 0;
let isImage = false;
objects.some((object, i) => {
const isPreviewableImage = Validations.isPreviewableImage(object.data.type);
if (isPreviewableImage) (objectIdx = i), (isImage = true);
return isPreviewableImage;
});
return { ...objects[objectIdx], isImage };
};
const Preview = ({ collection, children, ...props }) => {
const [isLoading, setLoading] = React.useState(true);
const handleOnLoaded = () => setLoading(false);
const previewerRef = React.useRef();
const { isInView } = useInView({
ref: previewerRef,
});
const object = React.useMemo(() => getObjectToPreview(collection.objects), [collection.objects]);
const isCollectionEmpty = collection.objects.length === 0;
if (isCollectionEmpty) {
return (
<div css={STYLES_EMPTY_CONTAINER} {...props}>
{children}
<Logo style={{ height: 24, marginBottom: 8, color: Constants.system.grayLight2 }} />
<Typography.P2 color="grayLight2">This collection is empty</Typography.P2>
</div>
);
}
if (object.isImage) {
const { coverImage } = object.data;
const blurhash = getFileBlurHash(object);
const previewImage = coverImage
? Strings.getURLfromCID(coverImage?.cid)
: Strings.getURLfromCID(object.cid);
return (
<div ref={previewerRef} css={STYLES_PREVIEW} {...props}>
{children}
{isInView && (
<>
{isLoading && blurhash && (
<Blurhash
hash={blurhash}
style={{ position: "absolute", top: 0, left: 0 }}
height="100%"
width="100%"
resolutionX={32}
resolutionY={32}
punch={1}
/>
)}
<img src={previewImage} alt="Collection preview" onLoad={handleOnLoaded} />
</>
)}
</div>
);
}
return (
<div css={STYLES_PREVIEW} {...props}>
{children}
<ObjectPlaceholder ratio={1} containerCss={STYLES_PLACEHOLDER_CONTAINER} file={object} />
</div>
);
};
export default function CollectionPreview({ collection, viewer, owner, onAction }) { export default function CollectionPreview({ collection, viewer, owner, onAction }) {
const [areControlsVisible, setShowControls] = React.useState(false); const [areControlsVisible, setShowControls] = React.useState(false);
const showControls = () => setShowControls(true); const showControls = () => setShowControls(true);
const hideControls = () => setShowControls(false); const hideControls = () => setShowControls(false);
const { isDescriptionVisible, showDescription, hideDescription } = useShowDescription();
const description = collection?.data?.body; const description = collection?.data?.body;
const { isDescriptionVisible, showDescription, hideDescription } = useShowDescription({
disabled: !description,
});
const extendedDescriptionRef = React.useRef();
const descriptionRef = React.useRef();
const animationController = useAnimateDescription({
extendedDescriptionRef: extendedDescriptionRef,
descriptionRef: descriptionRef,
isDescriptionVisible,
});
const { follow, followCount, isFollowed } = useFollowHandler({ collection, viewer }); const { follow, followCount, isFollowed } = useFollowHandler({ collection, viewer });
@ -202,113 +102,191 @@ export default function CollectionPreview({ collection, viewer, owner, onAction
return ( return (
<div css={STYLES_CONTAINER}> <div css={STYLES_CONTAINER}>
<Preview collection={collection} onMouseEnter={showControls} onMouseLeave={hideControls}> <AspectRatio ratio={248 / 382}>
<motion.div <div css={Styles.VERTICAL_CONTAINER}>
initial={{ opacity: 0 }} <Preview collection={collection} onMouseEnter={showControls} onMouseLeave={hideControls}>
animate={{ opacity: areControlsVisible ? 1 : 0 }} <motion.div
css={STYLES_CONTROLS}
>
<FollowButton
onClick={follow}
isFollowed={isFollowed}
followCount={followCount}
disabled={collection.ownerId === viewer?.id}
/>
</motion.div>
</Preview>
<div style={{ position: "relative", height: 61 }}>
<motion.div
css={STYLES_DESCRIPTION_CONTAINER}
onMouseEnter={showDescription}
onMouseLeave={hideDescription}
transition={{ duration: 0.4, ease: "easeOut" }}
>
<div css={[Styles.HORIZONTAL_CONTAINER_CENTERED, STYLES_SPACE_BETWEEN]}>
<Typography.H5 color="textBlack" nbrOflines={1} title={collection.slatename}>
{collection.slatename}
</Typography.H5>
</div>
<motion.div
style={{ marginTop: 4 }}
initial={{ height: 0 }}
animate={{
height: isDescriptionVisible ? 108 : 0,
}}
transition={{
type: "spring",
stiffness: 170,
damping: 26,
delay: isDescriptionVisible ? 0 : 0.25,
}}
>
<Typography.P2
as={motion.p}
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ animate={{ opacity: areControlsVisible ? 1 : 0 }}
opacity: isDescriptionVisible ? 1 : 0, css={STYLES_CONTROLS}
}}
transition={{ delay: isDescriptionVisible ? 0.25 : 0 }}
color="textGrayDark"
nbrOflines={5}
> >
{description || ""} <FollowButton
</Typography.P2> onClick={follow}
</motion.div> isFollowed={isFollowed}
<div css={STYLES_METRICS}> followCount={followCount}
<div css={[Styles.CONTAINER_CENTERED, STYLES_TEXT_GRAY]}> disabled={collection.ownerId === viewer?.id}
<SVG.Box /> />
<Typography.P3 style={{ marginLeft: 4 }} color="textGray"> </motion.div>
{fileCount} </Preview>
</Typography.P3>
</div> <motion.article
{owner && ( css={STYLES_DESCRIPTION}
<div style={{ alignItems: "end" }} css={Styles.CONTAINER_CENTERED}> onMouseMove={showDescription}
<Link onMouseLeave={hideDescription}
href={`/$/user/${owner.id}`} >
onAction={onAction} <div style={{ position: "relative", paddingTop: 9 }}>
aria-label={`Visit ${owner.username}'s profile`} <H5 nbrOflines={1} style={{ visibility: "hidden" }}>
title={`Visit ${owner.username}'s profile`} {collection.slatename}
</H5>
<div ref={descriptionRef}>
<P3
style={{ paddingTop: 3, visibility: "hidden" }}
nbrOflines={1}
color="textGrayDark"
> >
<img {description}
css={STYLES_PROFILE_IMAGE} </P3>
src={owner?.data?.photo}
alt={`${owner.username} profile`}
onError={(e) => (e.target.src = Constants.profileDefaultPicture)}
/>
</Link>
<Link
href={`/$/user/${owner.id}`}
onAction={onAction}
aria-label={`Visit ${owner.username}'s profile`}
title={`Visit ${owner.username}'s profile`}
>
<Typography.P3 style={{ marginLeft: 8 }} color="textGray">
{owner.username}
</Typography.P3>
</Link>
</div> </div>
)}
</div> <motion.div
</motion.div> css={STYLES_INNER_DESCRIPTION}
</div> initial={false}
animate={isDescriptionVisible ? "hovered" : "initial"}
variants={animationController.containerVariants}
>
<H5 color="textBlack" nbrOflines={1} title={collection.slatename}>
{collection.slatename}
</H5>
{!isDescriptionVisible && (
<P3 style={{ paddingTop: 3 }} nbrOflines={1} color="textGrayDark">
{description}
</P3>
)}
{description && (
<div ref={extendedDescriptionRef}>
<P2
as={motion.p}
style={{ paddingTop: 3 }}
nbrOflines={7}
initial={{ opacity: 0 }}
color="textGrayDark"
animate={animationController.descriptionControls}
>
{description}
</P2>
</div>
)}
</motion.div>
</div>
<Metrics owner={owner} fileCount={fileCount} onAction={onAction} />
</motion.article>
</div>
</AspectRatio>
</div> </div>
); );
} }
const useShowDescription = () => { function Metrics({ fileCount, owner, onAction }) {
return (
<div css={STYLES_METRICS}>
<div css={[Styles.CONTAINER_CENTERED, STYLES_TEXT_GRAY]}>
<SVG.Box />
<P3 style={{ marginLeft: 4 }} color="textGray">
{fileCount}
</P3>
</div>
{owner && (
<div style={{ alignItems: "end" }} css={Styles.CONTAINER_CENTERED}>
<Link
href={`/$/user/${owner.id}`}
onAction={onAction}
aria-label={`Visit ${owner.username}'s profile`}
title={`Visit ${owner.username}'s profile`}
>
<img
css={STYLES_PROFILE_IMAGE}
src={owner?.data?.photo}
alt={`${owner.username} profile`}
onError={(e) => (e.target.src = Constants.profileDefaultPicture)}
/>
</Link>
<Link
href={`/$/user/${owner.id}`}
onAction={onAction}
aria-label={`Visit ${owner.username}'s profile`}
title={`Visit ${owner.username}'s profile`}
>
<P3 style={{ marginLeft: 8 }} color="textGray">
{owner.username}
</P3>
</Link>
</div>
)}
</div>
);
}
const useShowDescription = ({ disabled }) => {
const [isDescriptionVisible, setShowDescription] = React.useState(false); const [isDescriptionVisible, setShowDescription] = React.useState(false);
const timeoutId = React.useRef(); const timeoutId = React.useRef();
const showDescription = () => { const showDescription = () => {
if (disabled) return;
clearTimeout(timeoutId.current); clearTimeout(timeoutId.current);
const id = setTimeout(() => setShowDescription(true), 250); const id = setTimeout(() => setShowDescription(true), 250);
timeoutId.current = id; timeoutId.current = id;
}; };
const hideDescription = () => { const hideDescription = () => {
if (disabled) return;
clearTimeout(timeoutId.current); clearTimeout(timeoutId.current);
setShowDescription(false); setShowDescription(false);
}; };
return { isDescriptionVisible, showDescription, hideDescription }; return { isDescriptionVisible, showDescription, hideDescription };
}; };
const useAnimateDescription = ({
extendedDescriptionRef,
descriptionRef,
isDescriptionVisible,
}) => {
const descriptionHeights = React.useRef({
extended: 0,
static: 0,
});
React.useEffect(() => {
const extendedDescriptionElement = extendedDescriptionRef.current;
const descriptionElement = descriptionRef.current;
if (descriptionElement && extendedDescriptionElement) {
descriptionHeights.current.static = descriptionElement.offsetHeight;
descriptionHeights.current.extended = extendedDescriptionElement.offsetHeight;
}
}, []);
const containerVariants = {
initial: {
borderRadius: "0px",
y: 0,
transition: {
type: "spring",
stiffness: 170,
damping: 26,
delay: 0.3,
},
},
hovered: {
borderRadius: "16px",
y: -descriptionHeights.current.extended + descriptionHeights.current.static,
transition: {
type: "spring",
stiffness: 170,
damping: 26,
},
},
};
const descriptionControls = useAnimation();
useMounted(() => {
if (isDescriptionVisible) {
descriptionControls.start({ opacity: 1, transition: { delay: 0.2 } });
return;
}
descriptionControls.set({ opacity: 0 });
}, [isDescriptionVisible]);
return { containerVariants, descriptionControls };
};

View File

@ -4,6 +4,7 @@ import * as Content from "~/components/core/FontFrame/Views/content";
import * as Strings from "~/common/strings"; import * as Strings from "~/common/strings";
import { generateNumberByStep } from "~/common/utilities"; import { generateNumberByStep } from "~/common/utilities";
import { useIsomorphicLayoutEffect } from "~/common/hooks";
export const useFont = ({ cid }, deps) => { export const useFont = ({ cid }, deps) => {
const url = Strings.getURLfromCID(cid); const url = Strings.getURLfromCID(cid);
@ -191,7 +192,7 @@ export const useFontControls = () => {
const FONT_PREVIEW_STORAGE_TOKEN = "SLATE_FONT_PREVIEW_SETTINGS"; const FONT_PREVIEW_STORAGE_TOKEN = "SLATE_FONT_PREVIEW_SETTINGS";
React.useLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
const initialState = JSON.parse(localStorage.getItem(FONT_PREVIEW_STORAGE_TOKEN)); const initialState = JSON.parse(localStorage.getItem(FONT_PREVIEW_STORAGE_TOKEN));
if (initialState) handlers.initialState(initialState); if (initialState) handlers.initialState(initialState);
}, []); }, []);

View File

@ -1,59 +1,74 @@
import * as React from "react"; import * as React from "react";
import * as Styles from "~/common/styles"; import * as Styles from "~/common/styles";
import * as Constants from "~/common/constants"; import * as SVG from "~/common/svg";
import * as Typography from "~/components/system/components/Typography";
import { H5, P3 } from "~/components/system/components/Typography"; import { P3 } from "~/components/system/components/Typography";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import { Logo } from "~/common/logo";
import ObjectPreviewPrimitive from "~/components/core/ObjectPreview/ObjectPreviewPrimitive"; import ObjectPreviewPrimitive from "~/components/core/ObjectPreview/ObjectPreviewPrimitive";
import LinkPlaceholder from "~/components/core/ObjectPreview/placeholders/Link";
const STYLES_SOURCE_LOGO = css` const STYLES_SOURCE_LOGO = css`
height: 14px; height: 12px;
width: 14px; width: 12px;
border-radius: 4px; border-radius: 4px;
`; `;
const STYLES_EMPTY_CONTAINER = css` const STYLES_PLACEHOLDER_CONTAINER = css`
display: flex;
overflow: hidden;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%; height: 100%;
width: 100%; ${Styles.CONTAINER_CENTERED}
`; `;
export default function LinkObjectPreview({ file }) { const STYLES_TAG_CONTAINER = (theme) => css`
color: ${theme.semantic.textGrayLight};
svg {
opacity: 0;
transition: opacity 0.3s;
}
:hover svg {
opacity: 1;
}
${Styles.HORIZONTAL_CONTAINER_CENTERED}
`;
export default function LinkObjectPreview({ file, ratio, ...props }) {
const { const {
data: { link }, data: { link },
} = file; } = file;
const tag = ( const tag = (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ transform: "translateY(3px)" }}> <a
{link.logo && ( css={Styles.LINK}
<img href={file.url}
src={link.logo} target="_blank"
alt="Link source logo" rel="noreferrer"
style={{ marginRight: 4 }} style={{ position: "relative", zIndex: 2 }}
css={STYLES_SOURCE_LOGO} onClick={(e) => e.stopPropagation()}
/> >
)} <div css={STYLES_TAG_CONTAINER}>
<P3 as="small" color="textGray"> {link.logo && (
{link.source} <img
</P3> src={link.logo}
</div> alt="Link source logo"
style={{ marginRight: 4 }}
css={STYLES_SOURCE_LOGO}
/>
)}
<P3 as="small" color="textGray" nbrOflines={1}>
{link.source}
</P3>
<SVG.ExternalLink height={12} width={12} style={{ marginLeft: 4 }} />
</div>
</a>
); );
return ( return (
<ObjectPreviewPrimitive file={file} tag={tag}> <ObjectPreviewPrimitive file={file} tag={tag} {...props}>
{link.image ? ( {link.image ? (
<img src={link.image} alt="Link preview" css={Styles.IMAGE_FILL} /> <img src={link.image} alt="Link preview" css={Styles.IMAGE_FILL} />
) : ( ) : (
<div css={STYLES_EMPTY_CONTAINER}> <div css={STYLES_PLACEHOLDER_CONTAINER}>
<Logo style={{ height: 24, marginBottom: 8, color: Constants.system.grayLight2 }} /> <LinkPlaceholder ratio={ratio} />
<Typography.P2 color="grayLight2">No image found</Typography.P2>
</div> </div>
)} )}
</ObjectPreviewPrimitive> </ObjectPreviewPrimitive>

View File

@ -1,11 +1,13 @@
import * as React from "react"; import * as React from "react";
import * as Styles from "~/common/styles";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import { H5, P3 } from "~/components/system/components/Typography"; import { H5, P2, P3 } from "~/components/system/components/Typography";
import { AspectRatio } from "~/components/system"; import { AspectRatio } from "~/components/system";
// import { LikeButton, SaveButton } from "./components"; // import { LikeButton, SaveButton } from "./components";
// import { useLikeHandler, useSaveHandler } from "~/common/hooks"; // import { useLikeHandler, useSaveHandler } from "~/common/hooks";
import { motion } from "framer-motion"; import { motion, useAnimation } from "framer-motion";
import { useMounted } from "~/common/hooks";
import ImageObjectPreview from "./ImageObjectPreview"; import ImageObjectPreview from "./ImageObjectPreview";
@ -13,7 +15,7 @@ const STYLES_WRAPPER = (theme) => css`
position: relative; position: relative;
background-color: ${theme.semantic.bgLight}; background-color: ${theme.semantic.bgLight};
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
box-shadow: 0 0 0 0.5px ${theme.system.grayLight4}, ${theme.shadow.lightSmall}; box-shadow: 0 0 0 0.5px ${theme.system.grayLight4}, ${theme.shadow.card};
border-radius: 16px; border-radius: 16px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
@ -21,14 +23,10 @@ const STYLES_WRAPPER = (theme) => css`
const STYLES_DESCRIPTION = (theme) => css` const STYLES_DESCRIPTION = (theme) => css`
position: relative; position: relative;
box-shadow: 0 -0.5px 0.5px ${theme.system.grayLight4};
border-radius: 0px 0px 16px 16px; border-radius: 0px 0px 16px 16px;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
background-color: ${theme.semantic.bgLight}; background-color: ${theme.semantic.bgLight};
border-radius: 16px;
height: calc(170px + 61px);
padding: 9px 16px 8px;
z-index: 1; z-index: 1;
@media (max-width: ${theme.sizes.mobile}px) { @media (max-width: ${theme.sizes.mobile}px) {
@ -36,10 +34,28 @@ const STYLES_DESCRIPTION = (theme) => css`
} }
`; `;
const STYLES_INNER_DESCRIPTION = (theme) => css`
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: ${theme.semantic.bgLight};
padding: 9px 16px 0px;
box-shadow: 0 -0.5px 0.5px ${theme.system.grayLight4};
`;
const STYLES_TAG = css`
position: relative;
z-index: 1;
display: flex;
padding: 4px 16px 8px;
`;
const STYLES_PREVIEW = css` const STYLES_PREVIEW = css`
overflow: hidden; overflow: hidden;
position: relative; position: relative;
bottom: 0.5px; bottom: 0.5px;
flex-grow: 1;
`; `;
const STYLES_SELECTED_RING = (theme) => css` const STYLES_SELECTED_RING = (theme) => css`
@ -71,7 +87,6 @@ export default function ObjectPreviewPrimitive({
isImage, isImage,
onAction, onAction,
}) { }) {
const { isDescriptionVisible, showDescription, hideDescription } = useShowDescription();
// const { like, isLiked, likeCount } = useLikeHandler({ file, viewer }); // const { like, isLiked, likeCount } = useLikeHandler({ file, viewer });
// const { save, isSaved, saveCount } = useSaveHandler({ file, viewer }); // const { save, isSaved, saveCount } = useSaveHandler({ file, viewer });
// const showSaveButton = viewer?.id !== file?.ownerId; // const showSaveButton = viewer?.id !== file?.ownerId;
@ -80,7 +95,20 @@ export default function ObjectPreviewPrimitive({
// const showControls = () => setShowControls(true); // const showControls = () => setShowControls(true);
// const hideControls = () => setShowControls(false); // const hideControls = () => setShowControls(false);
const body = file?.data?.body; const description = file?.data?.body;
const { isDescriptionVisible, showDescription, hideDescription } = useShowDescription({
disabled: !description,
});
const extendedDescriptionRef = React.useRef();
const descriptionRef = React.useRef();
const animationController = useAnimateDescription({
extendedDescriptionRef: extendedDescriptionRef,
descriptionRef: descriptionRef,
isDescriptionVisible,
});
const { isLink } = file; const { isLink } = file;
const title = file.data.name || file.filename; const title = file.data.name || file.filename;
@ -99,11 +127,13 @@ export default function ObjectPreviewPrimitive({
return ( return (
<div css={[STYLES_WRAPPER, isSelected && STYLES_SELECTED_RING]}> <div css={[STYLES_WRAPPER, isSelected && STYLES_SELECTED_RING]}>
<div <AspectRatio ratio={248 / 248}>
css={STYLES_PREVIEW} <div css={Styles.VERTICAL_CONTAINER}>
// onMouseEnter={showControls} onMouseLeave={hideControls} <div
> css={STYLES_PREVIEW}
{/* <AnimatePresence> // onMouseEnter={showControls} onMouseLeave={hideControls}
>
{/* <AnimatePresence>
{areControlsVisible && ( {areControlsVisible && (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
@ -118,63 +148,150 @@ export default function ObjectPreviewPrimitive({
</motion.div> </motion.div>
)} )}
</AnimatePresence> */} </AnimatePresence> */}
<AspectRatio ratio={248 / 248}> {children}
<div>{children}</div>
</AspectRatio>
</div>
<div style={{ maxHeight: 61 }}>
<motion.article
css={STYLES_DESCRIPTION}
onMouseMove={showDescription}
onMouseLeave={hideDescription}
initial={{ y: 0 }}
animate={{
y: isDescriptionVisible ? -170 : 0,
borderRadius: isDescriptionVisible ? "16px" : "0px",
}}
transition={{ type: "spring", stiffness: 170, damping: 26 }}
>
<H5 as="h2" nbrOflines={1} color="textBlack" title={title}>
{title}
</H5>
<div style={{ marginTop: 3, display: "flex" }}>
{typeof tag === "string" ? (
<P3 as="small" css={STYLES_UPPERCASE} color="textGray">
{tag}
</P3>
) : (
tag
)}
</div> </div>
<H5
as={motion.p} <motion.article
initial={{ opacity: 0 }} css={STYLES_DESCRIPTION}
animate={{ opacity: isDescriptionVisible ? 1 : 0 }} onMouseMove={showDescription}
style={{ marginTop: 5 }} onMouseLeave={hideDescription}
nbrOflines={8}
color="textGrayDark"
> >
{body || ""} <div style={{ position: "relative", paddingTop: 9 }}>
</H5> <H5 as="h2" nbrOflines={1} style={{ visibility: "hidden" }}>
</motion.article> {title}
</div> </H5>
<div ref={descriptionRef}>
<P3
style={{ paddingTop: 3, visibility: "hidden" }}
nbrOflines={1}
color="textGrayDark"
>
{description}
</P3>
</div>
<motion.div
css={STYLES_INNER_DESCRIPTION}
initial={false}
animate={isDescriptionVisible ? "hovered" : "initial"}
variants={animationController.containerVariants}
>
<H5 as="h2" nbrOflines={1} color="textBlack" title={title}>
{title}
</H5>
{!isDescriptionVisible && (
<P3 style={{ paddingTop: 3 }} nbrOflines={1} color="textGrayDark">
{description}
</P3>
)}
{description && (
<div ref={extendedDescriptionRef}>
<P2
as={motion.p}
style={{ paddingTop: 3 }}
nbrOflines={7}
initial={{ opacity: 0 }}
color="textGrayDark"
animate={animationController.descriptionControls}
>
{description}
</P2>
</div>
)}
</motion.div>
</div>
<TagComponent tag={tag} />
</motion.article>
</div>
</AspectRatio>
</div> </div>
); );
} }
const useShowDescription = () => { const TagComponent = ({ tag }) => (
<div css={STYLES_TAG}>
{typeof tag === "string" ? (
<P3 as="small" css={STYLES_UPPERCASE} color="textGray">
{tag}
</P3>
) : (
tag
)}
</div>
);
const useShowDescription = ({ disabled }) => {
const [isDescriptionVisible, setShowDescription] = React.useState(false); const [isDescriptionVisible, setShowDescription] = React.useState(false);
const timeoutId = React.useRef(); const timeoutId = React.useRef();
const showDescription = () => { const showDescription = () => {
if (disabled) return;
clearTimeout(timeoutId.current); clearTimeout(timeoutId.current);
const id = setTimeout(() => setShowDescription(true), 250); const id = setTimeout(() => setShowDescription(true), 250);
timeoutId.current = id; timeoutId.current = id;
}; };
const hideDescription = () => { const hideDescription = () => {
if (disabled) return;
clearTimeout(timeoutId.current); clearTimeout(timeoutId.current);
setShowDescription(false); setShowDescription(false);
}; };
return { isDescriptionVisible, showDescription, hideDescription }; return { isDescriptionVisible, showDescription, hideDescription };
}; };
const useAnimateDescription = ({
extendedDescriptionRef,
descriptionRef,
isDescriptionVisible,
}) => {
const descriptionHeights = React.useRef({
extended: 0,
static: 0,
});
React.useEffect(() => {
const extendedDescriptionElement = extendedDescriptionRef.current;
const descriptionElement = descriptionRef.current;
if (descriptionElement && extendedDescriptionElement) {
descriptionHeights.current.static = descriptionElement.offsetHeight;
descriptionHeights.current.extended = extendedDescriptionElement.offsetHeight;
}
}, []);
const containerVariants = {
initial: {
borderRadius: "0px",
y: 0,
transition: {
type: "spring",
stiffness: 170,
damping: 26,
delay: 0.3,
},
},
hovered: {
borderRadius: "16px",
y: -descriptionHeights.current.extended + descriptionHeights.current.static,
transition: {
type: "spring",
stiffness: 170,
damping: 26,
},
},
};
const descriptionControls = useAnimation();
useMounted(() => {
if (isDescriptionVisible) {
descriptionControls.start({ opacity: 1, transition: { delay: 0.2 } });
return;
}
descriptionControls.set({ opacity: 0 });
}, [isDescriptionVisible]);
return { containerVariants, descriptionControls };
};

View File

@ -5,6 +5,7 @@ import * as Styles from "~/common/styles";
import * as Utilities from "~/common/utilities"; import * as Utilities from "~/common/utilities";
import { P3 } from "~/components/system"; import { P3 } from "~/components/system";
import { useIsomorphicLayoutEffect } from "~/common/hooks";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File"; import FilePlaceholder from "~/components/core/ObjectPreview/placeholders/File";
@ -30,7 +31,7 @@ const STYLES_TEXT_PREVIEW = (theme) =>
export default function TextObjectPreview({ url, file, ...props }) { export default function TextObjectPreview({ url, file, ...props }) {
const [{ content, error }, setState] = React.useState({ content: "", error: undefined }); const [{ content, error }, setState] = React.useState({ content: "", error: undefined });
React.useLayoutEffect(() => { useIsomorphicLayoutEffect(() => {
fetch(url) fetch(url)
.then(async (res) => { .then(async (res) => {
const content = await res.text(); const content = await res.text();

View File

@ -28,8 +28,10 @@ const ObjectPreview = ({ file, ...props }) => {
const url = Strings.getURLfromCID(file.cid); const url = Strings.getURLfromCID(file.cid);
const PLACEHOLDER_RATIO = 1.3;
if (link) { if (link) {
return <LinkObjectPreview file={file} />; return <LinkObjectPreview file={file} ratio={PLACEHOLDER_RATIO} {...props} />;
} }
if (Validations.isPreviewableImage(type)) { if (Validations.isPreviewableImage(type)) {
@ -40,7 +42,7 @@ const ObjectPreview = ({ file, ...props }) => {
const tag = type.split("/")[1]; const tag = type.split("/")[1];
return ( return (
<PlaceholderWrapper tag={tag} file={file} {...props}> <PlaceholderWrapper tag={tag} file={file} {...props}>
<VideoPlaceholder /> <VideoPlaceholder ratio={PLACEHOLDER_RATIO} />
</PlaceholderWrapper> </PlaceholderWrapper>
); );
} }
@ -48,7 +50,7 @@ const ObjectPreview = ({ file, ...props }) => {
if (Validations.isPdfType(type)) { if (Validations.isPdfType(type)) {
return ( return (
<PlaceholderWrapper tag="PDF" file={file} {...props}> <PlaceholderWrapper tag="PDF" file={file} {...props}>
<PdfPlaceholder /> <PdfPlaceholder ratio={PLACEHOLDER_RATIO} />
</PlaceholderWrapper> </PlaceholderWrapper>
); );
} }
@ -57,7 +59,7 @@ const ObjectPreview = ({ file, ...props }) => {
const tag = Utilities.getFileExtension(file.filename) || "audio"; const tag = Utilities.getFileExtension(file.filename) || "audio";
return ( return (
<PlaceholderWrapper tag={tag} file={file} {...props}> <PlaceholderWrapper tag={tag} file={file} {...props}>
<AudioPlaceholder /> <AudioPlaceholder ratio={PLACEHOLDER_RATIO} />
</PlaceholderWrapper> </PlaceholderWrapper>
); );
} }
@ -65,7 +67,7 @@ const ObjectPreview = ({ file, ...props }) => {
if (type === "application/epub+zip") { if (type === "application/epub+zip") {
return ( return (
<PlaceholderWrapper tag="epub" file={file} {...props}> <PlaceholderWrapper tag="epub" file={file} {...props}>
<EbookPlaceholder /> <EbookPlaceholder ratio={PLACEHOLDER_RATIO} />
</PlaceholderWrapper> </PlaceholderWrapper>
); );
} }
@ -73,7 +75,7 @@ const ObjectPreview = ({ file, ...props }) => {
if (file.filename.endsWith(".key")) { if (file.filename.endsWith(".key")) {
return ( return (
<PlaceholderWrapper tag="keynote" file={file} {...props}> <PlaceholderWrapper tag="keynote" file={file} {...props}>
<KeynotePlaceholder /> <KeynotePlaceholder ratio={PLACEHOLDER_RATIO} />
</PlaceholderWrapper> </PlaceholderWrapper>
); );
} }
@ -82,7 +84,7 @@ const ObjectPreview = ({ file, ...props }) => {
const tag = Utilities.getFileExtension(file.filename) || "code"; const tag = Utilities.getFileExtension(file.filename) || "code";
return ( return (
<PlaceholderWrapper tag={tag} file={file} {...props}> <PlaceholderWrapper tag={tag} file={file} {...props}>
<CodePlaceholder /> <CodePlaceholder ratio={PLACEHOLDER_RATIO} />
</PlaceholderWrapper> </PlaceholderWrapper>
); );
} }
@ -98,14 +100,14 @@ const ObjectPreview = ({ file, ...props }) => {
if (Validations.is3dFile(file.filename)) { if (Validations.is3dFile(file.filename)) {
return ( return (
<PlaceholderWrapper tag="3d" file={file} {...props}> <PlaceholderWrapper tag="3d" file={file} {...props}>
<Object3DPlaceholder /> <Object3DPlaceholder ratio={PLACEHOLDER_RATIO} />
</PlaceholderWrapper> </PlaceholderWrapper>
); );
} }
return ( return (
<PlaceholderWrapper tag="file" file={file} {...props}> <PlaceholderWrapper tag="file" file={file} {...props}>
<FilePlaceholder /> <FilePlaceholder ratio={PLACEHOLDER_RATIO} />
</PlaceholderWrapper> </PlaceholderWrapper>
); );
}; };

View File

@ -0,0 +1,72 @@
import * as React from "react";
import { css } from "@emotion/react";
export default function LinkPlaceholder({ ratio = 1, ...props }) {
const STYLES_PLACEHOLDER = React.useMemo(
() => css`
overflow: visible !important;
width: ${(96 / 248) * 100 * ratio}%;
height: ${(64 / 248) * 100 * ratio}%;
`,
[ratio]
);
return (
<svg
width={96}
height={64}
viewBox="62 50 96 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
css={STYLES_PLACEHOLDER}
{...props}
>
<g filter="url(#prefix__filter0_d_link)">
<rect x={64} y={52} width={96} height={64} rx={8} fill="#fff" />
</g>
<path d="M64 60a8 8 0 018-8h80a8 8 0 018 8H64z" fill="#E5E8EA" />
<path
d="M121 92v-8a2.001 2.001 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4a2.003 2.003 0 00-1 1.73v8a2.002 2.002 0 001 1.73l7 4a2 2 0 002 0l7-4a2.003 2.003 0 001-1.73z"
fill="#E5E8EA"
stroke="#fff"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M103.27 82.96l8.73 5.05 8.73-5.05M112 98.08V88"
stroke="#fff"
strokeWidth={1.25}
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx={70.5} cy={56.5} r={1.5} fill="#FF4530" />
<circle cx={75.5} cy={56.5} r={1.5} fill="#C4C4C4" />
<circle cx={80.5} cy={56.5} r={1.5} fill="#34D159" />
<defs>
<filter
id="prefix__filter0_d_link"
x={0}
y={0}
width={224}
height={192}
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity={0} result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy={12} />
<feGaussianBlur stdDeviation={32} />
<feColorMatrix values="0 0 0 0 0.682353 0 0 0 0 0.690196 0 0 0 0 0.698039 0 0 0 0.3 0" />
<feBlend in2="BackgroundImageFix" result="effect1_dropShadow" />
<feBlend in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
</filter>
</defs>
</svg>
);
}

View File

@ -13,6 +13,7 @@ import KeynotePlaceholder from "./Keynote";
import Object3DPlaceholder from "./3D"; import Object3DPlaceholder from "./3D";
import FilePlaceholder from "./File"; import FilePlaceholder from "./File";
import VideoPlaceholder from "./Video"; import VideoPlaceholder from "./Video";
import LinkPlaceholder from "./Link";
const STYLES_PLACEHOLDER_CONTAINER = (theme) => css` const STYLES_PLACEHOLDER_CONTAINER = (theme) => css`
position: relative; position: relative;
@ -39,8 +40,11 @@ const STYLES_TAG = (theme) => css`
`; `;
const PlaceholderPrimitive = ({ file, ratio }) => { const PlaceholderPrimitive = ({ file, ratio }) => {
const { type } = file.data; const { type, link } = file.data;
if (link) {
return <LinkPlaceholder ratio={ratio} />;
}
if (type.startsWith("video/")) { if (type.startsWith("video/")) {
return <VideoPlaceholder ratio={ratio} />; return <VideoPlaceholder ratio={ratio} />;
} }

View File

@ -34,7 +34,6 @@ const truncateElements = (nbrOfLines) =>
nbrOfLines && nbrOfLines &&
css` css`
overflow: hidden; overflow: hidden;
line-height: 1.5;
word-break: break-word; word-break: break-word;
text-overflow: ellipsis; text-overflow: ellipsis;
-webkit-line-clamp: ${nbrOfLines}; -webkit-line-clamp: ${nbrOfLines};