diff --git a/components/Activity/Hackathon/TeamMember.module.less b/components/Activity/Hackathon/TeamMember.module.less new file mode 100644 index 0000000..b02a64d --- /dev/null +++ b/components/Activity/Hackathon/TeamMember.module.less @@ -0,0 +1,45 @@ +@import './theme.less'; + +.card { + // prettier-ignore + .panel-card(); + padding: 1.35rem; +} + +.avatar { + flex-shrink: 0; + box-shadow: 0 0 24px rgba(44, 232, 255, 0.14); + border: 1px solid rgba(44, 232, 255, 0.26); + width: 4rem; + height: 4rem; +} + +.name { + margin: 0; + color: #fff; + font-weight: 700; + font-size: 1.05rem; +} + +.link { + color: @cyan; + text-decoration: none; + + &:hover { + color: #fff; + } +} + +.summary { + margin: 1rem 0 0; + color: @muted; + line-height: 1.75; +} + +.skill { + // prettier-ignore + .chip(); + padding: 0.45rem 0.72rem; + color: rgba(255, 255, 255, 0.8); + font-size: 0.82rem; +} diff --git a/components/Activity/Hackathon/TeamMember.tsx b/components/Activity/Hackathon/TeamMember.tsx new file mode 100644 index 0000000..59cdda3 --- /dev/null +++ b/components/Activity/Hackathon/TeamMember.tsx @@ -0,0 +1,48 @@ +import { Avatar } from 'idea-react'; +import { TableCellUser } from 'mobx-lark'; +import { FC } from 'react'; +import { Card } from 'react-bootstrap'; + +import { Member } from '../../../models/Hackathon'; +import styles from './TeamMember.module.less'; + +export const TeamMemberCard: FC = ({ person, githubAccount, summary, skills }) => { + const member = person as TableCellUser; + const githubName = githubAccount as string; + const memberSummary = summary as string; + const memberSkills = (skills as string[]).slice(0, 6); + + return ( + +
+ +
+

{member?.name || '-'}

+ + {githubName && ( + + @{githubName} + + )} +
+
+ + {memberSummary &&

{memberSummary}

} + + {memberSkills[0] && ( + + )} +
+ ); +}; diff --git a/components/Activity/Hackathon/constant.ts b/components/Activity/Hackathon/constant.ts index c39c078..1207e45 100644 --- a/components/Activity/Hackathon/constant.ts +++ b/components/Activity/Hackathon/constant.ts @@ -1,5 +1,4 @@ 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'; @@ -336,7 +335,6 @@ export const buildProjectItems = ( { projects, activity }: { projects: Project[]; activity: Activity }, ) => projects.map(({ id, name, score, summary, createdBy, members }) => { - const creator = createdBy as TableCellUser | undefined; const scoreText = score === null || score === undefined || score === '' ? '—' : `${score}`; return { @@ -346,11 +344,10 @@ export const buildProjectItems = ( score: scoreText, description: (summary as string) || '', meta: [ - creator + createdBy ? { label: t('created_by'), - value: creator.name || '—', - valueHref: creator.email ? `mailto:${creator.email}` : undefined, + value: (createdBy as TableCellUser)?.name || '—', } : { 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..5012d4e 100644 --- a/components/Activity/Hackathon/utility.ts +++ b/components/Activity/Hackathon/utility.ts @@ -1,5 +1,5 @@ import { TableCellValue, TableFormView } from 'mobx-lark'; -import { formatDate } from 'web-utility'; +import { Day, formatDate } from 'web-utility'; import type { HackathonScheduleTone } from './Schedule'; import { i18n, I18nKey } from '../../../models/Translation'; @@ -33,14 +33,124 @@ 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 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 +185,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,12 +205,12 @@ 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; - return Math.max(1, Math.ceil((end - start) / (24 * 60 * 60 * 1000))); + return Math.max(1, Math.ceil((end - start) / Day)); }; export const normalizeAgendaType = (value?: TableCellValue) => diff --git a/components/Activity/ProductCard.tsx b/components/Activity/ProductCard.tsx index 630cc06..6344d75 100644 --- a/components/Activity/ProductCard.tsx +++ b/components/Activity/ProductCard.tsx @@ -68,7 +68,11 @@ export const ProductCard: FC = observer( )} -