diff --git a/.env b/.env index 6efe19b..217f4b9 100644 --- a/.env +++ b/.env @@ -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/ diff --git a/components/Navigator/MainNavigator.tsx b/components/Navigator/MainNavigator.tsx index 87ea119..c53f700 100644 --- a/components/Navigator/MainNavigator.tsx +++ b/components/Navigator/MainNavigator.tsx @@ -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') }, diff --git a/components/award/Award.module.less b/components/award/Award.module.less new file mode 100644 index 0000000..cd12e25 --- /dev/null +++ b/components/award/Award.module.less @@ -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); +} diff --git a/components/award/AwardCard.tsx b/components/award/AwardCard.tsx new file mode 100644 index 0000000..b202610 --- /dev/null +++ b/components/award/AwardCard.tsx @@ -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) => { + 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 = ({ 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 ( + + {bilibiliId ? ( +
+