diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000..bc14e35 --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,10 @@ +{ + "rules": { + "selector-pseudo-class-no-unknown": [ + true, + { + "ignorePseudoClasses": ["global"] + } + ] + } +} diff --git a/components/Activity/HeroCarousel.module.less b/components/Activity/HeroCarousel.module.less new file mode 100644 index 0000000..388b962 --- /dev/null +++ b/components/Activity/HeroCarousel.module.less @@ -0,0 +1,168 @@ +.heroCarousel { + --hero-carousel-offset: 4.125rem; + + background: #050816; + min-height: calc(100vh - var(--hero-carousel-offset) - 2.5rem); + + @media (max-width: 991.98px) { + min-height: calc(100vh - var(--hero-carousel-offset) - 2rem); + } + + @media (max-width: 767.98px) { + min-height: calc(100svh - var(--hero-carousel-offset) - 1.25rem); + } +} + +.carousel, +.item, +.slideCard { + min-height: inherit; +} + +.carousel { + :global(.carousel-inner) { + min-height: inherit; + } + + :global(.carousel-item) { + min-height: inherit; + } + + :global(.carousel-indicators) { + right: auto; + bottom: 2rem; + left: 1rem; + justify-content: flex-start; + margin: 0; + } + + :global(.carousel-indicators button) { + opacity: 0.9; + margin: 0 0.45rem 0 0; + border: 0; + border-radius: 999px; + background-color: rgb(255 255 255 / 65%); + width: 2.25rem; + height: 0.32rem; + } + + :global(.carousel-control-prev), + :global(.carousel-control-next) { + opacity: 0.95; + width: 6rem; + } + + :global(.carousel-control-prev-icon), + :global(.carousel-control-next-icon) { + filter: drop-shadow(0 12px 24px rgb(0 0 0 / 50%)); + } + + @media (max-width: 767.98px) { + :global(.carousel-indicators) { + right: 1rem; + bottom: 1rem; + left: 1rem; + justify-content: center; + } + + :global(.carousel-indicators button) { + margin: 0 0.3rem; + width: 1.4rem; + height: 0.26rem; + } + + :global(.carousel-control-prev), + :global(.carousel-control-next) { + display: none; + } + } +} + +.slideCard { + background: linear-gradient( + 90deg, + rgb(5 8 22 / 96%) 0%, + rgb(5 8 22 / 88%) 28%, + rgb(5 8 22 / 46%) 58%, + rgb(5 8 22 / 16%) 100% + ); + + @media (max-width: 767.98px) { + background: linear-gradient( + 180deg, + rgb(5 8 22 / 18%) 0%, + rgb(5 8 22 / 54%) 44%, + rgb(5 8 22 / 92%) 100% + ); + } +} + +.mediaPane { + min-height: 19rem; + + @media (max-width: 767.98px) { + min-height: 14rem; + } +} + +.mediaPane::after { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgb(5 8 22 / 18%) 0%, rgb(5 8 22 / 48%) 58%, rgb(5 8 22 / 82%) 100%), + linear-gradient(90deg, rgb(5 8 22 / 10%) 0%, rgb(5 8 22 / 24%) 32%, rgb(5 8 22 / 72%) 100%); + pointer-events: none; + content: ''; + + @media (max-width: 767.98px) { + background: + linear-gradient(180deg, rgb(5 8 22 / 16%) 0%, rgb(5 8 22 / 42%) 42%, rgb(5 8 22 / 88%) 100%), + linear-gradient(90deg, rgb(5 8 22 / 18%) 0%, rgb(5 8 22 / 16%) 100%); + } +} + +.description { + max-width: 40rem; + + :global(p) { + margin-bottom: 0; + font-size: clamp(1rem, 1.6vw, 1.28rem); + line-height: 1.55; + } + + :global(button) { + display: none; + } + + @media (max-width: 767.98px) { + max-width: none; + } +} + +.actionButton { + transition: + transform 180ms ease, + box-shadow 180ms ease, + background 180ms ease, + border-color 180ms ease; + box-shadow: 0 12px 28px rgb(14 165 233 / 18%); + border: 1px solid rgb(255 255 255 / 20%); + border-radius: 0.9rem; + background: linear-gradient(135deg, rgb(255 255 255 / 92%) 0%, rgb(224 242 254 / 96%) 100%); + color: #0f172a; + letter-spacing: 0.02em; + + @media (max-width: 767.98px) { + border-radius: 0.8rem; + } +} + +.actionButton:hover, +.actionButton:focus, +.actionButton:active { + transform: translateY(-2px); + box-shadow: 0 18px 40px rgb(14 165 233 / 22%); + border-color: rgb(255 255 255 / 34%); + background: linear-gradient(135deg, #ffffff 0%, #e0f2fe 100%); + color: #0f172a; +} diff --git a/components/Activity/HeroCarousel.tsx b/components/Activity/HeroCarousel.tsx new file mode 100644 index 0000000..76e3993 --- /dev/null +++ b/components/Activity/HeroCarousel.tsx @@ -0,0 +1,185 @@ +import { CSSProperties, FC, useContext, useEffect, useState } from 'react'; +import { TextTruncate } from 'idea-react'; +import { TableCellLocation } from 'mobx-lark'; +import { Badge, Button, Card, Carousel, Col, Container, Row, Stack } from 'react-bootstrap'; + +import { Activity, ActivityModel } from '../../models/Activity'; +import { I18nContext } from '../../models/Translation'; +import { LarkImage } from '../LarkImage'; +import styles from './HeroCarousel.module.less'; + +const timestampOf = (value: unknown) => { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const time = new Date(value).getTime(); + + return Number.isFinite(time) ? time : 0; + } + + return 0; +}; + +const formatDateLabel = (value: unknown, locale: string) => { + const timestamp = timestampOf(value); + + return !timestamp + ? '' + : new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + }).format(timestamp); +}; + +const locationTextOf = ({ city, location }: Activity) => + [(city as string) || '', (location as TableCellLocation | undefined)?.full_address || ''] + .filter(Boolean) + .join(' ยท '); + +const descriptionOf = (activity: Activity) => + (activity.summary as string) || locationTextOf(activity) || (activity.type as string) || ''; + +export const HeroCarousel: FC<{ activities: Activity[] }> = ({ activities }) => { + const { currentLanguage, t } = useContext(I18nContext); + const [heroStyle, setHeroStyle] = useState(); + const [descriptionRows, setDescriptionRows] = useState(3); + const infoBodyStyle = { minHeight: 'clamp(0rem, 38vh, 24rem)' } as CSSProperties; + + useEffect(() => { + const navbar = document.querySelector('nav'); + const syncHeroOffset = () => { + const navbarHeight = navbar?.getBoundingClientRect().height || 56; + + setHeroStyle({ + '--hero-carousel-offset': `${navbarHeight}px`, + } as CSSProperties); + }; + const observer = navbar && new ResizeObserver(syncHeroOffset); + + syncHeroOffset(); + if (navbar) observer?.observe(navbar); + + return () => observer?.disconnect(); + }, []); + + useEffect(() => { + const syncDescriptionRows = () => setDescriptionRows(window.innerWidth <= 767.98 ? 4 : 3); + syncDescriptionRows(); + + const observer = new ResizeObserver(syncDescriptionRows); + observer.observe(document.body); + + return () => observer.disconnect(); + }, []); + + return ( + + 1} + controls={activities.length > 1} + className={`${styles.carousel} h-100`} + > + {activities.map(activity => { + const { id, type, name, host, startTime, cardImage } = activity; + + const href = ActivityModel.getLink(activity); + const hosts = ((host as string[]) || []).slice(0, 2); + const locationText = locationTextOf(activity); + const dateText = formatDateLabel(startTime, currentLanguage); + const title = (name as string) || t('activity'); + const description = descriptionOf(activity); + const image = cardImage || activity.image; + + return ( + + + + + + + + {(hosts.length ? hosts : [(type as string) || t('hackathon')]).map( + item => ( + + {item} + + ), + )} + {(dateText || t('event_duration')) && ( + + {dateText || t('event_duration')} + + )} + + + + {title} + + + + {description} + + + + + {locationText || t('open_source_bazaar')} + + + + + + + + + + + + + + ); + })} + + + ); +}; diff --git a/components/Navigator/MainNavigator.tsx b/components/Navigator/MainNavigator.tsx index bc99f46..943f739 100644 --- a/components/Navigator/MainNavigator.tsx +++ b/components/Navigator/MainNavigator.tsx @@ -97,7 +97,7 @@ export const MainNavigator: FC = observer(({ menu }) => { return ( - + {t('open_source_bazaar')} {t('open_source_bazaar')} @@ -117,7 +117,7 @@ export const MainNavigator: FC = observer(({ menu }) => { {title} diff --git a/package.json b/package.json index 779837b..8f0c9f1 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "core-js": "^3.49.0", "echarts-jsx": "^0.6.0", "file-type": "^22.0.1", - "idea-react": "^2.2.0", + "idea-react": "^2.2.2", "jsonwebtoken": "^9.0.3", "koa": "^3.2.0", "koa-jwt": "^4.0.4", @@ -50,7 +50,7 @@ "remark-frontmatter": "^5.0.0", "remark-mdx-frontmatter": "^5.2.0", "web-utility": "^4.6.6", - "yaml": "^2.8.3" + "yaml": "^2.8.4" }, "devDependencies": { "@babel/plugin-proposal-decorators": "^7.29.0", @@ -74,7 +74,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^13.0.0", - "globals": "^17.5.0", + "globals": "^17.6.0", "husky": "^9.1.7", "jiti": "^2.6.1", "less": "^4.6.4", diff --git a/pages/index.tsx b/pages/index.tsx index 88f9654..41c16a2 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,20 +1,35 @@ import { observer } from 'mobx-react'; +import { cache, compose, errorLogger } from 'next-ssr-middleware'; import { FC, useContext } from 'react'; import { Card, Col, Row } from 'react-bootstrap'; import { renderToStaticMarkup } from 'react-dom/server'; import ReactTyped from 'react-typed-component'; +import { HeroCarousel } from '../components/Activity/HeroCarousel'; import { PageHead } from '../components/Layout/PageHead'; +import { Activity, ActivityModel } from '../models/Activity'; import { I18nContext } from '../models/Translation'; import styles from '../styles/Home.module.less'; -const HomePage: FC = observer(() => { +interface HomePageProps { + activities: Activity[]; +} + +export const getServerSideProps = compose(cache(), errorLogger, async () => { + const activities = await new ActivityModel().getList({}, 1, 3); + + return { props: JSON.parse(JSON.stringify({ activities })) }; +}); + +const HomePage: FC = observer(({ activities }) => { const { t } = useContext(I18nContext); return ( <> + {activities[0] && } +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ad4916..1c56da0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ importers: specifier: ^22.0.1 version: 22.0.1 idea-react: - specifier: ^2.2.0 - version: 2.2.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(typescript@5.9.3) + specifier: ^2.2.2 + version: 2.2.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(typescript@5.9.3) jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -124,8 +124,8 @@ importers: specifier: ^4.6.6 version: 4.6.6(typescript@5.9.3) yaml: - specifier: ^2.8.3 - version: 2.8.3 + specifier: ^2.8.4 + version: 2.8.4 devDependencies: '@babel/plugin-proposal-decorators': specifier: ^7.29.0 @@ -191,8 +191,8 @@ importers: specifier: ^13.0.0 version: 13.0.0(eslint@10.3.0(jiti@2.6.1)) globals: - specifier: ^17.5.0 - version: 17.5.0 + specifier: ^17.6.0 + version: 17.6.0 husky: specifier: ^9.1.7 version: 9.1.7 @@ -2494,8 +2494,8 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-to-chromium@1.5.348: - resolution: {integrity: sha512-QC2X59nRlycQQMc4ZXjSVBX+tSgJfgRtcrYHbIZLgOV2dCvefoQGegLR7lLXKgpPpSuVmJU19LMzGrSa2C7k3Q==} + electron-to-chromium@1.5.349: + resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2913,8 +2913,8 @@ packages: resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} engines: {node: '>=18'} - globals@17.5.0: - resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} engines: {node: '>=18'} globalthis@1.0.4: @@ -3013,8 +3013,8 @@ packages: idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} - idea-react@2.2.0: - resolution: {integrity: sha512-JJMHnap4PmgkTO7GAlG2PTOQRzRcPF12xFBKRfpYJVESOeex26QYo3PDqURR3WfgoW3pcLXdxvHOsgCBA84PQg==} + idea-react@2.2.2: + resolution: {integrity: sha512-TZBPBT0beybzx49e/fEmrmZf6cZ4GyPvhLlqM/k7cXLwnqumWa1/AWKDYqtlBYkMcdLMnBC68U6TCxw0HdqhzQ==} peerDependencies: react: '>=16' react-dom: '>=16' @@ -4930,8 +4930,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.8.3: - resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} engines: {node: '>= 14.6'} hasBin: true @@ -4945,8 +4945,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.4.1: - resolution: {integrity: sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==} + zod@4.4.2: + resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==} zrender@6.0.0: resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==} @@ -7049,7 +7049,7 @@ snapshots: dependencies: baseline-browser-mapping: 2.10.25 caniuse-lite: 1.0.30001791 - electron-to-chromium: 1.5.348 + electron-to-chromium: 1.5.349 node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -7190,7 +7190,7 @@ snapshots: '@cspell/cspell-types': 10.0.0 comment-json: 4.6.2 smol-toml: 1.6.1 - yaml: 2.8.3 + yaml: 2.8.4 cspell-dictionary@10.0.0: dependencies: @@ -7392,7 +7392,7 @@ snapshots: dependencies: jake: 10.9.4 - electron-to-chromium@1.5.348: {} + electron-to-chromium@1.5.349: {} emoji-regex@10.6.0: {} @@ -7645,8 +7645,8 @@ snapshots: '@babel/parser': 7.29.3 eslint: 10.3.0(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.4.1 - zod-validation-error: 4.0.2(zod@4.4.1) + zod: 4.4.2 + zod-validation-error: 4.0.2(zod@4.4.2) transitivePeerDependencies: - supports-color @@ -7975,7 +7975,7 @@ snapshots: globals@16.4.0: {} - globals@17.5.0: {} + globals@17.6.0: {} globalthis@1.0.4: dependencies: @@ -8117,7 +8117,7 @@ snapshots: idb@7.1.1: {} - idea-react@2.2.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(typescript@5.9.3): + idea-react@2.2.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@16.13.1)(react@19.2.5)(typescript@5.9.3): dependencies: '@editorjs/editorjs': 2.31.6 '@editorjs/paragraph': 2.11.7 @@ -8538,7 +8538,7 @@ snapshots: picomatch: 4.0.4 string-argv: 0.3.2 tinyexec: 1.1.2 - yaml: 2.8.3 + yaml: 2.8.4 listr2@9.0.5: dependencies: @@ -9708,7 +9708,7 @@ snapshots: toml: 3.0.0 unified: 11.0.5 unist-util-mdx-define: 1.1.2 - yaml: 2.8.3 + yaml: 2.8.4 remark-mdx@3.1.1: dependencies: @@ -10631,15 +10631,15 @@ snapshots: yallist@3.1.1: {} - yaml@2.8.3: {} + yaml@2.8.4: {} yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.4.1): + zod-validation-error@4.0.2(zod@4.4.2): dependencies: - zod: 4.4.1 + zod: 4.4.2 - zod@4.4.1: {} + zod@4.4.2: {} zrender@6.0.0: dependencies: