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"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

这里会渲染出 undefined 徽标。

components/Layout/SectionTitle.tsx:8-15 里会无条件渲染 countBadge。这里没传 count,页面上会直接出现 undefined。要么传一个显式值,要么先让 SectionTitlecount != null 条件渲染徽标。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/award/index.tsx` at line 89, The SectionTitle usage for
award_intro_video is rendering an undefined badge because SectionTitle always
shows its count Badge even when no count is passed. Update the award page to
pass an explicit count, or change SectionTitle in
components/Layout/SectionTitle.tsx so the Badge is rendered only when count !=
null. Use the SectionTitle component and its count prop to locate and fix the
issue.

<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 } };
});
Comment on lines +10 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 确认 next-ssr-middleware compose 回调中 params 的类型定义
fd -t f 'index.d.ts|index.ts' node_modules/next-ssr-middleware 2>/dev/null | xargs -r rg -n 'params' -C2 || echo "查看 next-ssr-middleware 类型以确认 params 是否可能为 undefined"

Repository: Open-Source-Bazaar/Open-Source-Bazaar.github.io

Length of output: 185


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) 定位相关页面与 compose 用法
git ls-files 'pages/library/[id].tsx' 'pages/**/*.tsx' 'package.json' 'tsconfig.json' | sed -n '1,120p'

echo '--- pages/library/[id].tsx ---'
wc -l pages/library/[id].tsx
cat -n pages/library/[id].tsx | sed -n '1,180p'

echo '--- search compose/cache/errorLogger/getServerSideProps ---'
rg -n "compose\\(|cache\\(|errorLogger|getServerSideProps|params\\.id|notFound" pages components lib . -g '!node_modules' -g '!dist' -g '!build'

# 2) 若存在相关类型/实现,查看定义
echo '--- possible middleware package references ---'
rg -n "next-ssr-middleware|compose\\s*\\(" . -g '!node_modules' -g '!dist' -g '!build'

echo '--- package.json ---'
cat -n package.json | sed -n '1,220p'

echo '--- tsconfig.json ---'
cat -n tsconfig.json | sed -n '1,220p'

Repository: Open-Source-Bazaar/Open-Source-Bazaar.github.io

Length of output: 20949


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 3) 精准检查该页面的动态路由数据获取写法
ast-grep outline pages/library/[id].tsx --view expanded || true

Repository: Open-Source-Bazaar/Open-Source-Bazaar.github.io

Length of output: 391


🌐 Web query:

next-ssr-middleware compose async callback params type params undefined string string[] GetServerSideProps

💡 Result:

In Next.js, the params object within GetServerSidePropsContext is typed as ParsedUrlQuery | undefined [1][2]. Because dynamic route parameters in Next.js can resolve to either a string or a string[] (in the case of catch-all routes), accessing them directly requires narrowing the type or using optional chaining [3][4]. When using the next-ssr-middleware library's compose function, you can provide a generic type argument to define the expected structure of params [5][6]. This allows you to treat the parameters as a specific object type within your async callback [5][7]. Example implementation with next-ssr-middleware: import { compose } from 'next-ssr-middleware'; // Define the shape of your route params type MyParams = { id: string }; export const getServerSideProps = compose<MyParams, MyPageProps>( async ({ params }) => { // params is typed as MyParams | undefined // Use optional chaining or a null check to safely access it const id = params?.id; if (!id) { return { notFound: true }; } return { props: { data: await fetchData(id) } }; }); Standard Next.js Type Handling: If you are not using the middleware utility, context.params may be undefined if the page is not dynamic [4]. To safely access dynamic parameters, perform a type check or cast the value after verifying the expected route structure [3]: export const getServerSideProps: GetServerSideProps = async (context) => { const { params } = context; // Narrow the type to ensure params exists if (!params) return { props: {} }; const id = params.id as string; // Cast if you are certain of the format //... }; The type string | string[] | undefined arises because Next.js treats all query and path parameters as ParsedUrlQuery, which is essentially an object mapping keys to string | string[] [3][1]. When accessing a specific key, you must account for the possibility that the parameter is an array (from a catch-all route) or undefined (if the route does not match) [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 查看 libraryBooks 的 id 类型,判断 params.id 的比较是否存在实际类型不匹配风险
ast-grep outline pages/library/books.ts --view expanded || true
echo '--- pages/library/books.ts ---'
wc -l pages/library/books.ts
cat -n pages/library/books.ts | sed -n '1,220p'

Repository: Open-Source-Bazaar/Open-Source-Bazaar.github.io

Length of output: 6429


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 仅读取与 book 数据结构相关的定义
rg -n "export const libraryBooks|id:|interface .*Book|type .*Book" pages/library -g '!node_modules'

Repository: Open-Source-Bazaar/Open-Source-Bazaar.github.io

Length of output: 1099


params.id 补上显式类型收窄。
params 在 Pages Router 里可能为空,这里直接用于 find 会把路由契约留在隐式类型上;改成 compose<{ id: string }>(...) 后再用 params?.id 判空更稳,也和同类动态路由页保持一致。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/library/`[id].tsx around lines 10 - 14, `getServerSideProps` is relying
on an implicit `params` shape, so `params.id` should be narrowed explicitly
before using it in the `libraryBooks.find` lookup. Update the `compose` call in
`getServerSideProps` to carry the `{ id: string }` generic, and then guard
access with `params?.id` so the dynamic route handling matches the rest of the
Pages Router patterns and avoids assuming `params` is always present.


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>
Comment on lines +22 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

封面图与返回链接应使用 React Bootstrap 组件 / next/imagenext/link

第 28-33 行的原生 <img>、第 22-24 与 105 行的原生 <a class="btn"> 均违反“UI 必须使用 React Bootstrap 组件”的规范,且内部跳转应走 next/link。建议:图片用 next/image(或 Card.Img),返回按钮用 Button + next/link

As per coding guidelines: "ALWAYS use React Bootstrap components instead of custom HTML elements in UI code".

Also applies to: 28-33, 105-105

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pages/library/`[id].tsx around lines 22 - 24, The page still uses native
`<a>` and `<img>` elements, which violates the UI component guideline. In
`pages/library/[id].tsx`, replace the back-link and any other internal
navigation anchors with `next/link` wrapped around a React Bootstrap `Button`,
and replace the cover image rendering with `next/image` (or `Card.Img`) instead
of a raw `<img>`. Make the same UI-component-only update anywhere else in this
component that uses the native anchor/button pattern, using the existing
`t(...)` text and the library detail render flow as your guide.

Source: Coding guidelines


<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