|
| 1 | +import { |
| 2 | + Box, |
| 3 | + Card, |
| 4 | + CardActionArea, |
| 5 | + Stack, |
| 6 | + Typography, |
| 7 | +} from "@mui/material"; |
| 8 | +import { Trans, useI18next } from "gatsby-plugin-react-i18next"; |
| 9 | +import CalendarIcon from "media/icons/calendar.svg"; |
| 10 | +import ClockIcon from "media/icons/clock.svg"; |
| 11 | +import UserIcon from "media/icons/user.svg"; |
| 12 | + |
| 13 | +const BADGE_COLORS = { |
| 14 | + blog: { bg: "#E0F0F8", text: "#1480B8" }, |
| 15 | + lab: { bg: "#E8E7F9", text: "#4A40BF" }, |
| 16 | + video: { bg: "#FBE7E7", text: "#BF404B" }, |
| 17 | +} as const; |
| 18 | + |
| 19 | +const parseDateOnly = (date: string) => { |
| 20 | + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); |
| 21 | + |
| 22 | + if (!match) { |
| 23 | + return undefined; |
| 24 | + } |
| 25 | + |
| 26 | + const [, year, month, day] = match; |
| 27 | + |
| 28 | + return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))); |
| 29 | +}; |
| 30 | + |
| 31 | +interface MetaRowProps { |
| 32 | + author?: string; |
| 33 | + formattedDate?: string; |
| 34 | + duration?: string; |
| 35 | +} |
| 36 | + |
| 37 | +function MetaRow({ author, formattedDate, duration }: MetaRowProps) { |
| 38 | + const items = [ |
| 39 | + author && ( |
| 40 | + <Stack key="author" direction="row" spacing={0.5} alignItems="center"> |
| 41 | + <UserIcon width="14px" height="14px" aria-hidden="true" /> |
| 42 | + <Typography component="span" variant="body2" color="text.secondary" sx={{ fontSize: "14px" }}> |
| 43 | + {author} |
| 44 | + </Typography> |
| 45 | + </Stack> |
| 46 | + ), |
| 47 | + formattedDate && ( |
| 48 | + <Stack key="date" direction="row" spacing={0.5} alignItems="center"> |
| 49 | + <CalendarIcon width="14px" height="14px" aria-hidden="true" /> |
| 50 | + <Typography component="span" variant="body2" color="text.secondary" sx={{ fontSize: "14px" }}> |
| 51 | + {formattedDate} |
| 52 | + </Typography> |
| 53 | + </Stack> |
| 54 | + ), |
| 55 | + duration && ( |
| 56 | + <Stack key="duration" direction="row" spacing={0.5} alignItems="center"> |
| 57 | + <ClockIcon width="14px" height="14px" aria-hidden="true" /> |
| 58 | + <Typography component="span" variant="body2" color="text.secondary" sx={{ fontSize: "14px" }}> |
| 59 | + {duration} |
| 60 | + </Typography> |
| 61 | + </Stack> |
| 62 | + ), |
| 63 | + ].filter(Boolean); |
| 64 | + |
| 65 | + if (items.length === 0) { |
| 66 | + return null; |
| 67 | + } |
| 68 | + |
| 69 | + return ( |
| 70 | + <Stack |
| 71 | + direction={{ xs: "column", sm: "row" }} |
| 72 | + spacing={1.5} |
| 73 | + alignItems={{ xs: "flex-start", sm: "center" }} |
| 74 | + > |
| 75 | + {items.flatMap((item, i) => |
| 76 | + i === 0 |
| 77 | + ? [item] |
| 78 | + : [ |
| 79 | + <Box |
| 80 | + key={`sep-${i}`} |
| 81 | + sx={{ |
| 82 | + width: "1px", |
| 83 | + height: "14px", |
| 84 | + bgcolor: "divider", |
| 85 | + flexShrink: 0, |
| 86 | + display: { xs: "none", sm: "block" }, |
| 87 | + }} |
| 88 | + />, |
| 89 | + item, |
| 90 | + ] |
| 91 | + )} |
| 92 | + </Stack> |
| 93 | + ); |
| 94 | +} |
| 95 | + |
| 96 | +interface ResourceCardProps { |
| 97 | + /** Card title */ |
| 98 | + title: string; |
| 99 | + /** Resource type — controls badge label and color */ |
| 100 | + type: "blog" | "video" | "lab"; |
| 101 | + /** Destination URL; always opens in a new tab (resource cards point to external blogs, videos, and labs) */ |
| 102 | + link: string; |
| 103 | + /** |
| 104 | + * Thumbnail image CDN URL, e.g. |
| 105 | + * "https://docs-download.pingcap.com/media/blog-cover.png" |
| 106 | + */ |
| 107 | + imgSrc: string; |
| 108 | + /** Author display name */ |
| 109 | + author?: string; |
| 110 | + /** |
| 111 | + * Publication date in ISO 8601 format (YYYY-MM-DD), e.g. "2025-12-12". |
| 112 | + * Formatted automatically per locale: en → "December 12, 2025", zh → "2025年12月12日". |
| 113 | + */ |
| 114 | + date?: string; |
| 115 | + /** Estimated reading / watch time, e.g. "120 mins" */ |
| 116 | + duration?: string; |
| 117 | +} |
| 118 | + |
| 119 | +export function ResourceCard({ |
| 120 | + title, |
| 121 | + type, |
| 122 | + link, |
| 123 | + imgSrc, |
| 124 | + author, |
| 125 | + date, |
| 126 | + duration, |
| 127 | +}: ResourceCardProps) { |
| 128 | + const { language } = useI18next(); |
| 129 | + const safeType = BADGE_COLORS[type] ? type : "blog"; |
| 130 | + const colors = BADGE_COLORS[safeType]; |
| 131 | + const parsedDate = date ? parseDateOnly(date) : undefined; |
| 132 | + |
| 133 | + const formattedDate = parsedDate |
| 134 | + ? new Intl.DateTimeFormat(language, { |
| 135 | + year: "numeric", |
| 136 | + month: "long", |
| 137 | + day: "numeric", |
| 138 | + timeZone: "UTC", |
| 139 | + }).format(parsedDate) |
| 140 | + : undefined; |
| 141 | + |
| 142 | + return ( |
| 143 | + <Card |
| 144 | + variant="outlined" |
| 145 | + sx={(theme) => ({ |
| 146 | + borderRadius: 0, |
| 147 | + borderColor: theme.palette.carbon[400], |
| 148 | + overflow: "hidden", |
| 149 | + display: "flex", |
| 150 | + flexDirection: "column", |
| 151 | + transition: "all 0.3s ease-in-out", |
| 152 | + minHeight: { |
| 153 | + xs: "auto", |
| 154 | + sm: "126px", |
| 155 | + }, |
| 156 | + "&:hover": { |
| 157 | + background: |
| 158 | + "linear-gradient(120.16deg, #FFFFFF 63.62%, #FBFDFD 74.22%, #F5F8FA 98.17%)", |
| 159 | + borderColor: theme.palette.secondary.main, |
| 160 | + boxShadow: "0px 1px 5px 0px rgba(76, 81, 83, 0.1)", |
| 161 | + }, |
| 162 | + "> a.MuiCardActionArea-root:hover": { textDecoration: "none" }, |
| 163 | + })} |
| 164 | + > |
| 165 | + <CardActionArea |
| 166 | + component="a" |
| 167 | + href={link} |
| 168 | + target="_blank" |
| 169 | + rel="noopener noreferrer" |
| 170 | + disableRipple |
| 171 | + disableTouchRipple |
| 172 | + draggable="false" |
| 173 | + style={{ display: "block", width: "100%" }} |
| 174 | + sx={(theme) => ({ |
| 175 | + flex: 1, |
| 176 | + outline: "none", |
| 177 | + "&.Mui-focusVisible": { |
| 178 | + outline: `2px solid ${theme.palette.carbon[700]}`, |
| 179 | + outlineOffset: "-2px", |
| 180 | + }, |
| 181 | + "> .MuiCardActionArea-focusHighlight": { display: "none" }, |
| 182 | + })} |
| 183 | + > |
| 184 | + <Box |
| 185 | + sx={{ |
| 186 | + display: "flex", |
| 187 | + flexDirection: { |
| 188 | + xs: "column", |
| 189 | + sm: "row", |
| 190 | + }, |
| 191 | + padding: "8px", |
| 192 | + gap: { |
| 193 | + xs: 2, |
| 194 | + sm: 3, |
| 195 | + }, |
| 196 | + alignItems: { |
| 197 | + xs: "stretch", |
| 198 | + sm: "center", |
| 199 | + }, |
| 200 | + }} |
| 201 | + > |
| 202 | + {/* Thumbnail */} |
| 203 | + <Box |
| 204 | + component="img" |
| 205 | + src={imgSrc} |
| 206 | + alt="" |
| 207 | + loading="lazy" |
| 208 | + sx={{ |
| 209 | + margin: 0, |
| 210 | + flexShrink: 0, |
| 211 | + display: "block", |
| 212 | + width: { |
| 213 | + xs: "100%", |
| 214 | + sm: "224px", |
| 215 | + }, |
| 216 | + height: { |
| 217 | + xs: "180px", |
| 218 | + sm: "110px", |
| 219 | + }, |
| 220 | + objectFit: "cover", |
| 221 | + objectPosition: "left center", |
| 222 | + }} |
| 223 | + /> |
| 224 | + |
| 225 | + {/* Content column */} |
| 226 | + <Box |
| 227 | + sx={{ flex: 1, display: "flex", flexDirection: "column", gap: "8px", minWidth: 0 }} |
| 228 | + > |
| 229 | + {/* Row 1: title + type badge */} |
| 230 | + <Box |
| 231 | + sx={{ |
| 232 | + display: "grid", |
| 233 | + gridTemplateColumns: { |
| 234 | + xs: "1fr", |
| 235 | + sm: "minmax(0, 1fr) auto", |
| 236 | + }, |
| 237 | + alignItems: { |
| 238 | + xs: "start", |
| 239 | + sm: "end", |
| 240 | + }, |
| 241 | + columnGap: "16px", |
| 242 | + rowGap: { |
| 243 | + xs: 1, |
| 244 | + sm: 0, |
| 245 | + }, |
| 246 | + width: { |
| 247 | + xs: "100%", |
| 248 | + sm: "fit-content", |
| 249 | + }, |
| 250 | + maxWidth: "100%", |
| 251 | + minWidth: 0, |
| 252 | + }} |
| 253 | + > |
| 254 | + <Typography |
| 255 | + component="div" |
| 256 | + variant="h5" |
| 257 | + sx={{ |
| 258 | + minWidth: 0, |
| 259 | + overflow: "hidden", |
| 260 | + textOverflow: "ellipsis", |
| 261 | + whiteSpace: { |
| 262 | + xs: "normal", |
| 263 | + sm: "nowrap", |
| 264 | + }, |
| 265 | + }} |
| 266 | + > |
| 267 | + {title} |
| 268 | + </Typography> |
| 269 | + <Box |
| 270 | + component="span" |
| 271 | + sx={{ |
| 272 | + display: "inline-flex", |
| 273 | + alignItems: "center", |
| 274 | + justifySelf: "start", |
| 275 | + height: "28px", |
| 276 | + padding: "0 12px", |
| 277 | + borderRadius: "999px", |
| 278 | + backgroundColor: colors.bg, |
| 279 | + color: colors.text, |
| 280 | + fontWeight: 500, |
| 281 | + fontSize: "14px", |
| 282 | + lineHeight: "16px", |
| 283 | + }} |
| 284 | + > |
| 285 | + <Trans i18nKey={`resourceCard.type.${safeType}`} /> |
| 286 | + </Box> |
| 287 | + </Box> |
| 288 | + |
| 289 | + {/* Row 2: author / date / duration — only rendered items, separators between adjacent pairs */} |
| 290 | + <MetaRow author={author} formattedDate={formattedDate} duration={duration} /> |
| 291 | + |
| 292 | + {/* Row 3: "Learn More ↗" — visual affordance; card itself is the link */} |
| 293 | + <Typography |
| 294 | + component="div" |
| 295 | + sx={(theme) => ({ |
| 296 | + color: theme.palette.secondary.light, |
| 297 | + fontWeight: 500, |
| 298 | + fontSize: "16px", |
| 299 | + lineHeight: "20px", |
| 300 | + padding: "4px 0", |
| 301 | + })} |
| 302 | + > |
| 303 | + <Trans i18nKey="resourceCard.learnMore" /> |
| 304 | + </Typography> |
| 305 | + </Box> |
| 306 | + </Box> |
| 307 | + </CardActionArea> |
| 308 | + </Card> |
| 309 | + ); |
| 310 | +} |
0 commit comments