From 45e1b85ad0313b628db1a8261e7054bf25d4bbd5 Mon Sep 17 00:00:00 2001 From: berezinant Date: Thu, 11 Sep 2025 20:51:49 +0200 Subject: [PATCH 1/3] feat(ktl-2781): added case studies page blocks --- .../card/case-studies-card.module.css | 172 +++++++++++ .../case-studies/card/case-studies-card.tsx | 266 ++++++++++++++++++ blocks/case-studies/case-studies.ts | 37 +++ .../filter/case-studies-filter.module.css | 49 ++++ .../filter/case-studies-filter.tsx | 110 ++++++++ .../grid/case-studies-grid.module.css | 22 ++ .../case-studies/grid/case-studies-grid.tsx | 24 ++ .../hero/case-studies-hero.module.css | 24 ++ .../case-studies/hero/case-studies-hero.tsx | 26 ++ pages/case-studies/index.tsx | 17 ++ 10 files changed, 747 insertions(+) create mode 100644 blocks/case-studies/card/case-studies-card.module.css create mode 100644 blocks/case-studies/card/case-studies-card.tsx create mode 100644 blocks/case-studies/case-studies.ts create mode 100644 blocks/case-studies/filter/case-studies-filter.module.css create mode 100644 blocks/case-studies/filter/case-studies-filter.tsx create mode 100644 blocks/case-studies/grid/case-studies-grid.module.css create mode 100644 blocks/case-studies/grid/case-studies-grid.tsx create mode 100644 blocks/case-studies/hero/case-studies-hero.module.css create mode 100644 blocks/case-studies/hero/case-studies-hero.tsx create mode 100644 pages/case-studies/index.tsx diff --git a/blocks/case-studies/card/case-studies-card.module.css b/blocks/case-studies/card/case-studies-card.module.css new file mode 100644 index 00000000000..1e1c189812d --- /dev/null +++ b/blocks/case-studies/card/case-studies-card.module.css @@ -0,0 +1,172 @@ +.card { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + border-radius: 12px; + background: #fff; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + border: 1px solid rgba(0,0,0,0.12); + max-width: 500px; + box-sizing: border-box; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.badge { + font-size: 12px; + font-weight: 600; + padding: 6px 10px; + border-radius: 999px; + line-height: 1; + white-space: nowrap; +} + +.badgeMultiplatform { + background: #eef7ff; + color: #1565c0; + border: 1px solid rgba(21,101,192,0.15); +} + +.badgeServerSide { + background: #eef9f2; + color: #1b5e20; + border: 1px solid rgba(27,94,32,0.15); +} + +.logos { + display: flex; + align-items: center; + gap: 10px; +} + +.logosDouble {} + +.logo { + height: 28px; + width: auto; + object-fit: contain; + max-width: 140px; + opacity: 0.95; +} + +.logoSecond { + border-left: 1px solid rgba(0,0,0,0.06); + padding-left: 10px; +} + +.media { + border-radius: 10px; + overflow: hidden; + background: #f6f7f9; +} + +.mediaVideo { + position: relative; + padding-bottom: 56.25%; + height: 0; +} + +.iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.mediaImage { + width: 100%; + height: auto; + display: block; +} + +.description { + color: #1f2328; + font-size: 16px; + line-height: 1.6; +} +.description :global(a) { + color: #0b65c2; + text-decoration: underline; +} +.description :global(strong) { + font-weight: 700; +} + +.signature { + margin-top: 4px; + margin-bottom: 24px; +} +.signatureLine1 { + font-weight: 600; + color: #101214; +} +.signatureLine2 { + color: #555c63; +} + +.platforms { + display: flex; + flex-wrap: wrap; + gap: 10px 14px; + align-items: center; +} + +.platform { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid rgba(0,0,0,0.06); + border-radius: 999px; + background: #fafbfc; + color: #2b2f35; + font-size: 13px; + line-height: 1; +} + +.platformIcon { + width: 16px; + height: 16px; + object-fit: contain; +} + +.platformLabel { + text-transform: capitalize; +} + +.actions { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.link { + color: #0b65c2; + text-decoration: underline; + font-weight: 600; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 14px; + border-radius: 8px; + border: 1px solid #0b65c2; + color: #0b65c2; + font-weight: 600; + text-decoration: none; + transition: all 0.15s ease; +} + +.button:hover { + background: #0b65c2; + color: #fff; +} diff --git a/blocks/case-studies/card/case-studies-card.tsx b/blocks/case-studies/card/case-studies-card.tsx new file mode 100644 index 00000000000..490f0e48632 --- /dev/null +++ b/blocks/case-studies/card/case-studies-card.tsx @@ -0,0 +1,266 @@ +// TypeScript React +import React from 'react'; +import cn from 'classnames'; +import styles from './case-studies-card.module.css'; + +type Platform = + | 'android' + | 'ios' + | 'desktop' + | 'frontend' + | 'backend' + | 'compose-multi-platform'; + +type CaseType = 'multiplatform' | 'server-side'; + +type Media = + | { type: 'youtube'; url: string } + | { type: 'image'; path: string }; + +interface Signature { + // markdown allowed (e.g., **Name Surname**, Role) + line1: string; + // plain text + line2: string; +} + +export interface CaseCardItem { + logos?: [string] | [string, string]; // 0–2 logos, local paths or filenames + description: string; // markdown-enabled text + signature?: Signature; + readMoreUrl?: string; // "Read the full story →" + exploreUrl?: string; // "Explore the stories" + type: CaseType; + platforms?: Platform[]; // platform icons row + media?: Media; // youtube or local image + /** Optional: mark case as selected for the home page */ + featuredOnHome?: boolean; +} + +/** + * Resolve asset path from YAML: + * - http/https or path starting with "/" => return as is + * - otherwise => treat as a filename under /images/case-studies/ + */ +const resolveAssetPath = (v?: string) => { + if (!v) return ''; + const lower = v.toLowerCase(); + if (lower.startsWith('http://') || lower.startsWith('https://') || v.startsWith('/')) return v; + return `/images/case-studies/${v}`; +}; + +/** + * Very small markdown -> HTML for bold (**text**) and links [text](url). + * Safe subset suitable for our content. Extend if needed. + */ +const mdToHtml = (md: string) => { + // escape basic HTML first + const esc = md + .replace(/&/g, '&') + .replace(//g, '>'); + + // bold: **text** + const withBold = esc.replace(/\*\*(.+?)\*\*/g, '$1'); + + // links: [text](url) + const withLinks = withBold.replace( + /\[([^\]]+?)\]\((https?:\/\/[^\s)]+)\)/g, + '$1' + ); + + return withLinks; +}; + +const badgeText: Record = { + 'multiplatform': 'Kotlin Multiplatform', + 'server-side': 'Server-side', +}; + +const badgeClass: Record = { + 'multiplatform': 'badgeMultiplatform', + 'server-side': 'badgeServerSide', +}; + +// Platform icon path builder. If you keep icons in (for example) /images/platforms/*.svg, +// they’ll be resolved automatically by key. If an icon is missing, we still render the label. +const platformIconPath = (p: Platform) => `/images/platforms/${p}.svg`; + +type Props = { + item: CaseCardItem; + className?: string; +}; + +export const CaseStudyCard: React.FC = ({ item, className }) => { + const logos = item.logos ?? []; + const logoSrc1 = resolveAssetPath(logos[0]); + const logoSrc2 = resolveAssetPath(logos[1]); + + const hasMedia = Boolean(item.media); + const isYoutube = item.media?.type === 'youtube'; + const mediaUrl = item.media?.type === 'youtube' ? item.media.url : undefined; + const mediaImgSrc = item.media?.type === 'image' ? resolveAssetPath(item.media.path) : undefined; + + return ( +
+ {/* Header: type badge and logos (0–2) */} +
+ + {badgeText[item.type]} + + + {(logoSrc1 || logoSrc2) && ( +
+ {logoSrc1 && ( + Logo + )} + {logoSrc2 && ( + Second logo + )} +
+ )} +
+ + {/* Media (optional): YouTube or Image */} + {hasMedia && ( +
+ {isYoutube && mediaUrl && ( +
+