From 6df3619fef5989603638ccb20963d9c035c3d808 Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 16:08:53 +0800 Subject: [PATCH 01/14] Add static hackathon fallback for niuma 2026 --- components/Activity/StaticHackathonDetail.tsx | 143 +++++++++++++++ constants/staticHackathons.ts | 172 ++++++++++++++++++ pages/hackathon/[id].tsx | 39 +++- pages/hackathon/[id]/team/[tid].tsx | 3 + 4 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 components/Activity/StaticHackathonDetail.tsx create mode 100644 constants/staticHackathons.ts diff --git a/components/Activity/StaticHackathonDetail.tsx b/components/Activity/StaticHackathonDetail.tsx new file mode 100644 index 0000000..665a4e6 --- /dev/null +++ b/components/Activity/StaticHackathonDetail.tsx @@ -0,0 +1,143 @@ +import type { FC } from 'react'; +import { Badge, Button, Card, Col, Container, Row } from 'react-bootstrap'; + +import type { StaticHackathonProfile } from '../../constants/staticHackathons'; +import styles from '../../styles/Hackathon.module.less'; + +const AgendaTypeLabel: Record = { + enrollment: '报名', + formation: '组队', + competition: '比赛', + evaluation: '评审', + break: '展示', +}; + +export interface StaticHackathonDetailProps { + profile: StaticHackathonProfile; +} + +export const StaticHackathonDetail: FC = ({ profile }) => ( + <> +
+ +
+ + {profile.theme} + +
+

{profile.name}

+

{profile.summary}

+ + + + + +
📍 活动形式
+

{profile.location}

+
+
+ + + + +
⏰ 活动时间
+

{profile.timeline}

+
+
+ +
+ + + {profile.entryLinks.slice(0, 2).map(({ href, label }) => ( + + + + ))} + +
+
+ + +
+

🔗 报名与入口

+ + {profile.entryLinks.map(({ href, label, note }) => ( + + +

{label}

+

{note}

+ +
+ + ))} +
+
+ +
+

📅 活动日程

+
    + {profile.agenda.map(({ endedAt, name, startedAt, summary, type }) => ( +
  1. +

    {name}

    +

    {summary}

    +
    + {AgendaTypeLabel[type]} +
    + {startedAt} - {endedAt} +
    +
    +
  2. + ))} +
+
+ +
+

🏆 奖项设置

+ + {profile.awards.map(({ name, quota, summary }) => ( + + +
+

{name}

+ {quota} +
+

{summary}

+
+ + ))} +
+
+ +
+

🧾 参赛说明

+ + {profile.notes.map(note => ( + + +

{note}

+
+ + ))} +
+
+ +
+

❓ FAQ

+ + {profile.faq.map(({ answer, question }) => ( + + +

{question}

+

{answer}

+
+ + ))} +
+
+
+ +); diff --git a/constants/staticHackathons.ts b/constants/staticHackathons.ts new file mode 100644 index 0000000..f9d92af --- /dev/null +++ b/constants/staticHackathons.ts @@ -0,0 +1,172 @@ +export interface StaticHackathonLink { + href: string; + label: string; + note: string; +} + +export interface StaticHackathonAgendaItem { + endedAt: string; + name: string; + startedAt: string; + summary: string; + type: 'break' | 'competition' | 'enrollment' | 'evaluation' | 'formation'; +} + +export interface StaticHackathonPrize { + name: string; + quota: string; + summary: string; +} + +export interface StaticHackathonFAQ { + answer: string; + question: string; +} + +export interface StaticHackathonProfile { + agenda: StaticHackathonAgendaItem[]; + awards: StaticHackathonPrize[]; + entryLinks: StaticHackathonLink[]; + faq: StaticHackathonFAQ[]; + id: string; + location: string; + name: string; + notes: string[]; + summary: string; + theme: string; + timeline: string; +} + +export const staticHackathons: Record = { + 'niuma-hackathon-2026': { + id: 'niuma-hackathon-2026', + name: '2026 五一牛马 AI 黑客松', + summary: + '劳动节,用 AI 把自己从重复劳动中解放出来。报名一个月拉满人,组队只给 7 天做决策,比赛压缩为 3 天核心冲刺,再用 Demo Day 做集中展示。', + theme: 'AI 解放牛马', + location: '线上 + 线下同步', + timeline: '2026-03-25 00:00 - 2026-05-06 晚上(UTC+8)', + entryLinks: [ + { + label: '队员注册', + href: 'https://open-source-bazaar.feishu.cn/share/base/shrcnZAJd1yhqHYRmQXmAwlXjkc', + note: '所有参赛者先完成报名,进入组队池与官方群。', + }, + { + label: '项目注册', + href: 'https://open-source-bazaar.feishu.cn/share/base/shrcnKsgpHpBXcwh4W6GDg2G7Nc', + note: '组队阶段由队长登记项目、成员、赛道和一句话介绍。', + }, + { + label: '代码库创建', + href: 'https://open-source-bazaar.feishu.cn/share/base/shrcnc1mbGsxMm8mS69au3B7uzf', + note: '需要官方统一协助建库时使用,不是所有队伍都必须填写。', + }, + { + label: '产品提交', + href: 'https://open-source-bazaar.feishu.cn/share/base/shrcnkrO7EMQlXYR1I41w26Ioyf', + note: '比赛截止前由队长或指定提交人统一提交最终作品。', + }, + { + label: '活动官网', + href: 'https://hack.digitalvio.shop/', + note: '查看当前活动主页、时间线和最新说明。', + }, + ], + agenda: [ + { + name: '报名阶段', + summary: '所有队员完成注册,开始找人、找方向、进入官方群。', + startedAt: '2026-03-25 00:00', + endedAt: '2026-04-20 23:59', + type: 'enrollment', + }, + { + name: '组队 / 项目注册', + summary: '队长完成项目注册,锁定队伍、赛道和项目介绍。', + startedAt: '2026-04-21 00:00', + endedAt: '2026-04-28 23:59', + type: 'formation', + }, + { + name: '比赛冲刺', + summary: '3 天核心开发冲刺,围绕 AI Agents、开发者工具、创意娱乐与社会影响展开。', + startedAt: '2026-05-01 10:00', + endedAt: '2026-05-03 20:00', + type: 'competition', + }, + { + name: '评审阶段', + summary: '评委完成打分与复核,筛出进入 Demo Day 的队伍。', + startedAt: '2026-05-03 20:00', + endedAt: '2026-05-05 18:00', + type: 'evaluation', + }, + { + name: 'Demo Day', + summary: '集中展示项目成果,并公布最终结果。', + startedAt: '2026-05-06 晚上', + endedAt: '2026-05-06 晚上', + type: 'break', + }, + ], + awards: [ + { + name: '一等奖', + quota: '1 队', + summary: '首页展示 + 资源连接 + 一等奖数字徽章', + }, + { + name: '二等奖', + quota: '2 队', + summary: '社区展示 + 官方收录 + 二等奖数字徽章', + }, + { + name: '三等奖', + quota: '3 队', + summary: '荣誉名单 + 项目合集收录 + 三等奖数字徽章', + }, + { + name: '最佳创意奖', + quota: '1 队', + summary: '专题推荐 + 最佳创意数字徽章', + }, + { + name: '最佳牛马精神奖', + quota: '1 队', + summary: '社区荣誉 + 最佳牛马精神数字徽章', + }, + { + name: 'Demo Day 入选', + quota: '若干', + summary: 'Demo Day 展示资格 + 入选数字徽章', + }, + ], + notes: [ + '每队 1-4 人,支持跨城市、跨公司组队。', + '作品需在本次黑客松期间完成核心创作,并包含 AI 核心能力或 AI 驱动体验。', + '最终提交建议包含 GitHub 仓库链接、Demo 视频、产品说明和 AI 技术栈说明。', + '奖项以数字徽章 + 展示 / 资源连接为主,不承诺实体奖章或现金奖励。', + ], + faq: [ + { + question: '可以一个人参赛吗?', + answer: '可以,单人队伍有效参赛,也可以在官方群里继续找队友。', + }, + { + question: '活动鼓励哪些方向?', + answer: '鼓励 AI Agents、开发者工具、创意娱乐、社会影响等方向,也支持自由赛道。', + }, + { + question: '什么时候截止报名?', + answer: '报名截止时间是 2026-04-20 23:59(UTC+8)。', + }, + { + question: '什么时候开始比赛?', + answer: '比赛将在 2026-05-01 10:00(UTC+8)正式开始。', + }, + ], + }, +}; + +export const getStaticHackathonProfile = (id: string) => staticHackathons[id]; diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index 6f469b0..2f7d141 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -17,9 +17,14 @@ import { import { text2color, UserRankView } from 'idea-react'; import { formatDate } from 'web-utility'; +import { StaticHackathonDetail } from '../../components/Activity/StaticHackathonDetail'; import { GitCard } from '../../components/Git/Card'; import { LarkImage } from '../../components/LarkImage'; import { PageHead } from '../../components/Layout/PageHead'; +import { + getStaticHackathonProfile, + type StaticHackathonProfile, +} from '../../constants/staticHackathons'; import { Activity, ActivityModel } from '../../models/Activity'; import { fileURLOf } from '../../models/Base'; import { @@ -43,6 +48,10 @@ export const getServerSideProps = compose<{ id: string }>( cache(), errorLogger, async ({ params }) => { + const staticHackathon = getStaticHackathonProfile(params!.id); + + if (staticHackathon) return { props: { staticHackathon } }; + const activity = await new ActivityModel().getOne(params!.id); const { appId, tableIdMap } = activity.databaseSchema as BiTableSchema; @@ -59,15 +68,22 @@ export const getServerSideProps = compose<{ id: string }>( return { props: { activity, - hackathon: { people, organizations, agenda, prizes, templates, projects }, + hackathon: { + people, + organizations, + agenda, + prizes, + templates, + projects, + }, }, }; }, ); interface HackathonDetailProps { - activity: Activity; - hackathon: { + activity?: Activity; + hackathon?: { people: Person[]; organizations: Organization[]; agenda: Agenda[]; @@ -75,11 +91,14 @@ interface HackathonDetailProps { templates: Template[]; projects: Project[]; }; + staticHackathon?: StaticHackathonProfile; } const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation']; const HackathonDetail: FC = observer(({ activity, hackathon }) => { + if (!activity || !hackathon) return null; + const { t } = useContext(I18nContext); const { name, summary, location, startTime, endTime, databaseSchema } = activity, @@ -286,4 +305,16 @@ const HackathonDetail: FC = observer(({ activity, hackatho ); }); -export default HackathonDetail; +const HackathonPage: FC = props => { + if (props.staticHackathon) + return ( + <> + + + + ); + + return ; +}; + +export default HackathonPage; diff --git a/pages/hackathon/[id]/team/[tid].tsx b/pages/hackathon/[id]/team/[tid].tsx index 8f6ae7b..0db0b21 100644 --- a/pages/hackathon/[id]/team/[tid].tsx +++ b/pages/hackathon/[id]/team/[tid].tsx @@ -19,6 +19,7 @@ import { import { CommentBox } from '../../../../components/Activity/CommentBox'; import { ProductCard } from '../../../../components/Activity/ProductCard'; import { PageHead } from '../../../../components/Layout/PageHead'; +import { getStaticHackathonProfile } from '../../../../constants/staticHackathons'; import { Activity, ActivityModel } from '../../../../models/Activity'; import { Member, @@ -35,6 +36,8 @@ export const getServerSideProps = compose>( cache(), errorLogger, async ({ params }) => { + if (getStaticHackathonProfile(params!.id)) return { notFound: true, props: {} }; + const activity = await new ActivityModel().getOne(params!.id); const { appId, tableIdMap } = activity.databaseSchema; From fec9d0aa6157c2a71018ffbbae15944484a0671c Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 20:57:19 +0800 Subject: [PATCH 02/14] Generalize hackathon page redesign --- components/Activity/StaticHackathonDetail.tsx | 143 -------- constants/staticHackathons.ts | 172 ---------- pages/hackathon/[id].tsx | 313 ++++++++++++------ pages/hackathon/[id]/team/[tid].tsx | 3 - styles/Hackathon.module.less | 139 ++++++++ 5 files changed, 353 insertions(+), 417 deletions(-) delete mode 100644 components/Activity/StaticHackathonDetail.tsx delete mode 100644 constants/staticHackathons.ts diff --git a/components/Activity/StaticHackathonDetail.tsx b/components/Activity/StaticHackathonDetail.tsx deleted file mode 100644 index 665a4e6..0000000 --- a/components/Activity/StaticHackathonDetail.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import type { FC } from 'react'; -import { Badge, Button, Card, Col, Container, Row } from 'react-bootstrap'; - -import type { StaticHackathonProfile } from '../../constants/staticHackathons'; -import styles from '../../styles/Hackathon.module.less'; - -const AgendaTypeLabel: Record = { - enrollment: '报名', - formation: '组队', - competition: '比赛', - evaluation: '评审', - break: '展示', -}; - -export interface StaticHackathonDetailProps { - profile: StaticHackathonProfile; -} - -export const StaticHackathonDetail: FC = ({ profile }) => ( - <> -
- -
- - {profile.theme} - -
-

{profile.name}

-

{profile.summary}

- - - - - -
📍 活动形式
-

{profile.location}

-
-
- - - - -
⏰ 活动时间
-

{profile.timeline}

-
-
- -
- - - {profile.entryLinks.slice(0, 2).map(({ href, label }) => ( - - - - ))} - -
-
- - -
-

🔗 报名与入口

- - {profile.entryLinks.map(({ href, label, note }) => ( - - -

{label}

-

{note}

- -
- - ))} -
-
- -
-

📅 活动日程

-
    - {profile.agenda.map(({ endedAt, name, startedAt, summary, type }) => ( -
  1. -

    {name}

    -

    {summary}

    -
    - {AgendaTypeLabel[type]} -
    - {startedAt} - {endedAt} -
    -
    -
  2. - ))} -
-
- -
-

🏆 奖项设置

- - {profile.awards.map(({ name, quota, summary }) => ( - - -
-

{name}

- {quota} -
-

{summary}

-
- - ))} -
-
- -
-

🧾 参赛说明

- - {profile.notes.map(note => ( - - -

{note}

-
- - ))} -
-
- -
-

❓ FAQ

- - {profile.faq.map(({ answer, question }) => ( - - -

{question}

-

{answer}

-
- - ))} -
-
-
- -); diff --git a/constants/staticHackathons.ts b/constants/staticHackathons.ts deleted file mode 100644 index f9d92af..0000000 --- a/constants/staticHackathons.ts +++ /dev/null @@ -1,172 +0,0 @@ -export interface StaticHackathonLink { - href: string; - label: string; - note: string; -} - -export interface StaticHackathonAgendaItem { - endedAt: string; - name: string; - startedAt: string; - summary: string; - type: 'break' | 'competition' | 'enrollment' | 'evaluation' | 'formation'; -} - -export interface StaticHackathonPrize { - name: string; - quota: string; - summary: string; -} - -export interface StaticHackathonFAQ { - answer: string; - question: string; -} - -export interface StaticHackathonProfile { - agenda: StaticHackathonAgendaItem[]; - awards: StaticHackathonPrize[]; - entryLinks: StaticHackathonLink[]; - faq: StaticHackathonFAQ[]; - id: string; - location: string; - name: string; - notes: string[]; - summary: string; - theme: string; - timeline: string; -} - -export const staticHackathons: Record = { - 'niuma-hackathon-2026': { - id: 'niuma-hackathon-2026', - name: '2026 五一牛马 AI 黑客松', - summary: - '劳动节,用 AI 把自己从重复劳动中解放出来。报名一个月拉满人,组队只给 7 天做决策,比赛压缩为 3 天核心冲刺,再用 Demo Day 做集中展示。', - theme: 'AI 解放牛马', - location: '线上 + 线下同步', - timeline: '2026-03-25 00:00 - 2026-05-06 晚上(UTC+8)', - entryLinks: [ - { - label: '队员注册', - href: 'https://open-source-bazaar.feishu.cn/share/base/shrcnZAJd1yhqHYRmQXmAwlXjkc', - note: '所有参赛者先完成报名,进入组队池与官方群。', - }, - { - label: '项目注册', - href: 'https://open-source-bazaar.feishu.cn/share/base/shrcnKsgpHpBXcwh4W6GDg2G7Nc', - note: '组队阶段由队长登记项目、成员、赛道和一句话介绍。', - }, - { - label: '代码库创建', - href: 'https://open-source-bazaar.feishu.cn/share/base/shrcnc1mbGsxMm8mS69au3B7uzf', - note: '需要官方统一协助建库时使用,不是所有队伍都必须填写。', - }, - { - label: '产品提交', - href: 'https://open-source-bazaar.feishu.cn/share/base/shrcnkrO7EMQlXYR1I41w26Ioyf', - note: '比赛截止前由队长或指定提交人统一提交最终作品。', - }, - { - label: '活动官网', - href: 'https://hack.digitalvio.shop/', - note: '查看当前活动主页、时间线和最新说明。', - }, - ], - agenda: [ - { - name: '报名阶段', - summary: '所有队员完成注册,开始找人、找方向、进入官方群。', - startedAt: '2026-03-25 00:00', - endedAt: '2026-04-20 23:59', - type: 'enrollment', - }, - { - name: '组队 / 项目注册', - summary: '队长完成项目注册,锁定队伍、赛道和项目介绍。', - startedAt: '2026-04-21 00:00', - endedAt: '2026-04-28 23:59', - type: 'formation', - }, - { - name: '比赛冲刺', - summary: '3 天核心开发冲刺,围绕 AI Agents、开发者工具、创意娱乐与社会影响展开。', - startedAt: '2026-05-01 10:00', - endedAt: '2026-05-03 20:00', - type: 'competition', - }, - { - name: '评审阶段', - summary: '评委完成打分与复核,筛出进入 Demo Day 的队伍。', - startedAt: '2026-05-03 20:00', - endedAt: '2026-05-05 18:00', - type: 'evaluation', - }, - { - name: 'Demo Day', - summary: '集中展示项目成果,并公布最终结果。', - startedAt: '2026-05-06 晚上', - endedAt: '2026-05-06 晚上', - type: 'break', - }, - ], - awards: [ - { - name: '一等奖', - quota: '1 队', - summary: '首页展示 + 资源连接 + 一等奖数字徽章', - }, - { - name: '二等奖', - quota: '2 队', - summary: '社区展示 + 官方收录 + 二等奖数字徽章', - }, - { - name: '三等奖', - quota: '3 队', - summary: '荣誉名单 + 项目合集收录 + 三等奖数字徽章', - }, - { - name: '最佳创意奖', - quota: '1 队', - summary: '专题推荐 + 最佳创意数字徽章', - }, - { - name: '最佳牛马精神奖', - quota: '1 队', - summary: '社区荣誉 + 最佳牛马精神数字徽章', - }, - { - name: 'Demo Day 入选', - quota: '若干', - summary: 'Demo Day 展示资格 + 入选数字徽章', - }, - ], - notes: [ - '每队 1-4 人,支持跨城市、跨公司组队。', - '作品需在本次黑客松期间完成核心创作,并包含 AI 核心能力或 AI 驱动体验。', - '最终提交建议包含 GitHub 仓库链接、Demo 视频、产品说明和 AI 技术栈说明。', - '奖项以数字徽章 + 展示 / 资源连接为主,不承诺实体奖章或现金奖励。', - ], - faq: [ - { - question: '可以一个人参赛吗?', - answer: '可以,单人队伍有效参赛,也可以在官方群里继续找队友。', - }, - { - question: '活动鼓励哪些方向?', - answer: '鼓励 AI Agents、开发者工具、创意娱乐、社会影响等方向,也支持自由赛道。', - }, - { - question: '什么时候截止报名?', - answer: '报名截止时间是 2026-04-20 23:59(UTC+8)。', - }, - { - question: '什么时候开始比赛?', - answer: '比赛将在 2026-05-01 10:00(UTC+8)正式开始。', - }, - ], - }, -}; - -export const getStaticHackathonProfile = (id: string) => staticHackathons[id]; diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index 2f7d141..4fc3ec4 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -3,28 +3,13 @@ import { observer } from 'mobx-react'; import Link from 'next/link'; import { cache, compose, errorLogger } from 'next-ssr-middleware'; import { FC, useContext } from 'react'; -import { - Badge, - Button, - ButtonGroup, - Card, - Col, - Container, - Dropdown, - DropdownButton, - Row, -} from 'react-bootstrap'; +import { Badge, Button, Card, Col, Container, Row } from 'react-bootstrap'; import { text2color, UserRankView } from 'idea-react'; import { formatDate } from 'web-utility'; -import { StaticHackathonDetail } from '../../components/Activity/StaticHackathonDetail'; import { GitCard } from '../../components/Git/Card'; import { LarkImage } from '../../components/LarkImage'; import { PageHead } from '../../components/Layout/PageHead'; -import { - getStaticHackathonProfile, - type StaticHackathonProfile, -} from '../../constants/staticHackathons'; import { Activity, ActivityModel } from '../../models/Activity'; import { fileURLOf } from '../../models/Base'; import { @@ -48,10 +33,6 @@ export const getServerSideProps = compose<{ id: string }>( cache(), errorLogger, async ({ params }) => { - const staticHackathon = getStaticHackathonProfile(params!.id); - - if (staticHackathon) return { props: { staticHackathon } }; - const activity = await new ActivityModel().getOne(params!.id); const { appId, tableIdMap } = activity.databaseSchema as BiTableSchema; @@ -68,22 +49,15 @@ export const getServerSideProps = compose<{ id: string }>( return { props: { activity, - hackathon: { - people, - organizations, - agenda, - prizes, - templates, - projects, - }, + hackathon: { people, organizations, agenda, prizes, templates, projects }, }, }; }, ); interface HackathonDetailProps { - activity?: Activity; - hackathon?: { + activity: Activity; + hackathon: { people: Person[]; organizations: Organization[]; agenda: Agenda[]; @@ -91,83 +65,240 @@ interface HackathonDetailProps { templates: Template[]; projects: Project[]; }; - staticHackathon?: StaticHackathonProfile; } -const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation']; +type FormGroupKey = 'Person' | 'Project' | 'Product' | 'Evaluation'; -const HackathonDetail: FC = observer(({ activity, hackathon }) => { - if (!activity || !hackathon) return null; +interface FormLink { + name: string; + shared_limit?: string; + shared_url: string; +} + +interface FormGroupMeta { + description: string; + eyebrow: string; + title: string; +} + +interface FormGroup { + key: FormGroupKey; + list: FormLink[]; + meta: FormGroupMeta; +} +const FormButtonBar: FormGroupKey[] = ['Person', 'Project', 'Product', 'Evaluation']; + +const FormSectionMeta: Record = { + Person: { + eyebrow: 'Participants', + title: '参与者登记', + description: '收集报名成员、建立参赛者池,并为后续组队和通知打底。', + }, + Project: { + eyebrow: 'Team Lead', + title: '项目注册', + description: '由队长登记项目名称、成员、赛道和一句话介绍,完成队伍锁定。', + }, + Product: { + eyebrow: 'Submission', + title: '作品提交', + description: '比赛截止前统一提交最终作品、演示链接和补充说明。', + }, + Evaluation: { + eyebrow: 'Review', + title: '评审入口', + description: '评委或导师在评审阶段使用,用于评分、复核与结果整理。', + }, +}; + +const isPublicForm = ({ shared_limit }: FormLink) => shared_limit === 'anyone_editable'; + +const HackathonDetail: FC = observer(({ activity, hackathon }) => { const { t } = useContext(I18nContext); - const { name, summary, location, startTime, endTime, databaseSchema } = activity, + const { + name, + summary, + location, + startTime, + endTime, + databaseSchema, + host, + image, + type: activityType, + } = activity, { people, organizations, agenda, prizes, templates, projects } = hackathon; - const { forms } = databaseSchema as BiTableSchema; + const forms = (((databaseSchema as BiTableSchema).forms || {}) as Partial< + Record + >)!; + const formGroups = FormButtonBar.flatMap(key => { + const list = forms[key]?.filter(isPublicForm) || []; + + return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; + }); + const primaryForm = + formGroups.find(({ key }) => key === 'Person') || + formGroups.find(({ key }) => key === 'Project') || + formGroups[0]; + const secondaryForm = + formGroups.find(({ key }) => key === 'Project' && key !== primaryForm?.key) || + formGroups.find(({ key }) => key !== primaryForm?.key); + const heroStats = [ + { label: t('participants'), value: people.length }, + { label: t('projects'), value: projects.length }, + { label: t('templates'), value: templates.length }, + { label: t('organizations'), value: organizations.length }, + ]; + const hostTags = ((host as string[] | undefined) || []).slice(0, 3); + const agendaPreview = agenda.slice(0, 3); return ( <> - {/* Hero Section */}
-

{name as string}

-

{summary as string}

+ + +
+ {(activityType as string) || t('hackathon')} + {hostTags.map(tag => ( + + {tag} + + ))} +
- - - - -
📍 {t('event_location')}
-

- {(location as TableCellLocation)?.full_address} -

-
-
+

{name as string}

+

{summary as string}

+ +
+ {heroStats.map(({ label, value }) => ( + + {value} {label} + + ))} +
+ +
+ {primaryForm && ( + + )} + {secondaryForm && ( + + )} + {formGroups[0] && ( + + )} +
+ + + + + +
📍 {t('event_location')}
+

+ {(location as TableCellLocation)?.full_address} +

+
+
+ + + + +
⏰ {t('event_duration')}
+

+ {formatDate(startTime as string)} - {formatDate(endTime as string)} +

+
+
+ +
- - + + + + {image && ( +
+ +
+ )} -
⏰ {t('event_duration')}
-

- {formatDate(startTime as string)} - {formatDate(endTime as string)} -

+
+ Agenda Preview + {agendaPreview[0]?.name as string} +
+ +
+ {agendaPreview.map(({ name, startedAt, endedAt }) => ( +
+ {name as string} + + {formatDate(startedAt as string)} - {formatDate(endedAt as string)} + +
+ ))} +
- - - {FormButtonBar.map((key, index) => { - const list = forms[key]?.filter( - // @ts-expect-error Upstream types bug - ({ shared_limit }) => shared_limit === 'anyone_editable', - ); - - return !list?.[0] ? null : list.length < 2 ? ( - - ) : ( - - {list.map(({ name, shared_url }) => ( - - {name} - - ))} - - ); - })} -
+ {formGroups[0] && ( +
+

Action Hub · Forms

+

报名与提交流程

+

+ 入口根据活动当前配置自动生成。不同活动可以复用同一套页面结构,而不是为每次活动单开专页。 +

+ + + {formGroups.map(({ key, list, meta }, index) => ( + + + + Step {String(index + 1).padStart(2, '0')} · {meta.eyebrow} + +

{meta.title}

+

{meta.description}

+
+ {list.map(({ name, shared_url }) => ( + + ))} +
+
+ + ))} +
+
+ )} +

🏆 {t('prizes')}

@@ -206,7 +337,6 @@ const HackathonDetail: FC = observer(({ activity, hackatho
- {/* Mid-front: Organizations - Horizontal logo layout */}

🏢 {t('organizations')}

- {/* Mid-back: Templates - Using GitCard, 3-4 per row */}

🛠️ {t('templates')}

@@ -243,7 +372,6 @@ const HackathonDetail: FC = observer(({ activity, hackatho
- {/* Mid-back: Projects - Narrow cards, 3-4 per row */}

💡 {t('projects')}

@@ -278,7 +406,6 @@ const HackathonDetail: FC = observer(({ activity, hackatho
- {/* Footer: Participants - Circular avatars only */}

👥 {t('participants')}

+ + + + +
📍 {t('event_location')}
+
+ {(location as TableCellLocation)?.full_address} +
- - - -
⏰ {t('event_duration')}
-

- {formatDate(startTime as string)} - {formatDate(endTime as string)} -

-
+ + +
⏰ {t('event_duration')}
+

+ {' '} + - +

@@ -236,24 +229,32 @@ const HackathonDetail: FC = observer(({ activity, hackatho )} -
- Agenda Preview - {agendaPreview[0]?.name as string} -
- -
- {agendaPreview.map(({ name, startedAt, endedAt }) => ( -
- {name as string} - - {formatDate(startedAt as string)} - {formatDate(endedAt as string)} - + {agendaPreview[0] && ( +
+
+
{t('hackathon_agenda_preview')}
+
{agendaPreview[0].name as string}
- ))} -
+ + {agendaPreview.map(({ name, startedAt, endedAt }) => ( +
+
{name as string}
+
+ {' '} + -{' '} + +
+
+ ))} + + )} @@ -264,22 +265,23 @@ const HackathonDetail: FC = observer(({ activity, hackatho {formGroups[0] && (
-

Action Hub · Forms

-

报名与提交流程

-

- 入口根据活动当前配置自动生成。不同活动可以复用同一套页面结构,而不是为每次活动单开专页。 -

+
+

{t('hackathon_action_hub')}

+

{t('hackathon_entry_flow')}

+

{t('hackathon_entry_flow_description')}

+
- + {formGroups.map(({ key, list, meta }, index) => ( - + - Step {String(index + 1).padStart(2, '0')} · {meta.eyebrow} + {t('hackathon_step')} {String(index + 1).padStart(2, '0')} ·{' '} + {t(meta.eyebrow)} -

{meta.title}

-

{meta.description}

-
+

{t(meta.title)}

+

{t(meta.description)}

+
+
))} diff --git a/styles/Hackathon.module.less b/styles/Hackathon.module.less index e6fdec6..b5bfc5f 100644 --- a/styles/Hackathon.module.less +++ b/styles/Hackathon.module.less @@ -44,13 +44,6 @@ font-size: 1.2rem; } -.heroEyebrow { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-bottom: 1rem; -} - .heroTag { display: inline-flex; align-items: center; @@ -64,13 +57,6 @@ font-size: 0.9rem; } -.heroStats { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-top: 1.5rem; -} - .statChip { display: inline-flex; align-items: center; @@ -82,13 +68,6 @@ font-size: 0.9rem; } -.heroActions { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - margin-top: 1.75rem; -} - .heroVisualCard { overflow: hidden; box-shadow: 0 18px 50px rgba(10, 12, 30, 0.35); @@ -109,14 +88,6 @@ object-fit: cover; } -.heroVisualHead { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 0.75rem; - align-items: center; -} - .heroVisualKicker { letter-spacing: 0.08em; text-transform: uppercase; @@ -124,12 +95,6 @@ font-size: 0.8rem; } -.heroVisualList { - display: grid; - gap: 0.75rem; - margin-top: 1rem; -} - .heroVisualItem { display: flex; flex-direction: column; @@ -139,12 +104,24 @@ background: rgba(255, 255, 255, 0.06); padding: 0.85rem 1rem; - span { + dt { + margin: 0; + } + + dd { opacity: 0.74; + margin: 0; font-size: 0.88rem; } } +.heroVisualHead { + dd { + opacity: 1; + font-size: 1rem; + } +} + .infoCard { backdrop-filter: blur(10px); transition: all 0.3s ease; @@ -152,7 +129,6 @@ border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 16px; background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05)); - padding: 1.5rem; &:hover { transform: translateY(-5px); diff --git a/translation/en-US.ts b/translation/en-US.ts index c720c6d..f69d9cf 100644 --- a/translation/en-US.ts +++ b/translation/en-US.ts @@ -217,6 +217,27 @@ export default { event_duration: 'Duration', product_submission: 'Product Submission Form', agenda: 'Agenda', + hackathon_participant_registration: 'Participant Registration', + hackathon_participant_registration_description: + 'Collect participant profiles to support sign-up, team formation, and updates.', + hackathon_team_lead: 'Team Lead', + hackathon_project_registration: 'Project Registration', + hackathon_project_registration_description: + 'Register the project name, teammates, track, and one-line pitch to lock the team.', + hackathon_submission: 'Submission', + hackathon_product_submission_description: + 'Submit the final build, demo link, and supporting notes before the deadline.', + hackathon_review: 'Review', + hackathon_evaluation_entry: 'Evaluation Portal', + hackathon_evaluation_entry_description: + 'Used by judges or mentors during scoring, review, and result consolidation.', + hackathon_view_all_entries: 'View All Forms', + hackathon_agenda_preview: 'Agenda Preview', + hackathon_action_hub: 'Action Hub · Forms', + hackathon_entry_flow: 'Registration and Submission Flow', + hackathon_entry_flow_description: + 'Entries are generated from the current activity schema so different events can reuse the same page structure.', + hackathon_step: 'Step', participants: 'Participants', organizations: 'Organizations', prizes: 'Prizes', diff --git a/translation/zh-CN.ts b/translation/zh-CN.ts index 64c4e2a..3df6b91 100644 --- a/translation/zh-CN.ts +++ b/translation/zh-CN.ts @@ -213,6 +213,27 @@ export default { event_duration: '活动时间', product_submission: '产品提交', agenda: '日程安排', + hackathon_participant_registration: '参与者登记', + hackathon_participant_registration_description: + '收集报名成员、建立参赛者池,并为后续组队和通知打底。', + hackathon_team_lead: '队长', + hackathon_project_registration: '项目注册', + hackathon_project_registration_description: + '登记项目名称、成员、赛道和一句话介绍,完成队伍锁定。', + hackathon_submission: '提交', + hackathon_product_submission_description: + '比赛截止前统一提交最终作品、演示链接和补充说明。', + hackathon_review: '评审', + hackathon_evaluation_entry: '评审入口', + hackathon_evaluation_entry_description: + '评委或导师在评审阶段使用,用于评分、复核与结果整理。', + hackathon_view_all_entries: '查看全部入口', + hackathon_agenda_preview: '议程预览', + hackathon_action_hub: '入口中心 · 表单', + hackathon_entry_flow: '报名与提交流程', + hackathon_entry_flow_description: + '入口根据活动当前配置自动生成。不同活动可以复用同一套页面结构,而不是为每次活动单开专页。', + hackathon_step: '步骤', participants: '参与者', organizations: '组织方', prizes: '奖项', diff --git a/translation/zh-TW.ts b/translation/zh-TW.ts index 284879a..cdc305d 100644 --- a/translation/zh-TW.ts +++ b/translation/zh-TW.ts @@ -213,6 +213,27 @@ export default { event_duration: '活動時間', product_submission: '產品提交', agenda: '日程安排', + hackathon_participant_registration: '參與者登記', + hackathon_participant_registration_description: + '收集報名成員、建立參賽者池,並為後續組隊和通知打底。', + hackathon_team_lead: '隊長', + hackathon_project_registration: '項目註冊', + hackathon_project_registration_description: + '登記項目名稱、成員、賽道和一句話介紹,完成隊伍鎖定。', + hackathon_submission: '提交', + hackathon_product_submission_description: + '比賽截止前統一提交最終作品、演示連結和補充說明。', + hackathon_review: '評審', + hackathon_evaluation_entry: '評審入口', + hackathon_evaluation_entry_description: + '評委或導師在評審階段使用,用於評分、複核與結果整理。', + hackathon_view_all_entries: '查看全部入口', + hackathon_agenda_preview: '議程預覽', + hackathon_action_hub: '入口中心 · 表單', + hackathon_entry_flow: '報名與提交流程', + hackathon_entry_flow_description: + '入口會根據活動當前配置自動生成。不同活動可以共用同一套頁面結構,而不是為每次活動單開專頁。', + hackathon_step: '步驟', participants: '參與者', organizations: '組織方', prizes: '獎項', From 16740379f11346129ab33a663cde7c5dcd113fc5 Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 21:52:36 +0800 Subject: [PATCH 04/14] Polish remaining hackathon review fixes --- pages/hackathon/[id].tsx | 794 +++++++++++++++++++---------------- styles/Hackathon.module.less | 26 +- 2 files changed, 451 insertions(+), 369 deletions(-) diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index 6917103..aef78b9 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -1,17 +1,22 @@ -import { BiTableSchema, TableCellLocation, TableCellUser, TableFormView } from 'mobx-lark'; -import { observer } from 'mobx-react'; -import Link from 'next/link'; -import { cache, compose, errorLogger } from 'next-ssr-middleware'; -import { FC, useContext } from 'react'; -import { Badge, Button, Card, Col, Container, Row } from 'react-bootstrap'; -import { text2color, UserRankView } from 'idea-react'; -import { formatDate } from 'web-utility'; - -import { GitCard } from '../../components/Git/Card'; -import { LarkImage } from '../../components/LarkImage'; -import { PageHead } from '../../components/Layout/PageHead'; -import { Activity, ActivityModel } from '../../models/Activity'; -import { fileURLOf } from '../../models/Base'; +import { + BiTableSchema, + TableCellLocation, + TableCellUser, + TableFormView, +} from "mobx-lark"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { cache, compose, errorLogger } from "next-ssr-middleware"; +import { FC, useContext } from "react"; +import { Badge, Button, Card, Col, Container, Row } from "react-bootstrap"; +import { text2color, UserRankView } from "idea-react"; +import { formatDate } from "web-utility"; + +import { GitCard } from "../../components/Git/Card"; +import { LarkImage } from "../../components/LarkImage"; +import { PageHead } from "../../components/Layout/PageHead"; +import { Activity, ActivityModel } from "../../models/Activity"; +import { fileURLOf } from "../../models/Base"; import { Agenda, AgendaModel, @@ -25,9 +30,9 @@ import { ProjectModel, Template, TemplateModel, -} from '../../models/Hackathon'; -import { I18nContext, I18nKey } from '../../models/Translation'; -import styles from '../../styles/Hackathon.module.less'; +} from "../../models/Hackathon"; +import { I18nContext, I18nKey } from "../../models/Translation"; +import styles from "../../styles/Hackathon.module.less"; export const getServerSideProps = compose<{ id: string }>( cache(), @@ -37,19 +42,27 @@ export const getServerSideProps = compose<{ id: string }>( const { appId, tableIdMap } = activity.databaseSchema as BiTableSchema; - const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ - new PersonModel(appId, tableIdMap.Person).getAll(), - new OrganizationModel(appId, tableIdMap.Organization).getAll(), - new AgendaModel(appId, tableIdMap.Agenda).getAll(), - new PrizeModel(appId, tableIdMap.Prize).getAll(), - new TemplateModel(appId, tableIdMap.Template).getAll(), - new ProjectModel(appId, tableIdMap.Project).getAll(), - ]); + const [people, organizations, agenda, prizes, templates, projects] = + await Promise.all([ + new PersonModel(appId, tableIdMap.Person).getAll(), + new OrganizationModel(appId, tableIdMap.Organization).getAll(), + new AgendaModel(appId, tableIdMap.Agenda).getAll(), + new PrizeModel(appId, tableIdMap.Prize).getAll(), + new TemplateModel(appId, tableIdMap.Template).getAll(), + new ProjectModel(appId, tableIdMap.Project).getAll(), + ]); return { props: { activity, - hackathon: { people, organizations, agenda, prizes, templates, projects }, + hackathon: { + people, + organizations, + agenda, + prizes, + templates, + projects, + }, }, }; }, @@ -67,371 +80,434 @@ interface HackathonDetailProps { }; } -const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation'] as const; +const FormButtonBar = ["Person", "Project", "Product", "Evaluation"] as const; type FormGroupKey = (typeof FormButtonBar)[number]; -type FormLink = TableFormView; - -interface FormGroupMeta { - description: I18nKey; - eyebrow: I18nKey; - title: I18nKey; -} +type FormGroupMeta = Record<"title" | "description" | "eyebrow", I18nKey>; interface FormGroup { key: FormGroupKey; - list: FormLink[]; + list: TableFormView[]; meta: FormGroupMeta; } const FormSectionMeta: Record = { Person: { - eyebrow: 'participants', - title: 'hackathon_participant_registration', - description: 'hackathon_participant_registration_description', + eyebrow: "participants", + title: "hackathon_participant_registration", + description: "hackathon_participant_registration_description", }, Project: { - eyebrow: 'hackathon_team_lead', - title: 'hackathon_project_registration', - description: 'hackathon_project_registration_description', + eyebrow: "hackathon_team_lead", + title: "hackathon_project_registration", + description: "hackathon_project_registration_description", }, Product: { - eyebrow: 'hackathon_submission', - title: 'product_submission', - description: 'hackathon_product_submission_description', + eyebrow: "hackathon_submission", + title: "product_submission", + description: "hackathon_product_submission_description", }, Evaluation: { - eyebrow: 'hackathon_review', - title: 'hackathon_evaluation_entry', - description: 'hackathon_evaluation_entry_description', + eyebrow: "hackathon_review", + title: "hackathon_evaluation_entry", + description: "hackathon_evaluation_entry_description", }, }; -const isPublicForm = ({ shared, shared_limit }: FormLink) => - shared || ['tenant_editable', 'anyone_editable'].includes(shared_limit as string); - -const HackathonDetail: FC = observer(({ activity, hackathon }) => { - const { t } = useContext(I18nContext); - - const { - name, - summary, - location, - startTime, - endTime, - databaseSchema, - host, - image, - type: activityType, - } = activity, - { people, organizations, agenda, prizes, templates, projects } = hackathon; - const forms = (((databaseSchema as BiTableSchema).forms || {}) as Partial< - Record - >)!; - const formGroups = FormButtonBar.flatMap(key => { - const list = forms[key]?.filter(isPublicForm) || []; - - return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; - }); - const primaryForm = - formGroups.find(({ key }) => key === 'Person') || - formGroups.find(({ key }) => key === 'Project') || - formGroups[0]; - const secondaryForm = - formGroups.find(({ key }) => key === 'Project' && key !== primaryForm?.key) || - formGroups.find(({ key }) => key !== primaryForm?.key); - const heroStats = [ - { label: t('participants'), value: people.length }, - { label: t('projects'), value: projects.length }, - { label: t('templates'), value: templates.length }, - { label: t('organizations'), value: organizations.length }, - ]; - const hostTags = (host as string[] | undefined)?.slice(0, 3) || []; - const agendaPreview = agenda.slice(0, 3); - - return ( - <> - - -
- - - -
    -
  • {(activityType as string) || t('hackathon')}
  • - {hostTags.map(tag => ( -
  • - {tag} -
  • - ))} -
- -

{name as string}

-

{summary as string}

- -
    - {heroStats.map(({ label, value }) => ( -
  • - {value} {label} +const isPublicForm = ({ shared, shared_limit }: TableFormView) => + shared || + ["tenant_editable", "anyone_editable"].includes(shared_limit as string); + +const HackathonDetail: FC = observer( + ({ activity, hackathon }) => { + const { t } = useContext(I18nContext); + + const { + name, + summary, + location, + startTime, + endTime, + databaseSchema, + host, + image, + type: activityType, + } = activity, + { people, organizations, agenda, prizes, templates, projects } = + hackathon; + const forms = (((databaseSchema as BiTableSchema).forms || {}) as Partial< + Record + >)!; + const formGroups = FormButtonBar.flatMap((key) => { + const list = forms[key]?.filter(isPublicForm) || []; + + return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; + }); + const primaryForm = + formGroups.find(({ key }) => key === "Person") || + formGroups.find(({ key }) => key === "Project") || + formGroups[0]; + const secondaryForm = + formGroups.find( + ({ key }) => key === "Project" && key !== primaryForm?.key, + ) || formGroups.find(({ key }) => key !== primaryForm?.key); + const heroStats = [ + { label: t("participants"), value: people.length }, + { label: t("projects"), value: projects.length }, + { label: t("templates"), value: templates.length }, + { label: t("organizations"), value: organizations.length }, + ]; + const hostTags = (host as string[] | undefined)?.slice(0, 3) || []; + const agendaPreview = agenda.slice(0, 3); + + return ( + <> + + +
    + + + +
      +
    • + {(activityType as string) || t("hackathon")}
    • - ))} -
    - - - - - - -
    📍 {t('event_location')}
    -
    - {(location as TableCellLocation)?.full_address} -
    -
    - - - -
    ⏰ {t('event_duration')}
    -

    - {' '} - - -

    -
    - -
    - - - - - {image && ( -
    - -
    - )} - - {agendaPreview[0] && ( -
    -
    -
    {t('hackathon_agenda_preview')}
    -
    {agendaPreview[0].name as string}
    -
    + {hostTags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ +

{name as string}

+

{summary as string}

+ +
    + {heroStats.map(({ label, value }) => ( +
  • + {value} {label} +
  • + ))} +
+ + + + + + +
+ 📍 {t("event_location")} +
+
+ {(location as TableCellLocation)?.full_address} +
+
+ + + +
+ ⏰ {t("event_duration")} +
+

+ {" "} + -{" "} + +

+
+ +
+ - {agendaPreview.map(({ name, startedAt, endedAt }) => ( + + + {image && ( +
+ +
+ )} + + {agendaPreview[0] && ( +
-
{name as string}
-
- {' '} - -{' '} - +
+ {t("hackathon_agenda_preview")} +
+
+ {agendaPreview[0].name as string}
- ))} -
- )} -
-
- -
-
-
- - - {formGroups[0] && ( -
-
-

{t('hackathon_action_hub')}

-

{t('hackathon_entry_flow')}

-

{t('hackathon_entry_flow_description')}

-
- - - {formGroups.map(({ key, list, meta }, index) => ( - - - - {t('hackathon_step')} {String(index + 1).padStart(2, '0')} ·{' '} - {t(meta.eyebrow)} - -

{t(meta.title)}

-

{t(meta.description)}

- -
- - ))} + + {agendaPreview.map(({ name, startedAt, endedAt }) => ( +
+
{name as string}
+
+ {" "} + -{" "} + +
+
+ ))} + + )} + + +
-
- )} - -
-

🏆 {t('prizes')}

-
- ({ - id: `prize-${index}`, - name: name as string, - avatar: fileURLOf(image), - score: price as number, - }))} - /> -
+
-
-

📅 {t('agenda')}

-
    - {agenda.map(({ name, type, summary, startedAt, endedAt }) => ( -
  1. -
    {name as string}
    -

    {summary as string}

    -
    - - {t(type as I18nKey)} - -
    - {formatDate(startedAt as string)} - {formatDate(endedAt as string)} + + {formGroups[0] && ( +
    +
    +

    + {t("hackathon_action_hub")} +

    +

    + {t("hackathon_entry_flow")} +

    +

    + {t("hackathon_entry_flow_description")} +

    +
    + + + {formGroups.map(({ key, list, meta }, index) => ( + + + + {t("hackathon_step")}{" "} + {String(index + 1).padStart(2, "0")} · {t(meta.eyebrow)} + +

    {t(meta.title)}

    +

    + {t(meta.description)} +

    + +
    + + ))} +
    +
    + )} + +
    +

    🏆 {t("prizes")}

    +
    + ({ + id: `prize-${index}`, + name: name as string, + avatar: fileURLOf(image), + score: price as number, + }))} + /> +
    +
    + +
    +

    📅 {t("agenda")}

    +
      + {agenda.map(({ name, type, summary, startedAt, endedAt }) => ( +
    1. +
      {name as string}
      +

      + {summary as string} +

      +
      + + {t(type as I18nKey)} + +
      + {formatDate(startedAt as string)} -{" "} + {formatDate(endedAt as string)} +
      -
    -
  2. - ))} -
-
+ + ))} + +
-
-

🏢 {t('organizations')}

- -
+
+

🏢 {t("organizations")}

+ +
-
-

🛠️ {t('templates')}

- - {templates.map(({ name, languages, tags, sourceLink, summary, previewLink }) => ( - - - - ))} - -
+
+

🛠️ {t("templates")}

+ + {templates.map( + ({ + name, + languages, + tags, + sourceLink, + summary, + previewLink, + }) => ( + + + + ), + )} + +
-
-

💡 {t('projects')}

- - - {projects.map(({ id, name, score, summary, createdBy, members }) => ( - - -
-
- - {name as string} - -
-
{score as number}
-
-

{summary as string}

-
- {t('created_by')}:{' '} - - {(createdBy as TableCellUser)?.name} - -
-
- {t('members')}: {(members as string[]).join(', ')} -
-
- - ))} -
-
+
+

💡 {t("projects")}

+ + + {projects.map( + ({ id, name, score, summary, createdBy, members }) => ( + + +
+
+ + {name as string} + +
+
+ {score as number} +
+
+

+ {summary as string} +

+
+ {t("created_by")}:{" "} + + {(createdBy as TableCellUser)?.name} + +
+
+ {t("members")}:{" "} + {(members as string[] | undefined)?.join(", ") || "-"} +
+
+ + ), + )} +
+
-
-

👥 {t('participants')}

- -
-
- - ); -}); +
+

👥 {t("participants")}

+ +
+ + + ); + }, +); export default HackathonDetail; diff --git a/styles/Hackathon.module.less b/styles/Hackathon.module.less index b5bfc5f..26ae2e5 100644 --- a/styles/Hackathon.module.less +++ b/styles/Hackathon.module.less @@ -12,12 +12,16 @@ top: -50%; left: -50%; animation: grid-animation 20s linear infinite; - background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 1px, transparent 1px); + 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: ''; + content: ""; } } @@ -73,7 +77,11 @@ box-shadow: 0 18px 50px rgba(10, 12, 30, 0.35); border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 24px; - background: linear-gradient(135deg, rgba(10, 12, 30, 0.82), rgba(37, 44, 84, 0.62)); + background: linear-gradient( + 135deg, + rgba(10, 12, 30, 0.82), + rgba(37, 44, 84, 0.62) + ); color: #fff; } @@ -82,12 +90,6 @@ aspect-ratio: 16 / 10; } -.heroImage { - width: 100%; - height: 100%; - object-fit: cover; -} - .heroVisualKicker { letter-spacing: 0.08em; text-transform: uppercase; @@ -128,7 +130,11 @@ margin-bottom: 1rem; border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 16px; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05)); + background: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.1), + rgba(255, 255, 255, 0.05) + ); &:hover { transform: translateY(-5px); From ae8eed33aa16f2f8b0ef643770a53522233fdc78 Mon Sep 17 00:00:00 2001 From: TechQuery Date: Mon, 30 Mar 2026 22:07:11 +0800 Subject: [PATCH 05/14] [fix] many CodeX bugs --- pages/api/Lark/document/copy/[...slug].ts | 2 +- pages/hackathon/[id].tsx | 791 ++++++++++------------ styles/Hackathon.module.less | 42 +- 3 files changed, 371 insertions(+), 464 deletions(-) diff --git a/pages/api/Lark/document/copy/[...slug].ts b/pages/api/Lark/document/copy/[...slug].ts index dd9f453..8c2de97 100644 --- a/pages/api/Lark/document/copy/[...slug].ts +++ b/pages/api/Lark/document/copy/[...slug].ts @@ -18,7 +18,7 @@ router.post('/:type/:id', safeAPI, verifyJWT, async (context: Context) => { ? await lark.copyFile(`${type as 'wiki'}/${id}`, name, parentToken) : await lark.copyFile(`${type as LarkDocumentPathType}/${id}`, name, parentToken); - const newId = 'token' in copiedFile ? copiedFile.token : copiedFile.obj_token; + const newId = 'token' in copiedFile ? copiedFile.token : copiedFile.node_token; if (ownerType && ownerId) try { diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index aef78b9..cb015e2 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -1,22 +1,17 @@ -import { - BiTableSchema, - TableCellLocation, - TableCellUser, - TableFormView, -} from "mobx-lark"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { cache, compose, errorLogger } from "next-ssr-middleware"; -import { FC, useContext } from "react"; -import { Badge, Button, Card, Col, Container, Row } from "react-bootstrap"; -import { text2color, UserRankView } from "idea-react"; -import { formatDate } from "web-utility"; - -import { GitCard } from "../../components/Git/Card"; -import { LarkImage } from "../../components/LarkImage"; -import { PageHead } from "../../components/Layout/PageHead"; -import { Activity, ActivityModel } from "../../models/Activity"; -import { fileURLOf } from "../../models/Base"; +import { BiTableSchema, TableCellLocation, TableCellUser, TableFormView } from 'mobx-lark'; +import { observer } from 'mobx-react'; +import Link from 'next/link'; +import { cache, compose, errorLogger } from 'next-ssr-middleware'; +import { FC, useContext } from 'react'; +import { Badge, Button, Card, Col, Container, Row } from 'react-bootstrap'; +import { text2color, UserRankView } from 'idea-react'; +import { formatDate } from 'web-utility'; + +import { GitCard } from '../../components/Git/Card'; +import { LarkImage } from '../../components/LarkImage'; +import { PageHead } from '../../components/Layout/PageHead'; +import { Activity, ActivityModel } from '../../models/Activity'; +import { fileURLOf } from '../../models/Base'; import { Agenda, AgendaModel, @@ -30,9 +25,9 @@ import { ProjectModel, Template, TemplateModel, -} from "../../models/Hackathon"; -import { I18nContext, I18nKey } from "../../models/Translation"; -import styles from "../../styles/Hackathon.module.less"; +} from '../../models/Hackathon'; +import { I18nContext, I18nKey } from '../../models/Translation'; +import styles from '../../styles/Hackathon.module.less'; export const getServerSideProps = compose<{ id: string }>( cache(), @@ -40,29 +35,21 @@ export const getServerSideProps = compose<{ id: string }>( async ({ params }) => { const activity = await new ActivityModel().getOne(params!.id); - const { appId, tableIdMap } = activity.databaseSchema as BiTableSchema; + const { appId, tableIdMap } = (activity.databaseSchema || {}) as BiTableSchema; - const [people, organizations, agenda, prizes, templates, projects] = - await Promise.all([ - new PersonModel(appId, tableIdMap.Person).getAll(), - new OrganizationModel(appId, tableIdMap.Organization).getAll(), - new AgendaModel(appId, tableIdMap.Agenda).getAll(), - new PrizeModel(appId, tableIdMap.Prize).getAll(), - new TemplateModel(appId, tableIdMap.Template).getAll(), - new ProjectModel(appId, tableIdMap.Project).getAll(), - ]); + const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ + new PersonModel(appId, tableIdMap.Person).getAll(), + new OrganizationModel(appId, tableIdMap.Organization).getAll(), + new AgendaModel(appId, tableIdMap.Agenda).getAll(), + new PrizeModel(appId, tableIdMap.Prize).getAll(), + new TemplateModel(appId, tableIdMap.Template).getAll(), + new ProjectModel(appId, tableIdMap.Project).getAll(), + ]); return { props: { activity, - hackathon: { - people, - organizations, - agenda, - prizes, - templates, - projects, - }, + hackathon: { people, organizations, agenda, prizes, templates, projects }, }, }; }, @@ -80,10 +67,10 @@ interface HackathonDetailProps { }; } -const FormButtonBar = ["Person", "Project", "Product", "Evaluation"] as const; +const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation'] as const; type FormGroupKey = (typeof FormButtonBar)[number]; -type FormGroupMeta = Record<"title" | "description" | "eyebrow", I18nKey>; +type FormGroupMeta = Record<'title' | 'description' | 'eyebrow', I18nKey>; interface FormGroup { key: FormGroupKey; @@ -93,421 +80,355 @@ interface FormGroup { const FormSectionMeta: Record = { Person: { - eyebrow: "participants", - title: "hackathon_participant_registration", - description: "hackathon_participant_registration_description", + eyebrow: 'participants', + title: 'hackathon_participant_registration', + description: 'hackathon_participant_registration_description', }, Project: { - eyebrow: "hackathon_team_lead", - title: "hackathon_project_registration", - description: "hackathon_project_registration_description", + eyebrow: 'hackathon_team_lead', + title: 'hackathon_project_registration', + description: 'hackathon_project_registration_description', }, Product: { - eyebrow: "hackathon_submission", - title: "product_submission", - description: "hackathon_product_submission_description", + eyebrow: 'hackathon_submission', + title: 'product_submission', + description: 'hackathon_product_submission_description', }, Evaluation: { - eyebrow: "hackathon_review", - title: "hackathon_evaluation_entry", - description: "hackathon_evaluation_entry_description", + eyebrow: 'hackathon_review', + title: 'hackathon_evaluation_entry', + description: 'hackathon_evaluation_entry_description', }, }; -const isPublicForm = ({ shared, shared_limit }: TableFormView) => - shared || - ["tenant_editable", "anyone_editable"].includes(shared_limit as string); - -const HackathonDetail: FC = observer( - ({ activity, hackathon }) => { - const { t } = useContext(I18nContext); - - const { - name, - summary, - location, - startTime, - endTime, - databaseSchema, - host, - image, - type: activityType, - } = activity, - { people, organizations, agenda, prizes, templates, projects } = - hackathon; - const forms = (((databaseSchema as BiTableSchema).forms || {}) as Partial< - Record - >)!; - const formGroups = FormButtonBar.flatMap((key) => { - const list = forms[key]?.filter(isPublicForm) || []; - - return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; - }); - const primaryForm = - formGroups.find(({ key }) => key === "Person") || - formGroups.find(({ key }) => key === "Project") || - formGroups[0]; - const secondaryForm = - formGroups.find( - ({ key }) => key === "Project" && key !== primaryForm?.key, - ) || formGroups.find(({ key }) => key !== primaryForm?.key); - const heroStats = [ - { label: t("participants"), value: people.length }, - { label: t("projects"), value: projects.length }, - { label: t("templates"), value: templates.length }, - { label: t("organizations"), value: organizations.length }, - ]; - const hostTags = (host as string[] | undefined)?.slice(0, 3) || []; - const agendaPreview = agenda.slice(0, 3); - - return ( - <> - - -
- - - -
    -
  • - {(activityType as string) || t("hackathon")} +const isPublicForm = ({ shared_limit }: TableFormView) => + ['anyone_editable'].includes(shared_limit as string); + +const HackathonDetail: FC = observer(({ activity, hackathon }) => { + const { t } = useContext(I18nContext); + + const { + name, + summary, + location, + startTime, + endTime, + databaseSchema, + host, + image, + type: activityType, + } = activity, + { people, organizations, agenda, prizes, templates, projects } = hackathon; + const { forms } = (databaseSchema || {}) as BiTableSchema; + + const formGroups = FormButtonBar.flatMap(key => { + const list = forms[key]?.filter(isPublicForm) || []; + + return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; + }); + const primaryForm = + formGroups.find(({ key }) => key === 'Person') || + formGroups.find(({ key }) => key === 'Project') || + formGroups[0]; + const secondaryForm = + formGroups.find(({ key }) => key === 'Project' && key !== primaryForm?.key) || + formGroups.find(({ key }) => key !== primaryForm?.key); + const heroStats = [ + { label: t('participants'), value: people.length }, + { label: t('projects'), value: projects.length }, + { label: t('templates'), value: templates.length }, + { label: t('organizations'), value: organizations.length }, + ]; + const hostTags = (host as string[] | undefined)?.slice(0, 3) || []; + const agendaPreview = agenda.slice(0, 3); + + return ( + <> + + +
    + + + +
      +
    • {(activityType as string) || t('hackathon')}
    • + {hostTags.map(tag => ( +
    • + {tag}
    • - {hostTags.map((tag) => ( -
    • - {tag} -
    • - ))} -
    - -

    {name as string}

    -

    {summary as string}

    - -
      - {heroStats.map(({ label, value }) => ( -
    • - {value} {label} -
    • - ))} -
    - - - - - - -
    - 📍 {t("event_location")} -
    -
    - {(location as TableCellLocation)?.full_address} -
    -
    - - - -
    - ⏰ {t("event_duration")} -
    -

    - {" "} - -{" "} - -

    -
    - -
    - + ))} +
- - - {image && ( -
- -
- )} - - {agendaPreview[0] && ( -
+

{name as string}

+

{summary as string}

+ +
    + {heroStats.map(({ label, value }) => ( +
  • + {value} {label} +
  • + ))} +
+ + + + + + +
📍 {t('event_location')}
+
+ {(location as TableCellLocation)?.full_address} +
+
+ + + +
⏰ {t('event_duration')}
+

+ {' '} + - +

+
+ +
+ + + + + {image && ( +
+ +
+ )} + + {agendaPreview[0] && ( +
+
+
{t('hackathon_agenda_preview')}
+
{agendaPreview[0].name as string}
+
+ + {agendaPreview.map(({ name, startedAt, endedAt }) => (
-
- {t("hackathon_agenda_preview")} -
-
- {agendaPreview[0].name as string} +
{name as string}
+
+ {' '} + -{' '} +
- - {agendaPreview.map(({ name, startedAt, endedAt }) => ( -
-
{name as string}
-
- {" "} - -{" "} - -
-
- ))} -
- )} -
-
- + ))} +
+ )} +
+
+ +
+
+
+ + + {formGroups[0] && ( +
+
+

{t('hackathon_action_hub')}

+

{t('hackathon_entry_flow')}

+

{t('hackathon_entry_flow_description')}

+
+ + + {formGroups.map(({ key, list, meta }, index) => ( + + + + {t('hackathon_step')} {String(index + 1).padStart(2, '0')} · {t(meta.eyebrow)} + +

{t(meta.title)}

+

{t(meta.description)}

+ +
+ + ))}
- -
- - - {formGroups[0] && ( -
-
-

- {t("hackathon_action_hub")} -

-

- {t("hackathon_entry_flow")} -

-

- {t("hackathon_entry_flow_description")} -

-
- - - {formGroups.map(({ key, list, meta }, index) => ( - - - - {t("hackathon_step")}{" "} - {String(index + 1).padStart(2, "0")} · {t(meta.eyebrow)} - -

{t(meta.title)}

-

- {t(meta.description)} -

- -
- - ))} -
-
- )} - -
-

🏆 {t("prizes")}

-
- ({ - id: `prize-${index}`, - name: name as string, - avatar: fileURLOf(image), - score: price as number, - }))} - /> -
+ )} + +
+

🏆 {t('prizes')}

+
+ ({ + id: `prize-${index}`, + name: name as string, + avatar: fileURLOf(image), + score: price as number, + }))} + /> +
+
-
-

📅 {t("agenda")}

-
    - {agenda.map(({ name, type, summary, startedAt, endedAt }) => ( -
  1. -
    {name as string}
    -

    - {summary as string} -

    -
    - - {t(type as I18nKey)} - -
    - {formatDate(startedAt as string)} -{" "} - {formatDate(endedAt as string)} -
    +
    +

    📅 {t('agenda')}

    +
      + {agenda.map(({ name, type, summary, startedAt, endedAt }) => ( +
    1. +
      {name as string}
      +

      {summary as string}

      +
      + + {t(type as I18nKey)} + +
      + {formatDate(startedAt as string)} - {formatDate(endedAt as string)}
      -
    2. - ))} -
    -
    +
    +
  2. + ))} +
+
-
-

🏢 {t("organizations")}

- -
+
+

🏢 {t('organizations')}

+ +
-
-

🛠️ {t("templates")}

- - {templates.map( - ({ - name, - languages, - tags, - sourceLink, - summary, - previewLink, - }) => ( - - - - ), - )} - -
+
+

🛠️ {t('templates')}

+ + {templates.map(({ name, languages, tags, sourceLink, summary, previewLink }) => ( + + + + ))} + +
-
-

💡 {t("projects")}

- - - {projects.map( - ({ id, name, score, summary, createdBy, members }) => ( - - -
-
- - {name as string} - -
-
- {score as number} -
-
-

- {summary as string} -

-
- {t("created_by")}:{" "} - - {(createdBy as TableCellUser)?.name} - -
-
- {t("members")}:{" "} - {(members as string[] | undefined)?.join(", ") || "-"} -
-
- - ), - )} -
-
+
+

💡 {t('projects')}

+ + + {projects.map(({ id, name, score, summary, createdBy, members }) => ( + + +
+
+ + {name as string} + +
+
{score as number}
+
+

{summary as string}

+
+ {t('created_by')}:{' '} + + {(createdBy as TableCellUser)?.name} + +
+
+ {t('members')}:{' '} + {(members as string[] | undefined)?.join(', ') || '-'} +
+
+ + ))} +
+
-
-

👥 {t("participants")}

- -
-
- - ); - }, -); +
+

👥 {t('participants')}

+ +
+
+ + ); +}); export default HackathonDetail; diff --git a/styles/Hackathon.module.less b/styles/Hackathon.module.less index 26ae2e5..cb62098 100644 --- a/styles/Hackathon.module.less +++ b/styles/Hackathon.module.less @@ -12,16 +12,12 @@ top: -50%; left: -50%; animation: grid-animation 20s linear infinite; - background: radial-gradient( - circle, - rgba(255, 255, 255, 0.1) 1px, - transparent 1px - ); + 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: ""; + content: ''; } } @@ -73,15 +69,11 @@ } .heroVisualCard { - overflow: hidden; box-shadow: 0 18px 50px rgba(10, 12, 30, 0.35); border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 24px; - background: linear-gradient( - 135deg, - rgba(10, 12, 30, 0.82), - rgba(37, 44, 84, 0.62) - ); + background: linear-gradient(135deg, rgba(10, 12, 30, 0.82), rgba(37, 44, 84, 0.62)); + overflow: hidden; color: #fff; } @@ -91,10 +83,10 @@ } .heroVisualKicker { - letter-spacing: 0.08em; - text-transform: uppercase; opacity: 0.72; font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; } .heroVisualItem { @@ -117,11 +109,9 @@ } } -.heroVisualHead { - dd { - opacity: 1; - font-size: 1rem; - } +.heroVisualHead dd { + opacity: 1; + font-size: 1rem; } .infoCard { @@ -130,11 +120,7 @@ margin-bottom: 1rem; border: 2px solid rgba(255, 255, 255, 0.3); border-radius: 16px; - background: linear-gradient( - 135deg, - rgba(255, 255, 255, 0.1), - rgba(255, 255, 255, 0.05) - ); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05)); &:hover { transform: translateY(-5px); @@ -164,11 +150,11 @@ .sectionEyebrow { margin-bottom: 0.5rem; - letter-spacing: 0.08em; - text-transform: uppercase; color: #7d8ff5; font-weight: 700; font-size: 0.85rem; + letter-spacing: 0.08em; + text-transform: uppercase; } .sectionLead { @@ -194,10 +180,10 @@ } .entryStep { - letter-spacing: 0.06em; - text-transform: uppercase; opacity: 0.72; font-size: 0.82rem; + letter-spacing: 0.06em; + text-transform: uppercase; } // Vibrant cards - Multicolor gradient background From d6c6f97990f483041e14aa0e7c086463516670d0 Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 22:37:14 +0800 Subject: [PATCH 06/14] Rewrite hackathon detail layout --- pages/hackathon/[id].tsx | 1292 +++++++++++++++++++++++++--------- styles/Hackathon.module.less | 1262 ++++++++++++++++++++++++++------- 2 files changed, 1961 insertions(+), 593 deletions(-) diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index cb015e2..b98efff 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -1,17 +1,20 @@ -import { BiTableSchema, TableCellLocation, TableCellUser, TableFormView } from 'mobx-lark'; -import { observer } from 'mobx-react'; -import Link from 'next/link'; -import { cache, compose, errorLogger } from 'next-ssr-middleware'; -import { FC, useContext } from 'react'; -import { Badge, Button, Card, Col, Container, Row } from 'react-bootstrap'; -import { text2color, UserRankView } from 'idea-react'; -import { formatDate } from 'web-utility'; - -import { GitCard } from '../../components/Git/Card'; -import { LarkImage } from '../../components/LarkImage'; -import { PageHead } from '../../components/Layout/PageHead'; -import { Activity, ActivityModel } from '../../models/Activity'; -import { fileURLOf } from '../../models/Base'; +import { + BiTableSchema, + TableCellLocation, + TableCellUser, + TableCellValue, + TableFormView, +} from "mobx-lark"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { cache, compose, errorLogger } from "next-ssr-middleware"; +import { FC, useContext } from "react"; +import { Col, Container, Row } from "react-bootstrap"; +import { formatDate } from "web-utility"; + +import { LarkImage } from "../../components/LarkImage"; +import { PageHead } from "../../components/Layout/PageHead"; +import { Activity, ActivityModel } from "../../models/Activity"; import { Agenda, AgendaModel, @@ -25,31 +28,59 @@ import { ProjectModel, Template, TemplateModel, -} from '../../models/Hackathon'; -import { I18nContext, I18nKey } from '../../models/Translation'; -import styles from '../../styles/Hackathon.module.less'; +} from "../../models/Hackathon"; +import { I18nContext, I18nKey } from "../../models/Translation"; +import styles from "../../styles/Hackathon.module.less"; + +const RequiredTableKeys = [ + "Person", + "Organization", + "Agenda", + "Prize", + "Template", + "Project", +] as const; + +type RequiredTableKey = (typeof RequiredTableKeys)[number]; export const getServerSideProps = compose<{ id: string }>( cache(), errorLogger, async ({ params }) => { const activity = await new ActivityModel().getOne(params!.id); + const schema = activity.databaseSchema as + | Partial + | undefined; + const tableIdMap = schema?.tableIdMap as + | Partial> + | undefined; + + if (!schema?.appId || !tableIdMap) return { notFound: true, props: {} }; - const { appId, tableIdMap } = (activity.databaseSchema || {}) as BiTableSchema; + for (const key of RequiredTableKeys) + if (!tableIdMap[key]) return { notFound: true, props: {} }; - const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ - new PersonModel(appId, tableIdMap.Person).getAll(), - new OrganizationModel(appId, tableIdMap.Organization).getAll(), - new AgendaModel(appId, tableIdMap.Agenda).getAll(), - new PrizeModel(appId, tableIdMap.Prize).getAll(), - new TemplateModel(appId, tableIdMap.Template).getAll(), - new ProjectModel(appId, tableIdMap.Project).getAll(), - ]); + const [people, organizations, agenda, prizes, templates, projects] = + await Promise.all([ + new PersonModel(schema.appId, tableIdMap.Person).getAll(), + new OrganizationModel(schema.appId, tableIdMap.Organization).getAll(), + new AgendaModel(schema.appId, tableIdMap.Agenda).getAll(), + new PrizeModel(schema.appId, tableIdMap.Prize).getAll(), + new TemplateModel(schema.appId, tableIdMap.Template).getAll(), + new ProjectModel(schema.appId, tableIdMap.Project).getAll(), + ]); return { props: { activity, - hackathon: { people, organizations, agenda, prizes, templates, projects }, + hackathon: { + people, + organizations, + agenda, + prizes, + templates, + projects, + }, }, }; }, @@ -67,10 +98,23 @@ interface HackathonDetailProps { }; } -const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation'] as const; +const FormButtonBar = ["Person", "Project", "Product", "Evaluation"] as const; +const HeroBadgeTone = [ + "heroBadgeCyan", + "heroBadgeGold", + "heroBadgeGreen", + "heroBadgeRose", +] as const; +const HighlightIcons = ["👥", "🚀", "🛠", "🏆", "🤝", "📅"] as const; type FormGroupKey = (typeof FormButtonBar)[number]; -type FormGroupMeta = Record<'title' | 'description' | 'eyebrow', I18nKey>; +type FormGroupMeta = Record<"title" | "description" | "eyebrow", I18nKey>; +type AgendaToneClass = + | "formation" + | "enrollment" + | "competition" + | "break" + | "evaluation"; interface FormGroup { key: FormGroupKey; @@ -80,355 +124,923 @@ interface FormGroup { const FormSectionMeta: Record = { Person: { - eyebrow: 'participants', - title: 'hackathon_participant_registration', - description: 'hackathon_participant_registration_description', + eyebrow: "participants", + title: "hackathon_participant_registration", + description: "hackathon_participant_registration_description", }, Project: { - eyebrow: 'hackathon_team_lead', - title: 'hackathon_project_registration', - description: 'hackathon_project_registration_description', + eyebrow: "hackathon_team_lead", + title: "hackathon_project_registration", + description: "hackathon_project_registration_description", }, Product: { - eyebrow: 'hackathon_submission', - title: 'product_submission', - description: 'hackathon_product_submission_description', + eyebrow: "hackathon_submission", + title: "product_submission", + description: "hackathon_product_submission_description", }, Evaluation: { - eyebrow: 'hackathon_review', - title: 'hackathon_evaluation_entry', - description: 'hackathon_evaluation_entry_description', + eyebrow: "hackathon_review", + title: "hackathon_evaluation_entry", + description: "hackathon_evaluation_entry_description", }, }; +const AgendaTypeClassMap: Partial> = { + workshop: "formation", + formation: "formation", + presentation: "enrollment", + enrollment: "enrollment", + coding: "competition", + competition: "competition", + break: "break", + ceremony: "evaluation", + evaluation: "evaluation", +}; + +const AgendaTypeLabelMap: Partial> = { + workshop: "workshop", + presentation: "presentation", + coding: "coding", + break: "break", + ceremony: "ceremony", +}; + const isPublicForm = ({ shared_limit }: TableFormView) => - ['anyone_editable'].includes(shared_limit as string); - -const HackathonDetail: FC = observer(({ activity, hackathon }) => { - const { t } = useContext(I18nContext); - - const { - name, - summary, - location, - startTime, - endTime, - databaseSchema, - host, - image, - type: activityType, - } = activity, - { people, organizations, agenda, prizes, templates, projects } = hackathon; - const { forms } = (databaseSchema || {}) as BiTableSchema; - - const formGroups = FormButtonBar.flatMap(key => { - const list = forms[key]?.filter(isPublicForm) || []; - - return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; - }); - const primaryForm = - formGroups.find(({ key }) => key === 'Person') || - formGroups.find(({ key }) => key === 'Project') || - formGroups[0]; - const secondaryForm = - formGroups.find(({ key }) => key === 'Project' && key !== primaryForm?.key) || - formGroups.find(({ key }) => key !== primaryForm?.key); - const heroStats = [ - { label: t('participants'), value: people.length }, - { label: t('projects'), value: projects.length }, - { label: t('templates'), value: templates.length }, - { label: t('organizations'), value: organizations.length }, + ["anyone_editable"].includes(shared_limit as string); + +const formatMoment = (value?: TableCellValue) => + value ? formatDate(value as string) : ""; + +const formatPeriod = (startedAt?: TableCellValue, endedAt?: TableCellValue) => + [formatMoment(startedAt), formatMoment(endedAt)].filter(Boolean).join(" - "); + +const previewText = (items: TableCellValue[], fallback: string) => + items + .map((item) => item?.toString()) + .filter(Boolean) + .slice(0, 2) + .join(" · ") || fallback; + +const agendaToneClassOf = (type: TableCellValue, index: number) => { + const normalized = type?.toString().toLowerCase() || ""; + const fallbackOrder: AgendaToneClass[] = [ + "formation", + "enrollment", + "competition", + "break", + "evaluation", ]; - const hostTags = (host as string[] | undefined)?.slice(0, 3) || []; - const agendaPreview = agenda.slice(0, 3); return ( - <> - - -
- - - -
    -
  • {(activityType as string) || t('hackathon')}
  • - {hostTags.map(tag => ( -
  • - {tag} -
  • - ))} -
+ AgendaTypeClassMap[normalized] || + fallbackOrder[index % fallbackOrder.length] + ); +}; -

{name as string}

-

{summary as string}

+const agendaTypeLabelOf = ( + type: TableCellValue, + t: (key: I18nKey) => string, + fallback = "-", +) => { + const normalized = type?.toString().toLowerCase() || ""; + const i18nKey = AgendaTypeLabelMap[normalized]; -
    - {heroStats.map(({ label, value }) => ( -
  • - {value} {label} -
  • - ))} -
+ return i18nKey ? t(i18nKey) : type?.toString() || fallback; +}; + +const HackathonDetail: FC = observer( + ({ activity, hackathon }) => { + const { t } = useContext(I18nContext); + + const { + name, + summary, + location, + startTime, + endTime, + databaseSchema, + host, + image, + type: activityType, + } = activity, + { people, organizations, agenda, prizes, templates, projects } = + hackathon; + const forms = ((databaseSchema as Partial | undefined) + ?.forms || {}) as Partial>; + const agendaItems = [...agenda].sort( + ({ startedAt: left }, { startedAt: right }) => + new Date((left as string) || 0).getTime() - + new Date((right as string) || 0).getTime(), + ); + const hostTags = (host as string[] | undefined)?.slice(0, 2) || []; + const eventRange = formatPeriod(startTime, endTime); + const locationText = + (location as TableCellLocation | undefined)?.full_address || "-"; + const heroBadges = [ + (activityType as string) || t("hackathon"), + ...hostTags, + formatMoment(startTime), + formatMoment(endTime), + ].filter(Boolean); + const heroStats = [ + { label: t("participants"), value: people.length }, + { label: t("projects"), value: projects.length }, + { label: t("templates"), value: templates.length }, + { label: t("prizes"), value: prizes.length }, + ]; + const agendaPreview = agendaItems.slice(0, 3); + const overviewPills = agendaItems.slice(0, 6); + + const formGroups = FormButtonBar.flatMap((key) => { + const list = (forms[key] || []).filter(isPublicForm); + + return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; + }); + const primaryForm = + formGroups.find(({ key }) => key === "Person") || + formGroups.find(({ key }) => key === "Project") || + formGroups[0]; + const secondaryForm = + formGroups.find( + ({ key }) => key === "Project" && key !== primaryForm?.key, + ) || formGroups.find(({ key }) => key !== primaryForm?.key); + const formPreview = + formGroups + .map(({ meta }) => t(meta.eyebrow)) + .filter(Boolean) + .slice(0, 2) + .join(" · ") || t("hackathon_action_hub"); + + const highlightCards = [ + { + icon: HighlightIcons[0], + title: t("participants"), + value: people.length, + description: t(FormSectionMeta.Person.description), + }, + { + icon: HighlightIcons[1], + title: t("projects"), + value: projects.length, + description: t(FormSectionMeta.Project.description), + }, + { + icon: HighlightIcons[2], + title: t("templates"), + value: templates.length, + description: previewText( + templates.map(({ name }) => name), + t("templates"), + ), + }, + { + icon: HighlightIcons[3], + title: t("prizes"), + value: prizes.length, + description: previewText( + prizes.map(({ name }) => name), + t("hackathon_prizes"), + ), + }, + { + icon: HighlightIcons[4], + title: t("organizations"), + value: organizations.length, + description: previewText( + organizations.map(({ name }) => name), + t("organizations"), + ), + }, + { + icon: HighlightIcons[5], + title: t("agenda"), + value: agendaItems.length, + description: previewText( + agendaItems.map(({ name }) => name), + eventRange || t("agenda"), + ), + }, + ]; + + return ( + <> + + +
+ +
+
+
    + {heroBadges.map((badge, index) => ( +
  • + {badge} +
  • + ))} +
+ +

+ + {name as string} + + + {(activityType as string) || t("hackathon_detail")} + +

+ +

{summary as string}

+ + + +
    + {heroStats.map(({ label, value }) => ( +
  • + {value} + {label} +
  • + ))} +
+
+ +
+
+
+ + {t("event_info")} + + + {t("hackathon_detail")} + +
+ +
+
+ + {image ? ( + + ) : ( +
+ {(activityType as string) || t("hackathon")} +
+ )} +
+ +
+

{locationText}

+

+ {eventRange || (summary as string)} +

+
+
-
)} - - - - - -
📍 {t('event_location')}
-
- {(location as TableCellLocation)?.full_address} -
-
- - - -
⏰ {t('event_duration')}
-

- {' '} - - + + {agendaPreview[0] && ( +

+ + {t("hackathon_agenda_preview")} + + {agendaPreview[0].name as string} +

+ {formatPeriod( + agendaPreview[0].startedAt, + agendaPreview[0].endedAt, + )}

- - - - - - - - {image && ( -
-
)} - - {agendaPreview[0] && ( -
-
-
{t('hackathon_agenda_preview')}
-
{agendaPreview[0].name as string}
-
+
+
+
+
- {agendaPreview.map(({ name, startedAt, endedAt }) => ( -
-
{name as string}
-
- {' '} - -{' '} - -
-
- ))} - - )} - - - -
-
-
- - - {formGroups[0] && ( -
-
-

{t('hackathon_action_hub')}

-

{t('hackathon_entry_flow')}

-

{t('hackathon_entry_flow_description')}

-
- - - {formGroups.map(({ key, list, meta }, index) => ( - - - - {t('hackathon_step')} {String(index + 1).padStart(2, '0')} · {t(meta.eyebrow)} +
+ +
+

{t("event_info")}

+

{t("event_description")}

+
+
+ +
+
+ {(activityType as string) || t("hackathon")} +
+

{summary as string}

+
+ + + {highlightCards.map(({ icon, title, value, description }) => ( + +
+ {icon} +
{title}
+

{description}

+ + {value} {title} -

{t(meta.title)}

-

{t(meta.description)}

-
+ + {formGroups[0] && ( +
+ +
+ + +
+
+

+ {t("hackathon_entry_flow")} +

+

+ {t("hackathon_action_hub")} +

+

+ {t("hackathon_entry_flow_description")} +

+
+ + + {formGroups.map(({ key, list, meta }, index) => ( + +
+ + {t("hackathon_step")}{" "} + {String(index + 1).padStart(2, "0")} ·{" "} + {t(meta.eyebrow)} + +

{t(meta.title)}

+

{t(meta.description)}

+ +
+ + {list.length} + + + {t(meta.eyebrow)} + +
+ + +
+ + ))} +
+
+
+
)} -
-

🏆 {t('prizes')}

-
- ({ - id: `prize-${index}`, - name: name as string, - avatar: fileURLOf(image), - score: price as number, - }))} - /> -
-
+ {agendaItems[0] && ( +
+ +
+

{t("agenda")}

+

{t("event_duration")}

+
+
-
-

📅 {t('agenda')}

-
    - {agenda.map(({ name, type, summary, startedAt, endedAt }) => ( -
  1. -
    {name as string}
    -

    {summary as string}

    -
    - - {t(type as I18nKey)} - -
    - {formatDate(startedAt as string)} - {formatDate(endedAt as string)} -
    -
    -
  2. - ))} -
-
+
+

{eventRange}

+

{name as string}

+

{summary as string}

+
-
-

🏢 {t('organizations')}

- -
+
+ {overviewPills.map(({ id, name }) => ( +
+ {name as string} +
+ ))} +
-
-

🛠️ {t('templates')}

- - {templates.map(({ name, languages, tags, sourceLink, summary, previewLink }) => ( - - - - ))} - -
+
+ {agendaItems.map( + ({ id, name, type, summary, startedAt, endedAt }, index) => { + const toneClass = agendaToneClassOf(type, index); -
-

💡 {t('projects')}

- - - {projects.map(({ id, name, score, summary, createdBy, members }) => ( - - -
-
- - {name as string} - -
-
{score as number}
-
-

{summary as string}

-
- {t('created_by')}:{' '} - - {(createdBy as TableCellUser)?.name} - +
+ + PHASE {String(index + 1).padStart(2, "0")} + + +
+ +

{name as string}

+

+ {(summary as string) || agendaTypeLabelOf(type, t)} +

+ +
+
+
{t("type")}
+
+ {agendaTypeLabelOf(type, t)} + + {(summary as string) || + eventRange || + locationText} + +
+
+ +
+
+ {t("start_time")} +
+
+ {formatMoment(startedAt)} + {t("event_duration")} +
+
+ +
+
{t("end_time")}
+
+ {formatMoment(endedAt)} + + {t("event_location")}: {locationText} + +
+
+
+ + ); + }, + )} +
+ +
+ )} + + {(prizes[0] || organizations[0]) && ( +
+ + {prizes[0] && ( + <> +
+

{t("prizes")}

+

+ {t("hackathon_prizes")} +

+
+
+ +
+ {prizes.map( + ( + { + id, + name, + image, + summary, + level, + sponsor, + price, + amount, + }, + index, + ) => ( +
+ {image && ( +
+ +
+ )} + +
+ + {(level as string) || `#${index + 1}`} + +

+ {name as string} +

+

+ {(summary as string) || + previewText( + [sponsor, price, amount], + t("prizes"), + )} +

+ +
+ {sponsor && ( +
+
{t("sponsor")}
+
{sponsor as string}
+
+ )} + {price && ( +
+
{t("price")}
+
{price as string}
+
+ )} + {amount && ( +
+
{t("amount")}
+
{amount as string}
+
+ )} +
+
+
+ ), + )}
-
- {t('members')}:{' '} - {(members as string[] | undefined)?.join(', ') || '-'} + + )} + + {organizations[0] && ( +
+
+

+ {t("organizations")} +

+

+ {previewText( + organizations.map(({ name }) => name), + t("organizations"), + )} +

+

+ {summary as string} +

- - - ))} - -
-
-

👥 {t('participants')}

- -
- - - ); -}); + +
+ )} +
+
+ )} + + {(templates[0] || projects[0]) && ( +
+ + {templates[0] && ( + <> +
+

{t("templates")}

+

{t("source_code")}

+
+
+ + + {templates.map( + ({ + id, + name, + languages, + tags, + sourceLink, + summary, + previewLink, + }) => ( + +
+
+

+ {name as string} +

+
+

+ {summary as string} +

+ +
    + {(languages as string[]).map((language) => ( +
  • + {language} +
  • + ))} + {(tags as string[]).map((tag) => ( +
  • + {tag} +
  • + ))} +
+ + +
+ + ), + )} +
+ + )} + + {projects[0] && ( + <> +
+

{t("projects")}

+

{t("products")}

+
+
+ + + {projects.map( + ({ id, name, score, summary, createdBy, members }) => { + const creator = createdBy as TableCellUser | undefined; + const scoreText = + score === null || score === undefined || score === "" + ? "—" + : `${score}`; + const memberNames = + (members as string[] | undefined)?.join(", ") || "—"; + + return ( + +
+
+

+ + {name as string} + +

+
+ {scoreText} +
+
+ +

+ {summary as string} +

+ +
+
+
{t("created_by")}
+
+ {creator?.email ? ( + + {creator.name} + + ) : ( + creator?.name || "—" + )} +
+
+
+
{t("members")}
+
{memberNames}
+
+
+
+ + ); + }, + )} +
+ + )} +
+
+ )} + + {people[0] && ( +
+ +
+

{t("participants")}

+

{t("github_account")}

+
+
+ +
+ {people.map(({ id, name, avatar, githubLink }) => { + const content = ( + <> + + + {name as string} + + + ); + + return githubLink ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + })} +
+
+
+ )} + + ); + }, +); export default HackathonDetail; diff --git a/styles/Hackathon.module.less b/styles/Hackathon.module.less index cb62098..80aafed 100644 --- a/styles/Hackathon.module.less +++ b/styles/Hackathon.module.less @@ -1,382 +1,1138 @@ -// Tech-themed hackathon styling with blue-purple gradient and pink cards +@bg: #050814; +@bg-soft: #0b1328; +@panel: rgba(8, 18, 39, 0.76); +@panel-strong: rgba(6, 13, 30, 0.92); +@border: rgba(120, 160, 255, 0.18); +@copy: #eef5ff; +@muted: rgba(220, 232, 255, 0.72); +@cyan: #2ce8ff; +@gold: #ffc94d; +@green: #48f1a8; +@rose: #ff78ba; +@purple: #7b61ff; +@shadow: 0 28px 80px rgba(0, 0, 0, 0.34); +@heading: "Orbitron", "Avenir Next", "Segoe UI", sans-serif; +@body: "Outfit", "Avenir Next", "Segoe UI", sans-serif; .hero { position: relative; - background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); - padding: 4rem 0; overflow: hidden; - color: #fff; + background: + radial-gradient( + circle at top left, + rgba(44, 232, 255, 0.18), + transparent 32% + ), + radial-gradient( + circle at 85% 12%, + rgba(255, 120, 186, 0.15), + transparent 24% + ), + linear-gradient(180deg, @bg 0%, #091022 48%, #050814 100%); + padding: clamp(4.5rem, 8vw, 6.75rem) 0 clamp(3.5rem, 6vw, 5rem); + color: @copy; - &::before { + &::before, + &::after { 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%; + inset: 0; pointer-events: none; - content: ''; + content: ""; } -} -@keyframes grid-animation { - 0% { - transform: translate(0, 0); + &::before { + background-image: + linear-gradient(rgba(44, 232, 255, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(44, 232, 255, 0.08) 1px, transparent 1px); + background-size: 54px 54px; + mask-image: radial-gradient(circle at center, black 42%, transparent 100%); + opacity: 0.45; } - 100% { - transform: translate(50px, 50px); + + &::after { + inset: auto 0 0; + height: 140px; + background: linear-gradient(180deg, transparent, rgba(5, 8, 20, 0.95)); } } -.title { - margin-bottom: 1rem; - font-weight: 700; - font-size: 3rem; - text-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +.heroInner { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 460px); + gap: clamp(2rem, 4vw, 4rem); + align-items: center; } -.description { - opacity: 0.9; - margin: 0 auto; - max-width: 800px; - font-size: 1.2rem; +.heroContent { + display: flex; + flex-direction: column; + gap: 1.35rem; } -.heroTag { - display: inline-flex; - align-items: center; - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 999px; - background: rgba(4, 4, 15, 0.28); - padding: 0.45rem 0.9rem; - color: #fff; - font-weight: 600; - font-size: 0.9rem; +.heroEyebrow { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0; } -.statChip { +.heroBadge { display: inline-flex; align-items: center; + border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 999px; - background: rgba(255, 255, 255, 0.12); - padding: 0.5rem 0.95rem; - color: #fff; - font-weight: 600; - font-size: 0.9rem; + background: rgba(255, 255, 255, 0.06); + padding: 0.42rem 0.9rem; + color: @copy; + font-family: @heading; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; } -.heroVisualCard { - box-shadow: 0 18px 50px rgba(10, 12, 30, 0.35); - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: 24px; - background: linear-gradient(135deg, rgba(10, 12, 30, 0.82), rgba(37, 44, 84, 0.62)); - overflow: hidden; +.heroBadgeCyan { + box-shadow: 0 0 18px rgba(44, 232, 255, 0.22); + border-color: rgba(44, 232, 255, 0.28); + color: @cyan; +} + +.heroBadgeGold { + box-shadow: 0 0 18px rgba(255, 201, 77, 0.18); + border-color: rgba(255, 201, 77, 0.24); + color: @gold; +} + +.heroBadgeGreen { + box-shadow: 0 0 18px rgba(72, 241, 168, 0.2); + border-color: rgba(72, 241, 168, 0.24); + color: @green; +} + +.heroBadgeRose { + box-shadow: 0 0 18px rgba(255, 120, 186, 0.18); + border-color: rgba(255, 120, 186, 0.24); + color: @rose; +} + +.title { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin: 0; + font-family: @heading; + font-size: clamp(2.6rem, 5vw, 4.45rem); + font-weight: 900; + letter-spacing: 0.02em; + line-height: 1; +} + +.heroTitlePrimary { color: #fff; } -.heroImageWrap { - background: rgba(255, 255, 255, 0.05); - aspect-ratio: 16 / 10; +.heroTitleSecondary { + background: linear-gradient(90deg, @cyan, @gold 78%); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + font-size: clamp(1.05rem, 2vw, 1.6rem); + letter-spacing: 0.14em; + text-transform: uppercase; } -.heroVisualKicker { - opacity: 0.72; - font-size: 0.8rem; +.description { + max-width: 62ch; + margin: 0; + color: @muted; + font-family: @body; + font-size: 1.05rem; + line-height: 1.8; +} + +.heroActions, +.regActions, +.resourceLinks, +.entryLinks { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; +} + +.actionButton, +.actionButtonGhost, +.entryLink { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + border-color 0.22s ease, + background 0.22s ease, + color 0.22s ease; + border: 1px solid transparent; + border-radius: 10px; + padding: 0.9rem 1.35rem; + font-family: @heading; + font-size: 0.82rem; + font-weight: 700; letter-spacing: 0.08em; + text-align: center; + text-decoration: none; text-transform: uppercase; + + &:hover { + transform: translateY(-2px); + text-decoration: none; + } } -.heroVisualItem { +.actionButton { + box-shadow: 0 0 24px rgba(44, 232, 255, 0.18); + border-color: rgba(44, 232, 255, 0.3); + background: rgba(44, 232, 255, 0.12); + color: @cyan; + + &:hover { + box-shadow: 0 0 34px rgba(44, 232, 255, 0.28); + background: rgba(44, 232, 255, 0.2); + color: #fff; + } +} + +.actionButtonGhost, +.entryLink { + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: @copy; + + &:hover { + border-color: rgba(255, 201, 77, 0.24); + background: rgba(255, 201, 77, 0.1); + color: @gold; + } +} + +.heroStats { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + margin: 0; +} + +.statChip { display: flex; flex-direction: column; - gap: 0.3rem; - border: 1px solid rgba(255, 255, 255, 0.08); + gap: 0.1rem; + min-width: 108px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); border-radius: 16px; background: rgba(255, 255, 255, 0.06); padding: 0.85rem 1rem; - dt { - margin: 0; + strong { + font-family: @heading; + font-size: 1.25rem; + line-height: 1.1; } - dd { - opacity: 0.74; - margin: 0; - font-size: 0.88rem; + span { + color: @muted; + font-size: 0.82rem; + letter-spacing: 0.04em; + text-transform: uppercase; } } -.heroVisualHead dd { - opacity: 1; +.heroVisual { + position: relative; + min-height: 580px; +} + +.heroVisualCard, +.registerCard, +.entryHub, +.trackCard, +.badgeTile, +.resourceCard, +.projectCard, +.dayCard, +.supportCard, +.darkCard, +.lightCard { + box-shadow: @shadow; + border: 1px solid @border; + border-radius: 28px; + background: @panel; + backdrop-filter: blur(18px); +} + +.heroVisualCard { + position: relative; + z-index: 1; + overflow: hidden; + background: linear-gradient( + 180deg, + rgba(11, 19, 40, 0.95), + rgba(8, 18, 39, 0.9) + ); +} + +.heroVisualHead { + display: flex; + justify-content: space-between; + gap: 0.8rem; + padding: 1.15rem 1.25rem 0; +} + +.visualKicker, +.visualChip, +.floatingLabel, +.regEyebrow, +.entryEyebrow, +.supportEyebrow, +.entryStep, +.dayNo, +.sectionSubtitle { + font-family: @heading; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.visualKicker, +.floatingLabel, +.sectionSubtitle, +.regEyebrow, +.entryEyebrow, +.supportEyebrow { + color: @muted; +} + +.visualChip { + color: @gold; +} + +.heroImageFrame { + position: relative; + overflow: hidden; + margin: 1rem 1.25rem 0; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 24px; + background: linear-gradient( + 135deg, + rgba(44, 232, 255, 0.08), + rgba(123, 97, 255, 0.14) + ); + min-height: 340px; + + :global(img) { + position: relative; + z-index: 2; + } +} + +.mascotGlow { + position: absolute; + inset: 12% 18%; + z-index: 1; + border-radius: 999px; + background: radial-gradient( + circle, + rgba(44, 232, 255, 0.35), + transparent 70% + ); + filter: blur(28px); +} + +.heroImageFallback { + display: flex; + align-items: center; + justify-content: center; + min-height: 340px; + color: @cyan; + font-family: @heading; + font-size: clamp(1.25rem, 3vw, 2rem); + font-weight: 900; + letter-spacing: 0.12em; + text-align: center; + text-transform: uppercase; +} + +.heroVisualFoot { + padding: 1.15rem 1.25rem 1.35rem; +} + +.heroVisualTitle { + margin: 0 0 0.35rem; + color: #fff; + font-family: @heading; font-size: 1rem; } -.infoCard { - backdrop-filter: blur(10px); - transition: all 0.3s ease; - margin-bottom: 1rem; - border: 2px solid rgba(255, 255, 255, 0.3); - border-radius: 16px; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05)); +.heroVisualCopy { + margin: 0; + color: @muted; + line-height: 1.75; +} - &:hover { - transform: translateY(-5px); - box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4); - border-color: rgba(255, 255, 255, 0.5); +.heroFloatingCard { + position: absolute; + z-index: 2; + max-width: 260px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 22px; + background: rgba(8, 18, 39, 0.88); + padding: 1rem 1.1rem; + + strong { + display: block; + margin-top: 0.35rem; + color: #fff; + font-size: 1rem; + line-height: 1.4; } + + p { + margin: 0.45rem 0 0; + color: @muted; + font-size: 0.92rem; + line-height: 1.6; + } +} + +.heroFloatingCardTop { + top: 2.5rem; + left: -1.5rem; +} + +.heroFloatingCardBottom { + right: -1.5rem; + bottom: 1.5rem; } -.section { +.section, +.registerSection { position: relative; - padding: 3rem 0; + padding: clamp(4rem, 7vw, 6rem) 0; + background: linear-gradient( + 180deg, + rgba(5, 8, 20, 0.98), + rgba(7, 12, 26, 0.98) + ); + color: @copy; +} + +.registerSection { + padding-top: clamp(3rem, 5vw, 4rem); +} + +.sectionHeader { + text-align: center; + margin-bottom: clamp(2.3rem, 4vw, 3rem); } .sectionTitle { display: inline-block; - margin-bottom: 2rem; - border-bottom: 4px solid; - border-image: linear-gradient(90deg, #f093fb, #f5576c, #ffd140) 1; - background: linear-gradient(90deg, #f093fb, #f5576c, #ffd140); - -webkit-background-clip: text; - padding-bottom: 0.5rem; - font-weight: 700; - font-size: 2rem; - -webkit-text-fill-color: transparent; + margin: 0; + background: linear-gradient(135deg, #fff 20%, @cyan 80%); background-clip: text; + -webkit-background-clip: text; + color: transparent; + font-family: @heading; + font-size: clamp(1.9rem, 4vw, 2.6rem); + font-weight: 900; + letter-spacing: 0.12em; + text-transform: uppercase; } -.sectionEyebrow { - margin-bottom: 0.5rem; - color: #7d8ff5; +.accentLine { + width: 72px; + height: 2px; + margin: 1rem auto 0; + border-radius: 999px; + background: linear-gradient(90deg, @cyan, @purple); + box-shadow: 0 0 14px rgba(44, 232, 255, 0.32); +} + +.themePanel { + margin-bottom: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 28px; + background: linear-gradient( + 135deg, + rgba(44, 232, 255, 0.08), + rgba(123, 97, 255, 0.12) + ); + padding: 1.5rem 1.6rem; +} + +.themeText { + margin-bottom: 0.4rem; + color: #fff; + font-family: @heading; + font-size: clamp(1.35rem, 2.6vw, 2rem); + font-weight: 900; +} + +.themeSub { + margin: 0; + color: @muted; + line-height: 1.75; +} + +.trackCard { + transition: + transform 0.22s ease, + border-color 0.22s ease; + height: 100%; + padding: 1.4rem; + + &:hover { + transform: translateY(-4px); + border-color: rgba(44, 232, 255, 0.26); + } +} + +.trackIcon { + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + border-radius: 18px; + background: rgba(255, 255, 255, 0.06); + width: 54px; + height: 54px; + font-size: 1.35rem; +} + +.trackName, +.regTitle, +.entryTitle, +.scheduleLead, +.badgeTileTitle, +.resourceTitle, +.projectTitle, +.supportTitle { + margin: 0; + color: #fff; + font-family: @heading; + font-weight: 800; + line-height: 1.3; +} + +.trackName { + font-size: 1.08rem; +} + +.trackDesc, +.regDesc, +.entryCopy, +.scheduleCopy, +.badgeTileCopy, +.resourceDescription, +.projectSummary, +.supportDescription { + color: @muted; + line-height: 1.75; +} + +.trackDesc { + min-height: 4.8rem; + margin: 0.75rem 0 0.95rem; +} + +.trackMetric { + color: @cyan; + font-family: @heading; + font-size: 0.78rem; font-weight: 700; - font-size: 0.85rem; letter-spacing: 0.08em; text-transform: uppercase; } -.sectionLead { - margin: 0; - max-width: 760px; - color: rgba(33, 37, 41, 0.72); - font-size: 1rem; - line-height: 1.7; +.registerWrap { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + gap: 1.5rem; + align-items: start; } -.entryCard { - transition: all 0.3s ease; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 20px; - background: linear-gradient(135deg, #1d2f72, #5c2f91); - min-height: 100%; - color: #fff; +.registerCard { + background: linear-gradient( + 135deg, + rgba(44, 232, 255, 0.08), + rgba(123, 97, 255, 0.14) + ); +} - &:hover { - transform: translateY(-6px); - box-shadow: 0 16px 40px rgba(35, 48, 116, 0.25); +.registerCardInner, +.entryHub, +.supportCard { + padding: 1.55rem; +} + +.regTitle { + margin-top: 0.4rem; + font-size: clamp(1.55rem, 3vw, 2.1rem); +} + +.regDesc { + margin: 0.9rem 0 1.2rem; +} + +.regFacts { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 1.3rem 0 0; + + li { + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + padding: 0.48rem 0.85rem; + color: @copy; + font-size: 0.9rem; } } +.entryHubHead { + margin-bottom: 1.25rem; +} + +.entryTitle { + margin-top: 0.35rem; + font-size: 1.55rem; +} + +.entryCard { + height: 100%; + padding: 1.3rem; +} + .entryStep { - opacity: 0.72; - font-size: 0.82rem; + color: @cyan; +} + +.entryCard h4 { + margin: 0.55rem 0 0.45rem; + color: #fff; + font-family: @heading; + font-size: 1.02rem; +} + +.entryCard p { + margin: 0; + color: @muted; + line-height: 1.7; +} + +.entryMetaRow { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin: 1rem 0; +} + +.entryMeta { + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + padding: 0.35rem 0.7rem; + color: @gold; + font-family: @heading; + font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; } -// Vibrant cards - Multicolor gradient background -.darkCard { - transition: all 0.3s ease; - margin-bottom: 1rem; - box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); - border-radius: 16px; - background: linear-gradient(135deg, #667eea, #764ba2); - padding: 1.5rem; - color: #fff; +.scheduleIntro { + margin-bottom: 1.35rem; + text-align: center; +} - &:hover { - transform: translateY(-5px) scale(1.02); - box-shadow: 0 10px 30px rgba(102, 126, 234, 0.5); - background: linear-gradient(135deg, #7d8ff5, #8a5cb8); - } +.scheduleKicker { + margin: 0 0 0.35rem; + color: @cyan; + font-family: @heading; + font-size: 0.8rem; + letter-spacing: 0.1em; + text-transform: uppercase; } -// Bright cards - Warm gradient background -.lightCard { - transition: all 0.3s ease; - margin-bottom: 1rem; - box-shadow: 0 4px 15px rgba(250, 112, 154, 0.3); - border-radius: 16px; - background: linear-gradient(135deg, #fa709a, #fee140); - padding: 1.5rem; - color: #1f2937; +.scheduleLead { + font-size: clamp(1.45rem, 3vw, 2.05rem); +} - &:hover { - transform: translateY(-5px) scale(1.02); - box-shadow: 0 10px 30px rgba(250, 112, 154, 0.5); - background: linear-gradient(135deg, #ff6b9d, #ffa400); - } +.scheduleCopy { + max-width: 72ch; + margin: 0.8rem auto 0; } -// Agenda items with vibrant gradient backgrounds -.agendaItem { - transition: all 0.3s ease; - margin-bottom: 1rem; - border-left: 5px solid; - border-radius: 12px; - padding: 1.5rem; +.scheduleOverview { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.schedulePill { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + padding: 0.58rem 0.9rem; + color: @copy; + font-family: @heading; + font-size: 0.74rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.scheduleDays { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.dayCard { + height: 100%; + padding: 1.35rem; +} + +.formation { + background: linear-gradient( + 135deg, + rgba(44, 232, 255, 0.1), + rgba(14, 43, 80, 0.86) + ); +} + +.enrollment { + background: linear-gradient( + 135deg, + rgba(123, 97, 255, 0.12), + rgba(28, 20, 67, 0.86) + ); +} + +.competition { + background: linear-gradient( + 135deg, + rgba(255, 201, 77, 0.12), + rgba(60, 35, 8, 0.88) + ); +} + +.break { + background: linear-gradient( + 135deg, + rgba(255, 120, 186, 0.1), + rgba(72, 18, 48, 0.88) + ); +} + +.evaluation { + background: linear-gradient( + 135deg, + rgba(72, 241, 168, 0.12), + rgba(14, 50, 39, 0.88) + ); +} + +.dayCardHead { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: baseline; +} + +.dayNo { + color: @muted; +} + +.dayDate { + color: @copy; + font-size: 0.85rem; +} + +.dayTitle { + margin: 0.9rem 0 0.45rem; color: #fff; + font-family: @heading; + font-size: 1.08rem; +} - &:hover { - transform: translateX(8px) scale(1.02); - } +.daySub { + margin: 0 0 1rem; + color: @muted; + line-height: 1.7; +} - &.formation { - box-shadow: 0 4px 15px rgba(0, 201, 255, 0.3); - border-left-color: #00c9ff; - background: linear-gradient(135deg, #00c9ff, #92fe9d); - } +.dayAgenda, +.prizeMeta, +.projectMeta { + display: grid; + gap: 0.7rem; + margin: 0; - &.enrollment { - box-shadow: 0 4px 15px rgba(245, 87, 108, 0.3); - border-left-color: #f5576c; - background: linear-gradient(135deg, #f5576c, #f093fb); + div { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.8rem; + align-items: start; } - &.competition { - box-shadow: 0 4px 15px rgba(79, 172, 254, 0.3); - border-left-color: #4facfe; - background: linear-gradient(135deg, #4facfe, #00f2fe); + dt, + dd { + margin: 0; } +} - &.break { - box-shadow: 0 4px 15px rgba(255, 209, 64, 0.3); - border-left-color: #ffd140; - background: linear-gradient(135deg, #ffd140, #ff6b6b); +.timePill { + display: inline-flex; + align-items: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + min-height: 30px; + padding: 0.4rem 0.75rem; + color: @copy; + font-family: @heading; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.agendaCopy { + display: flex; + flex-direction: column; + gap: 0.14rem; + + strong { + color: #fff; + line-height: 1.4; } - &.evaluation { - box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); - border-left-color: #667eea; - background: linear-gradient(135deg, #667eea, #764ba2); + span { + color: @muted; + font-size: 0.92rem; + line-height: 1.55; } } -// Prize section using UserRankView -.prizeSection { - margin-bottom: 2rem; +.awardsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.badgeTile { + overflow: hidden; +} + +.badgeArtWrap { + padding: 1.1rem 1.1rem 0; +} + +.badgeArt { + border-radius: 22px; + background: rgba(255, 255, 255, 0.04); + width: 100%; + aspect-ratio: 1; + object-fit: cover; +} + +.badgeTileBody { + padding: 1.15rem 1.2rem 1.3rem; } -// Organization horizontal layout -.orgContainer { +.badgeTierLabel { + color: @gold; + font-family: @heading; + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.badgeTileTitle { + font-size: 1.05rem; +} + +.badgeTileCopy { + margin: 0.7rem 0 1rem; +} + +.prizeMeta dt, +.projectMeta dt { + color: @muted; + font-family: @heading; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.prizeMeta dd, +.projectMeta dd { + color: #fff; + line-height: 1.5; +} + +.supportCard { + display: grid; + grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.25fr); + gap: 1.25rem; + margin-top: 1.4rem; +} + +.supportTitle { + margin-top: 0.35rem; + font-size: 1.45rem; +} + +.partnerGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 0.8rem; + align-items: center; +} + +.partnerLink { display: flex; - flex-wrap: wrap; - justify-content: center; align-items: center; - gap: 2rem; - padding: 2rem 0; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + background: rgba(255, 255, 255, 0.04); + min-height: 104px; + padding: 1rem; } -.orgLogo { - filter: grayscale(0.3); - transition: all 0.3s ease; - border-radius: 12px; - width: 100px; - height: 100px; +.partnerLogo { + max-width: 100%; + max-height: 58px; object-fit: contain; +} - &:hover { - transform: scale(1.1); - filter: grayscale(0); - } +.resourceCard, +.projectCard { + height: 100%; + padding: 1.25rem; } -// Template section using GitCard -.templateSection { - margin-bottom: 2rem; +.resourceHead, +.projectHead { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; } -// Project cards with vibrant gradient -.projectCard { - transition: all 0.3s ease; - margin-bottom: 1.5rem; - border: 2px solid transparent; - border-radius: 16px; - background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); - padding: 1.5rem; - color: #1f2937; +.resourceTitle, +.projectTitle { + font-size: 1.02rem; +} - &:hover { - transform: translateY(-8px) scale(1.02); - box-shadow: 0 12px 35px rgba(250, 112, 154, 0.4); - background: linear-gradient(135deg, #ff6b9d 0%, #ffa400 100%); - } +.resourceTitle, +.projectTitle a { + color: #fff; + text-decoration: none; } -.scoreCircle { +.projectTitle a:hover { + color: @cyan; + text-decoration: none; +} + +.resourceDescription, +.projectSummary { + margin: 0.8rem 0 1rem; +} + +.topicList { display: flex; - justify-content: center; + flex-wrap: wrap; + gap: 0.55rem; + margin: 0 0 1rem; +} + +.topicChip, +.topicChipMuted, +.skillBadge { + display: inline-flex; align-items: center; - box-shadow: 0 0 25px rgba(0, 242, 254, 0.5); + border-radius: 999px; + padding: 0.34rem 0.72rem; + font-size: 0.76rem; +} + +.topicChip, +.skillBadge { + background: rgba(44, 232, 255, 0.12); + color: @cyan; +} + +.topicChipMuted { + background: rgba(255, 255, 255, 0.08); + color: @copy; +} + +.projectHeader { + margin-top: 2.75rem; +} + +.scoreCircle { + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 24px rgba(44, 232, 255, 0.18); + border: 1px solid rgba(44, 232, 255, 0.2); border-radius: 50%; - background: linear-gradient(135deg, #00f2fe 0%, #4facfe 100%); - width: 60px; - height: 60px; + background: rgba(44, 232, 255, 0.1); + width: 62px; + height: 62px; + color: @cyan; + font-family: @heading; + font-size: 1.05rem; + font-weight: 800; +} + +.participantCloud { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; +} + +.participantCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + border-radius: 24px; + background: rgba(255, 255, 255, 0.04); + padding: 1.1rem 0.9rem; color: #fff; - font-weight: 700; - font-size: 1.2rem; + text-align: center; + text-decoration: none; +} + +.participantCard:hover { + color: @cyan; + text-decoration: none; } -// Circular avatars only for participants .avatar { - transition: all 0.3s ease; - cursor: pointer; - box-shadow: 0 0 20px rgba(250, 112, 154, 0.5); - border: 4px solid transparent; + border: 2px solid rgba(44, 232, 255, 0.2); border-radius: 50%; - background: - linear-gradient(white, white) padding-box, - linear-gradient(135deg, #f093fb, #f5576c) border-box; - width: 80px; - height: 80px; + width: 84px; + height: 84px; + object-fit: cover; +} - &:hover { - transform: scale(1.15); - box-shadow: 0 0 30px rgba(250, 112, 154, 0.7); - } +.participantName { + font-size: 0.92rem; + line-height: 1.4; } -.skillBadge { - display: inline-block; - margin: 0.25rem; - box-shadow: 0 3px 10px rgba(102, 126, 234, 0.3); - border-radius: 20px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - padding: 0.35rem 0.85rem; - color: #fff; - font-weight: 600; - font-size: 0.85rem; +.darkCard, +.lightCard { + padding: 1.3rem; +} + +.lightCard { + background: rgba(255, 255, 255, 0.08); } .techGradient { - background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; + background: linear-gradient(90deg, @cyan, @gold 78%); background-clip: text; - font-weight: 700; + -webkit-background-clip: text; + color: transparent; } -.glowText { - text-shadow: 0 0 15px rgba(102, 126, 234, 0.5); +@media (max-width: 1199px) { + .heroInner, + .registerWrap, + .supportCard { + grid-template-columns: 1fr; + } + + .heroVisual { + min-height: 0; + } + + .heroFloatingCardTop { + left: 1rem; + } + + .heroFloatingCardBottom { + right: 1rem; + } } -.participantCloud { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: center; - gap: 1.5rem; - padding: 2rem 0; +@media (max-width: 991px) { + .scheduleDays { + grid-template-columns: 1fr; + } + + .title { + font-size: clamp(2.2rem, 9vw, 3.45rem); + } +} + +@media (max-width: 767px) { + .hero { + padding-top: 4rem; + } + + .heroFloatingCard { + position: static; + max-width: none; + margin-top: 1rem; + } + + .heroVisual { + display: flex; + flex-direction: column; + } + + .heroImageFrame, + .heroImageFallback { + min-height: 260px; + } + + .heroStats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sectionTitle { + font-size: 1.65rem; + letter-spacing: 0.08em; + } + + .heroBadge, + .schedulePill, + .regFacts li, + .topicChip, + .topicChipMuted, + .skillBadge { + font-size: 0.72rem; + } + + .participantCloud { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .scoreCircle { + width: 54px; + height: 54px; + font-size: 0.92rem; + } } From 2a5b181b1d67baca07f7abd192c515f1feffcd54 Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 22:39:13 +0800 Subject: [PATCH 07/14] Normalize hackathon quotes --- pages/hackathon/[id].tsx | 1599 +++++++++++++++++--------------------- 1 file changed, 721 insertions(+), 878 deletions(-) diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index b98efff..2808365 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -4,17 +4,17 @@ import { TableCellUser, TableCellValue, TableFormView, -} from "mobx-lark"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { cache, compose, errorLogger } from "next-ssr-middleware"; -import { FC, useContext } from "react"; -import { Col, Container, Row } from "react-bootstrap"; -import { formatDate } from "web-utility"; - -import { LarkImage } from "../../components/LarkImage"; -import { PageHead } from "../../components/Layout/PageHead"; -import { Activity, ActivityModel } from "../../models/Activity"; +} from 'mobx-lark'; +import { observer } from 'mobx-react'; +import Link from 'next/link'; +import { cache, compose, errorLogger } from 'next-ssr-middleware'; +import { FC, useContext } from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; +import { formatDate } from 'web-utility'; + +import { LarkImage } from '../../components/LarkImage'; +import { PageHead } from '../../components/Layout/PageHead'; +import { Activity, ActivityModel } from '../../models/Activity'; import { Agenda, AgendaModel, @@ -28,17 +28,17 @@ import { ProjectModel, Template, TemplateModel, -} from "../../models/Hackathon"; -import { I18nContext, I18nKey } from "../../models/Translation"; -import styles from "../../styles/Hackathon.module.less"; +} from '../../models/Hackathon'; +import { I18nContext, I18nKey } from '../../models/Translation'; +import styles from '../../styles/Hackathon.module.less'; const RequiredTableKeys = [ - "Person", - "Organization", - "Agenda", - "Prize", - "Template", - "Project", + 'Person', + 'Organization', + 'Agenda', + 'Prize', + 'Template', + 'Project', ] as const; type RequiredTableKey = (typeof RequiredTableKeys)[number]; @@ -48,27 +48,21 @@ export const getServerSideProps = compose<{ id: string }>( errorLogger, async ({ params }) => { const activity = await new ActivityModel().getOne(params!.id); - const schema = activity.databaseSchema as - | Partial - | undefined; - const tableIdMap = schema?.tableIdMap as - | Partial> - | undefined; + const schema = activity.databaseSchema as Partial | undefined; + const tableIdMap = schema?.tableIdMap as Partial> | undefined; if (!schema?.appId || !tableIdMap) return { notFound: true, props: {} }; - for (const key of RequiredTableKeys) - if (!tableIdMap[key]) return { notFound: true, props: {} }; + for (const key of RequiredTableKeys) if (!tableIdMap[key]) return { notFound: true, props: {} }; - const [people, organizations, agenda, prizes, templates, projects] = - await Promise.all([ - new PersonModel(schema.appId, tableIdMap.Person).getAll(), - new OrganizationModel(schema.appId, tableIdMap.Organization).getAll(), - new AgendaModel(schema.appId, tableIdMap.Agenda).getAll(), - new PrizeModel(schema.appId, tableIdMap.Prize).getAll(), - new TemplateModel(schema.appId, tableIdMap.Template).getAll(), - new ProjectModel(schema.appId, tableIdMap.Project).getAll(), - ]); + const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ + new PersonModel(schema.appId, tableIdMap.Person).getAll(), + new OrganizationModel(schema.appId, tableIdMap.Organization).getAll(), + new AgendaModel(schema.appId, tableIdMap.Agenda).getAll(), + new PrizeModel(schema.appId, tableIdMap.Prize).getAll(), + new TemplateModel(schema.appId, tableIdMap.Template).getAll(), + new ProjectModel(schema.appId, tableIdMap.Project).getAll(), + ]); return { props: { @@ -98,23 +92,18 @@ interface HackathonDetailProps { }; } -const FormButtonBar = ["Person", "Project", "Product", "Evaluation"] as const; +const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation'] as const; const HeroBadgeTone = [ - "heroBadgeCyan", - "heroBadgeGold", - "heroBadgeGreen", - "heroBadgeRose", + 'heroBadgeCyan', + 'heroBadgeGold', + 'heroBadgeGreen', + 'heroBadgeRose', ] as const; -const HighlightIcons = ["👥", "🚀", "🛠", "🏆", "🤝", "📅"] as const; +const HighlightIcons = ['👥', '🚀', '🛠', '🏆', '🤝', '📅'] as const; type FormGroupKey = (typeof FormButtonBar)[number]; -type FormGroupMeta = Record<"title" | "description" | "eyebrow", I18nKey>; -type AgendaToneClass = - | "formation" - | "enrollment" - | "competition" - | "break" - | "evaluation"; +type FormGroupMeta = Record<'title' | 'description' | 'eyebrow', I18nKey>; +type AgendaToneClass = 'formation' | 'enrollment' | 'competition' | 'break' | 'evaluation'; interface FormGroup { key: FormGroupKey; @@ -124,923 +113,777 @@ interface FormGroup { const FormSectionMeta: Record = { Person: { - eyebrow: "participants", - title: "hackathon_participant_registration", - description: "hackathon_participant_registration_description", + eyebrow: 'participants', + title: 'hackathon_participant_registration', + description: 'hackathon_participant_registration_description', }, Project: { - eyebrow: "hackathon_team_lead", - title: "hackathon_project_registration", - description: "hackathon_project_registration_description", + eyebrow: 'hackathon_team_lead', + title: 'hackathon_project_registration', + description: 'hackathon_project_registration_description', }, Product: { - eyebrow: "hackathon_submission", - title: "product_submission", - description: "hackathon_product_submission_description", + eyebrow: 'hackathon_submission', + title: 'product_submission', + description: 'hackathon_product_submission_description', }, Evaluation: { - eyebrow: "hackathon_review", - title: "hackathon_evaluation_entry", - description: "hackathon_evaluation_entry_description", + eyebrow: 'hackathon_review', + title: 'hackathon_evaluation_entry', + description: 'hackathon_evaluation_entry_description', }, }; const AgendaTypeClassMap: Partial> = { - workshop: "formation", - formation: "formation", - presentation: "enrollment", - enrollment: "enrollment", - coding: "competition", - competition: "competition", - break: "break", - ceremony: "evaluation", - evaluation: "evaluation", + workshop: 'formation', + formation: 'formation', + presentation: 'enrollment', + enrollment: 'enrollment', + coding: 'competition', + competition: 'competition', + break: 'break', + ceremony: 'evaluation', + evaluation: 'evaluation', }; const AgendaTypeLabelMap: Partial> = { - workshop: "workshop", - presentation: "presentation", - coding: "coding", - break: "break", - ceremony: "ceremony", + workshop: 'workshop', + presentation: 'presentation', + coding: 'coding', + break: 'break', + ceremony: 'ceremony', }; const isPublicForm = ({ shared_limit }: TableFormView) => - ["anyone_editable"].includes(shared_limit as string); + ['anyone_editable'].includes(shared_limit as string); -const formatMoment = (value?: TableCellValue) => - value ? formatDate(value as string) : ""; +const formatMoment = (value?: TableCellValue) => (value ? formatDate(value as string) : ''); const formatPeriod = (startedAt?: TableCellValue, endedAt?: TableCellValue) => - [formatMoment(startedAt), formatMoment(endedAt)].filter(Boolean).join(" - "); + [formatMoment(startedAt), formatMoment(endedAt)].filter(Boolean).join(' - '); const previewText = (items: TableCellValue[], fallback: string) => items - .map((item) => item?.toString()) + .map(item => item?.toString()) .filter(Boolean) .slice(0, 2) - .join(" · ") || fallback; + .join(' · ') || fallback; const agendaToneClassOf = (type: TableCellValue, index: number) => { - const normalized = type?.toString().toLowerCase() || ""; + const normalized = type?.toString().toLowerCase() || ''; const fallbackOrder: AgendaToneClass[] = [ - "formation", - "enrollment", - "competition", - "break", - "evaluation", + 'formation', + 'enrollment', + 'competition', + 'break', + 'evaluation', ]; - return ( - AgendaTypeClassMap[normalized] || - fallbackOrder[index % fallbackOrder.length] - ); + return AgendaTypeClassMap[normalized] || fallbackOrder[index % fallbackOrder.length]; }; -const agendaTypeLabelOf = ( - type: TableCellValue, - t: (key: I18nKey) => string, - fallback = "-", -) => { - const normalized = type?.toString().toLowerCase() || ""; +const agendaTypeLabelOf = (type: TableCellValue, t: (key: I18nKey) => string, fallback = '-') => { + const normalized = type?.toString().toLowerCase() || ''; const i18nKey = AgendaTypeLabelMap[normalized]; return i18nKey ? t(i18nKey) : type?.toString() || fallback; }; -const HackathonDetail: FC = observer( - ({ activity, hackathon }) => { - const { t } = useContext(I18nContext); - - const { - name, - summary, - location, - startTime, - endTime, - databaseSchema, - host, - image, - type: activityType, - } = activity, - { people, organizations, agenda, prizes, templates, projects } = - hackathon; - const forms = ((databaseSchema as Partial | undefined) - ?.forms || {}) as Partial>; - const agendaItems = [...agenda].sort( - ({ startedAt: left }, { startedAt: right }) => - new Date((left as string) || 0).getTime() - - new Date((right as string) || 0).getTime(), - ); - const hostTags = (host as string[] | undefined)?.slice(0, 2) || []; - const eventRange = formatPeriod(startTime, endTime); - const locationText = - (location as TableCellLocation | undefined)?.full_address || "-"; - const heroBadges = [ - (activityType as string) || t("hackathon"), - ...hostTags, - formatMoment(startTime), - formatMoment(endTime), - ].filter(Boolean); - const heroStats = [ - { label: t("participants"), value: people.length }, - { label: t("projects"), value: projects.length }, - { label: t("templates"), value: templates.length }, - { label: t("prizes"), value: prizes.length }, - ]; - const agendaPreview = agendaItems.slice(0, 3); - const overviewPills = agendaItems.slice(0, 6); - - const formGroups = FormButtonBar.flatMap((key) => { - const list = (forms[key] || []).filter(isPublicForm); - - return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; - }); - const primaryForm = - formGroups.find(({ key }) => key === "Person") || - formGroups.find(({ key }) => key === "Project") || - formGroups[0]; - const secondaryForm = - formGroups.find( - ({ key }) => key === "Project" && key !== primaryForm?.key, - ) || formGroups.find(({ key }) => key !== primaryForm?.key); - const formPreview = - formGroups - .map(({ meta }) => t(meta.eyebrow)) - .filter(Boolean) - .slice(0, 2) - .join(" · ") || t("hackathon_action_hub"); - - const highlightCards = [ - { - icon: HighlightIcons[0], - title: t("participants"), - value: people.length, - description: t(FormSectionMeta.Person.description), - }, - { - icon: HighlightIcons[1], - title: t("projects"), - value: projects.length, - description: t(FormSectionMeta.Project.description), - }, - { - icon: HighlightIcons[2], - title: t("templates"), - value: templates.length, - description: previewText( - templates.map(({ name }) => name), - t("templates"), - ), - }, - { - icon: HighlightIcons[3], - title: t("prizes"), - value: prizes.length, - description: previewText( - prizes.map(({ name }) => name), - t("hackathon_prizes"), - ), - }, - { - icon: HighlightIcons[4], - title: t("organizations"), - value: organizations.length, - description: previewText( - organizations.map(({ name }) => name), - t("organizations"), - ), - }, - { - icon: HighlightIcons[5], - title: t("agenda"), - value: agendaItems.length, - description: previewText( - agendaItems.map(({ name }) => name), - eventRange || t("agenda"), - ), - }, - ]; +const HackathonDetail: FC = observer(({ activity, hackathon }) => { + const { t } = useContext(I18nContext); + + const { + name, + summary, + location, + startTime, + endTime, + databaseSchema, + host, + image, + type: activityType, + } = activity, + { people, organizations, agenda, prizes, templates, projects } = hackathon; + const forms = ((databaseSchema as Partial | undefined)?.forms || {}) as Partial< + Record + >; + const agendaItems = [...agenda].sort( + ({ startedAt: left }, { startedAt: right }) => + new Date((left as string) || 0).getTime() - new Date((right as string) || 0).getTime(), + ); + const hostTags = (host as string[] | undefined)?.slice(0, 2) || []; + const eventRange = formatPeriod(startTime, endTime); + const locationText = (location as TableCellLocation | undefined)?.full_address || '-'; + const heroBadges = [ + (activityType as string) || t('hackathon'), + ...hostTags, + formatMoment(startTime), + formatMoment(endTime), + ].filter(Boolean); + const heroStats = [ + { label: t('participants'), value: people.length }, + { label: t('projects'), value: projects.length }, + { label: t('templates'), value: templates.length }, + { label: t('prizes'), value: prizes.length }, + ]; + const agendaPreview = agendaItems.slice(0, 3); + const overviewPills = agendaItems.slice(0, 6); + + const formGroups = FormButtonBar.flatMap(key => { + const list = (forms[key] || []).filter(isPublicForm); + + return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; + }); + const primaryForm = + formGroups.find(({ key }) => key === 'Person') || + formGroups.find(({ key }) => key === 'Project') || + formGroups[0]; + const secondaryForm = + formGroups.find(({ key }) => key === 'Project' && key !== primaryForm?.key) || + formGroups.find(({ key }) => key !== primaryForm?.key); + const formPreview = + formGroups + .map(({ meta }) => t(meta.eyebrow)) + .filter(Boolean) + .slice(0, 2) + .join(' · ') || t('hackathon_action_hub'); + + const highlightCards = [ + { + icon: HighlightIcons[0], + title: t('participants'), + value: people.length, + description: t(FormSectionMeta.Person.description), + }, + { + icon: HighlightIcons[1], + title: t('projects'), + value: projects.length, + description: t(FormSectionMeta.Project.description), + }, + { + icon: HighlightIcons[2], + title: t('templates'), + value: templates.length, + description: previewText( + templates.map(({ name }) => name), + t('templates'), + ), + }, + { + icon: HighlightIcons[3], + title: t('prizes'), + value: prizes.length, + description: previewText( + prizes.map(({ name }) => name), + t('hackathon_prizes'), + ), + }, + { + icon: HighlightIcons[4], + title: t('organizations'), + value: organizations.length, + description: previewText( + organizations.map(({ name }) => name), + t('organizations'), + ), + }, + { + icon: HighlightIcons[5], + title: t('agenda'), + value: agendaItems.length, + description: previewText( + agendaItems.map(({ name }) => name), + eventRange || t('agenda'), + ), + }, + ]; - return ( - <> - + return ( + <> + + +
+ +
+
+
    + {heroBadges.map((badge, index) => ( +
  • + {badge} +
  • + ))} +
+ +

+ {name as string} + + {(activityType as string) || t('hackathon_detail')} + +

+ +

{summary as string}

+ + + +
    + {heroStats.map(({ label, value }) => ( +
  • + {value} + {label} +
  • + ))} +
+
-

- - {name as string} - - - {(activityType as string) || t("hackathon_detail")} - -

- -

{summary as string}

- - - -
    - {heroStats.map(({ label, value }) => ( -
  • - {value} - {label} -
  • - ))} -
+
+

{locationText}

+

{eventRange || (summary as string)}

+
-
-
-
- - {t("event_info")} - - - {t("hackathon_detail")} - -
+ {primaryForm && ( +
+ {t(primaryForm.meta.eyebrow)} + {t(primaryForm.meta.title)} +

{t(primaryForm.meta.description)}

+
+ )} -
-
+ {agendaPreview[0] && ( +
+ {t('hackathon_agenda_preview')} + {agendaPreview[0].name as string} +

{formatPeriod(agendaPreview[0].startedAt, agendaPreview[0].endedAt)}

+
+ )} +
+
+ +
+ +
+ +
+

{t('event_info')}

+

{t('event_description')}

+
+
+ +
+
{(activityType as string) || t('hackathon')}
+

{summary as string}

+
+ + + {highlightCards.map(({ icon, title, value, description }) => ( + +
+ {icon} +
{title}
+

{description}

+ + {value} {title} + +
+ + ))} +
+
+
+ + {formGroups[0] && ( +
+ +
+
+
+

{t('hackathon_action_hub')}

+

+ {primaryForm ? t(primaryForm.meta.title) : t('hackathon_entry_flow')} +

+

+ {primaryForm + ? t(primaryForm.meta.description) + : t('hackathon_entry_flow_description')} +

+ + -
-

{locationText}

-

- {eventRange || (summary as string)} -

-
+
    +
  • {eventRange || t('event_duration')}
  • +
  • {locationText}
  • +
  • {formPreview}
  • +
+
+ +
+
+

{t('hackathon_entry_flow')}

+

{t('hackathon_action_hub')}

+

{t('hackathon_entry_flow_description')}

+
+ + + {formGroups.map(({ key, list, meta }, index) => ( + +
+ + {t('hackathon_step')} {String(index + 1).padStart(2, '0')} ·{' '} + {t(meta.eyebrow)} + +

{t(meta.title)}

+

{t(meta.description)}

+ +
+ {list.length} + {t(meta.eyebrow)} +
- {primaryForm && ( -
- - {t(primaryForm.meta.eyebrow)} - - {t(primaryForm.meta.title)} -

{t(primaryForm.meta.description)}

-
- )} - - {agendaPreview[0] && ( -
- - {t("hackathon_agenda_preview")} - - {agendaPreview[0].name as string} -

- {formatPeriod( - agendaPreview[0].startedAt, - agendaPreview[0].endedAt, - )} -

-
- )} + +
+ + ))} +
+ )} -
+ {agendaItems[0] && ( +
-

{t("event_info")}

-

{t("event_description")}

+

{t('agenda')}

+

{t('event_duration')}

-
-
- {(activityType as string) || t("hackathon")} -
-

{summary as string}

+
+

{eventRange}

+

{name as string}

+

{summary as string}

- - {highlightCards.map(({ icon, title, value, description }) => ( - -
- {icon} -
{title}
-

{description}

- - {value} {title} - -
- +
+ {overviewPills.map(({ id, name }) => ( +
+ {name as string} +
))} - - -
+
- {formGroups[0] && ( -
- -
-
-
-

- {t("hackathon_action_hub")} -

-

- {primaryForm - ? t(primaryForm.meta.title) - : t("hackathon_entry_flow")} -

-

- {primaryForm - ? t(primaryForm.meta.description) - : t("hackathon_entry_flow_description")} -

+
+ {agendaItems.map(({ id, name, type, summary, startedAt, endedAt }, index) => { + const toneClass = agendaToneClassOf(type, index); - - -
    -
  • {eventRange || t("event_duration")}
  • -
  • {locationText}
  • -
  • {formPreview}
  • -
-
-
+ return ( +
+
+ + PHASE {String(index + 1).padStart(2, '0')} + + +
-
-
-

- {t("hackathon_entry_flow")} -

-

- {t("hackathon_action_hub")} -

-

- {t("hackathon_entry_flow_description")} +

{name as string}

+

+ {(summary as string) || agendaTypeLabelOf(type, t)}

-
- - - {formGroups.map(({ key, list, meta }, index) => ( - -
- - {t("hackathon_step")}{" "} - {String(index + 1).padStart(2, "0")} ·{" "} - {t(meta.eyebrow)} + +
+
+
{t('type')}
+
+ {agendaTypeLabelOf(type, t)} + {(summary as string) || eventRange || locationText} +
+
+ +
+
{t('start_time')}
+
+ {formatMoment(startedAt)} + {t('event_duration')} +
+
+ +
+
{t('end_time')}
+
+ {formatMoment(endedAt)} + + {t('event_location')}: {locationText} -

{t(meta.title)}

-

{t(meta.description)}

- -
- - {list.length} - - - {t(meta.eyebrow)} - +
+
+
+
+ ); + })} +
+ +
+ )} + + {(prizes[0] || organizations[0]) && ( +
+ + {prizes[0] && ( + <> +
+

{t('prizes')}

+

{t('hackathon_prizes')}

+
+
+ +
+ {prizes.map( + ({ id, name, image, summary, level, sponsor, price, amount }, index) => ( +
+ {image && ( +
+
+ )} + +
+ + {(level as string) || `#${index + 1}`} + +

{name as string}

+

+ {(summary as string) || + previewText([sponsor, price, amount], t('prizes'))} +

+ +
+ {sponsor && ( +
+
{t('sponsor')}
+
{sponsor as string}
+
+ )} + {price && ( +
+
{t('price')}
+
{price as string}
+
+ )} + {amount && ( +
+
{t('amount')}
+
{amount as string}
+
+ )} +
+
+
+ ), + )} +
+ + )} + + {organizations[0] && ( +
+
+

{t('organizations')}

+

+ {previewText( + organizations.map(({ name }) => name), + t('organizations'), + )} +

+

{summary as string}

+
-
+ )} +
+
+ )} + + {(templates[0] || projects[0]) && ( +
+ + {templates[0] && ( + <> +
+

{t('templates')}

+

{t('source_code')}

+
+
+ + + {templates.map( + ({ id, name, languages, tags, sourceLink, summary, previewLink }) => ( + +
+
+

{name as string}

+
+

{summary as string}

+ +
    + {(languages as string[]).map(language => ( +
  • + {language} +
  • + ))} + {(tags as string[]).map(tag => ( +
  • + {tag} +
  • + ))} +
+ +
- ))} -
- - -
-
- )} - - {agendaItems[0] && ( -
- -
-

{t("agenda")}

-

{t("event_duration")}

-
-
- -
-

{eventRange}

-

{name as string}

-

{summary as string}

-
- -
- {overviewPills.map(({ id, name }) => ( -
- {name as string} -
- ))} -
- -
- {agendaItems.map( - ({ id, name, type, summary, startedAt, endedAt }, index) => { - const toneClass = agendaToneClassOf(type, index); + ), + )} + + + )} + + {projects[0] && ( + <> +
+

{t('projects')}

+

{t('products')}

+
+
+ + + {projects.map(({ id, name, score, summary, createdBy, members }) => { + const creator = createdBy as TableCellUser | undefined; + const scoreText = + score === null || score === undefined || score === '' ? '—' : `${score}`; + const memberNames = (members as string[] | undefined)?.join(', ') || '—'; return ( -
-
- - PHASE {String(index + 1).padStart(2, "0")} - - -
- -

{name as string}

-

- {(summary as string) || agendaTypeLabelOf(type, t)} -

- -
-
-
{t("type")}
-
- {agendaTypeLabelOf(type, t)} - - {(summary as string) || - eventRange || - locationText} - -
+ +
+
+

+ + {name as string} + +

+
{scoreText}
-
-
- {t("start_time")} -
-
- {formatMoment(startedAt)} - {t("event_duration")} -
-
+

{summary as string}

-
-
{t("end_time")}
-
- {formatMoment(endedAt)} - - {t("event_location")}: {locationText} - -
-
-
-
- ); - }, - )} -
-
-
- )} - - {(prizes[0] || organizations[0]) && ( -
- - {prizes[0] && ( - <> -
-

{t("prizes")}

-

- {t("hackathon_prizes")} -

-
-
- -
- {prizes.map( - ( - { - id, - name, - image, - summary, - level, - sponsor, - price, - amount, - }, - index, - ) => ( -
- {image && ( -
- -
- )} - -
- - {(level as string) || `#${index + 1}`} - -

- {name as string} -

-

- {(summary as string) || - previewText( - [sponsor, price, amount], - t("prizes"), +

+
+
{t('created_by')}
+
+ {creator?.email ? ( + {creator.name} + ) : ( + creator?.name || '—' )} -

- -
- {sponsor && ( -
-
{t("sponsor")}
-
{sponsor as string}
-
- )} - {price && ( -
-
{t("price")}
-
{price as string}
-
- )} - {amount && ( -
-
{t("amount")}
-
{amount as string}
-
- )} -
-
+ +
+
+
{t('members')}
+
{memberNames}
+
+
- ), - )} -
- - )} - - {organizations[0] && ( -
-
-

- {t("organizations")} -

-

- {previewText( - organizations.map(({ name }) => name), - t("organizations"), - )} -

-

- {summary as string} -

-
+ + ); + })} + + + )} + +
+ )} - - - )} - -
- )} - - {(templates[0] || projects[0]) && ( -
- - {templates[0] && ( - <> -
-

{t("templates")}

-

{t("source_code")}

-
-
- - - {templates.map( - ({ - id, - name, - languages, - tags, - sourceLink, - summary, - previewLink, - }) => ( - -
-
-

- {name as string} -

-
-

- {summary as string} -

- -
    - {(languages as string[]).map((language) => ( -
  • - {language} -
  • - ))} - {(tags as string[]).map((tag) => ( -
  • - {tag} -
  • - ))} -
- - -
- - ), - )} -
- - )} + {people[0] && ( +
+ +
+

{t('participants')}

+

{t('github_account')}

+
+
- {projects[0] && ( - <> -
+ {people.map(({ id, name, avatar, githubLink }) => { + const content = ( + <> + + {name as string} + + ); + + return githubLink ? ( + -

{t("projects")}

-

{t("products")}

-
-
- - - {projects.map( - ({ id, name, score, summary, createdBy, members }) => { - const creator = createdBy as TableCellUser | undefined; - const scoreText = - score === null || score === undefined || score === "" - ? "—" - : `${score}`; - const memberNames = - (members as string[] | undefined)?.join(", ") || "—"; - - return ( - - - - ); - }, - )} - - - )} -
-
- )} - - {people[0] && ( -
- -
-

{t("participants")}

-

{t("github_account")}

-
-
- -
- {people.map(({ id, name, avatar, githubLink }) => { - const content = ( - <> - - - {name as string} - - - ); - - return githubLink ? ( - - {content} - - ) : ( -
- {content} -
- ); - })} -
-
-
- )} - - ); - }, -); + {content} + + ) : ( +
+ {content} +
+ ); + })} + +
+
+ )} + + ); +}); export default HackathonDetail; From d6c1dba1183d4d2a19795116fdb7ca953e447dfb Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 22:55:49 +0800 Subject: [PATCH 08/14] Refactor hackathon detail sections --- .../Hackathon/HackathonActionHub.module.less | 140 +++ .../Activity/Hackathon/HackathonActionHub.tsx | 116 +++ .../Hackathon/HackathonAwards.module.less | 154 +++ .../Activity/Hackathon/HackathonAwards.tsx | 122 +++ .../Hackathon/HackathonHero.module.less | 361 +++++++ .../Activity/Hackathon/HackathonHero.tsx | 162 +++ .../Hackathon/HackathonOverview.module.less | 95 ++ .../Activity/Hackathon/HackathonOverview.tsx | 61 ++ .../HackathonParticipants.module.less | 65 ++ .../Hackathon/HackathonParticipants.tsx | 58 ++ .../Hackathon/HackathonResources.module.less | 162 +++ .../Activity/Hackathon/HackathonResources.tsx | 155 +++ .../Hackathon/HackathonSchedule.module.less | 183 ++++ .../Activity/Hackathon/HackathonSchedule.tsx | 109 +++ components/Activity/Hackathon/theme.less | 143 +++ pages/hackathon/[id].tsx | 925 ++++++------------ translation/en-US.ts | 1 + translation/zh-CN.ts | 7 +- translation/zh-TW.ts | 7 +- 19 files changed, 2378 insertions(+), 648 deletions(-) create mode 100644 components/Activity/Hackathon/HackathonActionHub.module.less create mode 100644 components/Activity/Hackathon/HackathonActionHub.tsx create mode 100644 components/Activity/Hackathon/HackathonAwards.module.less create mode 100644 components/Activity/Hackathon/HackathonAwards.tsx create mode 100644 components/Activity/Hackathon/HackathonHero.module.less create mode 100644 components/Activity/Hackathon/HackathonHero.tsx create mode 100644 components/Activity/Hackathon/HackathonOverview.module.less create mode 100644 components/Activity/Hackathon/HackathonOverview.tsx create mode 100644 components/Activity/Hackathon/HackathonParticipants.module.less create mode 100644 components/Activity/Hackathon/HackathonParticipants.tsx create mode 100644 components/Activity/Hackathon/HackathonResources.module.less create mode 100644 components/Activity/Hackathon/HackathonResources.tsx create mode 100644 components/Activity/Hackathon/HackathonSchedule.module.less create mode 100644 components/Activity/Hackathon/HackathonSchedule.tsx create mode 100644 components/Activity/Hackathon/theme.less diff --git a/components/Activity/Hackathon/HackathonActionHub.module.less b/components/Activity/Hackathon/HackathonActionHub.module.less new file mode 100644 index 0000000..9bb37d3 --- /dev/null +++ b/components/Activity/Hackathon/HackathonActionHub.module.less @@ -0,0 +1,140 @@ +@import './theme.less'; + +.section { + .section-shell(); + padding-top: clamp(3rem, 5vw, 4rem); +} + +.registerWrap { + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); + gap: 1.5rem; + align-items: start; +} + +.registerCard { + .panel-card(); + background: linear-gradient(135deg, rgba(44, 232, 255, 0.08), rgba(123, 97, 255, 0.14)); +} + +.registerCardInner, +.entryHub { + padding: 1.55rem; +} + +.regEyebrow, +.entryEyebrow, +.entryStep { + .eyebrow(); +} + +.regTitle, +.entryTitle { + margin: 0; + color: #fff; + font-family: @heading; + font-size: clamp(1.55rem, 3vw, 2.1rem); + font-weight: 800; + line-height: 1.3; +} + +.regTitle { + margin-top: 0.4rem; +} + +.regDesc, +.entryCard p { + color: @muted; + line-height: 1.75; +} + +.regDesc { + margin: 0.9rem 0 1.2rem; +} + +.regActions, +.entryLinks { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; +} + +.actionButton { + .button-primary(); +} + +.actionButtonGhost, +.entryLink { + .button-ghost(); +} + +.regFacts { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 1.3rem 0 0; + + li { + .chip(); + padding: 0.48rem 0.85rem; + color: @copy; + font-size: 0.9rem; + } +} + +.entryHub { + .panel-card(); +} + +.entryHubHead { + margin-bottom: 1.25rem; +} + +.entryTitle { + margin-top: 0.35rem; + font-size: 1.55rem; +} + +.entryCard { + .panel-card(); + height: 100%; + padding: 1.3rem; +} + +.entryStep { + color: @cyan; +} + +.entryCard h4 { + margin: 0.55rem 0 0.45rem; + color: #fff; + font-family: @heading; + font-size: 1.02rem; +} + +.entryCard p { + margin: 0; +} + +.entryMetaRow { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + margin: 1rem 0; +} + +.entryMeta { + .chip(); + padding: 0.35rem 0.7rem; + color: @gold; + font-family: @heading; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +@media (max-width: 1199px) { + .registerWrap { + grid-template-columns: 1fr; + } +} diff --git a/components/Activity/Hackathon/HackathonActionHub.tsx b/components/Activity/Hackathon/HackathonActionHub.tsx new file mode 100644 index 0000000..6daab6d --- /dev/null +++ b/components/Activity/Hackathon/HackathonActionHub.tsx @@ -0,0 +1,116 @@ +import { FC } from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; + +import { HackathonHeroAction } from './HackathonHero'; +import styles from './HackathonActionHub.module.less'; + +export interface HackathonActionHubEntry { + count: number; + description: string; + eyebrow: string; + links: HackathonHeroAction[]; + title: string; +} + +export interface HackathonActionHubProps { + entries: HackathonActionHubEntry[]; + facts: string[]; + primaryAction?: HackathonHeroAction; + primaryDescription: string; + primaryTitle: string; + secondaryAction: HackathonHeroAction; + subtitle: string; + title: string; +} + +const ActionHubLink: FC<{ action: HackathonHeroAction; variant: 'ghost' | 'primary' }> = ({ + action, + variant, +}) => ( + + {action.label} + +); + +const ActionEntryCard: FC<{ entry: HackathonActionHubEntry; step: string }> = ({ entry, step }) => ( +
+ + {step} · {entry.eyebrow} + +

{entry.title}

+

{entry.description}

+ +
+ {entry.count} + {entry.eyebrow} +
+ + +
+); + +export const HackathonActionHub: FC = ({ + entries, + facts, + primaryAction, + primaryDescription, + primaryTitle, + secondaryAction, + subtitle, + title, +}) => ( +
+ +
+
+
+

{title}

+

{primaryTitle}

+

{primaryDescription}

+ + + +
    + {facts.map(fact => ( +
  • {fact}
  • + ))} +
+
+
+ +
+
+

{subtitle}

+

{title}

+
+ + + {entries.map((entry, index) => ( + + + + ))} + +
+
+
+
+); diff --git a/components/Activity/Hackathon/HackathonAwards.module.less b/components/Activity/Hackathon/HackathonAwards.module.less new file mode 100644 index 0000000..1518b10 --- /dev/null +++ b/components/Activity/Hackathon/HackathonAwards.module.less @@ -0,0 +1,154 @@ +@import './theme.less'; + +.section { + .section-shell(); +} + +.sectionHeader { + .section-header(); +} + +.sectionTitle { + .section-title(); +} + +.sectionSubtitle, +.supportEyebrow { + .section-subtitle(); +} + +.accentLine { + .accent-line(); +} + +.awardsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.badgeTile { + .panel-card(); + overflow: hidden; +} + +.badgeArtWrap { + padding: 1.1rem 1.1rem 0; +} + +.badgeArt { + border-radius: 22px; + background: rgba(255, 255, 255, 0.04); + width: 100%; + aspect-ratio: 1; + object-fit: cover; +} + +.badgeTileBody { + padding: 1.15rem 1.2rem 1.3rem; +} + +.badgeTierLabel { + color: @gold; + font-family: @heading; + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.badgeTileTitle, +.supportTitle { + margin: 0; + color: #fff; + font-family: @heading; + font-weight: 800; +} + +.badgeTileTitle { + font-size: 1.05rem; +} + +.badgeTileCopy, +.supportDescription { + color: @muted; + line-height: 1.75; +} + +.badgeTileCopy { + margin: 0.7rem 0 1rem; +} + +.prizeMeta { + display: grid; + gap: 0.7rem; + margin: 0; + + div { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.8rem; + align-items: start; + } + + dt, + dd { + margin: 0; + } + + dt { + color: @muted; + font-family: @heading; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + dd { + color: #fff; + line-height: 1.5; + } +} + +.supportCard { + .panel-card(); + display: grid; + grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.25fr); + gap: 1.25rem; + margin-top: 1.4rem; + padding: 1.55rem; +} + +.supportTitle { + margin-top: 0.35rem; + font-size: 1.45rem; +} + +.partnerGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 0.8rem; + align-items: center; +} + +.partnerLink { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 20px; + background: rgba(255, 255, 255, 0.04); + min-height: 104px; + padding: 1rem; +} + +.partnerLogo { + max-width: 100%; + max-height: 58px; + object-fit: contain; +} + +@media (max-width: 1199px) { + .supportCard { + grid-template-columns: 1fr; + } +} diff --git a/components/Activity/Hackathon/HackathonAwards.tsx b/components/Activity/Hackathon/HackathonAwards.tsx new file mode 100644 index 0000000..53d9a88 --- /dev/null +++ b/components/Activity/Hackathon/HackathonAwards.tsx @@ -0,0 +1,122 @@ +import { TableCellValue } from 'mobx-lark'; +import { FC } from 'react'; +import { Container } from 'react-bootstrap'; + +import { LarkImage } from '../../LarkImage'; +import styles from './HackathonAwards.module.less'; + +export interface HackathonAwardsMeta { + label: string; + value: string; +} + +export interface HackathonPrizeItem { + description: string; + id: string; + image?: TableCellValue; + meta: HackathonAwardsMeta[]; + tier: string; + title: string; +} + +export interface HackathonOrganizationItem { + href?: string; + id: string; + logo?: TableCellValue; + name: string; +} + +export interface HackathonAwardsProps { + organizations: HackathonOrganizationItem[]; + prizes: HackathonPrizeItem[]; + subtitle: string; + supportDescription: string; + supportEyebrow: string; + supportTitle: string; + title: string; +} + +const PrizeCard: FC = ({ description, image, meta, tier, title }) => ( +
+ {image && ( +
+ +
+ )} + +
+ {tier} +

{title}

+

{description}

+ +
+ {meta.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+
+); + +const OrganizationLogo: FC = ({ href, logo, name }) => { + const imageNode = ; + + return href ? ( + + {imageNode} + + ) : ( +
+ {imageNode} +
+ ); +}; + +export const HackathonAwards: FC = ({ + organizations, + prizes, + subtitle, + supportDescription, + supportEyebrow, + supportTitle, + title, +}) => ( +
+ + {prizes[0] && ( + <> +
+

{title}

+

{subtitle}

+
+
+ +
+ {prizes.map(prize => ( + + ))} +
+ + )} + + {organizations[0] && ( +
+
+

{supportEyebrow}

+

{supportTitle}

+

{supportDescription}

+
+ + +
+ )} +
+
+); diff --git a/components/Activity/Hackathon/HackathonHero.module.less b/components/Activity/Hackathon/HackathonHero.module.less new file mode 100644 index 0000000..7d8137b --- /dev/null +++ b/components/Activity/Hackathon/HackathonHero.module.less @@ -0,0 +1,361 @@ +@import './theme.less'; + +.hero { + position: relative; + overflow: hidden; + background: + radial-gradient(circle at top left, rgba(44, 232, 255, 0.18), transparent 32%), + radial-gradient(circle at 85% 12%, rgba(255, 120, 186, 0.15), transparent 24%), + linear-gradient(180deg, @bg 0%, #091022 48%, #050814 100%); + padding: clamp(4.5rem, 8vw, 6.75rem) 0 clamp(3.5rem, 6vw, 5rem); + color: @copy; + + &::before, + &::after { + position: absolute; + inset: 0; + pointer-events: none; + content: ''; + } + + &::before { + background-image: + linear-gradient(rgba(44, 232, 255, 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgba(44, 232, 255, 0.08) 1px, transparent 1px); + background-size: 54px 54px; + mask-image: radial-gradient(circle at center, black 42%, transparent 100%); + opacity: 0.45; + } + + &::after { + inset: auto 0 0; + height: 140px; + background: linear-gradient(180deg, transparent, rgba(5, 8, 20, 0.95)); + } +} + +.heroInner { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 460px); + gap: clamp(2rem, 4vw, 4rem); + align-items: center; +} + +.heroContent { + display: flex; + flex-direction: column; + gap: 1.35rem; +} + +.heroEyebrow { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0; +} + +.heroBadge { + display: inline-flex; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + padding: 0.42rem 0.9rem; + color: @copy; + font-family: @heading; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.heroBadgeCyan { + box-shadow: 0 0 18px rgba(44, 232, 255, 0.22); + border-color: rgba(44, 232, 255, 0.28); + color: @cyan; +} + +.heroBadgeGold { + box-shadow: 0 0 18px rgba(255, 201, 77, 0.18); + border-color: rgba(255, 201, 77, 0.24); + color: @gold; +} + +.heroBadgeGreen { + box-shadow: 0 0 18px rgba(72, 241, 168, 0.2); + border-color: rgba(72, 241, 168, 0.24); + color: @green; +} + +.heroBadgeRose { + box-shadow: 0 0 18px rgba(255, 120, 186, 0.18); + border-color: rgba(255, 120, 186, 0.24); + color: @rose; +} + +.title { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin: 0; + font-family: @heading; + font-size: clamp(2.6rem, 5vw, 4.45rem); + font-weight: 900; + letter-spacing: 0.02em; + line-height: 1; +} + +.heroTitlePrimary { + color: #fff; +} + +.heroTitleSecondary { + background: linear-gradient(90deg, @cyan, @gold 78%); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + font-size: clamp(1.05rem, 2vw, 1.6rem); + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.description { + max-width: 62ch; + margin: 0; + color: @muted; + font-family: @body; + font-size: 1.05rem; + line-height: 1.8; +} + +.heroActions { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; +} + +.actionButton { + .button-primary(); +} + +.actionButtonGhost { + .button-ghost(); +} + +.heroStats { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + margin: 0; +} + +.statChip { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 108px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.06); + padding: 0.85rem 1rem; + + strong { + font-family: @heading; + font-size: 1.25rem; + line-height: 1.1; + } + + span { + color: @muted; + font-size: 0.82rem; + letter-spacing: 0.04em; + text-transform: uppercase; + } +} + +.heroVisual { + position: relative; + min-height: 580px; +} + +.heroVisualCard { + .panel-card(); + position: relative; + z-index: 1; + overflow: hidden; + background: linear-gradient(180deg, rgba(11, 19, 40, 0.95), rgba(8, 18, 39, 0.9)); +} + +.heroVisualHead { + display: flex; + justify-content: space-between; + gap: 0.8rem; + padding: 1.15rem 1.25rem 0; +} + +.visualKicker, +.visualChip, +.floatingLabel { + font-family: @heading; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.visualKicker, +.floatingLabel { + color: @muted; +} + +.visualChip { + color: @gold; +} + +.heroImageFrame { + position: relative; + overflow: hidden; + margin: 1rem 1.25rem 0; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 24px; + background: linear-gradient(135deg, rgba(44, 232, 255, 0.08), rgba(123, 97, 255, 0.14)); + min-height: 340px; + + :global(img) { + position: relative; + z-index: 2; + } +} + +.mascotGlow { + position: absolute; + inset: 12% 18%; + z-index: 1; + border-radius: 999px; + background: radial-gradient(circle, rgba(44, 232, 255, 0.35), transparent 70%); + filter: blur(28px); +} + +.heroImageFallback { + display: flex; + align-items: center; + justify-content: center; + min-height: 340px; + color: @cyan; + font-family: @heading; + font-size: clamp(1.25rem, 3vw, 2rem); + font-weight: 900; + letter-spacing: 0.12em; + text-align: center; + text-transform: uppercase; +} + +.heroVisualFoot { + padding: 1.15rem 1.25rem 1.35rem; +} + +.heroVisualTitle { + margin: 0 0 0.35rem; + color: #fff; + font-family: @heading; + font-size: 1rem; +} + +.heroVisualCopy, +.heroVisualDescription { + margin: 0; + color: @muted; + line-height: 1.75; +} + +.heroFloatingCard { + position: absolute; + z-index: 2; + max-width: 260px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.28); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 22px; + background: rgba(8, 18, 39, 0.88); + padding: 1rem 1.1rem; + + strong { + display: block; + margin-top: 0.35rem; + color: #fff; + font-size: 1rem; + line-height: 1.4; + } + + p { + margin: 0.45rem 0 0; + color: @muted; + font-size: 0.92rem; + line-height: 1.6; + } +} + +.heroFloatingCardTop { + top: 2.5rem; + left: -1.5rem; +} + +.heroFloatingCardBottom { + right: -1.5rem; + bottom: 1.5rem; +} + +@media (max-width: 1199px) { + .heroInner { + grid-template-columns: 1fr; + } + + .heroVisual { + min-height: 0; + } + + .heroFloatingCardTop { + left: 1rem; + } + + .heroFloatingCardBottom { + right: 1rem; + } +} + +@media (max-width: 991px) { + .title { + font-size: clamp(2.2rem, 9vw, 3.45rem); + } +} + +@media (max-width: 767px) { + .hero { + padding-top: 4rem; + } + + .heroFloatingCard { + position: static; + max-width: none; + margin-top: 1rem; + } + + .heroVisual { + display: flex; + flex-direction: column; + } + + .heroImageFrame { + min-height: 260px; + } + + .heroStats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .heroBadge { + font-size: 0.72rem; + } +} diff --git a/components/Activity/Hackathon/HackathonHero.tsx b/components/Activity/Hackathon/HackathonHero.tsx new file mode 100644 index 0000000..2641f08 --- /dev/null +++ b/components/Activity/Hackathon/HackathonHero.tsx @@ -0,0 +1,162 @@ +import { TableCellValue } from 'mobx-lark'; +import { FC } from 'react'; +import { Container } from 'react-bootstrap'; + +import { LarkImage } from '../../LarkImage'; +import styles from './HackathonHero.module.less'; + +export interface HackathonHeroAction { + external?: boolean; + href: string; + label: string; +} + +export interface HackathonHeroCard { + description: string; + eyebrow: string; + title: string; +} + +export interface HackathonHeroStat { + label: string; + value: number; +} + +export interface HackathonHeroProps { + badges: string[]; + bottomCard?: HackathonHeroCard; + description: string; + image?: TableCellValue; + imageFallback: string; + locationText: string; + name: string; + primaryAction: HackathonHeroAction; + secondaryAction: HackathonHeroAction; + stats: HackathonHeroStat[]; + subtitle: string; + topCard?: HackathonHeroCard; + visualChip: string; + visualCopy: string; + visualKicker: string; + visualTitle: string; +} + +const BadgeToneClass = [ + styles.heroBadgeCyan, + styles.heroBadgeGold, + styles.heroBadgeGreen, + styles.heroBadgeRose, +]; + +const HeroLink: FC<{ action: HackathonHeroAction; variant: 'ghost' | 'primary' }> = ({ + action, + variant, +}) => ( + + {action.label} + +); + +const FloatingCard: FC<{ card: HackathonHeroCard; position: 'bottom' | 'top' }> = ({ + card, + position, +}) => ( +
+ {card.eyebrow} + {card.title} +

{card.description}

+
+); + +export const HackathonHero: FC = ({ + badges, + bottomCard, + description, + image, + imageFallback, + locationText, + name, + primaryAction, + secondaryAction, + stats, + subtitle, + topCard, + visualChip, + visualCopy, + visualKicker, + visualTitle, +}) => ( +
+ +
+
+
    + {badges.map((badge, index) => ( +
  • + {badge} +
  • + ))} +
+ +

+ {name} + {subtitle} +

+ +

{description}

+ + + +
    + {stats.map(({ label, value }) => ( +
  • + {value} + {label} +
  • + ))} +
+
+ +
+
+
+ {visualKicker} + {visualChip} +
+ +
+
+ + {image ? ( + + ) : ( +
{imageFallback}
+ )} +
+ +
+

{locationText}

+

{visualTitle}

+

{visualCopy}

+
+
+ + {topCard && } + {bottomCard && } +
+
+
+
+); diff --git a/components/Activity/Hackathon/HackathonOverview.module.less b/components/Activity/Hackathon/HackathonOverview.module.less new file mode 100644 index 0000000..b992344 --- /dev/null +++ b/components/Activity/Hackathon/HackathonOverview.module.less @@ -0,0 +1,95 @@ +@import './theme.less'; + +.section { + .section-shell(); +} + +.sectionHeader { + .section-header(); +} + +.sectionTitle { + .section-title(); +} + +.sectionSubtitle { + .section-subtitle(); +} + +.accentLine { + .accent-line(); +} + +.themePanel { + margin-bottom: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 28px; + background: linear-gradient(135deg, rgba(44, 232, 255, 0.08), rgba(123, 97, 255, 0.12)); + padding: 1.5rem 1.6rem; +} + +.themeText { + margin-bottom: 0.4rem; + color: #fff; + font-family: @heading; + font-size: clamp(1.35rem, 2.6vw, 2rem); + font-weight: 900; +} + +.themeSub { + margin: 0; + color: @muted; + line-height: 1.75; +} + +.trackCard { + .panel-card(); + transition: + transform 0.22s ease, + border-color 0.22s ease; + height: 100%; + padding: 1.4rem; + + &:hover { + transform: translateY(-4px); + border-color: rgba(44, 232, 255, 0.26); + } +} + +.trackIcon { + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + border-radius: 18px; + background: rgba(255, 255, 255, 0.06); + width: 54px; + height: 54px; + font-size: 1.35rem; +} + +.trackName { + margin: 0; + color: #fff; + font-family: @heading; + font-size: 1.08rem; + font-weight: 800; + line-height: 1.3; +} + +.trackDesc { + min-height: 4.8rem; + margin: 0.75rem 0 0.95rem; + color: @muted; + line-height: 1.75; +} + +.trackMetric { + color: @cyan; + font-family: @heading; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} diff --git a/components/Activity/Hackathon/HackathonOverview.tsx b/components/Activity/Hackathon/HackathonOverview.tsx new file mode 100644 index 0000000..6ad8120 --- /dev/null +++ b/components/Activity/Hackathon/HackathonOverview.tsx @@ -0,0 +1,61 @@ +import { FC } from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; + +import styles from './HackathonOverview.module.less'; + +export interface HackathonOverviewCard { + description: string; + icon: string; + title: string; + value: number; +} + +export interface HackathonOverviewProps { + cards: HackathonOverviewCard[]; + subtitle: string; + themeSub: string; + themeText: string; + title: string; +} + +const OverviewCard: FC = ({ description, icon, title, value }) => ( +
+ {icon} +
{title}
+

{description}

+ + {value} {title} + +
+); + +export const HackathonOverview: FC = ({ + cards, + subtitle, + themeSub, + themeText, + title, +}) => ( +
+ +
+

{title}

+

{subtitle}

+
+
+ +
+
{themeText}
+

{themeSub}

+
+ + + {cards.map(card => ( + + + + ))} + +
+
+); diff --git a/components/Activity/Hackathon/HackathonParticipants.module.less b/components/Activity/Hackathon/HackathonParticipants.module.less new file mode 100644 index 0000000..1ad3e3c --- /dev/null +++ b/components/Activity/Hackathon/HackathonParticipants.module.less @@ -0,0 +1,65 @@ +@import './theme.less'; + +.section { + .section-shell(); +} + +.sectionHeader { + .section-header(); +} + +.sectionTitle { + .section-title(); +} + +.sectionSubtitle { + .section-subtitle(); +} + +.accentLine { + .accent-line(); +} + +.participantCloud { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; +} + +.participantCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + border-radius: 24px; + background: rgba(255, 255, 255, 0.04); + padding: 1.1rem 0.9rem; + color: #fff; + text-align: center; + text-decoration: none; +} + +.participantCard:hover { + color: @cyan; + text-decoration: none; +} + +.avatar { + border: 2px solid rgba(44, 232, 255, 0.2); + border-radius: 50%; + width: 84px; + height: 84px; + object-fit: cover; +} + +.participantName { + font-size: 0.92rem; + line-height: 1.4; +} + +@media (max-width: 767px) { + .participantCloud { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/components/Activity/Hackathon/HackathonParticipants.tsx b/components/Activity/Hackathon/HackathonParticipants.tsx new file mode 100644 index 0000000..590ae05 --- /dev/null +++ b/components/Activity/Hackathon/HackathonParticipants.tsx @@ -0,0 +1,58 @@ +import { TableCellValue } from 'mobx-lark'; +import { FC } from 'react'; +import { Container } from 'react-bootstrap'; + +import { LarkImage } from '../../LarkImage'; +import styles from './HackathonParticipants.module.less'; + +export interface HackathonParticipantItem { + avatar?: TableCellValue; + githubLink?: string; + id: string; + name: string; +} + +export interface HackathonParticipantsProps { + participants: HackathonParticipantItem[]; + subtitle: string; + title: string; +} + +const ParticipantCard: FC = ({ avatar, githubLink, name }) => { + const content = ( + <> + + {name} + + ); + + return githubLink ? ( + + {content} + + ) : ( +
{content}
+ ); +}; + +export const HackathonParticipants: FC = ({ + participants, + subtitle, + title, +}) => ( +
+ +
+

{title}

+

{subtitle}

+
+
+ +
+ {participants.map(participant => ( + + ))} +
+
+
+); diff --git a/components/Activity/Hackathon/HackathonResources.module.less b/components/Activity/Hackathon/HackathonResources.module.less new file mode 100644 index 0000000..5157282 --- /dev/null +++ b/components/Activity/Hackathon/HackathonResources.module.less @@ -0,0 +1,162 @@ +@import './theme.less'; + +.section { + .section-shell(); +} + +.sectionHeader { + .section-header(); +} + +.sectionTitle { + .section-title(); +} + +.sectionSubtitle { + .section-subtitle(); +} + +.accentLine { + .accent-line(); +} + +.projectHeader { + margin-top: 2.75rem; +} + +.resourceCard, +.projectCard { + .panel-card(); + height: 100%; + padding: 1.25rem; +} + +.resourceHead, +.projectHead { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: start; +} + +.resourceTitle, +.projectTitle { + margin: 0; + color: #fff; + font-family: @heading; + font-size: 1.02rem; + font-weight: 800; +} + +.projectTitle a { + color: #fff; + text-decoration: none; + + &:hover { + color: @cyan; + text-decoration: none; + } +} + +.resourceDescription, +.projectSummary { + margin: 0.8rem 0 1rem; + color: @muted; + line-height: 1.75; +} + +.topicList { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + margin: 0 0 1rem; +} + +.topicChip, +.topicChipMuted { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.34rem 0.72rem; + font-size: 0.76rem; +} + +.topicChip { + background: rgba(44, 232, 255, 0.12); + color: @cyan; +} + +.topicChipMuted { + background: rgba(255, 255, 255, 0.08); + color: @copy; +} + +.resourceLinks { + display: flex; + flex-wrap: wrap; + gap: 0.9rem; +} + +.entryLink { + .button-ghost(); +} + +.scoreCircle { + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 24px rgba(44, 232, 255, 0.18); + border: 1px solid rgba(44, 232, 255, 0.2); + border-radius: 50%; + background: rgba(44, 232, 255, 0.1); + width: 62px; + height: 62px; + color: @cyan; + font-family: @heading; + font-size: 1.05rem; + font-weight: 800; +} + +.projectMeta { + display: grid; + gap: 0.7rem; + margin: 0; + + div { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.8rem; + align-items: start; + } + + dt, + dd { + margin: 0; + } + + dt { + color: @muted; + font-family: @heading; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + dd { + color: #fff; + line-height: 1.5; + } +} + +@media (max-width: 767px) { + .topicChip, + .topicChipMuted { + font-size: 0.72rem; + } + + .scoreCircle { + width: 54px; + height: 54px; + font-size: 0.92rem; + } +} diff --git a/components/Activity/Hackathon/HackathonResources.tsx b/components/Activity/Hackathon/HackathonResources.tsx new file mode 100644 index 0000000..dd6fa17 --- /dev/null +++ b/components/Activity/Hackathon/HackathonResources.tsx @@ -0,0 +1,155 @@ +import { FC } from 'react'; +import { Col, Container, Row } from 'react-bootstrap'; + +import styles from './HackathonResources.module.less'; + +export interface HackathonTemplateItem { + description: string; + id: string; + languages: string[]; + previewLabel: string; + previewUrl?: string; + sourceLabel: string; + sourceUrl?: string; + tags: string[]; + title: string; +} + +export interface HackathonProjectMeta { + label: string; + value: string; + valueHref?: string; +} + +export interface HackathonProjectItem { + description: string; + id: string; + link: string; + meta: HackathonProjectMeta[]; + score: string; + title: string; +} + +export interface HackathonResourcesProps { + projectItems: HackathonProjectItem[]; + projectSubtitle: string; + projectTitle: string; + templateItems: HackathonTemplateItem[]; + templateSubtitle: string; + templateTitle: string; +} + +const TemplateCard: FC = ({ + description, + languages, + previewLabel, + previewUrl, + sourceLabel, + sourceUrl, + tags, + title, +}) => ( +
+
+

{title}

+
+

{description}

+ +
    + {languages.map(language => ( +
  • + {language} +
  • + ))} + {tags.map(tag => ( +
  • + {tag} +
  • + ))} +
+ + +
+); + +const ProjectCard: FC = ({ description, link, meta, score, title }) => ( +
+
+

+ {title} +

+
{score}
+
+ +

{description}

+ +
+ {meta.map(({ label, value, valueHref }) => ( +
+
{label}
+
{valueHref ? {value} : value}
+
+ ))} +
+
+); + +export const HackathonResources: FC = ({ + projectItems, + projectSubtitle, + projectTitle, + templateItems, + templateSubtitle, + templateTitle, +}) => ( +
+ + {templateItems[0] && ( + <> +
+

{templateTitle}

+

{templateSubtitle}

+
+
+ + + {templateItems.map(template => ( + + + + ))} + + + )} + + {projectItems[0] && ( + <> +
+

{projectTitle}

+

{projectSubtitle}

+
+
+ + + {projectItems.map(project => ( + + + + ))} + + + )} +
+
+); diff --git a/components/Activity/Hackathon/HackathonSchedule.module.less b/components/Activity/Hackathon/HackathonSchedule.module.less new file mode 100644 index 0000000..7c6955b --- /dev/null +++ b/components/Activity/Hackathon/HackathonSchedule.module.less @@ -0,0 +1,183 @@ +@import './theme.less'; + +.section { + .section-shell(); +} + +.sectionHeader { + .section-header(); +} + +.sectionTitle { + .section-title(); +} + +.sectionSubtitle { + .section-subtitle(); +} + +.accentLine { + .accent-line(); +} + +.scheduleIntro { + margin-bottom: 1.35rem; + text-align: center; +} + +.scheduleKicker { + margin: 0 0 0.35rem; + color: @cyan; + font-family: @heading; + font-size: 0.8rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.scheduleLead { + margin: 0; + color: #fff; + font-family: @heading; + font-size: clamp(1.45rem, 3vw, 2.05rem); + font-weight: 800; +} + +.scheduleOverview { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.schedulePill { + .chip(); + padding: 0.58rem 0.9rem; + color: @copy; + font-family: @heading; + font-size: 0.74rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.scheduleDays { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.dayCard { + .panel-card(); + height: 100%; + padding: 1.35rem; +} + +.formation { + background: linear-gradient(135deg, rgba(44, 232, 255, 0.1), rgba(14, 43, 80, 0.86)); +} + +.enrollment { + background: linear-gradient(135deg, rgba(123, 97, 255, 0.12), rgba(28, 20, 67, 0.86)); +} + +.competition { + background: linear-gradient(135deg, rgba(255, 201, 77, 0.12), rgba(60, 35, 8, 0.88)); +} + +.break { + background: linear-gradient(135deg, rgba(255, 120, 186, 0.1), rgba(72, 18, 48, 0.88)); +} + +.evaluation { + background: linear-gradient(135deg, rgba(72, 241, 168, 0.12), rgba(14, 50, 39, 0.88)); +} + +.dayCardHead { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: baseline; +} + +.dayNo { + .section-subtitle(); +} + +.dayDate { + color: @copy; + font-size: 0.85rem; +} + +.dayTitle { + margin: 0.9rem 0 0.45rem; + color: #fff; + font-family: @heading; + font-size: 1.08rem; +} + +.daySub { + margin: 0 0 1rem; + color: @muted; + line-height: 1.7; +} + +.dayAgenda { + display: grid; + gap: 0.7rem; + margin: 0; + + div { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.8rem; + align-items: start; + } + + dt, + dd { + margin: 0; + } +} + +.timePill { + display: inline-flex; + align-items: center; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + min-height: 30px; + padding: 0.4rem 0.75rem; + color: @copy; + font-family: @heading; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.agendaCopy { + display: flex; + flex-direction: column; + gap: 0.14rem; + + strong { + color: #fff; + line-height: 1.4; + } + + span { + color: @muted; + font-size: 0.92rem; + line-height: 1.55; + } +} + +@media (max-width: 991px) { + .scheduleDays { + grid-template-columns: 1fr; + } +} + +@media (max-width: 767px) { + .schedulePill { + font-size: 0.72rem; + } +} diff --git a/components/Activity/Hackathon/HackathonSchedule.tsx b/components/Activity/Hackathon/HackathonSchedule.tsx new file mode 100644 index 0000000..e067f22 --- /dev/null +++ b/components/Activity/Hackathon/HackathonSchedule.tsx @@ -0,0 +1,109 @@ +import { FC } from 'react'; +import { Container } from 'react-bootstrap'; + +import styles from './HackathonSchedule.module.less'; + +export type HackathonScheduleTone = + | 'break' + | 'competition' + | 'enrollment' + | 'evaluation' + | 'formation'; + +export interface HackathonScheduleFact { + label: string; + meta: string; + value: string; +} + +export interface HackathonScheduleItem { + dateText: string; + description: string; + facts: HackathonScheduleFact[]; + id: string; + phase: string; + title: string; + tone: HackathonScheduleTone; +} + +export interface HackathonScheduleProps { + items: HackathonScheduleItem[]; + kicker: string; + lead: string; + overviewPills: string[]; + subtitle: string; + title: string; +} + +const ScheduleCard: FC = ({ + dateText, + description, + facts, + phase, + phaseLabel, + title, + tone, +}) => ( +
+
+ + {phaseLabel} {phase} + + +
+ +

{title}

+

{description}

+ +
+ {facts.map(({ label, meta, value }) => ( +
+
{label}
+
+ {value} + {meta} +
+
+ ))} +
+
+); + +export const HackathonSchedule: FC = ({ + items, + kicker, + lead, + overviewPills, + phaseLabel, + subtitle, + title, +}) => ( +
+ +
+

{title}

+

{subtitle}

+
+
+ +
+

{kicker}

+

{lead}

+
+ +
+ {overviewPills.map(pill => ( +
+ {pill} +
+ ))} +
+ +
+ {items.map(item => ( + + ))} +
+
+
+); diff --git a/components/Activity/Hackathon/theme.less b/components/Activity/Hackathon/theme.less new file mode 100644 index 0000000..88f5c54 --- /dev/null +++ b/components/Activity/Hackathon/theme.less @@ -0,0 +1,143 @@ +@bg: #050814; +@panel: rgba(8, 18, 39, 0.76); +@panel-strong: rgba(6, 13, 30, 0.92); +@border: rgba(120, 160, 255, 0.18); +@copy: #eef5ff; +@muted: rgba(220, 232, 255, 0.72); +@cyan: #2ce8ff; +@gold: #ffc94d; +@green: #48f1a8; +@rose: #ff78ba; +@purple: #7b61ff; +@shadow: 0 28px 80px rgba(0, 0, 0, 0.34); +@heading: 'Orbitron', 'Avenir Next', 'Segoe UI', sans-serif; +@body: 'Outfit', 'Avenir Next', 'Segoe UI', sans-serif; + +.panel-card() { + box-shadow: @shadow; + border: 1px solid @border; + border-radius: 28px; + background: @panel; + backdrop-filter: blur(18px); +} + +.section-shell() { + position: relative; + padding: clamp(4rem, 7vw, 6rem) 0; + background: linear-gradient(180deg, rgba(5, 8, 20, 0.98), rgba(7, 12, 26, 0.98)); + color: @copy; +} + +.section-header() { + text-align: center; + margin-bottom: clamp(2.3rem, 4vw, 3rem); +} + +.section-title() { + display: inline-block; + margin: 0; + background: linear-gradient(135deg, #fff 20%, @cyan 80%); + background-clip: text; + -webkit-background-clip: text; + color: transparent; + font-family: @heading; + font-size: clamp(1.9rem, 4vw, 2.6rem); + font-weight: 900; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.section-subtitle() { + color: @muted; + font-family: @heading; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.accent-line() { + width: 72px; + height: 2px; + margin: 1rem auto 0; + border-radius: 999px; + background: linear-gradient(90deg, @cyan, @purple); + box-shadow: 0 0 14px rgba(44, 232, 255, 0.32); +} + +.button-base() { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: + transform 0.22s ease, + box-shadow 0.22s ease, + border-color 0.22s ease, + background 0.22s ease, + color 0.22s ease; + border: 1px solid transparent; + border-radius: 10px; + padding: 0.9rem 1.35rem; + font-family: @heading; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-align: center; + text-decoration: none; + text-transform: uppercase; + + &:hover { + transform: translateY(-2px); + text-decoration: none; + } +} + +.button-primary() { + .button-base(); + box-shadow: 0 0 24px rgba(44, 232, 255, 0.18); + border-color: rgba(44, 232, 255, 0.3); + background: rgba(44, 232, 255, 0.12); + color: @cyan; + + &:hover { + box-shadow: 0 0 34px rgba(44, 232, 255, 0.28); + background: rgba(44, 232, 255, 0.2); + color: #fff; + } +} + +.button-ghost() { + .button-base(); + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: @copy; + + &:hover { + border-color: rgba(255, 201, 77, 0.24); + background: rgba(255, 201, 77, 0.1); + color: @gold; + } +} + +.eyebrow() { + color: @muted; + font-family: @heading; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.chip() { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); +} + +@media (max-width: 767px) { + .section-title() { + font-size: 1.65rem; + letter-spacing: 0.08em; + } +} diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index 2808365..976385e 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -6,13 +6,20 @@ import { TableFormView, } from 'mobx-lark'; import { observer } from 'mobx-react'; -import Link from 'next/link'; import { cache, compose, errorLogger } from 'next-ssr-middleware'; import { FC, useContext } from 'react'; -import { Col, Container, Row } from 'react-bootstrap'; import { formatDate } from 'web-utility'; -import { LarkImage } from '../../components/LarkImage'; +import { HackathonActionHub } from '../../components/Activity/Hackathon/HackathonActionHub'; +import { HackathonAwards } from '../../components/Activity/Hackathon/HackathonAwards'; +import { HackathonHero } from '../../components/Activity/Hackathon/HackathonHero'; +import { HackathonOverview } from '../../components/Activity/Hackathon/HackathonOverview'; +import { HackathonParticipants } from '../../components/Activity/Hackathon/HackathonParticipants'; +import { HackathonResources } from '../../components/Activity/Hackathon/HackathonResources'; +import { + HackathonSchedule, + HackathonScheduleTone, +} from '../../components/Activity/Hackathon/HackathonSchedule'; import { PageHead } from '../../components/Layout/PageHead'; import { Activity, ActivityModel } from '../../models/Activity'; import { @@ -30,7 +37,6 @@ import { TemplateModel, } from '../../models/Hackathon'; import { I18nContext, I18nKey } from '../../models/Translation'; -import styles from '../../styles/Hackathon.module.less'; const RequiredTableKeys = [ 'Person', @@ -42,75 +48,36 @@ const RequiredTableKeys = [ ] as const; type RequiredTableKey = (typeof RequiredTableKeys)[number]; - -export const getServerSideProps = compose<{ id: string }>( - cache(), - errorLogger, - async ({ params }) => { - const activity = await new ActivityModel().getOne(params!.id); - const schema = activity.databaseSchema as Partial | undefined; - const tableIdMap = schema?.tableIdMap as Partial> | undefined; - - if (!schema?.appId || !tableIdMap) return { notFound: true, props: {} }; - - for (const key of RequiredTableKeys) if (!tableIdMap[key]) return { notFound: true, props: {} }; - - const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ - new PersonModel(schema.appId, tableIdMap.Person).getAll(), - new OrganizationModel(schema.appId, tableIdMap.Organization).getAll(), - new AgendaModel(schema.appId, tableIdMap.Agenda).getAll(), - new PrizeModel(schema.appId, tableIdMap.Prize).getAll(), - new TemplateModel(schema.appId, tableIdMap.Template).getAll(), - new ProjectModel(schema.appId, tableIdMap.Project).getAll(), - ]); - - return { - props: { - activity, - hackathon: { - people, - organizations, - agenda, - prizes, - templates, - projects, - }, - }, - }; - }, -); +type FormGroupKey = 'Evaluation' | 'Person' | 'Product' | 'Project'; interface HackathonDetailProps { activity: Activity; hackathon: { - people: Person[]; - organizations: Organization[]; agenda: Agenda[]; + organizations: Organization[]; + people: Person[]; prizes: Prize[]; - templates: Template[]; projects: Project[]; + templates: Template[]; }; } -const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation'] as const; -const HeroBadgeTone = [ - 'heroBadgeCyan', - 'heroBadgeGold', - 'heroBadgeGreen', - 'heroBadgeRose', -] as const; -const HighlightIcons = ['👥', '🚀', '🛠', '🏆', '🤝', '📅'] as const; - -type FormGroupKey = (typeof FormButtonBar)[number]; -type FormGroupMeta = Record<'title' | 'description' | 'eyebrow', I18nKey>; -type AgendaToneClass = 'formation' | 'enrollment' | 'competition' | 'break' | 'evaluation'; +interface FormGroupMeta { + description: I18nKey; + eyebrow: I18nKey; + title: I18nKey; +} -interface FormGroup { +interface FormGroupView { + description: string; + eyebrow: string; key: FormGroupKey; - list: TableFormView[]; - meta: FormGroupMeta; + links: { external: true; href: string; label: string }[]; + title: string; } +const FormButtonBar = ['Person', 'Project', 'Product', 'Evaluation'] as const; + const FormSectionMeta: Record = { Person: { eyebrow: 'participants', @@ -134,7 +101,7 @@ const FormSectionMeta: Record = { }, }; -const AgendaTypeClassMap: Partial> = { +const AgendaTypeClassMap: Partial> = { workshop: 'formation', formation: 'formation', presentation: 'enrollment', @@ -171,7 +138,7 @@ const previewText = (items: TableCellValue[], fallback: string) => const agendaToneClassOf = (type: TableCellValue, index: number) => { const normalized = type?.toString().toLowerCase() || ''; - const fallbackOrder: AgendaToneClass[] = [ + const fallbackOrder: HackathonScheduleTone[] = [ 'formation', 'enrollment', 'competition', @@ -189,6 +156,36 @@ const agendaTypeLabelOf = (type: TableCellValue, t: (key: I18nKey) => string, fa return i18nKey ? t(i18nKey) : type?.toString() || fallback; }; +export const getServerSideProps = compose<{ id: string }>( + cache(), + errorLogger, + async ({ params }) => { + const activity = await new ActivityModel().getOne(params!.id); + const schema = activity.databaseSchema as Partial | undefined; + const tableIdMap = schema?.tableIdMap as Partial> | undefined; + + if (!schema?.appId || !tableIdMap) return { notFound: true, props: {} }; + + for (const key of RequiredTableKeys) if (!tableIdMap[key]) return { notFound: true, props: {} }; + + const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ + new PersonModel(schema.appId, tableIdMap.Person).getAll(), + new OrganizationModel(schema.appId, tableIdMap.Organization).getAll(), + new AgendaModel(schema.appId, tableIdMap.Agenda).getAll(), + new PrizeModel(schema.appId, tableIdMap.Prize).getAll(), + new TemplateModel(schema.appId, tableIdMap.Template).getAll(), + new ProjectModel(schema.appId, tableIdMap.Project).getAll(), + ]); + + return { + props: { + activity, + hackathon: { people, organizations, agenda, prizes, templates, projects }, + }, + }; + }, +); + const HackathonDetail: FC = observer(({ activity, hackathon }) => { const { t } = useContext(I18nContext); @@ -207,6 +204,7 @@ const HackathonDetail: FC = observer(({ activity, hackatho const forms = ((databaseSchema as Partial | undefined)?.forms || {}) as Partial< Record >; + const summaryText = (summary as string) || ''; const agendaItems = [...agenda].sort( ({ startedAt: left }, { startedAt: right }) => new Date((left as string) || 0).getTime() - new Date((right as string) || 0).getTime(), @@ -219,7 +217,7 @@ const HackathonDetail: FC = observer(({ activity, hackatho ...hostTags, formatMoment(startTime), formatMoment(endTime), - ].filter(Boolean); + ].filter((value): value is string => Boolean(value)); const heroStats = [ { label: t('participants'), value: people.length }, { label: t('projects'), value: projects.length }, @@ -227,12 +225,25 @@ const HackathonDetail: FC = observer(({ activity, hackatho { label: t('prizes'), value: prizes.length }, ]; const agendaPreview = agendaItems.slice(0, 3); - const overviewPills = agendaItems.slice(0, 6); - const formGroups = FormButtonBar.flatMap(key => { + const formGroups = FormButtonBar.flatMap(key => { const list = (forms[key] || []).filter(isPublicForm); - return list[0] ? [{ key, list, meta: FormSectionMeta[key] }] : []; + return list[0] + ? [ + { + key, + eyebrow: t(FormSectionMeta[key].eyebrow), + title: t(FormSectionMeta[key].title), + description: t(FormSectionMeta[key].description), + links: list.map(({ name, shared_url }) => ({ + label: name as string, + href: shared_url, + external: true as const, + })), + }, + ] + : []; }); const primaryForm = formGroups.find(({ key }) => key === 'Person') || @@ -243,26 +254,26 @@ const HackathonDetail: FC = observer(({ activity, hackatho formGroups.find(({ key }) => key !== primaryForm?.key); const formPreview = formGroups - .map(({ meta }) => t(meta.eyebrow)) + .map(({ eyebrow }) => eyebrow) .filter(Boolean) .slice(0, 2) .join(' · ') || t('hackathon_action_hub'); const highlightCards = [ { - icon: HighlightIcons[0], + icon: '👥', title: t('participants'), value: people.length, description: t(FormSectionMeta.Person.description), }, { - icon: HighlightIcons[1], + icon: '🚀', title: t('projects'), value: projects.length, description: t(FormSectionMeta.Project.description), }, { - icon: HighlightIcons[2], + icon: '🛠', title: t('templates'), value: templates.length, description: previewText( @@ -271,7 +282,7 @@ const HackathonDetail: FC = observer(({ activity, hackatho ), }, { - icon: HighlightIcons[3], + icon: '🏆', title: t('prizes'), value: prizes.length, description: previewText( @@ -280,7 +291,7 @@ const HackathonDetail: FC = observer(({ activity, hackatho ), }, { - icon: HighlightIcons[4], + icon: '🤝', title: t('organizations'), value: organizations.length, description: previewText( @@ -289,7 +300,7 @@ const HackathonDetail: FC = observer(({ activity, hackatho ), }, { - icon: HighlightIcons[5], + icon: '📅', title: t('agenda'), value: agendaItems.length, description: previewText( @@ -299,588 +310,222 @@ const HackathonDetail: FC = observer(({ activity, hackatho }, ]; + const scheduleItems = agendaItems.map( + ({ id, name, type, summary, startedAt, endedAt }, index) => { + const typeLabel = agendaTypeLabelOf(type, t); + const description = (summary as string) || typeLabel; + + return { + id: id as string, + phase: String(index + 1).padStart(2, '0'), + dateText: formatPeriod(startedAt, endedAt), + title: name as string, + description, + tone: agendaToneClassOf(type, index), + facts: [ + { label: t('type'), value: typeLabel, meta: description || eventRange || locationText }, + { label: t('start_time'), value: formatMoment(startedAt), meta: t('event_duration') }, + { + label: t('end_time'), + value: formatMoment(endedAt), + meta: `${t('event_location')}: ${locationText}`, + }, + ], + }; + }, + ); + + const prizeItems = prizes.map( + ({ id, name, image, summary, level, sponsor, price, amount }, index) => ({ + id: id as string, + title: name as string, + tier: (level as string) || `#${index + 1}`, + description: (summary as string) || previewText([sponsor, price, amount], t('prizes')), + image, + meta: [ + sponsor ? { label: t('sponsor'), value: sponsor as string } : null, + price ? { label: t('price'), value: price as string } : null, + amount ? { label: t('amount'), value: amount as string } : null, + ].filter(Boolean) as { label: string; value: string }[], + }), + ); + + const organizationItems = organizations.map(({ id, name, link, logo }) => ({ + id: id as string, + name: name as string, + href: link as string | undefined, + logo, + })); + + const templateItems = templates.map( + ({ id, name, languages, tags, sourceLink, summary, previewLink }) => ({ + id: id as string, + title: name as string, + description: (summary as string) || '', + languages: ((languages as string[] | undefined) || []).filter(Boolean), + tags: ((tags as string[] | undefined) || []).filter(Boolean), + sourceUrl: sourceLink as string | undefined, + sourceLabel: t('source_code'), + previewUrl: previewLink as string | undefined, + previewLabel: t('preview'), + }), + ); + + const projectItems = projects.map(({ id, name, score, summary, createdBy, members }) => { + const creator = createdBy as TableCellUser | undefined; + const scoreText = score === null || score === undefined || score === '' ? '—' : `${score}`; + + return { + id: id as string, + title: name as string, + link: `${ActivityModel.getLink(activity)}/team/${id}`, + score: scoreText, + description: (summary as string) || '', + meta: [ + creator + ? { + label: t('created_by'), + value: creator.name || '—', + valueHref: creator.email ? `mailto:${creator.email}` : undefined, + } + : { label: t('created_by'), value: '—' }, + { + label: t('members'), + value: (members as string[] | undefined)?.join(', ') || '—', + }, + ], + }; + }); + + const participantItems = people.map(({ id, name, avatar, githubLink }) => ({ + id: id as string, + name: name as string, + avatar, + githubLink: githubLink as string | undefined, + })); + return ( <> -
- -
-
-
    - {heroBadges.map((badge, index) => ( -
  • - {badge} -
  • - ))} -
- -

- {name as string} - - {(activityType as string) || t('hackathon_detail')} - -

- -

{summary as string}

- - - -
    - {heroStats.map(({ label, value }) => ( -
  • - {value} - {label} -
  • - ))} -
-
- -
-
-
- {t('event_info')} - {t('hackathon_detail')} -
- -
-
- - {image ? ( - - ) : ( -
- {(activityType as string) || t('hackathon')} -
- )} -
- -
-

{locationText}

-

{eventRange || (summary as string)}

-
-
- - {primaryForm && ( -
- {t(primaryForm.meta.eyebrow)} - {t(primaryForm.meta.title)} -

{t(primaryForm.meta.description)}

-
- )} - - {agendaPreview[0] && ( -
- {t('hackathon_agenda_preview')} - {agendaPreview[0].name as string} -

{formatPeriod(agendaPreview[0].startedAt, agendaPreview[0].endedAt)}

-
- )} -
-
-
-
- -
- -
-

{t('event_info')}

-

{t('event_description')}

-
-
- -
-
{(activityType as string) || t('hackathon')}
-

{summary as string}

-
- - - {highlightCards.map(({ icon, title, value, description }) => ( - -
- {icon} -
{title}
-

{description}

- - {value} {title} - -
- - ))} -
-
-
+ + + {formGroups[0] && ( -
- -
-
-
-

{t('hackathon_action_hub')}

-

- {primaryForm ? t(primaryForm.meta.title) : t('hackathon_entry_flow')} -

-

- {primaryForm - ? t(primaryForm.meta.description) - : t('hackathon_entry_flow_description')} -

- - - -
    -
  • {eventRange || t('event_duration')}
  • -
  • {locationText}
  • -
  • {formPreview}
  • -
-
-
- -
-
-

{t('hackathon_entry_flow')}

-

{t('hackathon_action_hub')}

-

{t('hackathon_entry_flow_description')}

-
- - - {formGroups.map(({ key, list, meta }, index) => ( - -
- - {t('hackathon_step')} {String(index + 1).padStart(2, '0')} ·{' '} - {t(meta.eyebrow)} - -

{t(meta.title)}

-

{t(meta.description)}

- -
- {list.length} - {t(meta.eyebrow)} -
- - -
- - ))} -
-
-
-
-
+ ({ + title, + description, + eyebrow, + links, + count: links.length, + }))} + facts={[eventRange || t('event_duration'), locationText, formPreview]} + primaryAction={ + primaryForm + ? { label: primaryForm.title, href: primaryForm.links[0].href, external: true } + : undefined + } + primaryDescription={primaryForm?.description || t('hackathon_entry_flow_description')} + primaryTitle={primaryForm?.title || t('hackathon_entry_flow')} + secondaryAction={ + secondaryForm + ? { label: secondaryForm.title, href: secondaryForm.links[0].href, external: true } + : { label: t('agenda'), href: '#schedule' } + } + subtitle={t('hackathon_entry_flow')} + title={t('hackathon_action_hub')} + /> )} - {agendaItems[0] && ( -
- -
-

{t('agenda')}

-

{t('event_duration')}

-
-
- -
-

{eventRange}

-

{name as string}

-

{summary as string}

-
- -
- {overviewPills.map(({ id, name }) => ( -
- {name as string} -
- ))} -
- -
- {agendaItems.map(({ id, name, type, summary, startedAt, endedAt }, index) => { - const toneClass = agendaToneClassOf(type, index); - - return ( -
-
- - PHASE {String(index + 1).padStart(2, '0')} - - -
- -

{name as string}

-

- {(summary as string) || agendaTypeLabelOf(type, t)} -

- -
-
-
{t('type')}
-
- {agendaTypeLabelOf(type, t)} - {(summary as string) || eventRange || locationText} -
-
- -
-
{t('start_time')}
-
- {formatMoment(startedAt)} - {t('event_duration')} -
-
- -
-
{t('end_time')}
-
- {formatMoment(endedAt)} - - {t('event_location')}: {locationText} - -
-
-
-
- ); - })} -
-
-
+ {scheduleItems[0] && ( + name as string)} + phaseLabel={t('hackathon_phase')} + subtitle={t('event_duration')} + title={t('agenda')} + /> )} - {(prizes[0] || organizations[0]) && ( -
- - {prizes[0] && ( - <> -
-

{t('prizes')}

-

{t('hackathon_prizes')}

-
-
- -
- {prizes.map( - ({ id, name, image, summary, level, sponsor, price, amount }, index) => ( -
- {image && ( -
- -
- )} - -
- - {(level as string) || `#${index + 1}`} - -

{name as string}

-

- {(summary as string) || - previewText([sponsor, price, amount], t('prizes'))} -

- -
- {sponsor && ( -
-
{t('sponsor')}
-
{sponsor as string}
-
- )} - {price && ( -
-
{t('price')}
-
{price as string}
-
- )} - {amount && ( -
-
{t('amount')}
-
{amount as string}
-
- )} -
-
-
- ), - )} -
- - )} - - {organizations[0] && ( -
-
-

{t('organizations')}

-

- {previewText( - organizations.map(({ name }) => name), - t('organizations'), - )} -

-

{summary as string}

-
- - -
- )} -
-
+ {(prizeItems[0] || organizationItems[0]) && ( + name), + t('organizations'), + )} + title={t('prizes')} + /> )} - {(templates[0] || projects[0]) && ( -
- - {templates[0] && ( - <> -
-

{t('templates')}

-

{t('source_code')}

-
-
- - - {templates.map( - ({ id, name, languages, tags, sourceLink, summary, previewLink }) => ( - -
-
-

{name as string}

-
-

{summary as string}

- -
    - {(languages as string[]).map(language => ( -
  • - {language} -
  • - ))} - {(tags as string[]).map(tag => ( -
  • - {tag} -
  • - ))} -
- - -
- - ), - )} -
- - )} - - {projects[0] && ( - <> -
-

{t('projects')}

-

{t('products')}

-
-
- - - {projects.map(({ id, name, score, summary, createdBy, members }) => { - const creator = createdBy as TableCellUser | undefined; - const scoreText = - score === null || score === undefined || score === '' ? '—' : `${score}`; - const memberNames = (members as string[] | undefined)?.join(', ') || '—'; - - return ( - -
-
-

- - {name as string} - -

-
{scoreText}
-
- -

{summary as string}

- -
-
-
{t('created_by')}
-
- {creator?.email ? ( - {creator.name} - ) : ( - creator?.name || '—' - )} -
-
-
-
{t('members')}
-
{memberNames}
-
-
-
- - ); - })} -
- - )} -
-
+ {(templateItems[0] || projectItems[0]) && ( + )} - {people[0] && ( -
- -
-

{t('participants')}

-

{t('github_account')}

-
-
- -
- {people.map(({ id, name, avatar, githubLink }) => { - const content = ( - <> - - {name as string} - - ); - - return githubLink ? ( - - {content} - - ) : ( -
- {content} -
- ); - })} -
-
-
+ {participantItems[0] && ( + )} ); diff --git a/translation/en-US.ts b/translation/en-US.ts index f69d9cf..ce03d98 100644 --- a/translation/en-US.ts +++ b/translation/en-US.ts @@ -238,6 +238,7 @@ export default { hackathon_entry_flow_description: 'Entries are generated from the current activity schema so different events can reuse the same page structure.', hackathon_step: 'Step', + hackathon_phase: 'Phase', participants: 'Participants', organizations: 'Organizations', prizes: 'Prizes', diff --git a/translation/zh-CN.ts b/translation/zh-CN.ts index 3df6b91..764681b 100644 --- a/translation/zh-CN.ts +++ b/translation/zh-CN.ts @@ -221,12 +221,10 @@ export default { hackathon_project_registration_description: '登记项目名称、成员、赛道和一句话介绍,完成队伍锁定。', hackathon_submission: '提交', - hackathon_product_submission_description: - '比赛截止前统一提交最终作品、演示链接和补充说明。', + hackathon_product_submission_description: '比赛截止前统一提交最终作品、演示链接和补充说明。', hackathon_review: '评审', hackathon_evaluation_entry: '评审入口', - hackathon_evaluation_entry_description: - '评委或导师在评审阶段使用,用于评分、复核与结果整理。', + hackathon_evaluation_entry_description: '评委或导师在评审阶段使用,用于评分、复核与结果整理。', hackathon_view_all_entries: '查看全部入口', hackathon_agenda_preview: '议程预览', hackathon_action_hub: '入口中心 · 表单', @@ -234,6 +232,7 @@ export default { hackathon_entry_flow_description: '入口根据活动当前配置自动生成。不同活动可以复用同一套页面结构,而不是为每次活动单开专页。', hackathon_step: '步骤', + hackathon_phase: '阶段', participants: '参与者', organizations: '组织方', prizes: '奖项', diff --git a/translation/zh-TW.ts b/translation/zh-TW.ts index cdc305d..716098f 100644 --- a/translation/zh-TW.ts +++ b/translation/zh-TW.ts @@ -221,12 +221,10 @@ export default { hackathon_project_registration_description: '登記項目名稱、成員、賽道和一句話介紹,完成隊伍鎖定。', hackathon_submission: '提交', - hackathon_product_submission_description: - '比賽截止前統一提交最終作品、演示連結和補充說明。', + hackathon_product_submission_description: '比賽截止前統一提交最終作品、演示連結和補充說明。', hackathon_review: '評審', hackathon_evaluation_entry: '評審入口', - hackathon_evaluation_entry_description: - '評委或導師在評審階段使用,用於評分、複核與結果整理。', + hackathon_evaluation_entry_description: '評委或導師在評審階段使用,用於評分、複核與結果整理。', hackathon_view_all_entries: '查看全部入口', hackathon_agenda_preview: '議程預覽', hackathon_action_hub: '入口中心 · 表單', @@ -234,6 +232,7 @@ export default { hackathon_entry_flow_description: '入口會根據活動當前配置自動生成。不同活動可以共用同一套頁面結構,而不是為每次活動單開專頁。', hackathon_step: '步驟', + hackathon_phase: '階段', participants: '參與者', organizations: '組織方', prizes: '獎項', From 6ffeb32aa0f34267e149e5ca36345599a87cd8d6 Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 23:28:17 +0800 Subject: [PATCH 09/14] Address remaining review threads --- .../Hackathon/HackathonActionHub.module.less | 18 +- .../Activity/Hackathon/HackathonActionHub.tsx | 44 +- .../Hackathon/HackathonAwards.module.less | 29 +- .../Activity/Hackathon/HackathonAwards.tsx | 37 +- .../Activity/Hackathon/HackathonHero.tsx | 32 +- .../Hackathon/HackathonOverview.module.less | 28 +- .../HackathonParticipants.module.less | 32 +- .../Hackathon/HackathonResources.module.less | 53 +- .../Activity/Hackathon/HackathonResources.tsx | 48 +- .../Hackathon/HackathonSchedule.module.less | 72 +- .../Activity/Hackathon/HackathonSchedule.tsx | 33 +- components/Activity/Hackathon/theme.less | 32 +- pages/api/Lark/document/copy/[...slug].ts | 13 +- pages/hackathon/[id].tsx | 770 ++++++++++-------- 14 files changed, 663 insertions(+), 578 deletions(-) diff --git a/components/Activity/Hackathon/HackathonActionHub.module.less b/components/Activity/Hackathon/HackathonActionHub.module.less index 9bb37d3..575da0a 100644 --- a/components/Activity/Hackathon/HackathonActionHub.module.less +++ b/components/Activity/Hackathon/HackathonActionHub.module.less @@ -1,4 +1,4 @@ -@import './theme.less'; +@import "./theme.less"; .section { .section-shell(); @@ -10,11 +10,19 @@ grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); gap: 1.5rem; align-items: start; + + @media (max-width: 1199px) { + grid-template-columns: 1fr; + } } .registerCard { .panel-card(); - background: linear-gradient(135deg, rgba(44, 232, 255, 0.08), rgba(123, 97, 255, 0.14)); + background: linear-gradient( + 135deg, + rgba(44, 232, 255, 0.08), + rgba(123, 97, 255, 0.14) + ); } .registerCardInner, @@ -132,9 +140,3 @@ letter-spacing: 0.06em; text-transform: uppercase; } - -@media (max-width: 1199px) { - .registerWrap { - grid-template-columns: 1fr; - } -} diff --git a/components/Activity/Hackathon/HackathonActionHub.tsx b/components/Activity/Hackathon/HackathonActionHub.tsx index 6daab6d..9294766 100644 --- a/components/Activity/Hackathon/HackathonActionHub.tsx +++ b/components/Activity/Hackathon/HackathonActionHub.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, type PropsWithChildren } from 'react'; import { Col, Container, Row } from 'react-bootstrap'; import { HackathonHeroAction } from './HackathonHero'; @@ -18,17 +18,18 @@ export interface HackathonActionHubProps { primaryAction?: HackathonHeroAction; primaryDescription: string; primaryTitle: string; - secondaryAction: HackathonHeroAction; subtitle: string; title: string; } -const ActionHubLink: FC<{ action: HackathonHeroAction; variant: 'ghost' | 'primary' }> = ({ - action, - variant, -}) => ( +export const HackathonActionHubLink: FC<{ + action: HackathonHeroAction; + variant: 'ghost' | 'primary'; +}> = ({ action, variant }) => ( @@ -36,7 +37,10 @@ const ActionHubLink: FC<{ action: HackathonHeroAction; variant: 'ghost' | 'prima ); -const ActionEntryCard: FC<{ entry: HackathonActionHubEntry; step: string }> = ({ entry, step }) => ( +const ActionEntryCard: FC<{ entry: HackathonActionHubEntry; step: string }> = ({ + entry, + step, +}) => (
{step} · {entry.eyebrow} @@ -50,7 +54,7 @@ const ActionEntryCard: FC<{ entry: HackathonActionHubEntry; step: string }> = ({
); -export const HackathonActionHub: FC = ({ +export const HackathonActionHub: FC< + PropsWithChildren +> = ({ + children, entries, facts, primaryAction, primaryDescription, primaryTitle, - secondaryAction, subtitle, title, }) => ( @@ -84,12 +90,17 @@ export const HackathonActionHub: FC = ({

{primaryDescription}

    - {facts.map(fact => ( + {facts.map((fact) => (
  • {fact}
  • ))}
@@ -105,7 +116,10 @@ export const HackathonActionHub: FC = ({ {entries.map((entry, index) => ( - + ))} diff --git a/components/Activity/Hackathon/HackathonAwards.module.less b/components/Activity/Hackathon/HackathonAwards.module.less index 1518b10..01348b2 100644 --- a/components/Activity/Hackathon/HackathonAwards.module.less +++ b/components/Activity/Hackathon/HackathonAwards.module.less @@ -1,26 +1,11 @@ -@import './theme.less'; +@import "./theme.less"; -.section { - .section-shell(); -} - -.sectionHeader { - .section-header(); -} +.section-frame(); -.sectionTitle { - .section-title(); -} - -.sectionSubtitle, .supportEyebrow { .section-subtitle(); } -.accentLine { - .accent-line(); -} - .awardsGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); @@ -116,6 +101,10 @@ gap: 1.25rem; margin-top: 1.4rem; padding: 1.55rem; + + @media (max-width: 1199px) { + grid-template-columns: 1fr; + } } .supportTitle { @@ -146,9 +135,3 @@ max-height: 58px; object-fit: contain; } - -@media (max-width: 1199px) { - .supportCard { - grid-template-columns: 1fr; - } -} diff --git a/components/Activity/Hackathon/HackathonAwards.tsx b/components/Activity/Hackathon/HackathonAwards.tsx index 53d9a88..2c767c8 100644 --- a/components/Activity/Hackathon/HackathonAwards.tsx +++ b/components/Activity/Hackathon/HackathonAwards.tsx @@ -5,10 +5,9 @@ import { Container } from 'react-bootstrap'; import { LarkImage } from '../../LarkImage'; import styles from './HackathonAwards.module.less'; -export interface HackathonAwardsMeta { - label: string; - value: string; -} +export type HackathonAwardsMeta = Record<'label', string> & { + value: Value; +}; export interface HackathonPrizeItem { description: string; @@ -36,7 +35,13 @@ export interface HackathonAwardsProps { title: string; } -const PrizeCard: FC = ({ description, image, meta, tier, title }) => ( +const PrizeCard: FC = ({ + description, + image, + meta, + tier, + title, +}) => (
{image && (
@@ -61,11 +66,23 @@ const PrizeCard: FC = ({ description, image, meta, tier, tit
); -const OrganizationLogo: FC = ({ href, logo, name }) => { - const imageNode = ; +const OrganizationLogo: FC = ({ + href, + logo, + name, +}) => { + const imageNode = ( + + ); return href ? ( -
+ {imageNode} ) : ( @@ -95,7 +112,7 @@ export const HackathonAwards: FC = ({
- {prizes.map(prize => ( + {prizes.map((prize) => ( ))}
@@ -111,7 +128,7 @@ export const HackathonAwards: FC = ({ diff --git a/components/Activity/Hackathon/HackathonHero.tsx b/components/Activity/Hackathon/HackathonHero.tsx index 2641f08..917003c 100644 --- a/components/Activity/Hackathon/HackathonHero.tsx +++ b/components/Activity/Hackathon/HackathonHero.tsx @@ -3,6 +3,7 @@ import { FC } from 'react'; import { Container } from 'react-bootstrap'; import { LarkImage } from '../../LarkImage'; +import type { HackathonAwardsMeta } from './HackathonAwards'; import styles from './HackathonHero.module.less'; export interface HackathonHeroAction { @@ -17,10 +18,7 @@ export interface HackathonHeroCard { title: string; } -export interface HackathonHeroStat { - label: string; - value: number; -} +export type HackathonHeroStat = HackathonAwardsMeta; export interface HackathonHeroProps { badges: string[]; @@ -48,12 +46,14 @@ const BadgeToneClass = [ styles.heroBadgeRose, ]; -const HeroLink: FC<{ action: HackathonHeroAction; variant: 'ghost' | 'primary' }> = ({ - action, - variant, -}) => ( +const HeroLink: FC<{ + action: HackathonHeroAction; + variant: 'ghost' | 'primary'; +}> = ({ action, variant }) => ( @@ -61,10 +61,10 @@ const HeroLink: FC<{ action: HackathonHeroAction; variant: 'ghost' | 'primary' } ); -const FloatingCard: FC<{ card: HackathonHeroCard; position: 'bottom' | 'top' }> = ({ - card, - position, -}) => ( +const FloatingCard: FC<{ + card: HackathonHeroCard; + position: 'bottom' | 'top'; +}> = ({ card, position }) => (
@@ -140,7 +140,11 @@ export const HackathonHero: FC = ({
{image ? ( - + ) : (
{imageFallback}
)} diff --git a/components/Activity/Hackathon/HackathonOverview.module.less b/components/Activity/Hackathon/HackathonOverview.module.less index b992344..6b4329b 100644 --- a/components/Activity/Hackathon/HackathonOverview.module.less +++ b/components/Activity/Hackathon/HackathonOverview.module.less @@ -1,30 +1,16 @@ -@import './theme.less'; +@import "./theme.less"; -.section { - .section-shell(); -} - -.sectionHeader { - .section-header(); -} - -.sectionTitle { - .section-title(); -} - -.sectionSubtitle { - .section-subtitle(); -} - -.accentLine { - .accent-line(); -} +.section-frame(); .themePanel { margin-bottom: 1.5rem; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 28px; - background: linear-gradient(135deg, rgba(44, 232, 255, 0.08), rgba(123, 97, 255, 0.12)); + background: linear-gradient( + 135deg, + rgba(44, 232, 255, 0.08), + rgba(123, 97, 255, 0.12) + ); padding: 1.5rem 1.6rem; } diff --git a/components/Activity/Hackathon/HackathonParticipants.module.less b/components/Activity/Hackathon/HackathonParticipants.module.less index 1ad3e3c..19e5d71 100644 --- a/components/Activity/Hackathon/HackathonParticipants.module.less +++ b/components/Activity/Hackathon/HackathonParticipants.module.less @@ -1,29 +1,15 @@ -@import './theme.less'; +@import "./theme.less"; -.section { - .section-shell(); -} - -.sectionHeader { - .section-header(); -} - -.sectionTitle { - .section-title(); -} - -.sectionSubtitle { - .section-subtitle(); -} - -.accentLine { - .accent-line(); -} +.section-frame(); .participantCloud { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1rem; + + @media (max-width: 767px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } .participantCard { @@ -57,9 +43,3 @@ font-size: 0.92rem; line-height: 1.4; } - -@media (max-width: 767px) { - .participantCloud { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} diff --git a/components/Activity/Hackathon/HackathonResources.module.less b/components/Activity/Hackathon/HackathonResources.module.less index 5157282..2977440 100644 --- a/components/Activity/Hackathon/HackathonResources.module.less +++ b/components/Activity/Hackathon/HackathonResources.module.less @@ -1,24 +1,6 @@ -@import './theme.less'; +@import "./theme.less"; -.section { - .section-shell(); -} - -.sectionHeader { - .section-header(); -} - -.sectionTitle { - .section-title(); -} - -.sectionSubtitle { - .section-subtitle(); -} - -.accentLine { - .accent-line(); -} +.section-frame(); .projectHeader { margin-top: 2.75rem; @@ -32,13 +14,19 @@ } .resourceHead, -.projectHead { +.projectTop { display: flex; justify-content: space-between; gap: 1rem; align-items: start; } +.projectHead { + display: grid; + gap: 0.8rem; + margin: 0; +} + .resourceTitle, .projectTitle { margin: 0; @@ -79,6 +67,10 @@ border-radius: 999px; padding: 0.34rem 0.72rem; font-size: 0.76rem; + + @media (max-width: 767px) { + font-size: 0.72rem; + } } .topicChip { @@ -115,6 +107,12 @@ font-family: @heading; font-size: 1.05rem; font-weight: 800; + + @media (max-width: 767px) { + width: 54px; + height: 54px; + font-size: 0.92rem; + } } .projectMeta { @@ -147,16 +145,3 @@ line-height: 1.5; } } - -@media (max-width: 767px) { - .topicChip, - .topicChipMuted { - font-size: 0.72rem; - } - - .scoreCircle { - width: 54px; - height: 54px; - font-size: 0.92rem; - } -} diff --git a/components/Activity/Hackathon/HackathonResources.tsx b/components/Activity/Hackathon/HackathonResources.tsx index dd6fa17..e701708 100644 --- a/components/Activity/Hackathon/HackathonResources.tsx +++ b/components/Activity/Hackathon/HackathonResources.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; import { Col, Container, Row } from 'react-bootstrap'; +import type { HackathonAwardsMeta } from './HackathonAwards'; import styles from './HackathonResources.module.less'; export interface HackathonTemplateItem { @@ -15,9 +16,7 @@ export interface HackathonTemplateItem { title: string; } -export interface HackathonProjectMeta { - label: string; - value: string; +export interface HackathonProjectMeta extends HackathonAwardsMeta { valueHref?: string; } @@ -56,12 +55,12 @@ const TemplateCard: FC = ({

{description}

    - {languages.map(language => ( + {languages.map((language) => (
  • {language}
  • ))} - {tags.map(tag => ( + {tags.map((tag) => (
  • {tag}
  • @@ -70,12 +69,22 @@ const TemplateCard: FC = ({
- {items.map(item => ( + {items.map((item) => ( ))}
diff --git a/components/Activity/Hackathon/theme.less b/components/Activity/Hackathon/theme.less index 88f5c54..68b0bce 100644 --- a/components/Activity/Hackathon/theme.less +++ b/components/Activity/Hackathon/theme.less @@ -10,8 +10,8 @@ @rose: #ff78ba; @purple: #7b61ff; @shadow: 0 28px 80px rgba(0, 0, 0, 0.34); -@heading: 'Orbitron', 'Avenir Next', 'Segoe UI', sans-serif; -@body: 'Outfit', 'Avenir Next', 'Segoe UI', sans-serif; +@heading: "Orbitron", "Avenir Next", "Segoe UI", sans-serif; +@body: "Outfit", "Avenir Next", "Segoe UI", sans-serif; .panel-card() { box-shadow: @shadow; @@ -24,10 +24,36 @@ .section-shell() { position: relative; padding: clamp(4rem, 7vw, 6rem) 0; - background: linear-gradient(180deg, rgba(5, 8, 20, 0.98), rgba(7, 12, 26, 0.98)); + background: linear-gradient( + 180deg, + rgba(5, 8, 20, 0.98), + rgba(7, 12, 26, 0.98) + ); color: @copy; } +.section-frame() { + .section { + .section-shell(); + } + + .sectionHeader { + .section-header(); + } + + .sectionTitle { + .section-title(); + } + + .sectionSubtitle { + .section-subtitle(); + } + + .accentLine { + .accent-line(); + } +} + .section-header() { text-align: center; margin-bottom: clamp(2.3rem, 4vw, 3rem); diff --git a/pages/api/Lark/document/copy/[...slug].ts b/pages/api/Lark/document/copy/[...slug].ts index 8c2de97..df95ab3 100644 --- a/pages/api/Lark/document/copy/[...slug].ts +++ b/pages/api/Lark/document/copy/[...slug].ts @@ -11,14 +11,21 @@ const router = createKoaRouter(import.meta.url); router.post('/:type/:id', safeAPI, verifyJWT, async (context: Context) => { const { type, id } = context.params, - { name, parentToken, ownerType, ownerId } = Reflect.get(context.request, 'body'); + { name, parentToken, ownerType, ownerId } = Reflect.get( + context.request, + 'body', + ); const copiedFile = type === 'wiki' ? await lark.copyFile(`${type as 'wiki'}/${id}`, name, parentToken) - : await lark.copyFile(`${type as LarkDocumentPathType}/${id}`, name, parentToken); + : await lark.copyFile( + `${type as LarkDocumentPathType}/${id}`, + name, + parentToken, + ); - const newId = 'token' in copiedFile ? copiedFile.token : copiedFile.node_token; + const newId = copiedFile.token; if (ownerType && ownerId) try { diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index 976385e..75e97de 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -10,8 +10,12 @@ import { cache, compose, errorLogger } from 'next-ssr-middleware'; import { FC, useContext } from 'react'; import { formatDate } from 'web-utility'; -import { HackathonActionHub } from '../../components/Activity/Hackathon/HackathonActionHub'; +import { + HackathonActionHub, + HackathonActionHubLink, +} from '../../components/Activity/Hackathon/HackathonActionHub'; import { HackathonAwards } from '../../components/Activity/Hackathon/HackathonAwards'; +import type { HackathonAwardsMeta } from '../../components/Activity/Hackathon/HackathonAwards'; import { HackathonHero } from '../../components/Activity/Hackathon/HackathonHero'; import { HackathonOverview } from '../../components/Activity/Hackathon/HackathonOverview'; import { HackathonParticipants } from '../../components/Activity/Hackathon/HackathonParticipants'; @@ -124,14 +128,15 @@ const AgendaTypeLabelMap: Partial> = { const isPublicForm = ({ shared_limit }: TableFormView) => ['anyone_editable'].includes(shared_limit as string); -const formatMoment = (value?: TableCellValue) => (value ? formatDate(value as string) : ''); +const formatMoment = (value?: TableCellValue) => + value ? formatDate(value as string) : ''; const formatPeriod = (startedAt?: TableCellValue, endedAt?: TableCellValue) => [formatMoment(startedAt), formatMoment(endedAt)].filter(Boolean).join(' - '); const previewText = (items: TableCellValue[], fallback: string) => items - .map(item => item?.toString()) + .map((item) => item?.toString()) .filter(Boolean) .slice(0, 2) .join(' · ') || fallback; @@ -146,10 +151,17 @@ const agendaToneClassOf = (type: TableCellValue, index: number) => { 'evaluation', ]; - return AgendaTypeClassMap[normalized] || fallbackOrder[index % fallbackOrder.length]; + return ( + AgendaTypeClassMap[normalized] || + fallbackOrder[index % fallbackOrder.length] + ); }; -const agendaTypeLabelOf = (type: TableCellValue, t: (key: I18nKey) => string, fallback = '-') => { +const agendaTypeLabelOf = ( + type: TableCellValue, + t: (key: I18nKey) => string, + fallback = '-', +) => { const normalized = type?.toString().toLowerCase() || ''; const i18nKey = AgendaTypeLabelMap[normalized]; @@ -161,374 +173,436 @@ export const getServerSideProps = compose<{ id: string }>( errorLogger, async ({ params }) => { const activity = await new ActivityModel().getOne(params!.id); - const schema = activity.databaseSchema as Partial | undefined; - const tableIdMap = schema?.tableIdMap as Partial> | undefined; + const { appId, tableIdMap } = (activity.databaseSchema || + {}) as BiTableSchema; - if (!schema?.appId || !tableIdMap) return { notFound: true, props: {} }; + if (!appId || !tableIdMap) return { notFound: true, props: {} }; - for (const key of RequiredTableKeys) if (!tableIdMap[key]) return { notFound: true, props: {} }; + for (const key of RequiredTableKeys) + if (!tableIdMap[key]) return { notFound: true, props: {} }; - const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ - new PersonModel(schema.appId, tableIdMap.Person).getAll(), - new OrganizationModel(schema.appId, tableIdMap.Organization).getAll(), - new AgendaModel(schema.appId, tableIdMap.Agenda).getAll(), - new PrizeModel(schema.appId, tableIdMap.Prize).getAll(), - new TemplateModel(schema.appId, tableIdMap.Template).getAll(), - new ProjectModel(schema.appId, tableIdMap.Project).getAll(), - ]); + const [people, organizations, agenda, prizes, templates, projects] = + await Promise.all([ + new PersonModel(appId, tableIdMap.Person).getAll(), + new OrganizationModel(appId, tableIdMap.Organization).getAll(), + new AgendaModel(appId, tableIdMap.Agenda).getAll(), + new PrizeModel(appId, tableIdMap.Prize).getAll(), + new TemplateModel(appId, tableIdMap.Template).getAll(), + new ProjectModel(appId, tableIdMap.Project).getAll(), + ]); return { props: { activity, - hackathon: { people, organizations, agenda, prizes, templates, projects }, + hackathon: { + people, + organizations, + agenda, + prizes, + templates, + projects, + }, }, }; }, ); -const HackathonDetail: FC = observer(({ activity, hackathon }) => { - const { t } = useContext(I18nContext); - - const { - name, - summary, - location, - startTime, - endTime, - databaseSchema, - host, - image, - type: activityType, - } = activity, - { people, organizations, agenda, prizes, templates, projects } = hackathon; - const forms = ((databaseSchema as Partial | undefined)?.forms || {}) as Partial< - Record - >; - const summaryText = (summary as string) || ''; - const agendaItems = [...agenda].sort( - ({ startedAt: left }, { startedAt: right }) => - new Date((left as string) || 0).getTime() - new Date((right as string) || 0).getTime(), - ); - const hostTags = (host as string[] | undefined)?.slice(0, 2) || []; - const eventRange = formatPeriod(startTime, endTime); - const locationText = (location as TableCellLocation | undefined)?.full_address || '-'; - const heroBadges = [ - (activityType as string) || t('hackathon'), - ...hostTags, - formatMoment(startTime), - formatMoment(endTime), - ].filter((value): value is string => Boolean(value)); - const heroStats = [ - { label: t('participants'), value: people.length }, - { label: t('projects'), value: projects.length }, - { label: t('templates'), value: templates.length }, - { label: t('prizes'), value: prizes.length }, - ]; - const agendaPreview = agendaItems.slice(0, 3); - - const formGroups = FormButtonBar.flatMap(key => { - const list = (forms[key] || []).filter(isPublicForm); - - return list[0] - ? [ - { - key, - eyebrow: t(FormSectionMeta[key].eyebrow), - title: t(FormSectionMeta[key].title), - description: t(FormSectionMeta[key].description), - links: list.map(({ name, shared_url }) => ({ - label: name as string, - href: shared_url, - external: true as const, - })), - }, - ] - : []; - }); - const primaryForm = - formGroups.find(({ key }) => key === 'Person') || - formGroups.find(({ key }) => key === 'Project') || - formGroups[0]; - const secondaryForm = - formGroups.find(({ key }) => key === 'Project' && key !== primaryForm?.key) || - formGroups.find(({ key }) => key !== primaryForm?.key); - const formPreview = - formGroups - .map(({ eyebrow }) => eyebrow) - .filter(Boolean) - .slice(0, 2) - .join(' · ') || t('hackathon_action_hub'); - - const highlightCards = [ - { - icon: '👥', - title: t('participants'), - value: people.length, - description: t(FormSectionMeta.Person.description), - }, - { - icon: '🚀', - title: t('projects'), - value: projects.length, - description: t(FormSectionMeta.Project.description), - }, - { - icon: '🛠', - title: t('templates'), - value: templates.length, - description: previewText( - templates.map(({ name }) => name), - t('templates'), - ), - }, - { - icon: '🏆', - title: t('prizes'), - value: prizes.length, - description: previewText( - prizes.map(({ name }) => name), - t('hackathon_prizes'), - ), - }, - { - icon: '🤝', - title: t('organizations'), - value: organizations.length, - description: previewText( - organizations.map(({ name }) => name), - t('organizations'), - ), - }, - { - icon: '📅', - title: t('agenda'), - value: agendaItems.length, - description: previewText( - agendaItems.map(({ name }) => name), - eventRange || t('agenda'), - ), - }, - ]; - - const scheduleItems = agendaItems.map( - ({ id, name, type, summary, startedAt, endedAt }, index) => { - const typeLabel = agendaTypeLabelOf(type, t); - const description = (summary as string) || typeLabel; +const HackathonDetail: FC = observer( + ({ activity, hackathon }) => { + const { t } = useContext(I18nContext); + + const { + name, + summary, + location, + startTime, + endTime, + databaseSchema, + host, + image, + type: activityType, + } = activity, + { people, organizations, agenda, prizes, templates, projects } = + hackathon; + const { forms } = (databaseSchema || {}) as BiTableSchema; + const formMap = (forms || {}) as Partial< + Record + >; + const summaryText = (summary as string) || ''; + const agendaItems = [...agenda].sort( + ({ startedAt: left }, { startedAt: right }) => + new Date((left as string) || 0).getTime() - + new Date((right as string) || 0).getTime(), + ); + const hostTags = (host as string[] | undefined)?.slice(0, 2) || []; + const eventRange = formatPeriod(startTime, endTime); + const locationText = + (location as TableCellLocation | undefined)?.full_address || '-'; + const heroBadges = [ + (activityType as string) || t('hackathon'), + ...hostTags, + formatMoment(startTime), + formatMoment(endTime), + ].filter((value): value is string => Boolean(value)); + const heroStats = [ + { label: t('participants'), value: people.length }, + { label: t('projects'), value: projects.length }, + { label: t('templates'), value: templates.length }, + { label: t('prizes'), value: prizes.length }, + ]; + const agendaPreview = agendaItems.slice(0, 3); + + const formGroups = FormButtonBar.flatMap((key) => { + const list = (formMap[key] || []).filter(isPublicForm); + + return list[0] + ? [ + { + key, + eyebrow: t(FormSectionMeta[key].eyebrow), + title: t(FormSectionMeta[key].title), + description: t(FormSectionMeta[key].description), + links: list.map(({ name, shared_url }) => ({ + label: name as string, + href: shared_url, + external: true as const, + })), + }, + ] + : []; + }); + const primaryForm = + formGroups.find(({ key }) => key === 'Person') || + formGroups.find(({ key }) => key === 'Project') || + formGroups[0]; + const secondaryForm = + formGroups.find( + ({ key }) => key === 'Project' && key !== primaryForm?.key, + ) || formGroups.find(({ key }) => key !== primaryForm?.key); + const formPreview = + formGroups + .map(({ eyebrow }) => eyebrow) + .filter(Boolean) + .slice(0, 2) + .join(' · ') || t('hackathon_action_hub'); + + const highlightCards = [ + { + icon: '👥', + title: t('participants'), + value: people.length, + description: t(FormSectionMeta.Person.description), + }, + { + icon: '🚀', + title: t('projects'), + value: projects.length, + description: t(FormSectionMeta.Project.description), + }, + { + icon: '🛠', + title: t('templates'), + value: templates.length, + description: previewText( + templates.map(({ name }) => name), + t('templates'), + ), + }, + { + icon: '🏆', + title: t('prizes'), + value: prizes.length, + description: previewText( + prizes.map(({ name }) => name), + t('hackathon_prizes'), + ), + }, + { + icon: '🤝', + title: t('organizations'), + value: organizations.length, + description: previewText( + organizations.map(({ name }) => name), + t('organizations'), + ), + }, + { + icon: '📅', + title: t('agenda'), + value: agendaItems.length, + description: previewText( + agendaItems.map(({ name }) => name), + eventRange || t('agenda'), + ), + }, + ]; + + const scheduleItems = agendaItems.map( + ({ id, name, type, summary, startedAt, endedAt }, index) => { + const typeLabel = agendaTypeLabelOf(type, t); + const description = (summary as string) || typeLabel; + + return { + id: id as string, + phase: String(index + 1).padStart(2, '0'), + dateText: formatPeriod(startedAt, endedAt), + title: name as string, + description, + tone: agendaToneClassOf(type, index), + facts: [ + { + label: t('type'), + value: typeLabel, + meta: description || eventRange || locationText, + }, + { + label: t('start_time'), + value: formatMoment(startedAt), + meta: t('event_duration'), + }, + { + label: t('end_time'), + value: formatMoment(endedAt), + meta: `${t('event_location')}: ${locationText}`, + }, + ], + }; + }, + ); - return { + const prizeItems = prizes.map( + ({ id, name, image, summary, level, sponsor, price, amount }, index) => ({ id: id as string, - phase: String(index + 1).padStart(2, '0'), - dateText: formatPeriod(startedAt, endedAt), title: name as string, - description, - tone: agendaToneClassOf(type, index), - facts: [ - { label: t('type'), value: typeLabel, meta: description || eventRange || locationText }, - { label: t('start_time'), value: formatMoment(startedAt), meta: t('event_duration') }, - { - label: t('end_time'), - value: formatMoment(endedAt), - meta: `${t('event_location')}: ${locationText}`, - }, - ], - }; - }, - ); - - const prizeItems = prizes.map( - ({ id, name, image, summary, level, sponsor, price, amount }, index) => ({ + tier: (level as string) || `#${index + 1}`, + description: + (summary as string) || + previewText([sponsor, price, amount], t('prizes')), + image, + meta: [ + sponsor ? { label: t('sponsor'), value: sponsor as string } : null, + price ? { label: t('price'), value: price as string } : null, + amount ? { label: t('amount'), value: amount as string } : null, + ].filter(Boolean) as HackathonAwardsMeta[], + }), + ); + + const organizationItems = organizations.map(({ id, name, link, logo }) => ({ id: id as string, - title: name as string, - tier: (level as string) || `#${index + 1}`, - description: (summary as string) || previewText([sponsor, price, amount], t('prizes')), - image, - meta: [ - sponsor ? { label: t('sponsor'), value: sponsor as string } : null, - price ? { label: t('price'), value: price as string } : null, - amount ? { label: t('amount'), value: amount as string } : null, - ].filter(Boolean) as { label: string; value: string }[], - }), - ); + name: name as string, + href: link as string | undefined, + logo, + })); - const organizationItems = organizations.map(({ id, name, link, logo }) => ({ - id: id as string, - name: name as string, - href: link as string | undefined, - logo, - })); + const templateItems = templates.map( + ({ id, name, languages, tags, sourceLink, summary, previewLink }) => ({ + id: id as string, + title: name as string, + description: (summary as string) || '', + languages: ((languages as string[] | undefined) || []).filter(Boolean), + tags: ((tags as string[] | undefined) || []).filter(Boolean), + sourceUrl: sourceLink as string | undefined, + sourceLabel: t('source_code'), + previewUrl: previewLink as string | undefined, + previewLabel: t('preview'), + }), + ); + + const projectItems = projects.map( + ({ id, name, score, summary, createdBy, members }) => { + const creator = createdBy as TableCellUser | undefined; + const scoreText = + score === null || score === undefined || score === '' + ? '—' + : `${score}`; + + return { + id: id as string, + title: name as string, + link: `${ActivityModel.getLink(activity)}/team/${id}`, + score: scoreText, + description: (summary as string) || '', + meta: [ + creator + ? { + label: t('created_by'), + value: creator.name || '—', + valueHref: creator.email + ? `mailto:${creator.email}` + : undefined, + } + : { label: t('created_by'), value: '—' }, + { + label: t('members'), + value: (members as string[] | undefined)?.join(', ') || '—', + }, + ], + }; + }, + ); - const templateItems = templates.map( - ({ id, name, languages, tags, sourceLink, summary, previewLink }) => ({ + const participantItems = people.map(({ id, name, avatar, githubLink }) => ({ id: id as string, - title: name as string, - description: (summary as string) || '', - languages: ((languages as string[] | undefined) || []).filter(Boolean), - tags: ((tags as string[] | undefined) || []).filter(Boolean), - sourceUrl: sourceLink as string | undefined, - sourceLabel: t('source_code'), - previewUrl: previewLink as string | undefined, - previewLabel: t('preview'), - }), - ); - - const projectItems = projects.map(({ id, name, score, summary, createdBy, members }) => { - const creator = createdBy as TableCellUser | undefined; - const scoreText = score === null || score === undefined || score === '' ? '—' : `${score}`; - - return { - id: id as string, - title: name as string, - link: `${ActivityModel.getLink(activity)}/team/${id}`, - score: scoreText, - description: (summary as string) || '', - meta: [ - creator - ? { - label: t('created_by'), - value: creator.name || '—', - valueHref: creator.email ? `mailto:${creator.email}` : undefined, - } - : { label: t('created_by'), value: '—' }, - { - label: t('members'), - value: (members as string[] | undefined)?.join(', ') || '—', - }, - ], - }; - }); - - const participantItems = people.map(({ id, name, avatar, githubLink }) => ({ - id: id as string, - name: name as string, - avatar, - githubLink: githubLink as string | undefined, - })); - - return ( - <> - - - - - - - {formGroups[0] && ( - ({ - title, - description, - eyebrow, - links, - count: links.length, - }))} - facts={[eventRange || t('event_duration'), locationText, formPreview]} + name: name as string, + avatar, + githubLink: githubLink as string | undefined, + })); + + return ( + <> + + + - )} - - {scheduleItems[0] && ( - name as string)} - phaseLabel={t('hackathon_phase')} - subtitle={t('event_duration')} - title={t('agenda')} + visualChip={t('hackathon_detail')} + visualCopy={summaryText} + visualKicker={t('event_info')} + visualTitle={eventRange || summaryText} /> - )} - - {(prizeItems[0] || organizationItems[0]) && ( - name), - t('organizations'), - )} - title={t('prizes')} - /> - )} - - {(templateItems[0] || projectItems[0]) && ( - - )} - {participantItems[0] && ( - - )} - - ); -}); + + {formGroups[0] && ( + ({ + title, + description, + eyebrow, + links, + count: links.length, + }), + )} + facts={[ + eventRange || t('event_duration'), + locationText, + formPreview, + ]} + primaryAction={ + primaryForm + ? { + label: primaryForm.title, + href: primaryForm.links[0].href, + external: true, + } + : undefined + } + primaryDescription={ + primaryForm?.description || t('hackathon_entry_flow_description') + } + primaryTitle={primaryForm?.title || t('hackathon_entry_flow')} + subtitle={t('hackathon_entry_flow')} + title={t('hackathon_action_hub')} + > + + + )} + + {scheduleItems[0] && ( + name as string)} + phaseLabel={t('hackathon_phase')} + subtitle={t('event_duration')} + title={t('agenda')} + /> + )} + + {(prizeItems[0] || organizationItems[0]) && ( + name), + t('organizations'), + )} + title={t('prizes')} + /> + )} + + {(templateItems[0] || projectItems[0]) && ( + + )} + + {participantItems[0] && ( + + )} + + ); + }, +); export default HackathonDetail; From 77bbdfbe1aff8fbb9826fcd78cde97982ac6210f Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 23:30:20 +0800 Subject: [PATCH 10/14] Normalize LESS quotes --- components/Activity/Hackathon/HackathonActionHub.module.less | 2 +- components/Activity/Hackathon/HackathonAwards.module.less | 2 +- components/Activity/Hackathon/HackathonOverview.module.less | 2 +- .../Activity/Hackathon/HackathonParticipants.module.less | 2 +- components/Activity/Hackathon/HackathonResources.module.less | 2 +- components/Activity/Hackathon/HackathonSchedule.module.less | 2 +- components/Activity/Hackathon/theme.less | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/components/Activity/Hackathon/HackathonActionHub.module.less b/components/Activity/Hackathon/HackathonActionHub.module.less index 575da0a..e238059 100644 --- a/components/Activity/Hackathon/HackathonActionHub.module.less +++ b/components/Activity/Hackathon/HackathonActionHub.module.less @@ -1,4 +1,4 @@ -@import "./theme.less"; +@import './theme.less'; .section { .section-shell(); diff --git a/components/Activity/Hackathon/HackathonAwards.module.less b/components/Activity/Hackathon/HackathonAwards.module.less index 01348b2..bd36fd5 100644 --- a/components/Activity/Hackathon/HackathonAwards.module.less +++ b/components/Activity/Hackathon/HackathonAwards.module.less @@ -1,4 +1,4 @@ -@import "./theme.less"; +@import './theme.less'; .section-frame(); diff --git a/components/Activity/Hackathon/HackathonOverview.module.less b/components/Activity/Hackathon/HackathonOverview.module.less index 6b4329b..296f504 100644 --- a/components/Activity/Hackathon/HackathonOverview.module.less +++ b/components/Activity/Hackathon/HackathonOverview.module.less @@ -1,4 +1,4 @@ -@import "./theme.less"; +@import './theme.less'; .section-frame(); diff --git a/components/Activity/Hackathon/HackathonParticipants.module.less b/components/Activity/Hackathon/HackathonParticipants.module.less index 19e5d71..e8b4338 100644 --- a/components/Activity/Hackathon/HackathonParticipants.module.less +++ b/components/Activity/Hackathon/HackathonParticipants.module.less @@ -1,4 +1,4 @@ -@import "./theme.less"; +@import './theme.less'; .section-frame(); diff --git a/components/Activity/Hackathon/HackathonResources.module.less b/components/Activity/Hackathon/HackathonResources.module.less index 2977440..848a8a6 100644 --- a/components/Activity/Hackathon/HackathonResources.module.less +++ b/components/Activity/Hackathon/HackathonResources.module.less @@ -1,4 +1,4 @@ -@import "./theme.less"; +@import './theme.less'; .section-frame(); diff --git a/components/Activity/Hackathon/HackathonSchedule.module.less b/components/Activity/Hackathon/HackathonSchedule.module.less index a9478a7..a4d3f5c 100644 --- a/components/Activity/Hackathon/HackathonSchedule.module.less +++ b/components/Activity/Hackathon/HackathonSchedule.module.less @@ -1,4 +1,4 @@ -@import "./theme.less"; +@import './theme.less'; .section-frame(); diff --git a/components/Activity/Hackathon/theme.less b/components/Activity/Hackathon/theme.less index 68b0bce..e2eece2 100644 --- a/components/Activity/Hackathon/theme.less +++ b/components/Activity/Hackathon/theme.less @@ -10,8 +10,8 @@ @rose: #ff78ba; @purple: #7b61ff; @shadow: 0 28px 80px rgba(0, 0, 0, 0.34); -@heading: "Orbitron", "Avenir Next", "Segoe UI", sans-serif; -@body: "Outfit", "Avenir Next", "Segoe UI", sans-serif; +@heading: 'Orbitron', 'Avenir Next', 'Segoe UI', sans-serif; +@body: 'Outfit', 'Avenir Next', 'Segoe UI', sans-serif; .panel-card() { box-shadow: @shadow; From 0211cca2f36b3d3f6451d9fc063a311561bb2a2e Mon Sep 17 00:00:00 2001 From: Ellery Li Date: Mon, 30 Mar 2026 23:42:44 +0800 Subject: [PATCH 11/14] Apply latest PR review feedback --- .../Activity/Hackathon/HackathonActionHub.tsx | 2 +- .../Activity/Hackathon/HackathonAwards.tsx | 5 +-- pages/api/Lark/document/copy/[...slug].ts | 13 ++----- pages/hackathon/[id].tsx | 35 +++++++------------ 4 files changed, 20 insertions(+), 35 deletions(-) diff --git a/components/Activity/Hackathon/HackathonActionHub.tsx b/components/Activity/Hackathon/HackathonActionHub.tsx index 9294766..54b55b3 100644 --- a/components/Activity/Hackathon/HackathonActionHub.tsx +++ b/components/Activity/Hackathon/HackathonActionHub.tsx @@ -1,4 +1,4 @@ -import { FC, type PropsWithChildren } from 'react'; +import type { FC, PropsWithChildren } from 'react'; import { Col, Container, Row } from 'react-bootstrap'; import { HackathonHeroAction } from './HackathonHero'; diff --git a/components/Activity/Hackathon/HackathonAwards.tsx b/components/Activity/Hackathon/HackathonAwards.tsx index 2c767c8..6b66eb3 100644 --- a/components/Activity/Hackathon/HackathonAwards.tsx +++ b/components/Activity/Hackathon/HackathonAwards.tsx @@ -5,9 +5,10 @@ import { Container } from 'react-bootstrap'; import { LarkImage } from '../../LarkImage'; import styles from './HackathonAwards.module.less'; -export type HackathonAwardsMeta = Record<'label', string> & { +export interface HackathonAwardsMeta { + label: string; value: Value; -}; +} export interface HackathonPrizeItem { description: string; diff --git a/pages/api/Lark/document/copy/[...slug].ts b/pages/api/Lark/document/copy/[...slug].ts index df95ab3..8c2de97 100644 --- a/pages/api/Lark/document/copy/[...slug].ts +++ b/pages/api/Lark/document/copy/[...slug].ts @@ -11,21 +11,14 @@ const router = createKoaRouter(import.meta.url); router.post('/:type/:id', safeAPI, verifyJWT, async (context: Context) => { const { type, id } = context.params, - { name, parentToken, ownerType, ownerId } = Reflect.get( - context.request, - 'body', - ); + { name, parentToken, ownerType, ownerId } = Reflect.get(context.request, 'body'); const copiedFile = type === 'wiki' ? await lark.copyFile(`${type as 'wiki'}/${id}`, name, parentToken) - : await lark.copyFile( - `${type as LarkDocumentPathType}/${id}`, - name, - parentToken, - ); + : await lark.copyFile(`${type as LarkDocumentPathType}/${id}`, name, parentToken); - const newId = copiedFile.token; + const newId = 'token' in copiedFile ? copiedFile.token : copiedFile.node_token; if (ownerType && ownerId) try { diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index 75e97de..a5febb5 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -173,35 +173,26 @@ export const getServerSideProps = compose<{ id: string }>( errorLogger, async ({ params }) => { const activity = await new ActivityModel().getOne(params!.id); - const { appId, tableIdMap } = (activity.databaseSchema || - {}) as BiTableSchema; + const schema = activity.databaseSchema as Partial | undefined; + const tableIdMap = schema?.tableIdMap as Partial> | undefined; - if (!appId || !tableIdMap) return { notFound: true, props: {} }; + if (!schema?.appId || !tableIdMap) return { notFound: true, props: {} }; - for (const key of RequiredTableKeys) - if (!tableIdMap[key]) return { notFound: true, props: {} }; + for (const key of RequiredTableKeys) if (!tableIdMap[key]) return { notFound: true, props: {} }; - const [people, organizations, agenda, prizes, templates, projects] = - await Promise.all([ - new PersonModel(appId, tableIdMap.Person).getAll(), - new OrganizationModel(appId, tableIdMap.Organization).getAll(), - new AgendaModel(appId, tableIdMap.Agenda).getAll(), - new PrizeModel(appId, tableIdMap.Prize).getAll(), - new TemplateModel(appId, tableIdMap.Template).getAll(), - new ProjectModel(appId, tableIdMap.Project).getAll(), - ]); + const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ + new PersonModel(schema.appId, tableIdMap.Person).getAll(), + new OrganizationModel(schema.appId, tableIdMap.Organization).getAll(), + new AgendaModel(schema.appId, tableIdMap.Agenda).getAll(), + new PrizeModel(schema.appId, tableIdMap.Prize).getAll(), + new TemplateModel(schema.appId, tableIdMap.Template).getAll(), + new ProjectModel(schema.appId, tableIdMap.Project).getAll(), + ]); return { props: { activity, - hackathon: { - people, - organizations, - agenda, - prizes, - templates, - projects, - }, + hackathon: { people, organizations, agenda, prizes, templates, projects }, }, }; }, From 8ef8dd2b9decd05d43474f09d9a85050569fa779 Mon Sep 17 00:00:00 2001 From: TechQuery Date: Tue, 31 Mar 2026 00:18:19 +0800 Subject: [PATCH 12/14] [optimize] simplify CodeX codes [fix] some CodeX bugs --- ...nHub.module.less => ActionHub.module.less} | 27 +- .../{HackathonActionHub.tsx => ActionHub.tsx} | 33 +- ...nAwards.module.less => Awards.module.less} | 21 +- .../{HackathonAwards.tsx => Awards.tsx} | 32 +- ...athonHero.module.less => Hero.module.less} | 69 +- .../Hackathon/{HackathonHero.tsx => Hero.tsx} | 14 +- ...rview.module.less => Overview.module.less} | 27 +- .../{HackathonOverview.tsx => Overview.tsx} | 2 +- ...s.module.less => Participants.module.less} | 2 - ...athonParticipants.tsx => Participants.tsx} | 2 +- ...rces.module.less => Resources.module.less} | 22 +- .../{HackathonResources.tsx => Resources.tsx} | 34 +- ...edule.module.less => Schedule.module.less} | 55 +- .../{HackathonSchedule.tsx => Schedule.tsx} | 20 +- models/Hackathon.ts | 25 +- pages/hackathon/[id].tsx | 777 +++++++++--------- 16 files changed, 516 insertions(+), 646 deletions(-) rename components/Activity/Hackathon/{HackathonActionHub.module.less => ActionHub.module.less} (86%) rename components/Activity/Hackathon/{HackathonActionHub.tsx => ActionHub.tsx} (79%) rename components/Activity/Hackathon/{HackathonAwards.module.less => Awards.module.less} (96%) rename components/Activity/Hackathon/{HackathonAwards.tsx => Awards.tsx} (83%) rename components/Activity/Hackathon/{HackathonHero.module.less => Hero.module.less} (94%) rename components/Activity/Hackathon/{HackathonHero.tsx => Hero.tsx} (92%) rename components/Activity/Hackathon/{HackathonOverview.module.less => Overview.module.less} (89%) rename components/Activity/Hackathon/{HackathonOverview.tsx => Overview.tsx} (96%) rename components/Activity/Hackathon/{HackathonParticipants.module.less => Participants.module.less} (97%) rename components/Activity/Hackathon/{HackathonParticipants.tsx => Participants.tsx} (96%) rename components/Activity/Hackathon/{HackathonResources.module.less => Resources.module.less} (97%) rename components/Activity/Hackathon/{HackathonResources.tsx => Resources.tsx} (84%) rename components/Activity/Hackathon/{HackathonSchedule.module.less => Schedule.module.less} (77%) rename components/Activity/Hackathon/{HackathonSchedule.tsx => Schedule.tsx} (87%) diff --git a/components/Activity/Hackathon/HackathonActionHub.module.less b/components/Activity/Hackathon/ActionHub.module.less similarity index 86% rename from components/Activity/Hackathon/HackathonActionHub.module.less rename to components/Activity/Hackathon/ActionHub.module.less index e238059..cfd022f 100644 --- a/components/Activity/Hackathon/HackathonActionHub.module.less +++ b/components/Activity/Hackathon/ActionHub.module.less @@ -1,15 +1,14 @@ @import './theme.less'; .section { - .section-shell(); padding-top: clamp(3rem, 5vw, 4rem); } .registerWrap { display: grid; grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); - gap: 1.5rem; align-items: start; + gap: 1.5rem; @media (max-width: 1199px) { grid-template-columns: 1fr; @@ -17,12 +16,7 @@ } .registerCard { - .panel-card(); - background: linear-gradient( - 135deg, - rgba(44, 232, 255, 0.08), - rgba(123, 97, 255, 0.14) - ); + background: linear-gradient(135deg, rgba(44, 232, 255, 0.08), rgba(123, 97, 255, 0.14)); } .registerCardInner, @@ -33,17 +27,16 @@ .regEyebrow, .entryEyebrow, .entryStep { - .eyebrow(); } .regTitle, .entryTitle { margin: 0; color: #fff; - font-family: @heading; - font-size: clamp(1.55rem, 3vw, 2.1rem); font-weight: 800; + font-size: clamp(1.55rem, 3vw, 2.1rem); line-height: 1.3; + font-family: @heading; } .regTitle { @@ -68,12 +61,10 @@ } .actionButton { - .button-primary(); } .actionButtonGhost, .entryLink { - .button-ghost(); } .regFacts { @@ -83,7 +74,6 @@ margin: 1.3rem 0 0; li { - .chip(); padding: 0.48rem 0.85rem; color: @copy; font-size: 0.9rem; @@ -91,7 +81,6 @@ } .entryHub { - .panel-card(); } .entryHubHead { @@ -104,9 +93,8 @@ } .entryCard { - .panel-card(); - height: 100%; padding: 1.3rem; + height: 100%; } .entryStep { @@ -116,8 +104,8 @@ .entryCard h4 { margin: 0.55rem 0 0.45rem; color: #fff; - font-family: @heading; font-size: 1.02rem; + font-family: @heading; } .entryCard p { @@ -132,11 +120,10 @@ } .entryMeta { - .chip(); padding: 0.35rem 0.7rem; color: @gold; - font-family: @heading; font-size: 0.72rem; + font-family: @heading; letter-spacing: 0.06em; text-transform: uppercase; } diff --git a/components/Activity/Hackathon/HackathonActionHub.tsx b/components/Activity/Hackathon/ActionHub.tsx similarity index 79% rename from components/Activity/Hackathon/HackathonActionHub.tsx rename to components/Activity/Hackathon/ActionHub.tsx index 54b55b3..ab44cad 100644 --- a/components/Activity/Hackathon/HackathonActionHub.tsx +++ b/components/Activity/Hackathon/ActionHub.tsx @@ -1,8 +1,8 @@ import type { FC, PropsWithChildren } from 'react'; import { Col, Container, Row } from 'react-bootstrap'; -import { HackathonHeroAction } from './HackathonHero'; -import styles from './HackathonActionHub.module.less'; +import { HackathonHeroAction } from './Hero'; +import styles from './ActionHub.module.less'; export interface HackathonActionHubEntry { count: number; @@ -27,9 +27,7 @@ export const HackathonActionHubLink: FC<{ variant: 'ghost' | 'primary'; }> = ({ action, variant }) => ( @@ -37,10 +35,7 @@ export const HackathonActionHubLink: FC<{ ); -const ActionEntryCard: FC<{ entry: HackathonActionHubEntry; step: string }> = ({ - entry, - step, -}) => ( +const ActionEntryCard: FC<{ entry: HackathonActionHubEntry; step: string }> = ({ entry, step }) => (
{step} · {entry.eyebrow} @@ -54,7 +49,7 @@ const ActionEntryCard: FC<{ entry: HackathonActionHubEntry; step: string }> = ({
diff --git a/components/Activity/Hackathon/HackathonHero.module.less b/components/Activity/Hackathon/Hero.module.less similarity index 94% rename from components/Activity/Hackathon/HackathonHero.module.less rename to components/Activity/Hackathon/Hero.module.less index 7d8137b..50beac6 100644 --- a/components/Activity/Hackathon/HackathonHero.module.less +++ b/components/Activity/Hackathon/Hero.module.less @@ -2,12 +2,8 @@ .hero { position: relative; - overflow: hidden; - background: - radial-gradient(circle at top left, rgba(44, 232, 255, 0.18), transparent 32%), - radial-gradient(circle at 85% 12%, rgba(255, 120, 186, 0.15), transparent 24%), - linear-gradient(180deg, @bg 0%, #091022 48%, #050814 100%); padding: clamp(4.5rem, 8vw, 6.75rem) 0 clamp(3.5rem, 6vw, 5rem); + overflow: hidden; color: @copy; &::before, @@ -19,28 +15,28 @@ } &::before { + opacity: 0.45; + mask-image: radial-gradient(circle at center, black 42%, transparent 100%); background-image: linear-gradient(rgba(44, 232, 255, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(44, 232, 255, 0.08) 1px, transparent 1px); background-size: 54px 54px; - mask-image: radial-gradient(circle at center, black 42%, transparent 100%); - opacity: 0.45; } &::after { inset: auto 0 0; - height: 140px; background: linear-gradient(180deg, transparent, rgba(5, 8, 20, 0.95)); + height: 140px; } } .heroInner { - position: relative; - z-index: 1; display: grid; + position: relative; grid-template-columns: minmax(0, 1fr) minmax(320px, 460px); - gap: clamp(2rem, 4vw, 4rem); align-items: center; + gap: clamp(2rem, 4vw, 4rem); + z-index: 1; } .heroContent { @@ -64,9 +60,9 @@ background: rgba(255, 255, 255, 0.06); padding: 0.42rem 0.9rem; color: @copy; - font-family: @heading; - font-size: 0.78rem; font-weight: 700; + font-size: 0.78rem; + font-family: @heading; letter-spacing: 0.06em; text-transform: uppercase; } @@ -100,11 +96,11 @@ flex-direction: column; gap: 0.2rem; margin: 0; - font-family: @heading; - font-size: clamp(2.6rem, 5vw, 4.45rem); font-weight: 900; - letter-spacing: 0.02em; + font-size: clamp(2.6rem, 5vw, 4.45rem); line-height: 1; + font-family: @heading; + letter-spacing: 0.02em; } .heroTitlePrimary { @@ -122,12 +118,12 @@ } .description { - max-width: 62ch; margin: 0; + max-width: 62ch; color: @muted; - font-family: @body; font-size: 1.05rem; line-height: 1.8; + font-family: @body; } .heroActions { @@ -136,14 +132,6 @@ gap: 0.9rem; } -.actionButton { - .button-primary(); -} - -.actionButtonGhost { - .button-ghost(); -} - .heroStats { display: flex; flex-wrap: wrap; @@ -155,16 +143,16 @@ display: flex; flex-direction: column; gap: 0.1rem; - min-width: 108px; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); border-radius: 16px; background: rgba(255, 255, 255, 0.06); padding: 0.85rem 1rem; + min-width: 108px; strong { - font-family: @heading; font-size: 1.25rem; line-height: 1.1; + font-family: @heading; } span { @@ -181,11 +169,10 @@ } .heroVisualCard { - .panel-card(); position: relative; z-index: 1; - overflow: hidden; background: linear-gradient(180deg, rgba(11, 19, 40, 0.95), rgba(8, 18, 39, 0.9)); + overflow: hidden; } .heroVisualHead { @@ -198,9 +185,9 @@ .visualKicker, .visualChip, .floatingLabel { - font-family: @heading; - font-size: 0.75rem; font-weight: 700; + font-size: 0.75rem; + font-family: @heading; letter-spacing: 0.12em; text-transform: uppercase; } @@ -216,12 +203,12 @@ .heroImageFrame { position: relative; - overflow: hidden; margin: 1rem 1.25rem 0; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; background: linear-gradient(135deg, rgba(44, 232, 255, 0.08), rgba(123, 97, 255, 0.14)); min-height: 340px; + overflow: hidden; :global(img) { position: relative; @@ -231,22 +218,22 @@ .mascotGlow { position: absolute; - inset: 12% 18%; z-index: 1; + filter: blur(28px); + inset: 12% 18%; border-radius: 999px; background: radial-gradient(circle, rgba(44, 232, 255, 0.35), transparent 70%); - filter: blur(28px); } .heroImageFallback { display: flex; - align-items: center; justify-content: center; + align-items: center; min-height: 340px; color: @cyan; - font-family: @heading; - font-size: clamp(1.25rem, 3vw, 2rem); font-weight: 900; + font-size: clamp(1.25rem, 3vw, 2rem); + font-family: @heading; letter-spacing: 0.12em; text-align: center; text-transform: uppercase; @@ -259,8 +246,8 @@ .heroVisualTitle { margin: 0 0 0.35rem; color: #fff; - font-family: @heading; font-size: 1rem; + font-family: @heading; } .heroVisualCopy, @@ -273,12 +260,12 @@ .heroFloatingCard { position: absolute; z-index: 2; - max-width: 260px; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.28); border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 22px; background: rgba(8, 18, 39, 0.88); padding: 1rem 1.1rem; + max-width: 260px; strong { display: block; @@ -337,8 +324,8 @@ .heroFloatingCard { position: static; - max-width: none; margin-top: 1rem; + max-width: none; } .heroVisual { diff --git a/components/Activity/Hackathon/HackathonHero.tsx b/components/Activity/Hackathon/Hero.tsx similarity index 92% rename from components/Activity/Hackathon/HackathonHero.tsx rename to components/Activity/Hackathon/Hero.tsx index 917003c..6c62738 100644 --- a/components/Activity/Hackathon/HackathonHero.tsx +++ b/components/Activity/Hackathon/Hero.tsx @@ -3,8 +3,8 @@ import { FC } from 'react'; import { Container } from 'react-bootstrap'; import { LarkImage } from '../../LarkImage'; -import type { HackathonAwardsMeta } from './HackathonAwards'; -import styles from './HackathonHero.module.less'; +import type { HackathonAwardsMeta } from './Awards'; +import styles from './Hero.module.less'; export interface HackathonHeroAction { external?: boolean; @@ -51,9 +51,7 @@ const HeroLink: FC<{ variant: 'ghost' | 'primary'; }> = ({ action, variant }) => ( @@ -140,11 +138,7 @@ export const HackathonHero: FC = ({
{image ? ( - + ) : (
{imageFallback}
)} diff --git a/components/Activity/Hackathon/HackathonOverview.module.less b/components/Activity/Hackathon/Overview.module.less similarity index 89% rename from components/Activity/Hackathon/HackathonOverview.module.less rename to components/Activity/Hackathon/Overview.module.less index 296f504..c2a4a7e 100644 --- a/components/Activity/Hackathon/HackathonOverview.module.less +++ b/components/Activity/Hackathon/Overview.module.less @@ -1,25 +1,19 @@ @import './theme.less'; -.section-frame(); - .themePanel { margin-bottom: 1.5rem; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 28px; - background: linear-gradient( - 135deg, - rgba(44, 232, 255, 0.08), - rgba(123, 97, 255, 0.12) - ); + background: linear-gradient(135deg, rgba(44, 232, 255, 0.08), rgba(123, 97, 255, 0.12)); padding: 1.5rem 1.6rem; } .themeText { margin-bottom: 0.4rem; color: #fff; - font-family: @heading; - font-size: clamp(1.35rem, 2.6vw, 2rem); font-weight: 900; + font-size: clamp(1.35rem, 2.6vw, 2rem); + font-family: @heading; } .themeSub { @@ -29,12 +23,11 @@ } .trackCard { - .panel-card(); transition: transform 0.22s ease, border-color 0.22s ease; - height: 100%; padding: 1.4rem; + height: 100%; &:hover { transform: translateY(-4px); @@ -44,8 +37,8 @@ .trackIcon { display: inline-flex; - align-items: center; justify-content: center; + align-items: center; margin-bottom: 1rem; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); border-radius: 18px; @@ -58,24 +51,24 @@ .trackName { margin: 0; color: #fff; - font-family: @heading; - font-size: 1.08rem; font-weight: 800; + font-size: 1.08rem; line-height: 1.3; + font-family: @heading; } .trackDesc { - min-height: 4.8rem; margin: 0.75rem 0 0.95rem; + min-height: 4.8rem; color: @muted; line-height: 1.75; } .trackMetric { color: @cyan; - font-family: @heading; - font-size: 0.78rem; font-weight: 700; + font-size: 0.78rem; + font-family: @heading; letter-spacing: 0.08em; text-transform: uppercase; } diff --git a/components/Activity/Hackathon/HackathonOverview.tsx b/components/Activity/Hackathon/Overview.tsx similarity index 96% rename from components/Activity/Hackathon/HackathonOverview.tsx rename to components/Activity/Hackathon/Overview.tsx index 6ad8120..a60d1f6 100644 --- a/components/Activity/Hackathon/HackathonOverview.tsx +++ b/components/Activity/Hackathon/Overview.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { Col, Container, Row } from 'react-bootstrap'; -import styles from './HackathonOverview.module.less'; +import styles from './Overview.module.less'; export interface HackathonOverviewCard { description: string; diff --git a/components/Activity/Hackathon/HackathonParticipants.module.less b/components/Activity/Hackathon/Participants.module.less similarity index 97% rename from components/Activity/Hackathon/HackathonParticipants.module.less rename to components/Activity/Hackathon/Participants.module.less index e8b4338..56c1ff3 100644 --- a/components/Activity/Hackathon/HackathonParticipants.module.less +++ b/components/Activity/Hackathon/Participants.module.less @@ -1,7 +1,5 @@ @import './theme.less'; -.section-frame(); - .participantCloud { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); diff --git a/components/Activity/Hackathon/HackathonParticipants.tsx b/components/Activity/Hackathon/Participants.tsx similarity index 96% rename from components/Activity/Hackathon/HackathonParticipants.tsx rename to components/Activity/Hackathon/Participants.tsx index 590ae05..b1b1ee8 100644 --- a/components/Activity/Hackathon/HackathonParticipants.tsx +++ b/components/Activity/Hackathon/Participants.tsx @@ -3,7 +3,7 @@ import { FC } from 'react'; import { Container } from 'react-bootstrap'; import { LarkImage } from '../../LarkImage'; -import styles from './HackathonParticipants.module.less'; +import styles from './Participants.module.less'; export interface HackathonParticipantItem { avatar?: TableCellValue; diff --git a/components/Activity/Hackathon/HackathonResources.module.less b/components/Activity/Hackathon/Resources.module.less similarity index 97% rename from components/Activity/Hackathon/HackathonResources.module.less rename to components/Activity/Hackathon/Resources.module.less index 848a8a6..77f7a3a 100644 --- a/components/Activity/Hackathon/HackathonResources.module.less +++ b/components/Activity/Hackathon/Resources.module.less @@ -1,24 +1,21 @@ @import './theme.less'; -.section-frame(); - .projectHeader { margin-top: 2.75rem; } .resourceCard, .projectCard { - .panel-card(); - height: 100%; padding: 1.25rem; + height: 100%; } .resourceHead, .projectTop { display: flex; justify-content: space-between; - gap: 1rem; align-items: start; + gap: 1rem; } .projectHead { @@ -31,9 +28,9 @@ .projectTitle { margin: 0; color: #fff; - font-family: @heading; - font-size: 1.02rem; font-weight: 800; + font-size: 1.02rem; + font-family: @heading; } .projectTitle a { @@ -90,13 +87,12 @@ } .entryLink { - .button-ghost(); } .scoreCircle { display: inline-flex; - align-items: center; justify-content: center; + align-items: center; box-shadow: 0 0 24px rgba(44, 232, 255, 0.18); border: 1px solid rgba(44, 232, 255, 0.2); border-radius: 50%; @@ -104,9 +100,9 @@ width: 62px; height: 62px; color: @cyan; - font-family: @heading; - font-size: 1.05rem; font-weight: 800; + font-size: 1.05rem; + font-family: @heading; @media (max-width: 767px) { width: 54px; @@ -123,8 +119,8 @@ div { display: grid; grid-template-columns: auto 1fr; - gap: 0.8rem; align-items: start; + gap: 0.8rem; } dt, @@ -134,8 +130,8 @@ dt { color: @muted; - font-family: @heading; font-size: 0.72rem; + font-family: @heading; letter-spacing: 0.08em; text-transform: uppercase; } diff --git a/components/Activity/Hackathon/HackathonResources.tsx b/components/Activity/Hackathon/Resources.tsx similarity index 84% rename from components/Activity/Hackathon/HackathonResources.tsx rename to components/Activity/Hackathon/Resources.tsx index e701708..0c7870d 100644 --- a/components/Activity/Hackathon/HackathonResources.tsx +++ b/components/Activity/Hackathon/Resources.tsx @@ -1,8 +1,8 @@ import { FC } from 'react'; import { Col, Container, Row } from 'react-bootstrap'; -import type { HackathonAwardsMeta } from './HackathonAwards'; -import styles from './HackathonResources.module.less'; +import type { HackathonAwardsMeta } from './Awards'; +import styles from './Resources.module.less'; export interface HackathonTemplateItem { description: string; @@ -55,12 +55,12 @@ const TemplateCard: FC = ({

{description}

    - {languages.map((language) => ( + {languages.map(language => (
  • {language}
  • ))} - {tags.map((tag) => ( + {tags.map(tag => (
  • {tag}
  • @@ -69,22 +69,12 @@ const TemplateCard: FC = ({