Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ NEXT_PUBLIC_LARK_WIKI_URL = https://open-source-bazaar.feishu.cn/wiki/space/7052

NEXT_PUBLIC_LARK_BITABLE_ID = PNOGbGqhPacsHOsvJqHctS77nje
NEXT_PUBLIC_ACTIVITY_TABLE_ID = tblREEMxDOECZZrK
NEXT_PUBLIC_AWARD_TABLE_ID = tblmYd5V5BMngAp2

NEXT_PUBLIC_STRAPI_API_HOST = https://china-ngo-db.onrender.com/api/
2 changes: 1 addition & 1 deletion components/Navigator/MainNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const topNavBarMenu = ({ t }: typeof i18n): MenuItem[] => [
subs: [
{ href: '/article/join-us', title: t('join_us') },
{
href: '/article/open-collaborator-award',
href: '/award',
title: t('open_collaborator_award'),
},
{ href: '/volunteer', title: t('volunteer') },
Expand Down
154 changes: 154 additions & 0 deletions components/award/Award.module.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
.hero {
position: relative;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
padding: 5rem 0;
overflow: hidden;
color: #fff;

&::before {
position: absolute;
top: -50%;
left: -50%;
animation: grid-animation 20s linear infinite;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
width: 200%;
height: 200%;
pointer-events: none;
content: '';
}
}

@keyframes grid-animation {
to {
transform: translate(50px, 50px);
}
}

.heroContent {
position: relative;
z-index: 1;
}

.heroTitle {
font-weight: 700;
font-size: clamp(2.25rem, 6vw, 3.5rem);
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}

.heroDescription {
opacity: 0.9;
margin: 1rem auto 2rem;
max-width: 50rem;
font-size: 1.2rem;
}

.statNumber {
font-weight: 700;
font-size: clamp(2rem, 6vw, 3.5rem);
}

.section {
padding: 4rem 0;
}

.mutedSection {
background: #f8f9fa;
}

.sectionTitle {
margin-bottom: 2rem;
font-weight: 700;
text-align: center;
}

.infoCard,
.ruleCard,
.recipientCard,
.nomineeCard,
.faqCard {
box-shadow: 0 0.25rem 1rem rgba(15, 23, 42, 0.08);
border: 1px solid rgba(15, 23, 42, 0.06);
border-radius: 1rem;
}

.recipientCard {
display: flex;
flex-direction: column;
}

.initiative {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
}

.videoWrapper {
position: relative;
background: #000;
padding-bottom: 56.25%;
height: 0;
overflow: hidden;

iframe {
position: absolute;
inset: 0;
border: 0;
width: 100%;
height: 100%;
}
}

.initiativeVideo {
border-radius: 1rem;
}

.videoFallback {
background: linear-gradient(135deg, #667eea, #764ba2);
min-height: 14rem;
}

.ruleNumber {
display: inline-flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
width: 3rem;
height: 3rem;
color: #fff;
font-weight: 700;
font-size: 1.25rem;
}

.reason {
margin-bottom: 1rem;
border-left: 0.25rem solid #667eea;
border-radius: 0.5rem;
background: #f8f9fa;
padding: 1rem;
}

.progressTrack {
border-radius: 999px;
background: #e9ecef;
height: 0.5rem;
overflow: hidden;
}

.progressFill {
transition: width 0.3s ease;
background: linear-gradient(90deg, #667eea, #764ba2);
height: 100%;
}

.voteCount {
color: #667eea;
font-size: 1.25rem;
}

.nominationForm {
border: 0;
width: 100%;
min-height: min(75vh, 50rem);
}
118 changes: 118 additions & 0 deletions components/award/AwardCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { FC, useContext } from 'react';
import { Badge, Button, Card } from 'react-bootstrap';

import { Award } from '../../models/Award';
import { I18nContext } from '../../models/Translation';
import styles from './Award.module.less';

// cspell:ignore bilibili

export const AWARD_VOTE_THRESHOLD = 10;

export const voteCountOf = ({ votes }: Pick<Award, 'votes'>) => {
const count = Number(votes);

return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
};

const bilibiliURLOf = (value: Award['videoUrl']) => {
try {
const url = new URL(value?.toString() || ''),
{ hostname } = url;

if (
url.protocol !== 'https:' ||
!(hostname === 'bilibili.com' || hostname.endsWith('.bilibili.com') || hostname === 'b23.tv')
)
return undefined;

return url;
} catch {
return undefined;
}
};

const dateTextOf = (value: Award['createdAt'], locale: string) => {
const rawValue = value?.toString();

if (!rawValue) return '';

const timestamp = Number(rawValue),
normalizedTimestamp = timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp,
date = new Date(Number.isNaN(timestamp) ? rawValue : normalizedTimestamp),
dateLocale = ['zh-CN', 'zh-TW', 'en-US'].includes(locale) ? locale : 'zh-CN';

return Number.isNaN(date.valueOf()) ? rawValue : date.toLocaleDateString(dateLocale);
};

export interface AwardCardProps extends Award {
className?: string;
}

export const AwardCard: FC<AwardCardProps> = ({ className = '', ...award }) => {
const i18n = useContext(I18nContext),
{ t, currentLanguage } = i18n;
const { awardName, nomineeName, nomineeDesc, videoUrl, reason, nominator, createdAt } = award,
votes = voteCountOf(award),
winner = votes >= AWARD_VOTE_THRESHOLD,
videoURL = bilibiliURLOf(videoUrl),
bilibiliId = videoURL?.pathname.match(/BV[0-9A-Za-z]{10}/)?.[0],
progress = Math.min((votes / AWARD_VOTE_THRESHOLD) * 100, 100),
nominee = nomineeName?.toString() || t('award_unnamed_nominee');

return (
<Card
id={`award-${award.id}`}
as="article"
className={`${styles.nomineeCard} h-100 ${className}`}
>
{bilibiliId ? (
<div className={styles.videoWrapper}>
<iframe
src={`https://player.bilibili.com/player.html?bvid=${bilibiliId}&page=1&high_quality=1&danmaku=0&autoplay=0`}
title={`${t('award_nomination_video_title')}: ${nominee}`}
loading="lazy"
allowFullScreen
/>
</div>
) : videoURL ? (
<div className={`${styles.videoFallback} d-flex align-items-center justify-content-center`}>
<Button href={videoURL.href} target="_blank" rel="noreferrer" variant="light">
{t('award_watch_nomination_video')}
</Button>
</div>
) : null}

<Card.Body className="d-flex flex-column">
<div className="mb-2 d-flex align-items-start justify-content-between gap-2">
<Badge bg="primary">{awardName?.toString() || t('open_collaborator_award')}</Badge>
{winner && <Badge bg="success">{t('award_recognized')}</Badge>}
</div>

<Card.Title as="h3">{nominee}</Card.Title>
{nomineeDesc && <Card.Text className="text-muted">{nomineeDesc.toString()}</Card.Text>}

{reason && (
<div className={styles.reason}>
<strong>{t('award_nomination_reason')}</strong>
<p className="mb-0 mt-2">{reason.toString()}</p>
</div>
)}

<footer className="mt-auto pt-3">
<p className="text-muted small">
{t('award_nominated_by')}: {nominator?.toString() || t('award_anonymous')}
{createdAt && ` · ${dateTextOf(createdAt, currentLanguage)}`}
</p>
<div className={styles.progressTrack}>
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
</div>
<p className="mb-0 mt-2 small text-muted">
<strong className={styles.voteCount}>{votes}</strong> / {AWARD_VOTE_THRESHOLD}{' '}
{t('award_support_count')}
</p>
</footer>
</Card.Body>
</Card>
);
};
39 changes: 39 additions & 0 deletions components/award/NominationForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { FC, useContext } from 'react';
import { Button, Modal } from 'react-bootstrap';

import { I18nContext } from '../../models/Translation';
import styles from './Award.module.less';

export const AWARD_NOMINATION_FORM_URL =
'https://open-source-bazaar.feishu.cn/share/base/form/shrcniqv1nEnCrygFy0qX4fPcSg';

export interface NominationFormProps {
show: boolean;
onHide: () => void;
}

export const NominationForm: FC<NominationFormProps> = ({ show, onHide }) => {
const { t } = useContext(I18nContext);

return (
<Modal show={show} size="lg" centered scrollable onHide={onHide}>
<Modal.Header closeButton closeLabel={t('award_close')}>
<Modal.Title>{t('award_submit_nomination')}</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0">
<iframe
className={styles.nominationForm}
src={AWARD_NOMINATION_FORM_URL}
title={t('award_nomination_form_title')}
loading="lazy"
/>
</Modal.Body>
<Modal.Footer>
<small className="me-auto text-muted">{t('award_form_refresh_hint')}</small>
<Button variant="secondary" onClick={onHide}>
{t('award_close')}
</Button>
</Modal.Footer>
</Modal>
);
};
Loading