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
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

优先用各类 Bootstrap CSS 工具类:https://getbootstrap.com/docs/5.3/utilities/

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%;
}
Comment on lines +92 to +98

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
iframe {
position: absolute;
inset: 0;
border: 0;
width: 100%;
height: 100%;
}
iframe {
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,
Comment on lines +52 to +55

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const AwardCard: FC<AwardCardProps> = ({ className = '', ...award }) => {
const i18n = useContext(I18nContext),
{ t, currentLanguage } = i18n;
const { awardName, nomineeName, nomineeDesc, videoUrl, reason, nominator, createdAt } = award,
export const AwardCard: FC<AwardCardProps> = ({
className = '',
id,
awardName,
nomineeName,
nomineeDesc,
videoUrl,
reason,
nominator,
createdAt
}) => {
const { t, currentLanguage } = useContext(I18nContext);

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}
Comment on lines +69 to +84

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{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}
<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>

<iframe /> 就是个嵌入式网页,能兼容任何浏览器可识别的 URL。


<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>
Comment on lines +107 to +109

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

如果没有很特殊的样式,可以直接用 HTML 5 的 <progress /> 或 Bootstrap 的 <Progress />

<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}>
Comment on lines +2 to +19

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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}>
import { Button, Modal, ModalProps } 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 type NominationFormProps = Pick<ModalProps, 'show' | 'onHide'>;
export const NominationForm: FC<NominationFormProps> = observer(props => {
const { t } = useContext(I18nContext);
return (
<Modal size="lg" centered scrollable {...props}>

使用 MobX-i18n 的所有组件都要变成 MobX 的 observer,具体可参考其它组件。

<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>
);
};
49 changes: 48 additions & 1 deletion models/Award.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
import { BiDataQueryOptions, BiDataTable, BiSearch, TableCellValue } from 'mobx-lark';
import {
BiDataQueryOptions,
BiDataTable,
BiSearch,
TableCellLink,
TableCellRelation,
TableCellUser,
TableCellValue,
TableRecord,
} from 'mobx-lark';

import { larkClient } from './Base';
import { AwardTableId, LarkBitableId } from './configuration';

// cspell:ignore bilibili

const bilibiliLinkOf = (value: TableCellValue) => {
const { link = '', text = '' } = (value || {}) as Partial<TableCellLink>,
rawValue = `${link} ${text}`.trim() || value?.toString() || '';

return (
rawValue.match(/https:\/\/(?:[\w-]+\.)?bilibili\.com\/\S+|https:\/\/b23\.tv\/\S+/i)?.[0] ||
rawValue
);
};

const relationCountOf = (value: TableCellValue) => {
if (!(value instanceof Array)) return value;

return value.reduce(
(count, relation) =>
count + (((relation as TableCellRelation)?.record_ids || []) as string[]).length,
0,
);
};
Comment on lines +27 to +35

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const relationCountOf = (value: TableCellValue) => {
if (!(value instanceof Array)) return value;
return value.reduce(
(count, relation) =>
count + (((relation as TableCellRelation)?.record_ids || []) as string[]).length,
0,
);
};
const relationCountOf = (value: TableCellValue) =>
value instanceof Array
? value.reduce(
(count, relation) =>
count + (((relation as TableCellRelation)?.record_ids || []) as string[]).length,
0,
)
: value;


export type Award = Record<
| 'id'
| 'awardName'
| `nominee${'Name' | 'Desc'}`
| 'videoUrl'
Expand All @@ -22,6 +54,21 @@ export class AwardModel extends BiDataTable<Award>() {
constructor(appId = LarkBitableId, tableId = AwardTableId) {
super(appId, tableId);
}

extractFields({
id,
created_time,
fields: { videoUrl, nominator, votes, createdAt, ...fields },
}: TableRecord<Award>) {
return {
...fields,
id,
createdAt: createdAt ?? created_time,
nominator: (nominator as TableCellUser)?.name || nominator,
videoUrl: bilibiliLinkOf(videoUrl),
votes: relationCountOf(votes),
};
}
Comment on lines +58 to +71

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
extractFields({
id,
created_time,
fields: { videoUrl, nominator, votes, createdAt, ...fields },
}: TableRecord<Award>) {
return {
...fields,
id,
createdAt: createdAt ?? created_time,
nominator: (nominator as TableCellUser)?.name || nominator,
videoUrl: bilibiliLinkOf(videoUrl),
votes: relationCountOf(votes),
};
}
extractFields({
id,
fields: { videoUrl, nominator, ...fields },
}: TableRecord<Award>) {
return {
...fields,
id,
nominator: (nominator as TableCellUser)?.name || nominator,
videoUrl: videoUrl && normalizeText(videoUrl),
};
}
  1. createdAt 在表结构里存在
  2. videoUrl<iframe /> 加载就是通用的,不用限定平台,且工具函数在 https://idea2app.github.io/MobX-Lark/functions/normalizeText.html
  3. votes 等关联字段尽量不要改变类型,在 JSX 中使用时取值即可:(votes as TableCellRelation[]).length

}

export class SearchAwardModel extends BiSearch<Award>(AwardModel) {
Expand Down
2 changes: 1 addition & 1 deletion pages/article/join-us.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,6 @@

[1]: https://open-source-bazaar.feishu.cn/docs/doccnGSsshgO4ojuAHVzWJMXWog '即兴三月,开源开放!'
[2]: https://github.com/Open-Source-Bazaar/Open-Source-Bazaar.github.io '开源市集官网仓库'
[3]: /open-collaborator-award '开放协作人奖'
[3]: /award '开放协作人奖'
[4]: https://open-source-bazaar.feishu.cn/share/base/shrcnBelp0N5f8pvEo106mU14jd '「开源市集」加入表单'
[5]: https://github.com/orgs/Open-Source-Bazaar/repositories '「开源市集」开源仓库'
Loading
Loading