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
5 changes: 3 additions & 2 deletions components/Navigator/MainNavigator.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { textJoin } from 'mobx-i18n';
import { textJoin } from 'mobx-i18n';
import { observer } from 'mobx-react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
Expand Down 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 Expand Up @@ -63,6 +63,7 @@ const topNavBarMenu = ({ t }: typeof i18n): MenuItem[] => [
],
},
{ href: '/bounty', title: t('bounty') },
{ href: '/library', title: t('open_library') },
{
title: t('NGO'),
subs: [
Expand Down
168 changes: 162 additions & 6 deletions pages/award/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,172 @@
import { TableCellValue } from 'mobx-lark';
import { observer } from 'mobx-react';
import { cache, compose, errorLogger } from 'next-ssr-middleware';
import { FC } from 'react';
import { FC, useContext } from 'react';
import { Badge, Card, Col, Container, Row } from 'react-bootstrap';
const ALLOWED_EMBED_HOSTS = new Set([
'player.bilibili.com',
'www.youtube.com',
'www.youtube-nocookie.com',
]);

const getSafeEmbedUrl = (value: unknown): string | null => {
if (typeof value !== 'string' || !value) return null;
try {
const url = new URL(value);
return ALLOWED_EMBED_HOSTS.has(url.hostname) ? url.toString() : null;
} catch { return null; }
};

import { PageHead } from '../../components/Layout/PageHead';
import { SectionTitle } from '../../components/Layout/SectionTitle';
import { Award, AwardModel } from '../../models/Award';
import { I18nContext } from '../../models/Translation';

export const getServerSideProps = compose(cache(), errorLogger, async () => {
const awards = await new AwardModel().getAll();

return { props: { awards } };
});

const AwardPage: FC<{ awards: Award[] }> = ({ awards }) => {
return <></>;
};
const NominationCard: FC<{ award: Award; index: number }> = observer(({ award, index }) => {
const { t } = useContext(I18nContext);
const name = (award.nomineeName ?? award.awardName ?? '') as string;
const desc = (award.nomineeDesc ?? award.reason ?? '') as string;
const nominator = (award.nominator ?? '') as string;
const videoUrl = getSafeEmbedUrl(award.videoUrl) ?? '';
const votes = (award.votes ?? 0) as number;

return (
<Card className="h-100 shadow-sm border-0">
{videoUrl && (
<div className="ratio ratio-16x9">
<iframe
src=loading="lazy" {videoUrl}
title={name}
allowFullScreen
className="rounded-top"
/>
</div>
)}
<Card.Body>
<div className="d-flex justify-content-between align-items-start mb-2">
<Badge bg="primary" className="me-2">#{index + 1}</Badge>
{votes > 0 && (
<Badge bg="warning" text="dark">
{votes} {t('votes')}
</Badge>
)}
</div>
<Card.Title className="fs-5">{name}</Card.Title>
{desc && (
<Card.Text className="text-muted small mt-2" style={{ maxHeight: 80, overflow: 'hidden' }}>
{desc}
</Card.Text>
)}
{nominator && (
<div className="mt-2 small">
<span className="text-muted">{t('nominated_by')}: </span>
<span className="fw-medium">{nominator}</span>
</div>
)}
</Card.Body>
</Card>
);
});

const AwardPage: FC<{ awards: Award[] }> = observer(({ awards }) => {
const { t } = useContext(I18nContext);

return (
<Container className="py-4">
<PageHead
title={t('open_collaborator_award')}
description={t('award_page_description')}
/>

{/* Hero Section */}
<section className="text-center py-5 mb-4 bg-light rounded-3">
<h1 className="display-4 fw-bold mb-3">{t('open_collaborator_award')}</h1>
<p className="lead text-muted mb-4">{t('award_page_description')}</p>
<div className="d-flex justify-content-center gap-3 flex-wrap">
<a href="/article/open-collaborator-award" className="btn btn-outline-primary">
{t('learn_more')}
</a>
<a href="#nominations" className="btn btn-primary">
{t('view_nominations')}
</a>
</div>
</section>

{/* Video Intro */}
<section className="mb-5">
<SectionTitle>{t('award_intro_video')}</SectionTitle>
<div className="ratio ratio-16x9 border rounded overflow-hidden shadow-sm">
<iframe
src=loading="lazy" "//player.bilibili.com/player.html?aid=978564817&bvid=BV1c44y1x7ij&cid=494424932&page=1&high_quality=1&danmaku=0"
title={t('open_collaborator_award') as string}
scrolling="no"
frameBorder="0"
allowFullScreen
/>
</div>
</section>

{/* Awards List */}
<section id="nominations" className="mb-5">
<SectionTitle count={awards.length}>{t('award_nominations')}</SectionTitle>
{awards.length > 0 ? (
<Row className="g-4" xs={1} sm={2} lg={3}>
{awards.map((award, index) => (
<Col key={index}>
<NominationCard award={award} index={index} />
</Col>
))}
</Row>
) : (
<div className="text-center py-5 text-muted">
<p className="fs-5">{t('no_nominations_yet')}</p>
<p>{t('nomination_coming_soon')}</p>
</div>
)}
</section>

{/* How to Nominate */}
<section className="mb-5 bg-light rounded-3 p-4 p-md-5">
<h3 className="mb-4">{t('how_to_nominate')}</h3>
<Row className="g-4">
{[1, 2, 3, 4].map((step) => (
<Col key={step} md={3} sm={6}>
<div className="text-center">
<div
className="d-inline-flex justify-content-center align-items-center rounded-circle bg-primary text-white mb-3"
style={{ width: 48, height: 48, fontSize: 20 }}
>
{step}
</div>
<h5>{t('nomination_step' + step + '_title' as any)}</h5>
<p className="text-muted small">{t('nomination_step' + step + '_desc' as any)}</p>
</div>
</Col>
))}
</Row>
</section>

{/* Award Process / About */}
<section className="mb-5">
<h3 className="mb-4">{t('about_award')}</h3>
<Card className="border-0 shadow-sm">
<Card.Body className="p-4">
<p>{t('award_about_text')}</p>
<div className="d-flex gap-3 mt-4">
<a href="/article/open-collaborator-award" className="btn btn-outline-primary">
{t('read_full_details')}
</a>
</div>
</Card.Body>
</Card>
</section>
</Container>
);
});

export default AwardPage;
export default AwardPage;
112 changes: 112 additions & 0 deletions pages/library/[id].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { observer } from "mobx-react";
import { cache, compose, errorLogger } from "next-ssr-middleware";
import { FC, useContext } from "react";
import { Badge, Card, Col, Container, Row } from "react-bootstrap";

import { PageHead } from "../../../components/Layout/PageHead";
import { I18nContext } from "../../../models/Translation";
import { libraryBooks } from "../books";

export const getServerSideProps = compose(cache(), errorLogger, async ({ params }) => {
const book = libraryBooks.find((b) => b.id === params.id);
if (!book) return { notFound: true };
return { props: { book } };
});

const BookDetailPage: FC<{ book: (typeof libraryBooks)[number] }> = observer(({ book }) => {
const { t } = useContext(I18nContext);

return (
<Container className="py-4">
<PageHead title={book.title} description={book.description} />
<a href="/library" className="btn btn-sm btn-outline-secondary mb-4">
&larr; {t("back_to_library")}
</a>

<Row>
<Col md={4} className="text-center mb-4">
<img
src={book.cover}
alt={book.title}
className="rounded shadow"
style={{ maxWidth: "100%", maxHeight: 400, objectFit: "cover" }}
/>
<div className="mt-3">
<Badge bg={book.status === "available" ? "success" : "warning"} className="fs-6 px-3 py-2">
{t(book.status === "available" ? "library_status_available" : "library_status_borrowed" as any)}
</Badge>
</div>
</Col>

<Col md={8}>
<h2>{book.title}</h2>
<p className="text-muted fs-5">{book.author}</p>

{book.tags && (
<div className="mb-3 d-flex gap-2 flex-wrap">
{book.tags.map((tag) => (
<Badge key={tag} bg="info" className="bg-opacity-25 text-dark">{tag}</Badge>
))}
</div>
)}

<p className="lead">{book.description}</p>

<Card className="mb-4">
<Card.Header>{t("book_details")}</Card.Header>
<Card.Body>
<Row className="g-2">
{book.publisher && (
<Col xs={6}><small className="text-muted">{t("publisher")}</small><div>{book.publisher}</div></Col>
)}
{book.year && (
<Col xs={6}><small className="text-muted">{t("year")}</small><div>{book.year}</div></Col>
)}
{book.isbn && (
<Col xs={6}><small className="text-muted">ISBN</small><div>{book.isbn}</div></Col>
)}
{book.pages && (
<Col xs={6}><small className="text-muted">{t("pages")}</small><div>{book.pages}</div></Col>
)}
{book.language && (
<Col xs={6}><small className="text-muted">{t("language")}</small><div>{book.language}</div></Col>
)}
<Col xs={6}><small className="text-muted">{t("category")}</small><div>{t("library_category_" + book.category as any)}</div></Col>
</Row>
</Card.Body>
</Card>

{book.status === "borrowed" && book.borrower && (
<Card className="mb-4 border-warning">
<Card.Header className="bg-warning bg-opacity-10">{t("borrowing_info")}</Card.Header>
<Card.Body>
<Row>
<Col xs={6}><small className="text-muted">{t("borrower")}</small><div className="fw-bold">{book.borrower}</div></Col>
{book.borrowDate && <Col xs={6}><small className="text-muted">{t("borrow_date")}</small><div>{book.borrowDate}</div></Col>}
{book.returnDate && <Col xs={6} className="mt-2"><small className="text-muted">{t("expected_return_date")}</small><div>{book.returnDate}</div></Col>}
</Row>
</Card.Body>
</Card>
)}

<Card className="mb-4">
<Card.Header>{t("borrowing_guide")}</Card.Header>
<Card.Body>
<ol className="mb-0">
<li className="mb-2">{t("borrowing_guide_step1")}</li>
<li className="mb-2">{t("borrowing_guide_step2")}</li>
<li className="mb-2">{t("borrowing_guide_step3")}</li>
<li className="mb-2">{t("borrowing_guide_step4")}</li>
<li>{t("borrowing_guide_step5")}</li>
</ol>
</Card.Body>
</Card>

<a href="/library" className="btn btn-primary">&larr; {t("back_to_library")}</a>
</Col>
</Row>
</Container>
);
});

export default BookDetailPage;
Loading