From efe795078a3f82a5e52d6b001207572354409b5b Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 20 Sep 2024 12:47:01 +0200 Subject: [PATCH] feat: add big card layout --- src/Avatar/Avatar.tsx | 4 - src/Card/Card.stories.tsx | 2 +- src/Card/CardThumbnail.tsx | 15 +- src/Card/big/BigCard.stories.tsx | 194 ++++++++++++++++++ src/Card/big/BigCard.tsx | 146 +++++++++++++ src/Card/big/LikeCounterButton.tsx | 42 ++++ src/Card/big/TagCarousel.tsx | 82 ++++++++ .../CollapsibleText.stories.tsx | 48 +++++ src/CollapsibleText/CollapsibleText.tsx | 74 +++++++ src/Thumbnail/Thumbnail.tsx | 19 +- 10 files changed, 617 insertions(+), 9 deletions(-) create mode 100644 src/Card/big/BigCard.stories.tsx create mode 100644 src/Card/big/BigCard.tsx create mode 100644 src/Card/big/LikeCounterButton.tsx create mode 100644 src/Card/big/TagCarousel.tsx create mode 100644 src/CollapsibleText/CollapsibleText.stories.tsx create mode 100644 src/CollapsibleText/CollapsibleText.tsx diff --git a/src/Avatar/Avatar.tsx b/src/Avatar/Avatar.tsx index fcb8974d2..5690566ae 100644 --- a/src/Avatar/Avatar.tsx +++ b/src/Avatar/Avatar.tsx @@ -17,10 +17,6 @@ type AvatarProps = { isLoading?: boolean; maxHeight?: string | number; maxWidth?: string | number; - /** - * thumbnail size to fetch - */ - size?: string; sx?: SxProps; url?: string; /** diff --git a/src/Card/Card.stories.tsx b/src/Card/Card.stories.tsx index 180ca5849..6ddb1aed8 100644 --- a/src/Card/Card.stories.tsx +++ b/src/Card/Card.stories.tsx @@ -26,7 +26,7 @@ const meta = { export default meta; type Story = StoryObj; -export const Example: Story = { +export const Example = { args: { name: 'my card title', alt: 'my card title', diff --git a/src/Card/CardThumbnail.tsx b/src/Card/CardThumbnail.tsx index b1a8a89a3..0db22c173 100644 --- a/src/Card/CardThumbnail.tsx +++ b/src/Card/CardThumbnail.tsx @@ -12,19 +12,32 @@ export type CardThumbnailProps = { width?: number; minHeight: number; type?: DiscriminatedItem['type']; + minWidth?: string; + height?: string; + maxHeight?: string; }; const CardThumbnail = ({ thumbnail, alt, width, minHeight, + minWidth, + height, + maxHeight = '100%', type = ItemType.FOLDER, }: CardThumbnailProps): JSX.Element => { const theme = useTheme(); if (thumbnail) { return ( - + ); } diff --git a/src/Card/big/BigCard.stories.tsx b/src/Card/big/BigCard.stories.tsx new file mode 100644 index 000000000..45dd17a46 --- /dev/null +++ b/src/Card/big/BigCard.stories.tsx @@ -0,0 +1,194 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, within } from '@storybook/test'; +import { v4 } from 'uuid'; + +import Grid2 from '@mui/material/Unstable_Grid2'; + +import { BrowserRouter } from 'react-router-dom'; + +import { ItemType } from '@graasp/sdk'; + +import BigCard from './BigCard.js'; + +const meta = { + title: 'Common/BigCard', + component: BigCard, + + decorators: [ + (story) => { + return {story()}; + }, + ], + argTypes: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default = { + args: { + name: 'my card title', + tags: [ + '6th grade at school', + 'English', + 'Mathematics', + 'Taylor', + 'Biology', + 'French', + 'Good', + ], + likeCount: 213, + type: ItemType.DOCUMENT, + image: '/test-assets/big_photo.jpg', + creator: { + name: 'Name Surname', + id: v4(), + avatar: '/test-assets/small_photo.jpg', + }, + link: '/link', + description: + 'Tempor volutpat eget varius nisl cursus. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Enim cursus ultrices in natoque. Faucibus porttitor posuere consequat congue aliquam. Sapien tempus blandit massa rhoncus', + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + + // card link + await expect( + document.querySelector(`a[href="${args.link}"]`), + ).toBeVisible(); + + // tags + args.tags!.map((t) => { + expect(canvas.getByText(t)).toBeVisible(); + }); + + // creator + expect(canvas.getByText(args.creator!.name)).toBeVisible(); + + // likes + expect(canvas.getByText(args.likeCount!)).toBeVisible(); + + // name, description + expect(canvas.getByText(args.name)).toBeVisible(); + expect(canvas.getByText(args.description!)).toBeVisible(); + + // img + await expect( + document.querySelector(`img[src="${args.image}"]`), + ).toBeVisible(); + }, +} satisfies Story; + +export const LongTitleAndLiked = { + args: { + name: 'my card title that is very long because I want to show everything and have more lines', + tags: ['6th grade at school', 'English', 'Mathematics', 'Taylor'], + likeCount: 213, + type: ItemType.DOCUMENT, + image: '/test-assets/big_photo.jpg', + isLiked: true, + creator: { + name: 'Name Surname', + id: v4(), + avatar: '/test-assets/small_photo.jpg', + }, + description: + 'Tempor volutpat eget varius nisl cursus. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Enim cursus ultrices in natoque. Faucibus porttitor posuere consequat congue aliquam. Sapien tempus blandit massa rhoncus', + }, +} satisfies Story; + +export const OneTag = { + args: { + name: 'my card title that is very long because I want to show everything and have more lines', + tags: ['6th grade'], + likeCount: 213, + type: ItemType.DOCUMENT, + image: '/test-assets/big_photo.jpg', + isLiked: true, + creator: { + name: 'Name Surname', + id: v4(), + avatar: '/test-assets/small_photo.jpg', + }, + description: + 'Tempor volutpat eget varius nisl cursus. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Enim cursus ultrices in natoque. Faucibus porttitor posuere consequat congue aliquam. Sapien tempus blandit massa rhoncus', + }, +} satisfies Story; + +export const Smaller = { + args: { + name: 'my card title', + tags: ['6th grade at school', 'English', 'Mathematics', 'Taylor'], + likeCount: 213, + type: ItemType.DOCUMENT, + image: '/test-assets/big_photo.jpg', + creator: { + name: 'Name Surname', + id: v4(), + avatar: '/test-assets/small_photo.jpg', + }, + height: 200, + numberOfLinesToShow: 2, + description: + 'Tempor volutpat eget varius nisl cursus. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Enim cursus ultrices in natoque. Faucibus porttitor posuere consequat congue aliquam. Sapien tempus blandit massa rhoncus', + }, +} satisfies Story; + +export const Empty = { + args: { + name: 'my card title', + type: ItemType.DOCUMENT, + }, + play: async ({ canvasElement }) => { + // no link + await expect(document.querySelector('#storybook-root a')).toBeNull(); + }, +} satisfies Story; + +export const WithLink = { + args: { + id: 'card-id', + link: '/href', + name: 'my card title', + type: ItemType.DOCUMENT, + }, + play: async ({ canvasElement }) => { + // link exists + await expect(document.querySelector('#storybook-root a')).toBeVisible(); + }, +} satisfies Story; + +export const Grid = { + args: { + name: 'my card title', + tags: ['6th grade at school', 'English', 'Mathematics', 'Taylor'], + likeCount: 213, + type: ItemType.DOCUMENT, + image: '/test-assets/big_photo.jpg', + creator: { + name: 'Name Surname', + id: v4(), + avatar: '/test-assets/small_photo.jpg', + }, + description: + 'Tempor volutpat eget varius nisl cursus. Fusce cras commodo adipiscing dictumst gravida pharetra velit. Enim cursus ultrices in natoque. Faucibus porttitor posuere consequat congue aliquam. Sapien tempus blandit massa rhoncus', + }, + render: (args) => { + return ( + + + + + + + + + + + + + + + ); + }, +} satisfies Story; diff --git a/src/Card/big/BigCard.tsx b/src/Card/big/BigCard.tsx new file mode 100644 index 000000000..5ee7c3dfb --- /dev/null +++ b/src/Card/big/BigCard.tsx @@ -0,0 +1,146 @@ +import { Box, Card as MuiCard, Stack, Typography } from '@mui/material'; + +import { CSSProperties } from 'react'; +import { Link } from 'react-router-dom'; + +import { DiscriminatedItem, UUID } from '@graasp/sdk'; + +import Avatar from '@/Avatar/Avatar.js'; +import { useMobileView } from '@/hooks/useMobileView.js'; + +import { CollapsibleText } from '../../CollapsibleText/CollapsibleText.js'; +import CardThumbnail from '../CardThumbnail.js'; +import { LikeCounterButton } from './LikeCounterButton.js'; +import { TagCarousel } from './TagCarousel.js'; + +type CardProps = { + name: string; + id?: string; + likeCount?: number; + tags?: string[]; + type: DiscriminatedItem['type']; + image?: string; + height?: number; + description?: string; + numberOfLinesToShow?: number; + isLiked?: boolean; + link?: string; + creator?: { name: string; id: UUID; avatar?: string; link?: string }; +}; + +const LinkWrapper = ({ + to, + style, + children, +}: { + children: JSX.Element; + to?: string; + style?: CSSProperties; +}): JSX.Element => { + if (to) { + return ( + + {children} + + ); + } + return children; +}; + +const BigCard = ({ + id, + creator, + name, + image, + link, + description, + tags, + type, + likeCount = 0, + height = 300, + isLiked = false, + numberOfLinesToShow = 7, +}: CardProps): JSX.Element => { + const { isMobile } = useMobileView(); + + // merge name and description together + // so we can count name and description in the same line count and show the full title + const text = `

${name}

${description ?? ''}`; + + return ( + + + + + + + + + + + + + + + + + + + + + + {creator && ( + + + {!isMobile && {creator.name}} + + + + )} + + + + + ); +}; +export default BigCard; diff --git a/src/Card/big/LikeCounterButton.tsx b/src/Card/big/LikeCounterButton.tsx new file mode 100644 index 000000000..38caa66f8 --- /dev/null +++ b/src/Card/big/LikeCounterButton.tsx @@ -0,0 +1,42 @@ +import { Heart } from 'lucide-react'; + +import { IconButton, Typography, useTheme } from '@mui/material'; + +type Props = { likeCount: number; isLiked: boolean }; + +export const LikeCounterButton = ({ + likeCount, + isLiked, +}: Props): JSX.Element => { + const theme = useTheme(); + return ( + <> + + + {likeCount && ( + + {likeCount} + + )} + + + ); +}; diff --git a/src/Card/big/TagCarousel.tsx b/src/Card/big/TagCarousel.tsx new file mode 100644 index 000000000..12079172b --- /dev/null +++ b/src/Card/big/TagCarousel.tsx @@ -0,0 +1,82 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +import { Box, IconButton, Stack, Typography } from '@mui/material'; + +import { useRef } from 'react'; + +import { DEFAULT_LIGHT_PRIMARY_COLOR } from '@/theme.js'; + +const Tag = ({ title }: { title: string }): JSX.Element => { + return ( + + + {title} + + + ); +}; + +const SCROLL_OFFSET = 120; + +export const TagCarousel = ({ + tags, +}: { + tags?: string[]; +}): JSX.Element | null => { + const ref = useRef(null); + + const scrollHorizontally = (scrollOffset = SCROLL_OFFSET): void => { + if (ref.current) { + ref.current.scrollLeft += scrollOffset; + } + }; + + if (!tags?.length) { + return null; + } + + return ( + + + { + scrollHorizontally(-SCROLL_OFFSET); + }} + > + + + + + {tags.map((t) => ( + + ))} + + + { + scrollHorizontally(SCROLL_OFFSET); + }} + > + + + + + ); +}; diff --git a/src/CollapsibleText/CollapsibleText.stories.tsx b/src/CollapsibleText/CollapsibleText.stories.tsx new file mode 100644 index 000000000..2f351f772 --- /dev/null +++ b/src/CollapsibleText/CollapsibleText.stories.tsx @@ -0,0 +1,48 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from '@storybook/test'; + +import { CollapsibleText } from './CollapsibleText.js'; + +const meta = { + title: 'Common/CollapsibleText', + component: CollapsibleText, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Collapsed = { + args: { + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus scelerisque, nibh eu dapibus dignissim, eros orci tristique ipsum, id interdum ex neque a dui. Quisque pretium aliquam dui, pulvinar venenatis sem consectetur non. Sed felis mi, viverra sit amet blandit sed, vestibulum eu purus. Nulla eget tellus sodales, volutpat nulla in, blandit tellus. Fusce congue vitae elit ac scelerisque. Maecenas fringilla ipsum in enim volutpat dignissim. Aenean convallis urna vel nibh semper faucibus. Nunc non pellentesque nibh. Sed tempor, erat sit amet volutpat placerat, metus quam auctor nunc, in consectetur orci dui quis ligula.', + collapsed: true, + numberOfLinesToShow: 3, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText('Lorem', { exact: false })).toBeVisible(); + + await expect( + canvas.getByText('Lorem', { exact: false }).offsetHeight, + ).toBeLessThan(100); + }, +} satisfies Story; + +export const Uncollapsed = { + args: { + content: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus scelerisque, nibh eu dapibus dignissim, eros orci tristique ipsum, id interdum ex neque a dui. Quisque pretium aliquam dui, pulvinar venenatis sem consectetur non. Sed felis mi, viverra sit amet blandit sed, vestibulum eu purus. Nulla eget tellus sodales, volutpat nulla in, blandit tellus. Fusce congue vitae elit ac scelerisque. Maecenas fringilla ipsum in enim volutpat dignissim. Aenean convallis urna vel nibh semper faucibus. Nunc non pellentesque nibh. Sed tempor, erat sit amet volutpat placerat, metus quam auctor nunc, in consectetur orci dui quis ligula.', + collapsed: false, + numberOfLinesToShow: 3, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(canvas.getByText('Lorem', { exact: false })).toBeVisible(); + + await expect( + canvas.getByText('Lorem', { exact: false }).offsetHeight, + ).toBeGreaterThan(100); + }, +} satisfies Story; diff --git a/src/CollapsibleText/CollapsibleText.tsx b/src/CollapsibleText/CollapsibleText.tsx new file mode 100644 index 000000000..be5efcbea --- /dev/null +++ b/src/CollapsibleText/CollapsibleText.tsx @@ -0,0 +1,74 @@ +import { Interweave } from 'interweave'; +import 'katex/dist/katex.min.css'; + +import { Box, styled } from '@mui/material'; + +import 'react-quill/dist/quill.snow.css'; + +type CollapsibleDescriptionProps = { + collapsed: boolean; + numberOfLinesToShow?: number; + children: JSX.Element; + cursor?: string; +}; + +const StyledBox = ({ + collapsed, + numberOfLinesToShow = 1, + children, + cursor, +}: CollapsibleDescriptionProps): JSX.Element => ( + p': { + margin: 0, + }, + cursor: cursor ?? 'inherit', + }} + > + {children} + +); + +const StyledDiv = styled('div')({ + '& .ql-editor': { + paddingLeft: '0px !important', + '& p': { + paddingBottom: 3, + paddingTop: 3, + }, + }, +}); + +export type CollapsibleTextProps = { + content: string; + collapsed?: CollapsibleDescriptionProps['collapsed']; + numberOfLinesToShow?: CollapsibleDescriptionProps['numberOfLinesToShow']; + style?: { cursor?: string }; +}; + +export const CollapsibleText = ({ + content, + collapsed = true, + numberOfLinesToShow, + style, +}: CollapsibleTextProps): JSX.Element => ( + +
+
+ + + +
+
+
+); diff --git a/src/Thumbnail/Thumbnail.tsx b/src/Thumbnail/Thumbnail.tsx index 43bfa30ea..5276b6313 100644 --- a/src/Thumbnail/Thumbnail.tsx +++ b/src/Thumbnail/Thumbnail.tsx @@ -14,6 +14,7 @@ type ThumbnailProps = { defaultComponent?: JSX.Element; isLoading?: boolean; maxWidth?: string | number; + height?: string | number; maxHeight?: string | number; /** * size of the thumbnail to fetch @@ -28,6 +29,8 @@ type ThumbnailProps = { * skeleton's variant */ variant?: SkeletonProps['variant']; + width?: string; + minWidth?: string; }; const Thumbnail = ({ @@ -36,7 +39,10 @@ const Thumbnail = ({ defaultComponent, alt, sx, + width, + minWidth, maxWidth = '100%', + height, maxHeight = '100%', variant = Variant.RECT, isLoading = false, @@ -49,9 +55,10 @@ const Thumbnail = ({ alt={alt} sx={[ { + minWidth, objectFit: 'cover', - width: maxWidth, - height: maxHeight, + width: width ?? maxWidth, + height: height ?? maxHeight, maxWidth, maxHeight, }, @@ -67,7 +74,13 @@ const Thumbnail = ({ } if (isLoading) { - return ; + return ( + + ); } return null;