Skip to content

Commit

Permalink
feat(media): introduce new image preview card common component (#1620)
Browse files Browse the repository at this point in the history
* feat(utils): enhance time diff calculation function

* feat(media): introduce new image preview card common component

* style(media): update based on design feedback

* style(media): switch to use outline instead of border based on design
  • Loading branch information
dcshzj authored Oct 26, 2023
1 parent 7f144ee commit 1811bb9
Show file tree
Hide file tree
Showing 12 changed files with 255 additions and 18 deletions.
16 changes: 14 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
},
"dependencies": {
"@braintree/sanitize-url": "^6.0.1",
"@chakra-ui/anatomy": "^2.2.1",
"@chakra-ui/cli": "^2.4.1",
"@chakra-ui/react": "2.7.1",
"@chakra-ui/theme": "^3.1.2",
Expand Down
2 changes: 1 addition & 1 deletion src/components/ContextMenu/ContextMenuButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const ContextMenuButton = forwardRef<MenuButtonProps, "button">(
zIndex={2}
position="absolute"
bottom="1rem"
right="2rem"
right="1rem"
as={IconButton}
aria-label="Context Menu"
icon={
Expand Down
7 changes: 4 additions & 3 deletions src/components/ContextMenu/ContextMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ export type ContextMenuItemProps = Omit<MenuItemProps, "iconSpacing">

// eslint-disable-next-line import/prefer-default-export
export const ContextMenuItem = forwardRef<ContextMenuItemProps, "button">(
({ icon, ...props }: ContextMenuItemProps, ref): JSX.Element => {
const iconColour = useToken("colors", "icon.alt")
({ icon, color, ...props }: ContextMenuItemProps, ref): JSX.Element => {
// This is a safe cast as we always pass in a string to the color prop
const iconColour = useToken("colors", (color as string) || "icon.alt")
// NOTE: This will override the component props set on icon
const innerIcon =
icon &&
Expand All @@ -27,7 +28,7 @@ export const ContextMenuItem = forwardRef<ContextMenuItemProps, "button">(
}}
borderRadius={0}
ref={ref}
color="text.body"
color={color || "text.body"}
_hover={{
textDecoration: "none",
backgroundColor: "primary.100",
Expand Down
27 changes: 27 additions & 0 deletions src/components/ImagePreviewCard/ImagePreviewCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Meta, StoryFn } from "@storybook/react"

import { ImagePreviewCard, ImagePreviewCardProps } from "./ImagePreviewCard"

const imagePreviewCardMeta = {
title: "Components/Image Preview Card",
component: ImagePreviewCard,
} as Meta<typeof ImagePreviewCard>

type ImagePreviewCardTemplateArgs = ImagePreviewCardProps

const Template: StoryFn<ImagePreviewCardTemplateArgs> = (
props: ImagePreviewCardTemplateArgs
) => {
return <ImagePreviewCard {...props} />
}

export const Default = Template.bind({})
Default.args = {
name: "filename.png",
addedTime: 1693217477000,
mediaUrl: "https://placehold.co/600x400",
isMenuNeeded: true,
onCheck: (e) => console.log(e.target.checked),
}

export default imagePreviewCardMeta
171 changes: 171 additions & 0 deletions src/components/ImagePreviewCard/ImagePreviewCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
Box,
Center,
chakra,
Grid,
GridItem,
Image,
Text,
useMultiStyleConfig,
VStack,
} from "@chakra-ui/react"
import { Checkbox } from "@opengovsg/design-system-react"
import { ChangeEvent, useState } from "react"
import { BiEditAlt, BiTrash } from "react-icons/bi"

import { ContextMenu } from "components/ContextMenu"

import { convertUtcToTimeDiff } from "utils/dateUtils"

import { CARD_THEME_KEY } from "theme/components/Card"

export interface ImagePreviewCardProps {
name: string
addedTime: number
mediaUrl: string
isMenuNeeded?: boolean
onCheck?: (event: ChangeEvent<HTMLInputElement>) => void
}

// Note: This is written as a separate component as the current Card API is not
// flexible enough to support this use case.
export const ImagePreviewCard = ({
name,
addedTime,
mediaUrl,
isMenuNeeded = true,
onCheck,
}: ImagePreviewCardProps): JSX.Element => {
const [isSelected, setIsSelected] = useState(false)
const styles = useMultiStyleConfig(CARD_THEME_KEY, {})
const relativeTime = convertUtcToTimeDiff(addedTime)

return (
<Box position="relative" h="100%" data-group>
{/* Checkbox overlay over image */}
<Checkbox
position="absolute"
left="0"
top="0"
h="3.25rem"
w="3.25rem"
size="md"
p="1rem"
variant="transparent"
_groupHover={{
bg: "transparent",
}}
_focusWithin={{
outline: "none",
}}
zIndex={1}
onChange={(e) => {
setIsSelected(!isSelected)
if (onCheck) onCheck(e)
}}
/>

<Grid
as={chakra.button}
overflowY="hidden"
__css={styles.container}
p={0}
gridTemplateRows="1fr 4.5rem"
gridTemplateColumns="1fr 1fr"
gridTemplateAreas="'image image' 'footer footer'"
borderRadius="0.25rem"
_hover={{ bg: undefined }}
borderWidth="0px"
// Note: Outline is required to avoid the card from shifting when selected
outline={isSelected ? "solid 2px" : "solid 1px"}
outlineColor={isSelected ? "base.divider.brand" : "base.divider.medium"}
>
<GridItem gridArea="image">
<Box position="relative" backgroundColor="base.canvas.overlay">
{/* White overlay over image */}
<Box
position="absolute"
h="100%"
w="100%"
left="0"
top="0"
pointerEvents="none"
backgroundColor="white"
opacity="0"
_groupHover={{ opacity: 0.25 }}
/>

<Center>
<Image
align="center"
height="15rem"
src={mediaUrl}
fallbackSrc="/placeholder_no_image.png"
pointerEvents="all"
backgroundColor="white"
/>
</Center>
</Box>
</GridItem>
<GridItem
gridArea="footer"
paddingInline="1rem"
py="0.875rem"
borderTopWidth="1px"
borderColor="border.divider.alt"
bgColor="base.canvas.default"
_groupHover={{
bgColor: "base.canvas.brand-subtle",
}}
w="100%"
>
<Grid
gridTemplateColumns={isMenuNeeded ? "1fr 2.75rem" : "1fr 0px"}
gridTemplateAreas="'content button'"
>
<VStack
alignItems="flex-start"
gridArea="content"
spacing="0.25rem"
>
<Text
textStyle="subhead-1"
textColor="base.content.strong"
textAlign="left"
noOfLines={1}
>
{name}
</Text>
<Text textStyle="caption-1" textColor="base.content.medium">
Added {relativeTime}
</Text>
</VStack>
<Box gridArea="button" />
</Grid>
</GridItem>
</Grid>
{isMenuNeeded && (
<ContextMenu>
<ContextMenu.Button />
<ContextMenu.List>
<ContextMenu.Item
icon={<BiEditAlt />}
// as={RouterLink}
// to={`${url}/editMediaSettings/${encodedName}`}
>
<Text>Rename image</Text>
</ContextMenu.Item>
<ContextMenu.Item
icon={<BiTrash />}
color="interaction.critical.default"
// as={RouterLink}
// to={`${url}/deleteMedia/${encodedName}`}
>
<Text>Delete image</Text>
</ContextMenu.Item>
</ContextMenu.List>
</ContextMenu>
)}
</Box>
)
}
1 change: 1 addition & 0 deletions src/components/ImagePreviewCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./ImagePreviewCard"
2 changes: 1 addition & 1 deletion src/hooks/siteDashboardHooks/useGetLastUpdated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useErrorToast } from "utils/toasts"

const getRelativeLastUpdated = (siteName: string) => {
return SiteDashboardService.getLastUpdated(siteName).then((res) => {
return convertUtcToTimeDiff(res.lastUpdated)
return `Updated ${convertUtcToTimeDiff(res.lastUpdated)}`
})
}

Expand Down
2 changes: 1 addition & 1 deletion src/layouts/Sites.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const SitesCard = (
<Text fontSize="0.9em">{repoName}</Text>
{lastUpdated && (
<Text fontSize="0.6em" color="base.content.light">
{convertUtcToTimeDiff(lastUpdated)}
Updated {convertUtcToTimeDiff(lastUpdated)}
</Text>
)}
</VStack>
Expand Down
16 changes: 16 additions & 0 deletions src/theme/components/Checkbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { checkboxAnatomy } from "@chakra-ui/anatomy"
import { ComponentMultiStyleConfig } from "@chakra-ui/theme"

export const Checkbox: ComponentMultiStyleConfig = {
parts: checkboxAnatomy.keys,
variants: {
transparent: {
control: {
bg: "transparent",
borderColor: "interaction.main-subtle.default",
width: "1.25rem",
height: "1.25rem",
},
},
},
}
2 changes: 2 additions & 0 deletions src/theme/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Breadcrumb } from "./Breadcrumb"
import { Card, CARD_THEME_KEY } from "./Card"
import { Checkbox } from "./Checkbox"
import { DISPLAY_CARD_THEME_KEY, DisplayCard } from "./DisplayCard"
import { Infobox } from "./Infobox"
import { Rating } from "./Rating"
Expand All @@ -8,6 +9,7 @@ import { Rating } from "./Rating"
export const components = {
[CARD_THEME_KEY]: Card,
[DISPLAY_CARD_THEME_KEY]: DisplayCard,
Checkbox,
Breadcrumb,
Infobox,
Rating,
Expand Down
26 changes: 16 additions & 10 deletions src/utils/dateUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@ import { formatDuration, intervalToDuration } from "date-fns"
* Converts a date/time string retrieved from Github to human readable string representing time difference.
* e.g. 2022-08-24T08:30:46Z to Updated today
*/
export const convertUtcToTimeDiff = (lastUpdatedTime: string): string => {
const gapInUpdate = new Date().getTime() - new Date(lastUpdatedTime).getTime()
export const convertUtcToTimeDiff = (timestamp: string | number): string => {
const gapInUpdate = new Date().getTime() - new Date(timestamp).getTime()
const numDaysAgo = Math.floor(gapInUpdate / (1000 * 60 * 60 * 24))
// return a message for number of days ago repo was last updated
switch (numDaysAgo) {
case 0:
return "Updated today"
case 1:
return "Updated 1 day ago"
default:
return `Updated ${numDaysAgo} days ago`

if (gapInUpdate < 1000 * 60) {
// Threshold for "just now" is 1 minute
return "just now"
}
if (numDaysAgo === 0) {
return "today"
}
if (numDaysAgo === 1) {
return "yesterday"
}
if (numDaysAgo >= 365) {
return "more than a year ago"
}
return `${numDaysAgo} days ago`
}

/**
Expand Down

0 comments on commit 1811bb9

Please sign in to comment.