diff --git a/README.md b/README.md index ae839d2..2d02890 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,13 @@ ## 开始 ```bash +pnpm run sync-env pnpm install pnpm dev ``` +`pnpm run sync-env` 会在你显式执行时,从 `Open-Source-Bazaar/key-vault` 同步当前项目所需的私有环境文件。出于安全考虑,仓库不会在 `pnpm install` 阶段自动下载 `.env.local`。 + 可访问 http://localhost:3000. [1]: https://github.com/idea2app/Lark-Next-Bootstrap-ts diff --git a/components/Activity/Hackathon/Hero.tsx b/components/Activity/Hackathon/Hero.tsx index 855aee7..f2c6369 100644 --- a/components/Activity/Hackathon/Hero.tsx +++ b/components/Activity/Hackathon/Hero.tsx @@ -80,22 +80,22 @@ const useCountdown = (countdownTo?: string) => { return Number.isFinite(value) ? value : NaN; }, [countdownTo]); - const [now, setNow] = useState(null); + const [clientNow, setClientNow] = useState(null); useEffect(() => { if (!Number.isFinite(target)) return; - setNow(Date.now()); + setClientNow(Date.now()); - const timer = window.setInterval(() => setNow(Date.now()), 1000); + const timer = window.setInterval(() => setClientNow(Date.now()), 1000); return () => window.clearInterval(timer); }, [target]); return useMemo(() => { - if (!Number.isFinite(target) || now === null) return ['--', '--', '--', '--']; + if (!Number.isFinite(target) || clientNow === null) return ['--', '--', '--', '--']; - const rest = Math.max(0, target - now); + const rest = Math.max(0, target - clientNow); const totalSeconds = Math.floor(rest / 1000); const days = Math.floor(totalSeconds / 86400); const hours = Math.floor((totalSeconds % 86400) / 3600); @@ -103,7 +103,7 @@ const useCountdown = (countdownTo?: string) => { const seconds = totalSeconds % 60; return [days, hours, minutes, seconds].map(value => String(value).padStart(2, '0')); - }, [now, target]); + }, [clientNow, target]); }; const splitHeroTitle = (name: string, subtitle: string) => { diff --git a/components/Activity/Hackathon/constant.ts b/components/Activity/Hackathon/constant.ts index c39c078..febf3c1 100644 --- a/components/Activity/Hackathon/constant.ts +++ b/components/Activity/Hackathon/constant.ts @@ -1,5 +1,3 @@ -import { TableCellUser } from 'mobx-lark'; - import { Activity, ActivityModel } from '../../../models/Activity'; import { Agenda, Organization, Person, Prize, Project, Template } from '../../../models/Hackathon'; import { i18n } from '../../../models/Translation'; @@ -13,6 +11,7 @@ import { formatPeriod, normalizeAgendaType, previewText, + userOf, } from './utility'; export const RequiredTableKeys = [ @@ -336,7 +335,7 @@ export const buildProjectItems = ( { projects, activity }: { projects: Project[]; activity: Activity }, ) => projects.map(({ id, name, score, summary, createdBy, members }) => { - const creator = createdBy as TableCellUser | undefined; + const creator = userOf(createdBy); const scoreText = score === null || score === undefined || score === '' ? '—' : `${score}`; return { @@ -350,7 +349,6 @@ export const buildProjectItems = ( ? { label: t('created_by'), value: creator.name || '—', - valueHref: creator.email ? `mailto:${creator.email}` : undefined, } : { label: t('created_by'), value: '—' }, { diff --git a/components/Activity/Hackathon/useLiveCountdownState.ts b/components/Activity/Hackathon/useLiveCountdownState.ts new file mode 100644 index 0000000..09850c6 --- /dev/null +++ b/components/Activity/Hackathon/useLiveCountdownState.ts @@ -0,0 +1,44 @@ +import { TableCellValue } from 'mobx-lark'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { CountdownWindow, firstTextOf, resolveCountdownState, timeOf } from './utility'; + +export const useLiveCountdownState = ( + items: T[], + startTime?: TableCellValue, + endTime?: TableCellValue, +) => { + const [referenceTime, setReferenceTime] = useState(null); + const refreshReferenceTime = useCallback(() => setReferenceTime(Date.now()), []); + + useEffect(() => refreshReferenceTime(), [refreshReferenceTime]); + + const countdownState = useMemo( + () => + referenceTime === null + ? { + nextItem: undefined as T | undefined, + countdownTo: firstTextOf(startTime) || firstTextOf(endTime) || undefined, + } + : resolveCountdownState(items, referenceTime, startTime, endTime), + [endTime, items, referenceTime, startTime], + ); + + useEffect(() => { + if (referenceTime === null) return; + + const targetTime = timeOf(countdownState.countdownTo); + + if (!Number.isFinite(targetTime)) return; + + const delay = Math.min(2_147_483_647, Math.max(1000, targetTime - Date.now() + 1000)); + const timer = window.setTimeout( + refreshReferenceTime, + delay, + ); + + return () => window.clearTimeout(timer); + }, [countdownState.countdownTo, referenceTime, refreshReferenceTime]); + + return countdownState; +}; diff --git a/components/Activity/Hackathon/utility.ts b/components/Activity/Hackathon/utility.ts index 0ba13b8..3c4919c 100644 --- a/components/Activity/Hackathon/utility.ts +++ b/components/Activity/Hackathon/utility.ts @@ -1,4 +1,4 @@ -import { TableCellValue, TableFormView } from 'mobx-lark'; +import { TableCellUser, TableCellValue, TableFormView } from 'mobx-lark'; import { formatDate } from 'web-utility'; import type { HackathonScheduleTone } from './Schedule'; @@ -33,14 +33,133 @@ export const buildAgendaTypeLabelMap = ({ export const isPublicForm = ({ shared_limit }: TableFormView) => ['anyone_editable'].includes(shared_limit as string); +type NamedLike = { name?: string | null }; +type TextLike = TableCellValue | NamedLike | null | undefined; +type TextListLike = TextLike | TextLike[]; + +const textOf = (value: TextLike) => { + if (value === null || value === undefined) return ''; + if (typeof value === 'boolean') return ''; + + if (typeof value === 'object' && !Array.isArray(value)) { + const { + name, + text, + value: primitiveValue, + displayName, + display_name, + title, + content, + plainText, + plain_text, + user, + } = value as NamedLike & { + text?: string | null; + value?: string | number | null; + displayName?: string | null; + display_name?: string | null; + title?: string | null; + content?: string | null; + plainText?: string | null; + plain_text?: string | null; + user?: { + name?: string | null; + displayName?: string | null; + display_name?: string | null; + } | null; + }; + const candidate = [ + name, + text, + primitiveValue, + displayName, + display_name, + title, + content, + plainText, + plain_text, + user?.displayName, + user?.display_name, + user?.name, + ].find(item => item !== null && item !== undefined && `${item}`.trim()); + + return candidate === null || candidate === undefined ? '' : `${candidate}`.trim(); + } + + const text = value.toString().trim(); + + return text === '[object Object]' ? '' : text; +}; + +export const firstTextOf = (value: TextListLike) => + (Array.isArray(value) ? value.map(textOf).find(Boolean) : textOf(value)) || ''; + +export const textListOf = (value: TextListLike) => + (Array.isArray(value) ? value : [value]).map(textOf).filter(Boolean); + +export const relationNameOf = (value: TextListLike) => firstTextOf(value); + +export const userOf = (value?: TableCellValue | TableCellUser) => + value && typeof value === 'object' && !Array.isArray(value) && 'name' in value + ? (value as TableCellUser) + : undefined; + export const formatMoment = (value?: TableCellValue) => (value ? formatDate(value as string) : ''); export const formatPeriod = (startedAt?: TableCellValue, endedAt?: TableCellValue) => [formatMoment(startedAt), formatMoment(endedAt)].filter(Boolean).join(' - '); +export const timeOf = (value?: TableCellValue) => { + if (value instanceof Date) return value.getTime(); + + if (typeof value === 'number') return Number.isFinite(value) ? value : NaN; + + const text = firstTextOf(value as TextListLike); + + if (!text) return NaN; + + const time = Date.parse(text); + + return Number.isFinite(time) ? time : NaN; +}; + +export interface CountdownWindow { + startedAt?: TableCellValue; + endedAt?: TableCellValue; +} + +const countdownTextOf = (value?: TableCellValue) => { + const time = timeOf(value); + + return Number.isFinite(time) ? new Date(time).toISOString() : undefined; +}; + +export const resolveCountdownState = ( + items: T[], + referenceTime: number, + startTime?: TableCellValue, + endTime?: TableCellValue, +) => { + const nextItem = items.find(({ startedAt, endedAt }) => { + const started = timeOf(startedAt); + const ended = timeOf(endedAt); + + return Number.isFinite(started) && Number.isFinite(ended) && referenceTime <= ended; + }); + const nextStartedAt = timeOf(nextItem?.startedAt); + const nextCountdownTarget = + Number.isFinite(nextStartedAt) && nextStartedAt > referenceTime + ? nextItem?.startedAt + : nextItem?.endedAt; + const fallbackCountdownTarget = timeOf(startTime) > referenceTime ? startTime : endTime; + const countdownTo = countdownTextOf(nextCountdownTarget) || countdownTextOf(fallbackCountdownTarget); + + return { nextItem, countdownTo }; +}; + export const previewText = (items: TableCellValue[], fallback: string) => items - .map(item => item?.toString()) + .map(item => textOf(item)) .filter(Boolean) .slice(0, 2) .join(' · ') || fallback; @@ -75,10 +194,10 @@ export const compactSummaryOf = ( ) => { const source = Array.isArray(text) ? text - .map(item => item?.toString()) + .map(item => textOf(item)) .filter(Boolean) .join(' · ') - : text?.toString() || ''; + : textOf(text); const normalized = source.replace(/\s+/g, ' ').trim(); if (!normalized) return fallback; @@ -95,8 +214,8 @@ export const dateKeyOf = (value?: TableCellValue) => { export const compactDateKeyOf = (value?: TableCellValue) => dateKeyOf(value).replace('-', '.'); export const daysBetween = (startedAt?: TableCellValue, endedAt?: TableCellValue) => { - const start = new Date((startedAt as string) || '').getTime(); - const end = new Date((endedAt as string) || '').getTime(); + const start = timeOf(startedAt); + const end = timeOf(endedAt); if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0; diff --git a/components/Activity/ProductCard.tsx b/components/Activity/ProductCard.tsx index 630cc06..e6fcff5 100644 --- a/components/Activity/ProductCard.tsx +++ b/components/Activity/ProductCard.tsx @@ -4,74 +4,89 @@ import { FC } from 'react'; import { CardProps, Card, Button } from 'react-bootstrap'; import { formatDate } from 'web-utility'; +import { timeOf } from './Hackathon/utility'; import { Product } from '../../models/Hackathon'; import styles from '../../styles/Hackathon.module.less'; export type ProductCardProps = Product & Omit; export const ProductCard: FC = observer( - ({ className = '', id, createdAt, name, sourceLink, link = sourceLink, summary, ...props }) => ( - - - - {(name || link) as string} - -

