Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
af78882
chore: make env sync explicit
luojiyin1987 Apr 21, 2026
ebf6972
docs: document explicit env sync
luojiyin1987 Apr 21, 2026
7d82b01
fix: stabilize hackathon detail hydration
luojiyin1987 Apr 21, 2026
14d64e9
fix: suppress product card hydration mismatch
luojiyin1987 Apr 21, 2026
f194794
i18n: add hackathon team showcase label in en-US
luojiyin1987 Apr 21, 2026
f30369f
i18n: add hackathon team showcase label in zh-CN
luojiyin1987 Apr 21, 2026
d00122b
i18n: add hackathon team showcase label in zh-TW
luojiyin1987 Apr 21, 2026
cab62c1
feat: add hackathon team page styles
luojiyin1987 Apr 21, 2026
6e4f39e
feat: redesign hackathon team detail page
luojiyin1987 Apr 21, 2026
8c12e16
refactor: extract hackathon countdown resolver
luojiyin1987 Apr 21, 2026
8ed3e06
refactor: use shared hackathon user helper
luojiyin1987 Apr 21, 2026
1c7d4d8
refactor: simplify hackathon detail countdown
luojiyin1987 Apr 21, 2026
e00e2eb
refactor: simplify hackathon team countdown
luojiyin1987 Apr 21, 2026
e565a66
refactor: clarify hackathon countdown reference time
luojiyin1987 Apr 21, 2026
67c97d9
refactor: rename hero countdown client clock
luojiyin1987 Apr 21, 2026
e31bfea
refactor: rename hackathon agenda reference time
luojiyin1987 Apr 21, 2026
1b47f1a
refactor: rename team agenda reference time
luojiyin1987 Apr 21, 2026
ba6607f
fix: align hackathon hero fallback action
luojiyin1987 Apr 21, 2026
1e5b881
fix: align team hero fallback action
luojiyin1987 Apr 21, 2026
dc8ee07
fix: avoid epoch fallback for missing hackathon times
luojiyin1987 Apr 22, 2026
02fd7aa
fix: normalize hackathon time parsing
luojiyin1987 Apr 22, 2026
01f3472
feat: add live hackathon countdown state hook
luojiyin1987 Apr 22, 2026
aa5f4a6
fix: compute hackathon detail countdown on client
luojiyin1987 Apr 22, 2026
180ce0e
fix: compute hackathon team countdown on client
luojiyin1987 Apr 22, 2026
fb28d94
fix: remove public creator email links
luojiyin1987 Apr 22, 2026
e4d5075
fix: normalize hackathon rich text extraction
luojiyin1987 Apr 22, 2026
3351ccb
fix: guard hackathon detail schema access
luojiyin1987 Apr 22, 2026
f85c138
fix: harden hackathon team public access
luojiyin1987 Apr 22, 2026
0c45861
fix: localize hackathon team breadcrumb label
luojiyin1987 Apr 22, 2026
b5a70ed
feat: add english breadcrumb translation
luojiyin1987 Apr 22, 2026
729455e
feat: add simplified chinese breadcrumb translation
luojiyin1987 Apr 22, 2026
7ca1d72
feat: add traditional chinese breadcrumb translation
luojiyin1987 Apr 22, 2026
8d7649e
fix: stabilize live countdown refresh callback
luojiyin1987 Apr 22, 2026
26a42d2
fix: return standard hackathon detail notFound
luojiyin1987 Apr 22, 2026
72b77bf
fix: return standard hackathon team notFound
luojiyin1987 Apr 22, 2026
d30960b
fix: support string product timestamps
luojiyin1987 Apr 22, 2026
eb20e2d
fix: sort hackathon detail agenda safely
luojiyin1987 Apr 22, 2026
236fee5
fix: sort hackathon team agenda safely
luojiyin1987 Apr 22, 2026
79c8b39
refactor: clarify hackathon countdown fallback logic
luojiyin1987 Apr 22, 2026
ba51623
fix: require valid hackathon detail form links
luojiyin1987 Apr 22, 2026
2fd23a2
fix: require valid hackathon team form links
luojiyin1987 Apr 22, 2026
dc2661a
fix: ignore empty product timestamps
luojiyin1987 Apr 22, 2026
0e5e716
refactor: reuse hackathon time parsing
luojiyin1987 Apr 22, 2026
d14dde3
fix: use grouped hackathon forms consistently
luojiyin1987 Apr 22, 2026
e7e5249
fix: require shareable hackathon team forms
luojiyin1987 Apr 22, 2026
e8f504f
fix: del no use import
luojiyin1987 Apr 22, 2026
f5c705c
fix: ignore boolean hackathon text values
luojiyin1987 Apr 22, 2026
7759941
refactor: focus hackathon team page on team content
luojiyin1987 Apr 23, 2026
00f8e86
style: compact hackathon team hero layout
luojiyin1987 Apr 23, 2026
cb7340e
fix: normalize hackathon countdown target
luojiyin1987 Apr 23, 2026
7416884
fix: normalize hackathon team project queries
luojiyin1987 Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions components/Activity/Hackathon/Hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,30 +80,30 @@ const useCountdown = (countdownTo?: string) => {

return Number.isFinite(value) ? value : NaN;
}, [countdownTo]);
const [now, setNow] = useState<number | null>(null);
const [clientNow, setClientNow] = useState<number | null>(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);
const minutes = Math.floor((totalSeconds % 3600) / 60);
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) => {
Expand Down
6 changes: 2 additions & 4 deletions components/Activity/Hackathon/constant.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +11,7 @@ import {
formatPeriod,
normalizeAgendaType,
previewText,
userOf,
} from './utility';

export const RequiredTableKeys = [
Expand Down Expand Up @@ -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 {
Expand All @@ -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: '—' },
{
Expand Down
44 changes: 44 additions & 0 deletions components/Activity/Hackathon/useLiveCountdownState.ts
Original file line number Diff line number Diff line change
@@ -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 = <T extends CountdownWindow>(
items: T[],
startTime?: TableCellValue,
endTime?: TableCellValue,
) => {
const [referenceTime, setReferenceTime] = useState<number | null>(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,
);
Comment thread
luojiyin1987 marked this conversation as resolved.

return () => window.clearTimeout(timer);
}, [countdownState.countdownTo, referenceTime, refreshReferenceTime]);

return countdownState;
};
131 changes: 125 additions & 6 deletions components/Activity/Hackathon/utility.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
};
Comment thread
luojiyin1987 marked this conversation as resolved.

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 = <T extends CountdownWindow>(
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;

Expand Down
Loading
Loading