Skip to content

Commit 51d09ee

Browse files
committed
feat: replace promo bar with hero carousel
1 parent ed2cf15 commit 51d09ee

6 files changed

Lines changed: 383 additions & 243 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
.heroCarousel {
2+
--hero-carousel-offset: 4.125rem;
3+
background: #050816;
4+
min-height: calc(100vh - var(--hero-carousel-offset) - 2.5rem);
5+
}
6+
7+
.carousel,
8+
.item,
9+
.slideCard {
10+
min-height: inherit;
11+
}
12+
13+
.carousel {
14+
:global(.carousel-inner) {
15+
min-height: inherit;
16+
}
17+
18+
:global(.carousel-item) {
19+
min-height: inherit;
20+
}
21+
22+
:global(.carousel-indicators) {
23+
right: auto;
24+
bottom: 2rem;
25+
left: 1rem;
26+
justify-content: flex-start;
27+
margin: 0;
28+
}
29+
30+
:global(.carousel-indicators button) {
31+
opacity: 0.9;
32+
margin: 0 0.45rem 0 0;
33+
border: 0;
34+
border-radius: 999px;
35+
background-color: rgb(255 255 255 / 65%);
36+
width: 2.25rem;
37+
height: 0.32rem;
38+
}
39+
40+
:global(.carousel-control-prev),
41+
:global(.carousel-control-next) {
42+
opacity: 0.95;
43+
width: 6rem;
44+
}
45+
46+
:global(.carousel-control-prev-icon),
47+
:global(.carousel-control-next-icon) {
48+
filter: drop-shadow(0 12px 24px rgb(0 0 0 / 50%));
49+
}
50+
}
51+
52+
.slideCard {
53+
background: linear-gradient(
54+
90deg,
55+
rgb(5 8 22 / 96%) 0%,
56+
rgb(5 8 22 / 88%) 28%,
57+
rgb(5 8 22 / 46%) 58%,
58+
rgb(5 8 22 / 16%) 100%
59+
);
60+
}
61+
62+
.mediaPane {
63+
min-height: 19rem;
64+
}
65+
66+
.mediaPane::after {
67+
position: absolute;
68+
inset: 0;
69+
background:
70+
linear-gradient(180deg, rgb(5 8 22 / 18%) 0%, rgb(5 8 22 / 48%) 58%, rgb(5 8 22 / 82%) 100%),
71+
linear-gradient(90deg, rgb(5 8 22 / 10%) 0%, rgb(5 8 22 / 24%) 32%, rgb(5 8 22 / 72%) 100%);
72+
pointer-events: none;
73+
content: '';
74+
}
75+
76+
.description {
77+
display: -webkit-box;
78+
max-width: 40rem;
79+
overflow: hidden;
80+
font-size: clamp(1rem, 1.6vw, 1.28rem);
81+
line-height: 1.55;
82+
-webkit-box-orient: vertical;
83+
-webkit-line-clamp: 3;
84+
line-clamp: 3;
85+
}
86+
87+
.actionButton {
88+
transition:
89+
transform 180ms ease,
90+
box-shadow 180ms ease,
91+
background 180ms ease,
92+
border-color 180ms ease;
93+
box-shadow: 0 12px 28px rgb(14 165 233 / 18%);
94+
border: 1px solid rgb(255 255 255 / 20%);
95+
border-radius: 0.9rem;
96+
background: linear-gradient(135deg, rgb(255 255 255 / 92%) 0%, rgb(224 242 254 / 96%) 100%);
97+
color: #0f172a;
98+
letter-spacing: 0.02em;
99+
}
100+
101+
.actionButton:hover,
102+
.actionButton:focus,
103+
.actionButton:active {
104+
transform: translateY(-2px);
105+
box-shadow: 0 18px 40px rgb(14 165 233 / 22%);
106+
border-color: rgb(255 255 255 / 34%);
107+
background: linear-gradient(135deg, #ffffff 0%, #e0f2fe 100%);
108+
color: #0f172a;
109+
}
110+
111+
@media (max-width: 991.98px) {
112+
.heroCarousel {
113+
min-height: calc(100vh - var(--hero-carousel-offset) - 2rem);
114+
}
115+
}
116+
117+
@media (max-width: 767.98px) {
118+
.heroCarousel {
119+
min-height: calc(100svh - var(--hero-carousel-offset) - 1.25rem);
120+
}
121+
122+
.carousel {
123+
:global(.carousel-indicators) {
124+
right: 1rem;
125+
bottom: 1rem;
126+
left: 1rem;
127+
justify-content: center;
128+
}
129+
130+
:global(.carousel-indicators button) {
131+
margin: 0 0.3rem;
132+
width: 1.4rem;
133+
height: 0.26rem;
134+
}
135+
136+
:global(.carousel-control-prev),
137+
:global(.carousel-control-next) {
138+
display: none;
139+
}
140+
}
141+
142+
.slideCard {
143+
background: linear-gradient(
144+
180deg,
145+
rgb(5 8 22 / 18%) 0%,
146+
rgb(5 8 22 / 54%) 44%,
147+
rgb(5 8 22 / 92%) 100%
148+
);
149+
}
150+
151+
.mediaPane {
152+
min-height: 14rem;
153+
}
154+
155+
.mediaPane::after {
156+
background:
157+
linear-gradient(180deg, rgb(5 8 22 / 16%) 0%, rgb(5 8 22 / 42%) 42%, rgb(5 8 22 / 88%) 100%),
158+
linear-gradient(90deg, rgb(5 8 22 / 18%) 0%, rgb(5 8 22 / 16%) 100%);
159+
}
160+
161+
.description {
162+
max-width: none;
163+
-webkit-line-clamp: 4;
164+
line-clamp: 4;
165+
}
166+
167+
.actionButton {
168+
border-radius: 0.8rem;
169+
}
170+
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { CSSProperties, FC, useContext, useEffect, useState } from 'react';
2+
import { TableCellLocation } from 'mobx-lark';
3+
import { Badge, Button, Card, Carousel, Col, Container, Row, Stack } from 'react-bootstrap';
4+
5+
import { Activity, ActivityModel } from '../../models/Activity';
6+
import { I18nContext } from '../../models/Translation';
7+
import { LarkImage } from '../LarkImage';
8+
import styles from './HeroCarousel.module.less';
9+
10+
const FALLBACK_LINK = '/hackathon/Labor-AI-hackathon-2026';
11+
const MAX_ITEMS = 5;
12+
13+
const timestampOf = (value: unknown) => {
14+
if (typeof value === 'number') return value;
15+
if (typeof value === 'string') {
16+
const time = new Date(value).getTime();
17+
18+
return Number.isFinite(time) ? time : 0;
19+
}
20+
21+
return 0;
22+
};
23+
24+
const formatDateLabel = (value: unknown) => {
25+
const timestamp = timestampOf(value);
26+
27+
if (!timestamp) return '';
28+
29+
return new Intl.DateTimeFormat('zh-CN', {
30+
month: 'short',
31+
day: 'numeric',
32+
}).format(timestamp);
33+
};
34+
35+
const locationTextOf = ({ city, location }: Activity) =>
36+
[(city as string) || '', (location as TableCellLocation | undefined)?.full_address || '']
37+
.filter(Boolean)
38+
.join(' · ');
39+
40+
const descriptionOf = (activity: Activity) =>
41+
(activity.summary as string) || locationTextOf(activity) || (activity.type as string) || '';
42+
43+
export const HeroCarousel: FC = () => {
44+
const { t } = useContext(I18nContext);
45+
const [heroStyle, setHeroStyle] = useState<CSSProperties>();
46+
const [activities, setActivities] = useState<Activity[]>([]);
47+
const infoBodyStyle = { minHeight: 'clamp(0rem, 38vh, 24rem)' } as CSSProperties;
48+
49+
useEffect(() => {
50+
const navbar = document.querySelector('nav');
51+
const syncHeroOffset = () => {
52+
const navbarHeight = navbar?.getBoundingClientRect().height || 56;
53+
54+
setHeroStyle({
55+
'--hero-carousel-offset': `${navbarHeight}px`,
56+
} as CSSProperties);
57+
};
58+
const observer =
59+
typeof ResizeObserver === 'undefined' || !navbar
60+
? undefined
61+
: new ResizeObserver(syncHeroOffset);
62+
63+
syncHeroOffset();
64+
if (navbar) observer?.observe(navbar);
65+
window.addEventListener('resize', syncHeroOffset);
66+
67+
return () => {
68+
observer?.disconnect();
69+
window.removeEventListener('resize', syncHeroOffset);
70+
};
71+
}, []);
72+
73+
useEffect(() => {
74+
(async () => {
75+
try {
76+
const model = new ActivityModel();
77+
const data = await model.getAll();
78+
const latestActivities = data
79+
.filter(({ name }) => Boolean(name))
80+
.sort(
81+
({ startTime: left }, { startTime: right }) => timestampOf(right) - timestampOf(left),
82+
)
83+
.slice(0, MAX_ITEMS);
84+
85+
setActivities(latestActivities);
86+
} catch (err) {
87+
console.error('Failed to load activities:', err);
88+
}
89+
})();
90+
}, []);
91+
92+
const slides = activities.length
93+
? activities
94+
: [
95+
{
96+
id: 'fallback',
97+
name: 'Labor AI Hackathon 2026',
98+
summary: t('home_hackathon_top_bar_description'),
99+
} as Activity,
100+
];
101+
102+
return (
103+
<section
104+
className={`${styles.heroCarousel} position-relative`}
105+
aria-label={t('home_hackathon_top_bar_aria_label')}
106+
style={heroStyle}
107+
>
108+
<Carousel
109+
fade
110+
touch
111+
pause="hover"
112+
interval={6500}
113+
indicators={slides.length > 1}
114+
controls={slides.length > 1}
115+
className={`${styles.carousel} h-100`}
116+
>
117+
{slides.map(activity => {
118+
const href =
119+
(activity.id as string) === 'fallback'
120+
? FALLBACK_LINK
121+
: ActivityModel.getLink(activity);
122+
const hosts = ((activity.host as string[]) || []).slice(0, 2);
123+
const locationText = locationTextOf(activity);
124+
const dateText = formatDateLabel(activity.startTime);
125+
const title = (activity.name as string) || 'Activity';
126+
const description = descriptionOf(activity);
127+
const image = activity.cardImage || activity.image;
128+
129+
return (
130+
<Carousel.Item key={activity.id as string} className={`${styles.item} h-100`}>
131+
<Card
132+
className={`${styles.slideCard} h-100 rounded-0 border-0 bg-transparent text-white`}
133+
>
134+
<Row className="g-0 h-100 flex-column-reverse flex-md-row">
135+
<Col
136+
xs={12}
137+
md={6}
138+
lg={5}
139+
className="d-flex align-items-center position-relative z-1"
140+
>
141+
<Container fluid="md" className="px-3 px-md-4 px-xl-5 py-5">
142+
<Card.Body
143+
className="p-0 d-flex flex-column justify-content-center"
144+
style={infoBodyStyle}
145+
>
146+
<Stack direction="horizontal" gap={2} className="flex-wrap mb-3 mb-md-4">
147+
{(hosts.length
148+
? hosts
149+
: [(activity.type as string) || t('hackathon')]
150+
).map(item => (
151+
<Badge
152+
key={item}
153+
pill
154+
bg="info"
155+
text="dark"
156+
className="px-3 py-2 fw-semibold"
157+
>
158+
{item}
159+
</Badge>
160+
))}
161+
{(dateText || t('event_duration')) && (
162+
<Badge pill bg="light" text="dark" className="px-3 py-2 fw-semibold">
163+
{dateText || t('event_duration')}
164+
</Badge>
165+
)}
166+
</Stack>
167+
168+
<Card.Title
169+
as="h1"
170+
className="display-3 fw-bold lh-1 mb-3 mb-md-4"
171+
style={{ textWrap: 'balance' }}
172+
>
173+
{title}
174+
</Card.Title>
175+
176+
<Card.Text className={`${styles.description} text-white-50 mb-4`}>
177+
{description}
178+
</Card.Text>
179+
180+
<Stack
181+
direction="horizontal"
182+
gap={3}
183+
className="flex-wrap align-items-start align-items-md-center"
184+
>
185+
<Card.Text className="mb-0 fs-6 text-info-emphasis fw-semibold">
186+
{locationText || 'Open Source Bazaar'}
187+
</Card.Text>
188+
<Button
189+
href={href}
190+
variant="light"
191+
className={`${styles.actionButton} px-4 py-2 fw-semibold text-uppercase`}
192+
>
193+
{t('home_hackathon_top_bar_action')}
194+
</Button>
195+
</Stack>
196+
</Card.Body>
197+
</Container>
198+
</Col>
199+
200+
<Col xs={12} md={6} lg={7} className={`${styles.mediaPane} position-relative`}>
201+
<LarkImage src={image} alt={title} className="w-100 h-100 object-fit-cover" />
202+
</Col>
203+
</Row>
204+
</Card>
205+
</Carousel.Item>
206+
);
207+
})}
208+
</Carousel>
209+
</section>
210+
);
211+
};

0 commit comments

Comments
 (0)