{summary as string}

-
- -
+ ({ className = '', id, createdAt, name, sourceLink, link = sourceLink, summary, ...props }) => { + const createdAtTime = timeOf(createdAt); + const createdAtISO = Number.isFinite(createdAtTime) + ? new Date(createdAtTime).toJSON() + : undefined; + const createdAtText = Number.isFinite(createdAtTime) ? formatDate(createdAtTime) : ''; - {sourceLink && ( -
- - - - + return ( + + + + {(name || link) as string} + +

{summary as string}

+
+
- )} - -
-
- ), + {sourceLink && ( +
+ + + + +
+ )} + + {createdAtISO && ( + + )} + + + ); + }, ); diff --git a/package.json b/package.json index c4afaa1..81d43b8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "scripts": { "prepare": "husky", - "install": "pnpx git-utility download https://github.com/Open-Source-Bazaar/key-vault main Open-Source-Bazaar.github.io || true", + "sync-env": "pnpx git-utility download https://github.com/Open-Source-Bazaar/key-vault main Open-Source-Bazaar.github.io", "postinstall": "next typegen", "dev": "next dev --webpack", "build": "next build --webpack", diff --git a/pages/hackathon/[id].tsx b/pages/hackathon/[id].tsx index 5ee064e..92912a4 100644 --- a/pages/hackathon/[id].tsx +++ b/pages/hackathon/[id].tsx @@ -1,4 +1,4 @@ -import { BiTableSchema, TableCellLocation, TableFormView } from 'mobx-lark'; +import { TableCellLocation, TableFormView } from 'mobx-lark'; import { observer } from 'mobx-react'; import { cache, compose, errorLogger } from 'next-ssr-middleware'; import { FC, useContext } from 'react'; @@ -14,6 +14,7 @@ import { HackathonOverview } from '../../components/Activity/Hackathon/Overview' import { HackathonParticipants } from '../../components/Activity/Hackathon/Participants'; import { HackathonResources } from '../../components/Activity/Hackathon/Resources'; import { HackathonSchedule } from '../../components/Activity/Hackathon/Schedule'; +import { useLiveCountdownState } from '../../components/Activity/Hackathon/useLiveCountdownState'; import { PageHead } from '../../components/Layout/PageHead'; import { Activity, ActivityModel } from '../../models/Activity'; import { @@ -55,13 +56,17 @@ import { compactSummaryOf, dateKeyOf, daysBetween, + firstTextOf, formatMoment, formatPeriod, isPublicForm, normalizeAgendaType, previewText, + textListOf, + timeOf, } from '../../components/Activity/Hackathon/utility'; + interface HackathonDetailProps { activity: Activity; hackathon: { @@ -78,13 +83,14 @@ export const getServerSideProps = compose<{ id: string }>( cache(), errorLogger, async ({ params }) => { - const activity = await new ActivityModel().getOne(params!.id); + if (!params?.id) return { notFound: true }; - const { appId, tableIdMap } = (activity.databaseSchema || {}) as BiTableSchema; + const activity = await new ActivityModel().getOne(params!.id); + const { appId, tableIdMap } = activity.databaseSchema || {}; - if (!appId || !tableIdMap) return { notFound: true, props: {} }; + if (!appId || !tableIdMap) return { notFound: true }; - for (const key of RequiredTableKeys) if (!tableIdMap[key]) return { notFound: true, props: {} }; + for (const key of RequiredTableKeys) if (!tableIdMap[key]) return { notFound: true }; const [people, organizations, agenda, prizes, templates, projects] = await Promise.all([ new PersonModel(appId, tableIdMap.Person).getAll(), @@ -119,13 +125,19 @@ const HackathonDetail: FC = observer(({ activity, hackatho type: activityType, } = activity, { people, organizations, agenda, prizes, templates, projects } = hackathon; - const { forms } = (databaseSchema || {}) as BiTableSchema; + const { forms } = databaseSchema; const formMap = (forms || {}) as Partial>; - 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 summaryText = textListOf(summary).join(' · ') || firstTextOf(summary); + const agendaItems = [...agenda].sort(({ startedAt: left }, { startedAt: right }) => { + const leftTime = timeOf(left); + const rightTime = timeOf(right); + + if (!Number.isFinite(leftTime) && !Number.isFinite(rightTime)) return 0; + if (!Number.isFinite(leftTime)) return 1; + if (!Number.isFinite(rightTime)) return -1; + + return leftTime - rightTime; + }); const hostTags = (host as string[] | undefined)?.slice(0, 2) || []; const eventRange = formatPeriod(startTime, endTime); const locationText = (location as TableCellLocation | undefined)?.full_address || '-'; @@ -182,22 +194,11 @@ const HackathonDetail: FC = observer(({ activity, hackatho }; }) .filter(({ date, label }) => Boolean(date && label)); - const now = Date.now(); - const nextAgendaItem = agendaItems.find(({ startedAt, endedAt }) => { - const started = new Date((startedAt as string) || 0).getTime(); - const ended = new Date((endedAt as string) || 0).getTime(); - - return Number.isFinite(started) && Number.isFinite(ended) && now <= ended; - }); - const nextAgendaStarted = nextAgendaItem?.startedAt as string | undefined; - const nextAgendaEnded = nextAgendaItem?.endedAt as string | undefined; - const countdownTo = - (nextAgendaStarted && new Date(nextAgendaStarted).getTime() > now - ? nextAgendaStarted - : nextAgendaEnded) || - ((startTime as string | undefined) && new Date(startTime as string).getTime() > now - ? (startTime as string) - : (endTime as string | undefined)); + const { nextItem: nextAgendaItem, countdownTo } = useLiveCountdownState( + agendaItems, + startTime, + endTime, + ); const countdownLabel = nextAgendaItem ? agendaTypeLabelOf(nextAgendaItem.type, t, t('agenda')) : t('event_duration'); @@ -225,20 +226,26 @@ const HackathonDetail: FC = observer(({ activity, hackatho .join(','); const formGroups = FormButtonBar.flatMap(key => { - const list = (formMap[key] || []).filter(isPublicForm); + const links = (formMap[key] || []).filter(isPublicForm).flatMap(({ name, shared_url }) => + shared_url + ? [ + { + label: name as string, + href: shared_url, + external: true as const, + }, + ] + : [], + ); - return list[0] + return links[0] ? [ { key, eyebrow: buildFormSectionMeta(i18n)[key].eyebrow, title: buildFormSectionMeta(i18n)[key].title, description: buildFormSectionMeta(i18n)[key].description, - links: list.map(({ name, shared_url }) => ({ - label: name as string, - href: shared_url, - external: true as const, - })), + links, }, ] : []; @@ -247,6 +254,14 @@ const HackathonDetail: FC = observer(({ activity, hackatho formGroups.find(({ key }) => key === 'Person') || formGroups.find(({ key }) => key === 'Project') || formGroups[0]; + + const heroPrimaryAction = primaryForm + ? { + label: heroPrimaryActionLabel, + href: primaryForm.links[0]!.href, + external: true as const, + } + : { label: t('event_description'), href: '#overview' }; const secondaryForm = formGroups.find(({ key }) => key === 'Project' && key !== primaryForm?.key) || formGroups.find(({ key }) => key !== primaryForm?.key); @@ -348,15 +363,7 @@ const HackathonDetail: FC = observer(({ activity, hackatho locationText={locationText} name={name as string} navigation={heroNavigation(i18n)} - primaryAction={ - primaryForm - ? { - label: heroPrimaryActionLabel, - href: primaryForm.links[0].href, - external: true, - } - : { label: heroPrimaryActionLabel, href: '#entry-hub' } - } + primaryAction={heroPrimaryAction} secondaryAction={{ label: t('agenda'), href: '#schedule' }} chips={heroStatChips} subtitle={(activityType as string) || t('hackathon_detail')} @@ -401,7 +408,7 @@ const HackathonDetail: FC = observer(({ activity, hackatho primaryForm ? { label: primaryForm.title, - href: primaryForm.links[0].href, + href: primaryForm.links[0]!.href, external: true, } : undefined @@ -416,7 +423,7 @@ const HackathonDetail: FC = observer(({ activity, hackatho secondaryForm ? { label: secondaryForm.title, - href: secondaryForm.links[0].href, + href: secondaryForm.links[0]!.href, external: true, } : { label: t('agenda'), href: '#schedule' } diff --git a/pages/hackathon/[id]/team/[tid].tsx b/pages/hackathon/[id]/team/[tid].tsx index 8f6ae7b..9f4c016 100644 --- a/pages/hackathon/[id]/team/[tid].tsx +++ b/pages/hackathon/[id]/team/[tid].tsx @@ -1,22 +1,19 @@ import { Avatar } from 'idea-react'; -import { BiTableSchema, TableCellUser } from 'mobx-lark'; +import { TableCellLocation, TableFormView } from 'mobx-lark'; import { observer } from 'mobx-react'; import { cache, compose, errorLogger } from 'next-ssr-middleware'; -import { FC, useContext, useState } from 'react'; -import { - Breadcrumb, - Button, - Card, - Col, - Container, - Modal, - Ratio, - Row, - Tab, - Tabs, -} from 'react-bootstrap'; +import { FC, useContext, useMemo, useState } from 'react'; +import { Breadcrumb, Button, Card, Col, Container, Modal, Nav, Ratio, Row } from 'react-bootstrap'; import { CommentBox } from '../../../../components/Activity/CommentBox'; +import { + compactSummaryOf, + firstTextOf, + isPublicForm, + relationNameOf, + textListOf, + userOf, +} from '../../../../components/Activity/Hackathon/utility'; import { ProductCard } from '../../../../components/Activity/ProductCard'; import { PageHead } from '../../../../components/Layout/PageHead'; import { Activity, ActivityModel } from '../../../../models/Activity'; @@ -29,29 +26,48 @@ import { ProjectModel, } from '../../../../models/Hackathon'; import { I18nContext } from '../../../../models/Translation'; -import styles from '../../../../styles/Hackathon.module.less'; +import styles from '../../../../styles/HackathonTeam.module.less'; export const getServerSideProps = compose>( cache(), errorLogger, async ({ params }) => { + if (!params?.id || !params?.tid) return { notFound: true }; + const activity = await new ActivityModel().getOne(params!.id); + const { appId, tableIdMap } = activity.databaseSchema || {}; - const { appId, tableIdMap } = activity.databaseSchema; + if ( + !appId || + !tableIdMap?.Project || + !tableIdMap?.Member || + !tableIdMap?.Product + ) + return { notFound: true }; const project = await new ProjectModel(appId, tableIdMap.Project).getOne(params!.tid); + const projectName = firstTextOf(project.name); + + if (!projectName) return { notFound: true }; // Get approved members for this project const [members, products] = await Promise.all([ new MemberModel(appId, tableIdMap.Member).getAll({ - project: project.name as string, + project: projectName, status: 'approved', }), new ProductModel(appId, tableIdMap.Product).getAll({ - project: project.name as string, + project: projectName, }), ]); - return { props: { activity, project, members, products } }; + return { + props: { + activity, + project, + members, + products, + }, + }; }, ); @@ -62,144 +78,319 @@ interface ProjectPageProps { products: Product[]; } -const ProjectPage: FC = observer(({ activity, project, members, products }) => { - const { t } = useContext(I18nContext); - const [showScoreModal, setShowScoreModal] = useState(false); - - const { name: activityName, databaseSchema } = activity; - const { formLinkMap } = databaseSchema as unknown as BiTableSchema; - const { name: displayName, summary: description, createdBy, score } = project; - - const currentRoute = [ - { title: activityName as string, href: ActivityModel.getLink(activity) }, - { title: displayName as string }, - ]; - - return ( - <> - - - {/* Hero Section */} -
- - - {currentRoute.map(({ title, href }, index, { length }) => { - const isActive = index === length - 1; - - return ( - - {title} - - ); - })} - - -

{displayName as string}

-

{description as string}

- - {score != null && ( -
- -
- )} -
-
- - - {/* Team Members Section */} -
-

👥 {t('team_members')}

- - {members.map(({ id, person, githubAccount }) => ( - - -
- -
-

- {(person as TableCellUser).name} -

- - {githubAccount && ( - - @{githubAccount as string} - - )} -
+const ProjectPage: FC = observer( + ({ activity, project, members, products }) => { + const { t } = useContext(I18nContext); + const [showScoreModal, setShowScoreModal] = useState(false); + + const { + name: activityName, + databaseSchema, + location, + summary: activitySummary, + type: activityType, + } = activity; + const { forms } = databaseSchema; + const { + name: displayName, + summary: description, + createdBy, + score, + rank, + prize, + group, + } = project; + const creator = userOf(createdBy); + const displayTitle = firstTextOf(displayName) || t('projects'); + const projectDescription = textListOf(description).join(' · '); + const projectSummary = compactSummaryOf( + projectDescription, + firstTextOf(activitySummary) || displayTitle, + 140, + ); + const locationText = (location as TableCellLocation | undefined)?.full_address || '-'; + const groupName = relationNameOf(group); + const scoreText = firstTextOf(score); + const rankText = firstTextOf(rank); + const prizeText = firstTextOf(prize); + const publicForms = useMemo( + () => + Object.values(forms || {}) + .flat() + .filter((form): form is TableFormView => Boolean(form)) + .filter(isPublicForm) + .filter(({ shared_url }) => Boolean(shared_url)), + [forms], + ); + const hasShareUrl = ({ shared_url }: TableFormView) => Boolean(shared_url); + const primaryForm = + (forms?.Person || []).filter(isPublicForm).find(hasShareUrl) || + (forms?.Project || []).filter(isPublicForm).find(hasShareUrl) || + publicForms[0]; + const scoreForm = (forms?.Evaluation || []).filter(isPublicForm).find(hasShareUrl); + const currentRoute = [ + { title: activityName as string, href: ActivityModel.getLink(activity) }, + { title: displayTitle }, + ]; + const navigation = [ + { href: '#overview', label: t('event_description') }, + { href: '#members', label: t('team_members') }, + { href: '#works', label: t('team_works') }, + { href: '#creator', label: t('created_by') }, + ]; + const heroChips = [ + `${t('participants')} · ${members.length}`, + `${t('products')} · ${products.length}`, + groupName, + rankText ? `#${rankText}` : '', + scoreText ? `${t('score')} · ${scoreText}` : '', + ].filter(Boolean); + const heroPrimaryAction = primaryForm + ? { + label: t('hackathon_register_now'), + href: primaryForm.shared_url, + external: true as const, + } + : { label: t('hackathon_detail'), href: ActivityModel.getLink(activity) }; + const quickMetrics = [ + { + label: t('participants'), + value: String(members.length), + meta: t('team_members'), + }, + { + label: t('products'), + value: String(products.length), + meta: t('team_works'), + }, + { + label: t('created_by'), + value: creator?.name || '-', + meta: groupName || displayTitle, + }, + { + label: t('score'), + value: scoreText || '--', + meta: [prizeText, rankText ? `#${rankText}` : ''].filter(Boolean).join(' · ') || '--', + }, + ]; + + return ( + <> + + +
+
+ +
+
+ + {currentRoute.map(({ title, href }, index, { length }) => ( + + {title} + + ))} + + + + +
+ + {(activityType as string) || t('hackathon_detail')} + + {groupName && {groupName}} + {prizeText && {prizeText}} + {rankText && #{rankText}} +
+ +

{displayTitle}

+

{projectSummary}

+ + - - - ))} - -
- - {/* Team Products Section */} -
-

💡 {t('team_works')}

- - {products && products.length > 0 ? ( - - {products.map(product => ( - - - - ))} - - ) : ( -
{t('no_news_yet')}
- )} -
- - {/* Creator Information Section */} -
-

👤 {t('created_by')}

- -
{(createdBy as TableCellUser).name}
- - {(createdBy as TableCellUser).email} - -
-
- - - - - setShowScoreModal(false)}> - - {t('score')} - - - -