diff --git a/components/Base/CommentBox.tsx b/components/Base/CommentBox.tsx new file mode 100644 index 0000000..9958ace --- /dev/null +++ b/components/Base/CommentBox.tsx @@ -0,0 +1,23 @@ +import Giscus, { GiscusProps } from '@giscus/react'; +import { observer } from 'mobx-react'; +import { FC } from 'react'; + +import { i18n } from '../../models/Translation'; + +export const CommentBox: FC> = observer(props => { + const { currentLanguage } = i18n; + + return ( + + ); +}); diff --git a/components/Base/FileList.tsx b/components/Base/FileList.tsx new file mode 100644 index 0000000..c761618 --- /dev/null +++ b/components/Base/FileList.tsx @@ -0,0 +1,27 @@ +import { text2color } from 'idea-react'; +import { TableCellAttachment } from 'mobx-lark'; +import { observer } from 'mobx-react'; +import { FilePreview } from 'mobx-restful-table'; +import { FC, useContext } from 'react'; +import { Badge } from 'react-bootstrap'; + +import { I18nContext } from '../../models/Translation'; + +export const FileList: FC<{ data: TableCellAttachment[] }> = observer(({ data }) => { + const { t } = useContext(I18nContext); + + return ( +
+

{t('file_download')}

+
    + {data.map(({ id, name, mimeType, attachmentToken }) => ( +
  1. + + + {name} +
  2. + ))} +
+
+ ); +}); diff --git a/components/LarkImage.tsx b/components/Base/LarkImage.tsx similarity index 76% rename from components/LarkImage.tsx rename to components/Base/LarkImage.tsx index e0cfbac..121709a 100644 --- a/components/LarkImage.tsx +++ b/components/Base/LarkImage.tsx @@ -2,18 +2,14 @@ import { TableCellValue } from 'mobx-lark'; import { FC } from 'react'; import { Image, ImageProps } from 'react-bootstrap'; -import { fileURLOf } from '../models/Base'; -import { DefaultImage } from '../models/configuration'; +import { DefaultImage } from '../../utility/configuration'; +import { fileURLOf } from '../../utility/Lark'; export interface LarkImageProps extends Omit { src?: TableCellValue; } -export const LarkImage: FC = ({ - src = DefaultImage, - alt, - ...props -}) => ( +export const LarkImage: FC = ({ src = DefaultImage, alt, ...props }) => ( { + linkOf?: (value: string) => string; + list: string[]; + onCheck?: (value: string) => any; +} + +export const TagNav: FC = ({ className = '', list, linkOf, onCheck, ...props }) => ( + +); diff --git a/components/Base/ZodiacBar.tsx b/components/Base/ZodiacBar.tsx new file mode 100644 index 0000000..b54dd49 --- /dev/null +++ b/components/Base/ZodiacBar.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link'; +import { FC, ReactNode } from 'react'; + +export const ZodiacSigns = ['🐵', '🐔', '🐶', '🐷', '🐭', '🐮', '🐯', '🐰', '🐲', '🐍', '🐴', '🐐']; + +export interface ZodiacBarProps { + startYear: number; + endYear?: number; + itemOf?: (year: number, zodiac: string) => { link?: string; title?: ReactNode }; +} + +export const ZodiacBar: FC = ({ + startYear, + endYear = new Date().getFullYear(), + itemOf, +}) => ( +
    + {Array.from({ length: endYear - startYear + 1 }, (_, index) => { + const year = endYear - index; + const zodiac = ZodiacSigns[year % 12]; + const { link = '#', title } = itemOf?.(year, zodiac) || {}; + + return ( +
  1. + +
    {zodiac}
    + + {title} + +
  2. + ); + })} +
+); diff --git a/components/Department/Card.tsx b/components/Department/Card.tsx new file mode 100644 index 0000000..2810d6d --- /dev/null +++ b/components/Department/Card.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react'; + +import { Department } from '../../models/Personnel/Department'; +import { LarkImage } from '../Base/LarkImage'; +import { TagNav } from '../Base/TagNav'; + +export interface GroupCardProps + extends Pick { + className?: string; +} + +export const GroupCard: FC = ({ + className = '', + name, + logo, + tags, + summary, + email, +}) => ( +
+

+ + {name as string} + +

+ + {logo && ( + + )} + {tags && ( + `/search/department?keywords=${value}`} list={tags as string[]} /> + )} + {email && ( +
+
E-mail:
+
+ {email as string} +
+
+ )} +

+ {summary as string} +

+
+); diff --git a/components/Department/OKRCard.tsx b/components/Department/OKRCard.tsx new file mode 100644 index 0000000..f572450 --- /dev/null +++ b/components/Department/OKRCard.tsx @@ -0,0 +1,60 @@ +import { text2color } from 'idea-react'; +import { textJoin } from 'mobx-i18n'; +import { observer } from 'mobx-react'; +import { FC, useContext } from 'react'; +import { Badge, Card } from 'react-bootstrap'; +import { formatDate } from 'web-utility'; + +import { OKR } from '../../models/Governance/OKR'; +import { I18nContext } from '../../models/Translation'; + +export const OKRCard: FC = observer( + ({ + createdAt, + department, + object, + firstResult, + secondResult, + thirdResult, + planQ1, + planQ2, + planQ3, + planQ4, + }) => { + const { t } = useContext(I18nContext); + + return ( + + {object?.toString()} + + + {t('key_results')} + +
    + {[firstResult, secondResult, thirdResult].map( + result => result &&
  1. {result + ''}
  2. , + )} +
+
+ {textJoin(t('quarterly'), t('plan'))} + +
    + {[planQ1, planQ2, planQ3, planQ4].map((plan, index) => ( +
  1. + Q{index + 1}{' '} + {plan?.toString()} +
  2. + ))} +
+
+
+ + + {department + ''} + +
+ ); + }, +); diff --git a/components/Department/ReportCard.tsx b/components/Department/ReportCard.tsx new file mode 100644 index 0000000..9338ad7 --- /dev/null +++ b/components/Department/ReportCard.tsx @@ -0,0 +1,36 @@ +import { text2color } from 'idea-react'; +import { observer } from 'mobx-react'; +import { FC, useContext } from 'react'; +import { Badge, Card } from 'react-bootstrap'; +import { formatDate } from 'web-utility'; + +import { Report } from '../../models/Governance/Report'; +import { I18nContext } from '../../models/Translation'; + +export const ReportCard: FC = observer( + ({ createdAt, department, plan, progress, product, problem, meeting }) => { + const { t } = useContext(I18nContext); + + return ( + + {meeting?.toString()} + + {t('plan')} + + {t('progress')} + + {t('product')} + + {t('problem')} + + + + + {department + ''} + + + ); + }, +); diff --git a/components/Department/Tree.tsx b/components/Department/Tree.tsx new file mode 100644 index 0000000..cccf9cb --- /dev/null +++ b/components/Department/Tree.tsx @@ -0,0 +1,68 @@ +import { SVGCharts, Tooltip, TreeSeries } from 'echarts-jsx'; +import { Loading } from 'idea-react'; +import { observer } from 'mobx-react'; +import { ObservedComponent } from 'mobx-react-helper'; +import { Form } from 'react-bootstrap'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { DepartmentModel, DepartmentNode } from '../../models/Personnel/Department'; +import { i18n, I18nContext } from '../../models/Translation'; +import { GroupCard } from './Card'; + +@observer +export default class DepartmentTree extends ObservedComponent<{}, typeof i18n> { + static contextType = I18nContext; + + store = new DepartmentModel(); + + componentDidMount() { + this.store.getAll(); + } + + renderGroup(name: string) { + const group = this.store.allItems.find(({ name: n }) => n === name); + + return renderToStaticMarkup(group?.summary ? : <>); + } + + jumpLink({ name }: DepartmentNode) { + if (name === '理事会') { + location.href = '/department/board-of-directors'; + } else { + location.href = `/department/${name}`; + } + } + + render() { + const { t } = this.observedContext, + { downloading, activeShown, tree } = this.store; + + return ( + <> + {downloading > 0 && } + + + + + + + this.renderGroup(name), + }} + data={[tree]} + onClick={({ data }) => this.jumpLink(data as DepartmentNode)} + /> + + + ); + } +} diff --git a/components/Election/ElectorCard.tsx b/components/Election/ElectorCard.tsx new file mode 100644 index 0000000..2eb42b4 --- /dev/null +++ b/components/Election/ElectorCard.tsx @@ -0,0 +1,128 @@ +import { marked } from 'marked'; +import { FC, useContext } from 'react'; +import { Accordion, Badge, Card, CardProps } from 'react-bootstrap'; +import { formatDate } from 'web-utility'; + +import { Personnel } from '../../models/Personnel'; +import { I18nContext } from '../../models/Translation'; +import { LarkImage } from '../Base/LarkImage'; + +export interface ElectorCardProps extends CardProps, Omit { + order?: number; +} + +export const ElectorCard: FC = ({ + style, + createdAt, + recipient, + recipientAvatar, + applicants, + department, + position, + award, + reason, + contribution, + proposition, + recommenders, + recommendation1, + recommendation2, + approvers, + rejecters, + passed, + order, + ...props +}) => { + const { t } = useContext(I18nContext); + + return ( + + + + {(applicants as string[])?.[0] && ( + + {(applicants as string[]).join(', ')} {t('nominated')} + + )} + + + + + + + {recipient as string} + + + + {award + ? `${t('grant')} ${award as string}` + : `${t('take_charge_of')} ${department as string} ${position as string}`} + + + + + {[ + { title: t('nomination_reason'), content: reason as string }, + { + title: t('previous_term_contribution'), + content: contribution as string, + }, + { title: t('this_term_proposition'), content: proposition as string }, + { + title: `${applicants} ${t('recommendation')}`, + content: recommendation1 as string, + }, + { + title: `${recommenders} ${t('recommendation')}`, + content: recommendation2 as string, + }, + ].map( + ({ title, content }) => + content && ( + + {title} + + + + ), + )} + + + +
  • + ✔ {(approvers as string[])?.length} +
  • + {passed && ( + + {order} + + )} +
  • + ❌ {(rejecters as string[])?.length} +
  • +
    +
    + ); +}; diff --git a/components/Governance/AnnouncementCard.tsx b/components/Governance/AnnouncementCard.tsx new file mode 100644 index 0000000..40f3095 --- /dev/null +++ b/components/Governance/AnnouncementCard.tsx @@ -0,0 +1,35 @@ +import { TableCellAttachment } from 'mobx-lark'; +import { FC } from 'react'; +import { Card, CardProps } from 'react-bootstrap'; + +import { Announcement } from '../../models/Personnel/Announcement'; +import { FileList } from '../Base/FileList'; + +export type AnnouncementCardProps = Announcement & Omit; + +export const AnnouncementCard: FC = ({ + id, + title, + content, + files, + publishedAt, + ...props +}) => ( + + {title + ''} + + + {files && } + + +
    + + + +); diff --git a/components/Governance/MeetingCard.tsx b/components/Governance/MeetingCard.tsx new file mode 100644 index 0000000..d34fe1c --- /dev/null +++ b/components/Governance/MeetingCard.tsx @@ -0,0 +1,86 @@ +import { Nameplate } from 'idea-react'; +import { TableCellGroup, TableCellUser } from 'mobx-lark'; +import { observer } from 'mobx-react'; +import { FC, useContext } from 'react'; +import { Button, Card } from 'react-bootstrap'; +import { formatDate } from 'web-utility'; + +import { Meeting } from '../../models/Governance/Meeting'; +import { I18nContext } from '../../models/Translation'; +import { DefaultImage } from '../../utility/configuration'; + +export const MeetingCard: FC = observer( + ({ + title, + startedAt, + endedAt, + location, + participants, + groups, + summary, + videoCallURL, + minutesURL, + }) => { + const { t } = useContext(I18nContext); + + return ( + + + ⏱️{' '} + {' '} + ~{' '} + + + + + {title + ''} + + + {summary && {summary + ''}} + +
    + 👨‍👩‍👧‍👦 +
      + {(participants as TableCellUser[])?.map(({ id, name }) => ( +
    • + + + +
    • + ))} + {(groups as TableCellGroup[])?.map(({ id, name, avatar_url }) => ( +
    • + + + +
    • + ))} +
    +
    +
    + {videoCallURL && ( + + )} + {minutesURL && ( + + )} +
    +
    + {location && 🗺️ {location + ''}} +
    + ); + }, +); diff --git a/components/Issue/Card.tsx b/components/Issue/Card.tsx new file mode 100644 index 0000000..5ed08c0 --- /dev/null +++ b/components/Issue/Card.tsx @@ -0,0 +1,107 @@ +import { TimeDistance } from 'idea-react'; +import { marked } from 'marked'; +import { textJoin } from 'mobx-i18n'; +import { TableCellRelation } from 'mobx-lark'; +import { FC, useContext } from 'react'; +import { Card, Col, Row } from 'react-bootstrap'; + +import type { Issue } from '../../models/Governance/Issue'; +import { I18nContext } from '../../models/Translation'; +import { TagNav } from '../Base/TagNav'; +import { TimeOption } from '../data'; + +export interface IssueCardProps extends Issue { + className?: string; +} + +export const IssueCard: FC = ({ + className = '', + title, + detail, + type, + deadline, + createdBy, + createdAt, + meetings, + proposals, + department, +}) => { + const { t } = useContext(I18nContext); + + return ( + + + + + {title as string} + + + + `/search/issue?keywords=${value}`} + list={type as string[]} + /> + + {typeof deadline === 'number' && deadline > 0 ? ( + + ) : ( + '🕐' + )} + + + {textJoin(t('related'), t('department'))}: + + {department as string} + +
    + {textJoin(t('related'), t('meeting'))} +
      + {Array.isArray(meetings) && + (meetings[0] as TableCellRelation).text_arr.map((text, index) => ( +
    1. + + {text} + +
    2. + ))} +
    +
    +
    + {textJoin(t('related'), t('proposal'))} +
      + {Array.isArray(proposals) && + (proposals[0] as TableCellRelation).text_arr.map(text => ( +
    1. + {text} +
    2. + ))} +
    +
    +
    + {t('detail')} + {detail &&
    } +
    +
    + + + {createdBy as string} + + + +
    + ); +}; diff --git a/components/Issue/List.tsx b/components/Issue/List.tsx new file mode 100644 index 0000000..db3e100 --- /dev/null +++ b/components/Issue/List.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; +import { Col, Row, RowProps } from 'react-bootstrap'; + +import { Issue } from '../../models/Governance/Issue'; +import { IssueCard } from './Card'; + +export interface IssueListLayoutProps { + className?: string; + rowCols?: Pick; + defaultData: Issue[]; +} + +export const IssueListLayout: FC = ({ + className = 'g-3 my-4', + rowCols = { xs: 1, sm: 2, xl: 3 }, + defaultData, +}) => ( + + {defaultData.map(item => ( + + + + ))} + +); diff --git a/components/Layout/PageHead.tsx b/components/Layout/PageHead.tsx index 896b0af..8400b51 100644 --- a/components/Layout/PageHead.tsx +++ b/components/Layout/PageHead.tsx @@ -1,18 +1,14 @@ import Head from 'next/head'; import type { FC, PropsWithChildren } from 'react'; -import { Name, Summary } from '../../models/configuration'; +import { Name, Summary } from '../../utility/configuration'; export type PageHeadProps = PropsWithChildren<{ title?: string; description?: string; }>; -export const PageHead: FC = ({ - title, - description = Summary, - children, -}) => ( +export const PageHead: FC = ({ title, description = Summary, children }) => ( {`${title ? `${title} - ` : ''}${Name}`} diff --git a/components/Member/Card.module.less b/components/Member/Card.module.less new file mode 100644 index 0000000..7dbe671 --- /dev/null +++ b/components/Member/Card.module.less @@ -0,0 +1,63 @@ +.member { + flex: none; + content-visibility: auto; + contain-intrinsic-height: 10rem; + + .avatar { + transition: all 0.3s; + width: 6rem; + height: 6rem; + object-fit: contain; + } + .btn_tamaya { + width: 6rem; + height: 1rem; + line-height: 1rem; + &::before, + &::after { + position: absolute; + left: 0; + transition: transform 0.5s; + transition-timing-function: cubic-bezier(0.2, 1, 0.3, 1); + width: 6rem; + height: 0.5rem; + overflow: hidden; + content: attr(data-text); + color: #333; + text-overflow: ellipsis; + white-space: nowrap; + } + &::before { + top: 0; + } + &::after { + bottom: 0; + line-height: 0; + } + & > i { + transform: scale3d(0.2, 0.2, 1); + opacity: 0; + transition: + transform 0.5s, + opacity 0.5s; + transition-timing-function: cubic-bezier(0.2, 1, 0.3, 1); + } + } + &:hover { + .avatar { + transform: scale(1.05) translate3d(-1px, -5px, -4px); + } + .btn_tamaya { + &::before { + transform: translate3d(0, -120%, 0); + } + &::after { + transform: translate3d(0, 120%, 0); + } + & > i { + transform: scale3d(1, 1, 1); + opacity: 1; + } + } + } +} diff --git a/components/Member/Card.tsx b/components/Member/Card.tsx new file mode 100644 index 0000000..80f4869 --- /dev/null +++ b/components/Member/Card.tsx @@ -0,0 +1,39 @@ +import { TableCellValue } from 'mobx-lark'; +import { observer } from 'mobx-react'; +import { FC, useContext } from 'react'; + +import { I18nContext } from '../../models/Translation'; +import { LarkImage } from '../Base/LarkImage'; +import styles from './Card.module.less'; + +export interface MemberCardProps extends Partial> { + avatar?: TableCellValue; +} + +export const MemberCard: FC = observer(({ name, nickname, avatar }) => { + const { t } = useContext(I18nContext); + + return ( + + ); +}); diff --git a/components/Member/Group.tsx b/components/Member/Group.tsx new file mode 100644 index 0000000..b750d7d --- /dev/null +++ b/components/Member/Group.tsx @@ -0,0 +1,45 @@ +import 'array-unique-proposal'; + +import { observer } from 'mobx-react'; +import { FC, useContext } from 'react'; +import { TimeData } from 'web-utility'; + +import { Personnel } from '../../models/Personnel'; +import { I18nContext } from '../../models/Translation'; +import { MemberCard } from './Card'; +import { MemberTitle } from './Title'; + +export interface MemberGroupProps { + name: string; + list: Personnel[]; +} + +export const MemberGroup: FC = observer(({ name, list }) => { + const { t } = useContext(I18nContext); + + list = list.uniqueBy(({ recipient }) => recipient + ''); + + return ( +
    + +
      + {list.map(({ id, createdAt, position, recipient, recipientAvatar }) => ( +
    • + +
    • + ))} +
    +
    + ); +}); diff --git a/components/Member/Title.module.less b/components/Member/Title.module.less new file mode 100644 index 0000000..ca58b56 --- /dev/null +++ b/components/Member/Title.module.less @@ -0,0 +1,7 @@ +.memberTitle::before { + display: block; + margin-top: -88px; + width: 100%; + height: 88px; + content: ''; +} diff --git a/components/Member/Title.tsx b/components/Member/Title.tsx new file mode 100644 index 0000000..e766c0e --- /dev/null +++ b/components/Member/Title.tsx @@ -0,0 +1,32 @@ +import { FC, HTMLAttributes } from 'react'; +import { Badge } from 'react-bootstrap'; + +import { i18n } from '../../models/Translation'; +import styles from './Title.module.less'; + +export interface MemberTitleProps extends HTMLAttributes { + count?: number; +} + +export const MemberTitle: FC = ({ + className = '', + title = i18n.t('unclassified'), + count = 0, + ...props +}) => ( +

    + + {title} + + + {count} + +

    +); diff --git a/components/Navigator/MainNavigator.tsx b/components/Navigator/MainNavigator.tsx index f2332ca..6fb1b95 100644 --- a/components/Navigator/MainNavigator.tsx +++ b/components/Navigator/MainNavigator.tsx @@ -3,8 +3,8 @@ import dynamic from 'next/dynamic'; import { FC, useContext } from 'react'; import { Container, Nav, Navbar } from 'react-bootstrap'; -import { Name } from '../../models/configuration'; import { I18nContext } from '../../models/Translation'; +import { Name } from '../../utility/configuration'; const LanguageMenu = dynamic(() => import('./LanguageMenu'), { ssr: false }); @@ -12,13 +12,7 @@ export const MainNavigator: FC = observer(() => { const { t } = useContext(I18nContext); return ( - + {Name} diff --git a/components/Proposal/Card.tsx b/components/Proposal/Card.tsx new file mode 100644 index 0000000..573f914 --- /dev/null +++ b/components/Proposal/Card.tsx @@ -0,0 +1,85 @@ +import { TimeDistance } from 'idea-react'; +import { textJoin } from 'mobx-i18n'; +import { TableCellRelation } from 'mobx-lark'; +import { FC, useContext } from 'react'; +import { Card } from 'react-bootstrap'; + +import type { Proposal } from '../../models/Governance/Proposal'; +import { I18nContext } from '../../models/Translation'; +import { TimeOption } from '../data'; + +export interface ProposalCardProps extends Proposal { + className?: string; +} + +export const ProposalCard: FC = ({ + className = '', + title, + contentURL, + createdBy, + createdAt, + meetings, + issues, +}) => { + const { t } = useContext(I18nContext); + + return ( + + + + + {title as string} + + +
    + {textJoin(t('related'), t('meeting'))} +
      + {Array.isArray(meetings) && + (meetings[0] as TableCellRelation).text_arr.map((text, index) => ( +
    1. + + {text} + +
    2. + ))} +
    +
    +
    + {textJoin(t('related'), t('issue'))} +
      + {Array.isArray(issues) && + (issues[0] as TableCellRelation).text_arr.map(text => ( +
    1. + {text} +
    2. + ))} +
    +
    +
    + + + {createdBy as string} + + + +
    + ); +}; diff --git a/components/Proposal/List.tsx b/components/Proposal/List.tsx new file mode 100644 index 0000000..a328e13 --- /dev/null +++ b/components/Proposal/List.tsx @@ -0,0 +1,25 @@ +import type { FC } from 'react'; +import { Col, Row, RowProps } from 'react-bootstrap'; + +import type { Proposal } from '../../models/Governance/Proposal'; +import { ProposalCard } from './Card'; + +export interface ProposalListProps { + className?: string; + rowCols?: Pick; + defaultData: Proposal[]; +} + +export const ProposalListLayout: FC = ({ + className = 'g-3 my-4', + rowCols = { xs: 1, sm: 2, xl: 3 }, + defaultData, +}) => ( + + {defaultData.map(item => ( + + + + ))} + +); diff --git a/components/data.ts b/components/data.ts new file mode 100644 index 0000000..748b3ae --- /dev/null +++ b/components/data.ts @@ -0,0 +1,16 @@ +import { TimeDistanceProps } from 'idea-react'; + +export const TimeOption: Pick = { + unitWords: { + ms: '毫秒', + s: '秒', + m: '分', + H: '时', + D: '日', + W: '周', + M: '月', + Y: '年', + }, + beforeWord: '前', + afterWord: '后', +}; diff --git a/models/Base.ts b/models/Base.ts index 729d702..0e31cb5 100644 --- a/models/Base.ts +++ b/models/Base.ts @@ -2,11 +2,8 @@ import 'core-js/full/array/from-async'; import { HTTPClient } from 'koajax'; import { githubClient, RepositoryModel } from 'mobx-github'; -import { TableCellAttachment, TableCellMedia, TableCellValue } from 'mobx-lark'; -import { DataObject } from 'mobx-restful'; -import { buildURLData } from 'web-utility'; -import { GITHUB_TOKEN, LARK_API_HOST } from './configuration'; +import { GITHUB_TOKEN, LARK_API_HOST } from '../utility/configuration'; export const larkClient = new HTTPClient({ baseURI: LARK_API_HOST, @@ -42,36 +39,3 @@ export async function upload(file: Blob) { return body!.location; } - -export function fileURLOf(field: TableCellValue, cache = false) { - if (!(field instanceof Array) || !field[0]) return field + ''; - - const file = field[0] as TableCellMedia | TableCellAttachment; - - let URI = `/api/Lark/file/${'file_token' in file ? file.file_token : file.attachmentToken}/${file.name}`; - - if (cache) URI += '?cache=1'; - - return URI; -} - -export const prefillForm = (data: DataObject) => - buildURLData( - Object.entries(data).map(([key, value]) => [`prefill_${key}`, value]), - ); - -export const wrapTime = (date?: TableCellValue) => - date ? +new Date(date as string) : undefined; - -export const wrapURL = (link?: TableCellValue) => - link ? { link, text: link } : undefined; - -export const wrapFile = (URIs?: TableCellValue) => - (Array.isArray(URIs) ? URIs : [URIs]) - .map( - URI => typeof URI === 'string' && { file_token: URI.split('/').at(-2) }, - ) - .filter(Boolean) as TableCellValue; - -export const wrapRelation = (ID?: TableCellValue) => - ID ? (Array.isArray(ID) ? ID : ([ID] as TableCellValue)) : undefined; diff --git a/models/Document.ts b/models/Document.ts index c856f4f..b67bfb7 100644 --- a/models/Document.ts +++ b/models/Document.ts @@ -1,7 +1,7 @@ import { DocumentModel } from 'mobx-lark'; import { lark } from '../pages/api/Lark/core'; -import { LarkWikiDomain } from './configuration'; +import { LarkWikiDomain } from '../utility/configuration'; export class MyDocumentModel extends DocumentModel { client = lark.client; diff --git a/models/Governance/Issue.ts b/models/Governance/Issue.ts new file mode 100644 index 0000000..ffa0983 --- /dev/null +++ b/models/Governance/Issue.ts @@ -0,0 +1,52 @@ +import { + BiDataTable, + normalizeText, + normalizeTextArray, + TableCellRelation, + TableCellText, + TableCellUser, + TableCellValue, + TableRecord, +} from 'mobx-lark'; + +import { larkClient } from '../Base'; + +export const GOVERNANCE_BASE_ID = process.env.NEXT_PUBLIC_GOVERNANCE_BASE_ID!, + ISSUE_TABLE_ID = process.env.NEXT_PUBLIC_ISSUE_TABLE_ID!; + +export type Issue = Record< + | 'id' + | 'title' + | 'detail' + | 'type' + | 'deadline' + | `created${'At' | 'By'}` + | 'department' + | 'proposals' + | 'meetings', + TableCellValue +>; + +export class IssueModel extends BiDataTable() { + client = larkClient; + + constructor(appId = GOVERNANCE_BASE_ID, tableId = ISSUE_TABLE_ID) { + super(appId, tableId); + } + + extractFields({ + fields: { title, type, createdBy, department, ...fields }, + ...meta + }: TableRecord) { + return { + ...meta, + ...fields, + title: normalizeTextArray(title as TableCellText[])?.[0] || '', + type: (type as string)?.trim().split(/\s+/), + createdBy: (createdBy as TableCellUser)?.name || '', + department: (department as TableCellRelation[])?.map(normalizeText), + }; + } +} + +export default new IssueModel(); diff --git a/models/Governance/Meeting.ts b/models/Governance/Meeting.ts new file mode 100644 index 0000000..4179a3e --- /dev/null +++ b/models/Governance/Meeting.ts @@ -0,0 +1,89 @@ +import { + BiDataTable, + BiSearch, + normalizeText, + normalizeTextArray, + TableCellLink, + TableCellRelation, + TableCellText, + TableCellValue, + TableRecord, +} from 'mobx-lark'; + +import { larkClient } from '../Base'; +import { GOVERNANCE_BASE_ID } from './OKR'; + +export type Meeting = Record< + | 'id' + | 'name' + | 'title' + | 'type' + | 'departments' + | 'startedAt' + | 'endedAt' + | 'location' + | 'participants' + | 'groups' + | 'summary' + | 'videoCallURL' + | 'minutesURL' + | 'issues' + | 'proposals' + | 'reports', + TableCellValue +>; + +export const MEETING_TABLE_ID = process.env.NEXT_PUBLIC_MEETING_TABLE_ID!; + +export class MeetingModel extends BiDataTable() { + client = larkClient; + + constructor(appId = GOVERNANCE_BASE_ID, tableId = MEETING_TABLE_ID) { + super(appId, tableId); + } + + sort = { startedAt: 'DESC' } as const; + + extractFields({ + fields: { + title, + departments, + summary, + videoCallURL, + minutesURL, + issues, + proposals, + reports, + ...fields + }, + ...meta + }: TableRecord) { + return { + ...meta, + ...fields, + title: (title as TableCellText[]).map(normalizeText), + departments: (departments as TableCellRelation[])?.map(normalizeText), + summary: summary && normalizeTextArray(summary as TableCellText[]), + videoCallURL: (videoCallURL as TableCellLink)?.link, + minutesURL: (minutesURL as TableCellLink)?.link, + issues: (issues as TableCellRelation[])?.map(normalizeText), + proposals: (proposals as TableCellRelation[])?.map(normalizeText), + reports: (reports as TableCellRelation[])?.map(normalizeText), + }; + } +} + +export class SearchMeetingModel extends BiSearch(MeetingModel) { + searchKeys = [ + 'name', + 'title', + 'departments', + 'location', + 'participants', + 'groups', + 'summary', + 'issues', + 'proposals', + 'reports', + ] as const; +} diff --git a/models/Governance/OKR.ts b/models/Governance/OKR.ts new file mode 100644 index 0000000..e077b62 --- /dev/null +++ b/models/Governance/OKR.ts @@ -0,0 +1,73 @@ +import { + BiDataTable, + makeSimpleFilter, + normalizeText, + normalizeTextArray, + TableCellRelation, + TableCellText, + TableCellValue, + TableRecord, +} from 'mobx-lark'; +import { NewData } from 'mobx-restful'; +import { isEmpty } from 'web-utility'; + +import { larkClient } from '../Base'; + +export const GOVERNANCE_BASE_ID = process.env.NEXT_PUBLIC_GOVERNANCE_BASE_ID!, + OKR_TABLE_ID = process.env.NEXT_PUBLIC_OKR_TABLE_ID!; + +export type OKR = Record< + | 'id' + | `created${'At' | 'By'}` + | 'year' + | 'department' + | 'object' + | `${'first' | 'second' | 'third'}Result` + | `planQ${1 | 2 | 3 | 4}` + | 'budget', + TableCellValue +>; + +export class OKRModel extends BiDataTable() { + client = larkClient; + + constructor(appId = GOVERNANCE_BASE_ID, tableId = OKR_TABLE_ID) { + super(appId, tableId); + } + + makeFilter({ year, ...filter }: Partial>) { + return [`CurrentValue.[year]=${year}`, !isEmpty(filter) && makeSimpleFilter(filter)] + .filter(Boolean) + .join('&&'); + } + + extractFields({ + fields: { + department, + object, + firstResult, + secondResult, + thirdResult, + planQ1, + planQ2, + planQ3, + planQ4, + ...fields + }, + ...meta + }: TableRecord) { + return { + ...meta, + ...fields, + department: (department as TableCellRelation[])?.map(normalizeText), + object: object && normalizeTextArray(object as TableCellText[]), + firstResult: firstResult && normalizeTextArray(firstResult as TableCellText[]), + secondResult: secondResult && normalizeTextArray(secondResult as TableCellText[]), + thirdResult: thirdResult && normalizeTextArray(thirdResult as TableCellText[]), + planQ1: planQ1 && normalizeTextArray(planQ1 as TableCellText[]), + planQ2: planQ2 && normalizeTextArray(planQ2 as TableCellText[]), + planQ3: planQ3 && normalizeTextArray(planQ3 as TableCellText[]), + planQ4: planQ4 && normalizeTextArray(planQ4 as TableCellText[]), + }; + } +} diff --git a/models/Governance/Proposal.ts b/models/Governance/Proposal.ts new file mode 100644 index 0000000..9ce0400 --- /dev/null +++ b/models/Governance/Proposal.ts @@ -0,0 +1,50 @@ +import { + BiDataTable, + normalizeTextArray, + TableCellLink, + TableCellText, + TableCellUser, + TableCellValue, + TableRecord, +} from 'mobx-lark'; + +import { larkClient } from '../Base'; + +export const GOVERNANCE_BASE_ID = process.env.NEXT_PUBLIC_GOVERNANCE_BASE_ID!; +export const PROPOSAL_TABLE_ID = process.env.NEXT_PUBLIC_PROPOSAL_TABLE_ID!; + +export type Proposal = Record< + | 'id' + | 'title' + | 'types' + | 'issues' + | 'contentURL' + | `created${'At' | 'By'}` + | 'meetings' + | 'voteURL' + | 'passed', + TableCellValue +>; + +export class ProposalModel extends BiDataTable() { + client = larkClient; + + constructor(appId = GOVERNANCE_BASE_ID, tableId = PROPOSAL_TABLE_ID) { + super(appId, tableId); + } + + extractFields({ + fields: { title, contentURL, createdBy, ...fields }, + ...meta + }: TableRecord) { + return { + ...meta, + ...fields, + title: normalizeTextArray(title as TableCellText[])?.[0] || '', + contentURL: (contentURL as TableCellLink)?.link || '', + createdBy: (createdBy as TableCellUser)?.name || '', + }; + } +} + +export default new ProposalModel(); diff --git a/models/Governance/Report.ts b/models/Governance/Report.ts new file mode 100644 index 0000000..f1d1ba1 --- /dev/null +++ b/models/Governance/Report.ts @@ -0,0 +1,51 @@ +import { + BiDataTable, + normalizeText, + TableCellRelation, + TableCellText, + TableCellValue, + TableRecord, +} from 'mobx-lark'; + +import { normalizeMarkdownArray } from '../../utility/Lark'; +import { larkClient } from '../Base'; +import { GOVERNANCE_BASE_ID } from './OKR'; + +export type Report = Record< + | 'id' + | `created${'At' | 'By'}` + | 'summary' + | 'department' + | 'plan' + | 'progress' + | 'product' + | 'problem' + | 'meeting', + TableCellValue +>; + +export const REPORT_TABLE_ID = process.env.NEXT_PUBLIC_REPORT_TABLE_ID!; + +export class ReportModel extends BiDataTable() { + client = larkClient; + + constructor(appId = GOVERNANCE_BASE_ID, tableId = REPORT_TABLE_ID) { + super(appId, tableId); + } + + extractFields({ + fields: { department, plan, progress, product, problem, meeting, ...fields }, + ...meta + }: TableRecord) { + return { + ...meta, + ...fields, + department: (department as TableCellRelation[])?.map(normalizeText), + plan: plan && normalizeMarkdownArray(plan as TableCellText[]), + progress: progress && normalizeMarkdownArray(progress as TableCellText[]), + product: product && normalizeMarkdownArray(product as TableCellText[]), + problem: problem && normalizeMarkdownArray(problem as TableCellText[]), + meeting: (meeting as TableCellRelation[])?.map(normalizeText), + }; + } +} diff --git a/models/Personnel/Announcement.ts b/models/Personnel/Announcement.ts new file mode 100644 index 0000000..0f27ec4 --- /dev/null +++ b/models/Personnel/Announcement.ts @@ -0,0 +1,49 @@ +import { + BiDataTable, + normalizeText, + TableCellRelation, + TableCellText, + TableCellValue, + TableRecord, +} from 'mobx-lark'; + +import { normalizeMarkdownArray } from '../../utility/Lark'; +import { larkClient } from '../Base'; +import { HR_BASE_ID } from './Person'; + +export type Announcement = Record< + | 'id' + | 'createdAt' + | 'createdBy' + | 'title' + | 'tags' + | 'departments' + | 'content' + | 'files' + | 'emails' + | 'publishedAt', + TableCellValue +>; + +export const ANNOUNCEMENT_TABLE_ID = process.env.NEXT_PUBLIC_ANNOUNCEMENT_TABLE_ID!; + +export class AnnouncementModel extends BiDataTable() { + client = larkClient; + + constructor(appId = HR_BASE_ID, tableId = ANNOUNCEMENT_TABLE_ID) { + super(appId, tableId); + } + + extractFields({ + fields: { title, content, departments, ...fields }, + ...meta + }: TableRecord) { + return { + ...meta, + ...fields, + title: (title as TableCellText[]).map(normalizeText), + content: normalizeMarkdownArray(content as TableCellText[]), + departments: (departments as TableCellRelation[]).map(normalizeText), + }; + } +} diff --git a/models/Personnel/Department.ts b/models/Personnel/Department.ts new file mode 100644 index 0000000..b769a15 --- /dev/null +++ b/models/Personnel/Department.ts @@ -0,0 +1,96 @@ +import { computed, observable } from 'mobx'; +import { + BiDataQueryOptions, + BiDataTable, + BiSearch, + normalizeText, + TableCellLink, + TableCellRelation, + TableCellText, + TableCellValue, + TableRecord, +} from 'mobx-lark'; + +import { larkClient } from '../Base'; +import { HR_BASE_ID } from './Person'; + +export const DEPARTMENT_TABLE_ID = process.env.NEXT_PUBLIC_DEPARTMENT_TABLE_ID!; + +export type Department = Record< + | 'id' + | 'name' + | 'tags' + | 'startDate' + | 'summary' + | 'superior' + | 'document' + | 'email' + | 'link' + | 'codeLink' + | 'logo' + | 'active', + TableCellValue +>; + +export interface DepartmentNode extends Record { + // eslint-disable-next-line no-restricted-syntax + children: DepartmentNode[]; + collapsed: boolean; +} + +export class DepartmentModel extends BiDataTable() { + client = larkClient; + + constructor(appId = HR_BASE_ID, tableId = DEPARTMENT_TABLE_ID) { + super(appId, tableId); + } + + requiredKeys = ['name'] as const; + + queryOptions: BiDataQueryOptions = { text_field_as_array: false }; + + @observable + accessor activeShown = true; + + @computed + get tree() { + const { activeShown } = this; + let rootName = ''; + + const tempList = this.allItems.map(({ name, ...meta }) => [ + name, + { ...meta, name, children: [], collapsed: false }, + ]) as [string, DepartmentNode][]; + + const tempMap = Object.fromEntries(tempList) as Record; + + for (const [name, node] of tempList) + if (!name.endsWith('组') || (activeShown ? node.active : !node.active)) { + const superChildrenLength = tempMap[node.superior]?.children.push(node); + + if (superChildrenLength === undefined) rootName = name; + } + + return tempMap[rootName] || {}; + } + + extractFields({ + id, + fields: { superior, link, codeLink, active, ...fields }, + }: TableRecord): Department { + return { + ...fields, + id: id!, + superior: (superior as TableCellRelation[])?.map(normalizeText)[0], + link: (link as TableCellLink)?.link, + codeLink: (codeLink as TableCellLink)?.link, + active: JSON.parse((active as TableCellText[])!.map(normalizeText) + ''), + }; + } + + toggleActive = () => (this.activeShown = !this.activeShown); +} + +export class SearchDepartmentModel extends BiSearch(DepartmentModel) { + searchKeys = ['name', 'summary', 'email', 'link', 'codeLink'] as const; +} diff --git a/models/Personnel/Election.ts b/models/Personnel/Election.ts new file mode 100644 index 0000000..ad89605 --- /dev/null +++ b/models/Personnel/Election.ts @@ -0,0 +1,80 @@ +import { observable } from 'mobx'; +import { BaseModel, DataObject, persist, restore, toggle } from 'mobx-restful'; + +import { isServer } from '../../utility/configuration'; +import userStore from '../User'; + +export const buffer2hex = (buffer: ArrayBufferLike) => + Array.from(new Uint8Array(buffer), x => x.toString(16).padStart(2, '0')).join(''); + +export class ElectionModel extends BaseModel { + client = userStore.client; + algorithm = { name: 'ECDSA', namedCurve: 'P-384', hash: { name: 'SHA-256' } }; + + @persist() + @observable + accessor privateKey: CryptoKey | undefined; + + @persist() + @observable + accessor publicKey = ''; + + @persist() + @observable + accessor ticketMap = {} as Record; + + restored = !isServer() && restore(this, 'Electron'); + + @toggle('uploading') + async makePublicKey() { + await this.restored; + + if (this.publicKey) return this.publicKey; + + const { publicKey, privateKey } = await crypto.subtle.generateKey(this.algorithm, true, [ + 'sign', + 'verify', + ]); + this.privateKey = privateKey; + + const JWK = await crypto.subtle.exportKey('jwk', publicKey); + + return (this.publicKey = btoa(JSON.stringify(JWK))); + } + + @toggle('uploading') + async savePublicKey(electionName: string, jsonWebKey = this.publicKey) { + await userStore.restored; + + const { body } = await this.client.post(`election/${electionName}/public-key`, { jsonWebKey }); + + return body!; + } + + @toggle('uploading') + async signVoteTicket(electionName: string) { + await this.restored; + + let ticket = this.ticketMap[electionName]; + + if (ticket) return ticket; + + if (!this.publicKey) await this.makePublicKey(); + + await this.savePublicKey(electionName); + + const signature = await crypto.subtle.sign( + this.algorithm, + this.privateKey!, + new TextEncoder().encode(electionName), + ); + ticket = { + electionName, + publicKey: this.publicKey, + signature: buffer2hex(signature), + }; + this.ticketMap = { ...this.ticketMap, [electionName]: ticket }; + + return ticket; + } +} diff --git a/models/Personnel/Person.ts b/models/Personnel/Person.ts new file mode 100644 index 0000000..8ac0e1f --- /dev/null +++ b/models/Personnel/Person.ts @@ -0,0 +1,50 @@ +import { + BiDataQueryOptions, + BiDataTable, + BiSearch, + TableCellLink, + TableCellValue, + TableRecord, +} from 'mobx-lark'; + +import { larkClient } from '../Base'; + +export type Person = Record< + | 'id' + | 'name' + | 'gender' + | 'avatar' + | 'city' + | 'mobilePhone' + | 'email' + | 'website' + | 'github' + | 'skills' + | 'summary', + TableCellValue +>; + +export const HR_BASE_ID = process.env.NEXT_PUBLIC_HR_BASE_ID!, + PERSON_TABLE_ID = process.env.NEXT_PUBLIC_PERSON_TABLE_ID!; + +export class PersonModel extends BiDataTable() { + client = larkClient; + queryOptions: BiDataQueryOptions = { text_field_as_array: false }; + + constructor(appId = HR_BASE_ID, tableId = PERSON_TABLE_ID) { + super(appId, tableId); + } + + extractFields({ id, fields: { website, github, ...fields } }: TableRecord) { + return { + ...fields, + id: id!, + website: (website as TableCellLink)?.link, + github: (github as TableCellLink)?.link, + }; + } +} + +export class SearchPersonModel extends BiSearch(PersonModel) { + searchKeys = ['name', 'summary', 'city', 'email', 'website', 'github', 'skills'] as const; +} diff --git a/models/Personnel/index.ts b/models/Personnel/index.ts new file mode 100644 index 0000000..f8d9e9e --- /dev/null +++ b/models/Personnel/index.ts @@ -0,0 +1,153 @@ +import { observable } from 'mobx'; +import { + BiDataQueryOptions, + BiDataTable, + makeSimpleFilter, + normalizeText, + TableCellAttachment, + TableCellMedia, + TableCellRelation, + TableCellText, + TableCellValue, + TableRecord, +} from 'mobx-lark'; +import { NewData } from 'mobx-restful'; +import { groupBy, isEmpty, TimeData } from 'web-utility'; + +import { larkClient } from '../Base'; +import { HR_BASE_ID } from './Person'; + +export type Personnel = Record< + | 'id' + | 'createdAt' + | 'overview' + | 'type' + | 'applicants' + | 'recipient' + | 'recipientAvatar' + | 'department' + | 'position' + | 'award' + | 'reason' + | 'contribution' + | 'proposition' + | 'recommenders' + | `recommendation${1 | 2}` + | 'approvers' + | 'rejecters' + | 'score' + | 'passed', + TableCellValue +>; + +export const PERSONNEL_TABLE_ID = process.env.NEXT_PUBLIC_PERSONNEL_TABLE_ID!; + +export type ElectionTarget = '理事' | '正式成员'; + +export class PersonnelModel extends BiDataTable() { + client = larkClient; + + constructor(appId = HR_BASE_ID, tableId = PERSONNEL_TABLE_ID) { + super(appId, tableId); + } + + requiredKeys = ['recipient'] as const; + + sort = { createdAt: 'DESC' } as const; + + queryOptions: BiDataQueryOptions = { text_field_as_array: false }; + + @observable + accessor group: Record = {}; + + currentYear?: number; + + makeFilter({ position, passed, ...filter }: NewData) { + const { currentYear } = this; + + return [ + currentYear && `CurrentValue.[createdAt]>=TODATE("${currentYear}-01-01")`, + currentYear && `CurrentValue.[createdAt]<=TODATE("${currentYear}-12-31")`, + position && makeSimpleFilter({ position }, '=', 'OR'), + passed && `CurrentValue.[passed]=${passed}`, + !isEmpty(filter) && makeSimpleFilter(filter), + ] + .filter(Boolean) + .join('&&'); + } + + extractFields({ + id, + fields: { + overview, + applicants, + recipient, + recipientAvatar, + department, + position, + award, + recommenders, + approvers, + rejecters, + passed, + ...fields + }, + }: TableRecord) { + return { + ...fields, + id: id!, + overview: (overview as TableCellRelation[]).map(normalizeText), + applicants: (applicants as TableCellRelation[])?.map(normalizeText), + recipient: (recipient as TableCellRelation[])?.map(normalizeText)[0], + recipientAvatar: (recipientAvatar as TableCellAttachment[])?.map( + ({ attachmentToken, ...file }) => + ({ + ...file, + file_token: attachmentToken, + }) as unknown as TableCellMedia, + ), + department: (department as TableCellRelation[])?.map(normalizeText)[0], + position: (position as TableCellRelation[])?.map(normalizeText)[0], + award: (award as TableCellRelation[])?.map(normalizeText)[0], + recommenders: (recommenders as TableCellRelation[])?.map(normalizeText), + approvers: (approvers as TableCellText[]) + ?.map(normalizeText) + .toString() + .split(',') + .filter(Boolean), + rejecters: (rejecters as TableCellText[]) + ?.map(normalizeText) + .toString() + .split(',') + .filter(Boolean), + passed: JSON.parse(normalizeText((passed as TableCellText[])[0])), + }; + } + + async getGroup( + filter: Partial> = {}, + groupKeys: (keyof Personnel)[] = [], + year?: number, + ) { + this.currentYear = year; + + try { + return (this.group = groupBy(await this.getAll(filter), data => { + for (const key of groupKeys) if (data[key] != null) return data[key] as string; + + return ''; + })); + } finally { + this.currentYear = undefined; + } + } + + async getYearGroup(filter: Partial>, groupKeys: (keyof Personnel)[]) { + return (this.group = groupBy(await this.getAll(filter), data => { + for (const key of groupKeys) + if (data[key] != null) return new Date(data[key] as TimeData).getFullYear(); + + return 0; + })); + } +} diff --git a/models/System.ts b/models/System.ts index 559a99d..16f4b71 100644 --- a/models/System.ts +++ b/models/System.ts @@ -1,13 +1,55 @@ +import { observable } from 'mobx'; import { BiSearchModelClass } from 'mobx-lark'; -import { BaseModel } from 'mobx-restful'; +import { BaseModel, toggle } from 'mobx-restful'; +import { parseURLData, URLData } from 'web-utility'; + +import { larkClient } from './Base'; export type SearchPageMeta = Pick< InstanceType, 'pageIndex' | 'currentPage' | 'pageCount' >; +export type CityCoordinateMap = Record; export class SystemModel extends BaseModel { searchMap: Record = {}; + + @observable + accessor hashQuery: URLData = {}; + + @observable + accessor screenNarrow = false; + + @observable + accessor cityCoordinate: CityCoordinateMap = {}; + + constructor() { + super(); + + this.updateHashQuery(); + this.updateScreen(); + + globalThis.addEventListener?.('hashchange', this.updateHashQuery); + globalThis.addEventListener?.('resize', this.updateScreen); + } + + updateHashQuery = () => + (this.hashQuery = parseURLData( + globalThis.location?.hash.split('?')[1] || '', + ) as URLData); + + updateScreen = () => + (this.screenNarrow = + globalThis.innerWidth < globalThis.innerHeight || globalThis.innerWidth < 992); + + @toggle('downloading') + async getCityCoordinate() { + const { body } = await larkClient.get( + 'https://open-source-bazaar.github.io/public-meta-data/china-city-coordinate.json', + ); + + return (this.cityCoordinate = body!); + } } export default new SystemModel(); diff --git a/models/User.ts b/models/User.ts new file mode 100644 index 0000000..bf5e50c --- /dev/null +++ b/models/User.ts @@ -0,0 +1,51 @@ +import { clear } from 'idb-keyval'; +import { HTTPClient } from 'koajax'; +import { observable } from 'mobx'; +import { BaseListModel, DataObject, persist, restore, toggle } from 'mobx-restful'; +import { sleep } from 'web-utility'; + +import { API_Host, isServer } from '../utility/configuration'; + +export type User = DataObject; +export type SignInData = DataObject; + +export class UserModel extends BaseListModel { + baseURI = 'user'; + restored = !isServer() && restore(this, 'User'); + + @persist() + @observable + accessor session: User | undefined; + + client = new HTTPClient({ + baseURI: `${API_Host}/api/`, + responseType: 'json', + }).use(async ({ request }, next) => { + await this.restored; + + if (this.session?.token) + request.headers = { + ...request.headers, + Authorization: `Bearer ${this.session.token}`, + }; + + return next(); + }); + + @toggle('uploading') + async signIn(data: SignInData) { + const { body } = await this.client.post(`${this.baseURI}/session`, data); + + return (this.session = body!); + } + + async signOut() { + this.session = undefined; + + await sleep(1); + + await clear(); + } +} + +export default new UserModel(); diff --git a/models/Wiki.ts b/models/Wiki.ts index 9a5772d..941b2b5 100644 --- a/models/Wiki.ts +++ b/models/Wiki.ts @@ -1,7 +1,7 @@ import { WikiNodeModel } from 'mobx-lark'; import { lark } from '../pages/api/Lark/core'; -import { LarkWikiDomain, LarkWikiId } from './configuration'; +import { LarkWikiDomain, LarkWikiId } from '../utility/configuration'; export class MyWikiNodeModel extends WikiNodeModel { client = lark.client; diff --git a/package.json b/package.json index a3967ee..6097858 100644 --- a/package.json +++ b/package.json @@ -15,15 +15,19 @@ "@editorjs/list": "^2.0.8", "@editorjs/paragraph": "^2.11.7", "@editorjs/quote": "^2.7.6", + "@giscus/react": "^3.1.0", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@next/mdx": "^15.5.2", "@sentry/nextjs": "^10.9.0", + "array-unique-proposal": "^0.3.4", "copy-webpack-plugin": "^13.0.1", "core-js": "^3.45.1", + "echarts-jsx": "^0.5.4", "editorjs-html": "^4.0.5", "file-type": "^21.0.0", "formidable": "^3.5.4", + "idb-keyval": "^6.2.2", "idea-react": "^2.0.0-rc.13", "jsonwebtoken": "^9.0.2", "koa": "^3.0.1", @@ -47,6 +51,7 @@ "next-ssr-middleware": "^1.0.3", "next-with-less": "^3.0.1", "prismjs": "^1.30.0", + "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-bootstrap": "^2.10.10", "react-bootstrap-editor": "^2.1.1", @@ -80,6 +85,7 @@ "@types/next-pwa": "^5.6.9", "@types/node": "^22.18.0", "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "eslint": "^9.34.0", "eslint-config-next": "^15.5.2", "eslint-config-prettier": "^10.1.8", @@ -109,6 +115,7 @@ "singleQuote": true, "trailingComma": "all", "arrowParens": "avoid", + "printWidth": 100, "plugins": [ "prettier-plugin-css-order", "@softonus/prettier-plugin-duplicate-remover" diff --git a/pages/_app.tsx b/pages/_app.tsx index b527129..c14d52c 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -9,13 +9,8 @@ import { Image } from 'react-bootstrap'; import { MDXLayout } from '../components/Layout/MDXLayout'; import { MainNavigator } from '../components/Navigator/MainNavigator'; -import { isServer } from '../models/configuration'; -import { - createI18nStore, - I18nContext, - I18nProps, - loadSSRLanguage, -} from '../models/Translation'; +import { createI18nStore, I18nContext, I18nProps, loadSSRLanguage } from '../models/Translation'; +import { isServer } from '../utility/configuration'; configure({ enforceActions: 'never' }); @@ -74,12 +69,7 @@ export default class CustomApp extends App { > {t('powered_by')} - Vercel Logo + Vercel Logo diff --git a/pages/api/Lark/core.ts b/pages/api/Lark/core.ts index a5f027a..f31eeb7 100644 --- a/pages/api/Lark/core.ts +++ b/pages/api/Lark/core.ts @@ -1,14 +1,9 @@ import { Context, Middleware } from 'koa'; import { marked } from 'marked'; -import { - LarkApp, - LarkData, - normalizeTextArray, - TableCellText, -} from 'mobx-lark'; +import { LarkApp, LarkData, normalizeTextArray, TableCellText } from 'mobx-lark'; import { oauth2Signer } from 'next-ssr-middleware'; -import { LarkAppMeta } from '../../../models/configuration'; +import { LarkAppMeta } from '../../../utility/configuration'; export const lark = new LarkApp(LarkAppMeta); diff --git a/pages/api/Lark/file/[id]/[name].ts b/pages/api/Lark/file/[id]/[name].ts index 7c2efbc..2e75901 100644 --- a/pages/api/Lark/file/[id]/[name].ts +++ b/pages/api/Lark/file/[id]/[name].ts @@ -4,7 +4,7 @@ import MIME from 'mime'; import { createKoaRouter, withKoaRouter } from 'next-ssr-middleware'; import { Readable } from 'stream'; -import { CACHE_HOST } from '../../../../../models/configuration'; +import { CACHE_HOST } from '../../../../../utility/configuration'; import { safeAPI } from '../../../core'; import { lark } from '../../core'; @@ -22,10 +22,9 @@ const downloader: Middleware = async context => { const token = await lark.getAccessToken(); - const response = await fetch( - lark.client.baseURI + `drive/v1/medias/${id}/download`, - { headers: { Authorization: `Bearer ${token}` } }, - ); + const response = await fetch(lark.client.baseURI + `drive/v1/medias/${id}/download`, { + headers: { Authorization: `Bearer ${token}` }, + }); const { ok, status, headers, body } = response; if (!ok) { @@ -50,8 +49,6 @@ const downloader: Middleware = async context => { context.body = Readable.fromWeb(stream2); }; -router - .head('/:id/:name', safeAPI, downloader) - .get('/:id/:name', safeAPI, downloader); +router.head('/:id/:name', safeAPI, downloader).get('/:id/:name', safeAPI, downloader); export default withKoaRouter(router); diff --git a/pages/api/Lark/file/index.ts b/pages/api/Lark/file/index.ts index 92ed034..3967807 100644 --- a/pages/api/Lark/file/index.ts +++ b/pages/api/Lark/file/index.ts @@ -6,7 +6,7 @@ import { UploadTargetType } from 'mobx-lark'; import { createKoaRouter, withKoaRouter } from 'next-ssr-middleware'; import { parse } from 'path'; -import { LARK_API_HOST } from '../../../../models/configuration'; +import { LARK_API_HOST } from '../../../../utility/configuration'; import { safeAPI } from '../../core'; import { lark } from '../core'; @@ -17,18 +17,14 @@ const router = createKoaRouter(import.meta.url); router.post('/', safeAPI, async context => { const form = formidable(); - const [{ parent_type, parent_node }, { file }] = await form.parse( - context.req, - ); - if (!parent_type?.[0] || !parent_node?.[0] || !file?.[0]) - return (context.status = 400); + const [{ parent_type, parent_node }, { file }] = await form.parse(context.req); + if (!parent_type?.[0] || !parent_node?.[0] || !file?.[0]) return (context.status = 400); const [{ filepath, originalFilename, mimetype }] = file; const buffer = await readFile(filepath); const ext = - '.' + (await fileTypeFromBuffer(buffer))?.ext || - parse(originalFilename || filepath).ext; + '.' + (await fileTypeFromBuffer(buffer))?.ext || parse(originalFilename || filepath).ext; const name = parse(originalFilename || filepath).name + ext, type = MIME.getType(ext) || mimetype || 'application/octet-stream'; diff --git a/pages/api/core.ts b/pages/api/core.ts index 1919798..85f6c42 100644 --- a/pages/api/core.ts +++ b/pages/api/core.ts @@ -9,7 +9,7 @@ import { KoaOption, withKoa } from 'next-ssr-middleware'; import { ProxyAgent, setGlobalDispatcher } from 'undici'; import { parse } from 'yaml'; -import { CrawlerEmail, JWT_SECRET } from '../../models/configuration'; +import { CrawlerEmail, JWT_SECRET } from '../../utility/configuration'; const { HTTP_PROXY } = process.env; @@ -25,8 +25,7 @@ export const parseJWT = JWT({ passthrough: true, }); -if (JWT_SECRET) - console.info('🔑 [Crawler JWT]', sign({ email: CrawlerEmail }, JWT_SECRET)); +if (JWT_SECRET) console.info('🔑 [Crawler JWT]', sign({ email: CrawlerEmail }, JWT_SECRET)); export const safeAPI: Middleware = async (context: Context, next) => { try { @@ -89,10 +88,7 @@ export function splitFrontMatter(raw: string) { } } -export async function* pageListOf( - path: string, - prefix = 'pages', -): AsyncGenerator { +export async function* pageListOf(path: string, prefix = 'pages'): AsyncGenerator { const { readdir, readFile } = await import('fs/promises'); const list = await readdir(prefix + path, { withFileTypes: true }); diff --git a/pages/api/file/crawler/task.ts b/pages/api/file/crawler/task.ts index 2224294..fed11a5 100644 --- a/pages/api/file/crawler/task.ts +++ b/pages/api/file/crawler/task.ts @@ -1,10 +1,7 @@ import { createKoaRouter, withKoaRouter } from 'next-ssr-middleware'; import { githubClient } from '../../../../models/Base'; -import { - CACHE_REPOSITORY, - CrawlerEmail, -} from '../../../../models/configuration'; +import { CACHE_REPOSITORY, CrawlerEmail } from '../../../../utility/configuration'; import { JWTContext, parseJWT, safeAPI } from '../../core'; export interface CrawlerTask { @@ -20,19 +17,13 @@ router.post('/', safeAPI, parseJWT, async (context: JWTContext) => { if (!('user' in context.state) || context.state.user.email !== CrawlerEmail) return context.throw(401); - const { URI, title = URI } = Reflect.get( - context.request, - 'body', - ) as CrawlerTask; - - const { status, body } = await githubClient.post( - `repos/${CACHE_REPOSITORY}/issues`, - { - title, - labels: ['crawler'], - body: `### URL\n\n${URI}`, - }, - ); + const { URI, title = URI } = Reflect.get(context.request, 'body') as CrawlerTask; + + const { status, body } = await githubClient.post(`repos/${CACHE_REPOSITORY}/issues`, { + title, + labels: ['crawler'], + body: `### URL\n\n${URI}`, + }); context.status = status; context.body = body; }); diff --git a/pages/department/[name]/[year].tsx b/pages/department/[name]/[year].tsx new file mode 100644 index 0000000..14619bb --- /dev/null +++ b/pages/department/[name]/[year].tsx @@ -0,0 +1,113 @@ +import { observer } from 'mobx-react'; +import { cache, compose, errorLogger } from 'next-ssr-middleware'; +import { FC, useContext } from 'react'; +import { Button, Col, Container, Row } from 'react-bootstrap'; + +import { CommentBox } from '../../../components/Base/CommentBox'; +import { ZodiacBar } from '../../../components/Base/ZodiacBar'; +import { GroupCard } from '../../../components/Department/Card'; +import { OKRCard } from '../../../components/Department/OKRCard'; +import { ReportCard } from '../../../components/Department/ReportCard'; +import { PageHead } from '../../../components/Layout/PageHead'; +import { MemberGroup } from '../../../components/Member/Group'; +import { MemberTitle } from '../../../components/Member/Title'; +import { OKR, OKRModel } from '../../../models/Governance/OKR'; +import { Report, ReportModel } from '../../../models/Governance/Report'; +import { Personnel, PersonnelModel } from '../../../models/Personnel'; +import { Department, DepartmentModel } from '../../../models/Personnel/Department'; +import { I18nContext } from '../../../models/Translation'; + +interface DepartmentDetailPageProps { + department: Department; + group: Record; + okrList: OKR[]; + reportList: Report[]; +} + +export const getServerSideProps = compose< + Record<'name' | 'year', string>, + DepartmentDetailPageProps +>(cache(), errorLogger, async ({ params: { name, year = 0 } = {} }) => { + const [department] = await new DepartmentModel().getList({ name }); + + if (!department) return { notFound: true, props: {} }; + + const [group, okrList, reportList] = await Promise.all([ + new PersonnelModel().getGroup({ department: name, passed: true }, ['position'], +year), + new OKRModel().getAll({ department: name, year }), + new ReportModel().getAll({ department: name, summary: year }), + ]); + + return { props: JSON.parse(JSON.stringify({ department, group, okrList, reportList })) }; +}); + +const DepartmentDetailPage: FC = observer( + ({ department, group, okrList, reportList }) => { + const { t } = useContext(I18nContext); + + return ( + + + + + + + + + ({ + title: year, + link: `/department/${department.name}/${year}`, + })} + /> + + + {Object.entries(group).map(([position, list]) => ( + + ))} + + + + {okrList.map(item => ( + + + + ))} + + + + + {reportList.map(item => ( + + + + ))} + + + + + + + ); + }, +); +export default DepartmentDetailPage; diff --git a/pages/department/[name]/index.tsx b/pages/department/[name]/index.tsx new file mode 100644 index 0000000..3add7b2 --- /dev/null +++ b/pages/department/[name]/index.tsx @@ -0,0 +1,12 @@ +import { GetServerSideProps } from 'next'; + +export const getServerSideProps: GetServerSideProps = async ({ resolvedUrl }) => ({ + redirect: { + destination: `${resolvedUrl}/${new Date().getFullYear()}`, + permanent: false, + }, +}); + +export default function DepartmentPage() { + return <>; +} diff --git a/pages/department/board-of-directors.tsx b/pages/department/board-of-directors.tsx new file mode 100644 index 0000000..aaca62a --- /dev/null +++ b/pages/department/board-of-directors.tsx @@ -0,0 +1,63 @@ +import { observer } from 'mobx-react'; +import { compose, errorLogger } from 'next-ssr-middleware'; +import { FC, useContext } from 'react'; +import { Breadcrumb, Container } from 'react-bootstrap'; + +import { PageHead } from '../../components/Layout/PageHead'; +import { MemberCard } from '../../components/Member/Card'; +import { PersonnelModel } from '../../models/Personnel'; +import { I18nContext } from '../../models/Translation'; + +type CouncilPageProps = Pick; + +export const getServerSideProps = compose<{}, CouncilPageProps>(errorLogger, async () => { + const group = await new PersonnelModel().getYearGroup( + { + position: ['理事', '理事长', '副理事长'], + passed: true, + }, + ['createdAt'], + ); + + return { props: JSON.parse(JSON.stringify({ group })) }; +}); + +const CouncilPage: FC = observer(({ group }) => { + const { t } = useContext(I18nContext); + + return ( + + + + {t('OrgServer')} + {t('department')} + {t('board_of_directors')} + +

    {t('board_of_directors')}

    + + {Object.entries(group) + .sort(([a], [b]) => +b - +a) + .map(([year, list]) => ( +
    +

    {year}

    + +
      + {list.map(({ id, position, recipient, recipientAvatar }) => ( +
    • + +
    • + ))} +
    +
    + ))} +
    + ); +}); +export default CouncilPage; diff --git a/pages/department/committee/[name].tsx b/pages/department/committee/[name].tsx new file mode 100644 index 0000000..01bc2dd --- /dev/null +++ b/pages/department/committee/[name].tsx @@ -0,0 +1,62 @@ +import { observer } from 'mobx-react'; +import { compose, errorLogger, RouteProps, router } from 'next-ssr-middleware'; +import { FC, useContext } from 'react'; +import { Breadcrumb, Container } from 'react-bootstrap'; + +import { PageHead } from '../../../components/Layout/PageHead'; +import { MemberCard } from '../../../components/Member/Card'; +import { PersonnelModel } from '../../../models/Personnel'; +import { i18n, I18nContext } from '../../../models/Translation'; + +type CommitteePageProps = RouteProps<{ name: string }> & Pick; + +const nameMap = {}; + +export const getServerSideProps = compose<{ name: string }, CommitteePageProps>( + router, + errorLogger, + async ({ params }) => { + const department = nameMap[params!.name as keyof typeof nameMap]; + + if (!department) return { notFound: true, props: {} as CommitteePageProps }; + + const allItems = await new PersonnelModel().getAll({ + department, + passed: true, + }); + + return { props: JSON.parse(JSON.stringify({ allItems })) }; + }, +); + +const titleMap = ({ t }: typeof i18n) => ({}); + +const CommitteePage: FC = observer(({ route: { params }, allItems }) => { + const i18n = useContext(I18nContext); + const { t } = i18n, + title = titleMap(i18n)[params!.name as keyof ReturnType]; + + return ( + + + + {t('OrgServer')} + {t('department')} + {title} + +

    {title}

    + +
      + {allItems.map(({ id, position, recipient, recipientAvatar }) => ( +
    • + +
    • + ))} +
    +
    + ); +}); +export default CommitteePage; diff --git a/pages/department/index.tsx b/pages/department/index.tsx new file mode 100644 index 0000000..999eedc --- /dev/null +++ b/pages/department/index.tsx @@ -0,0 +1,24 @@ +import { observer } from 'mobx-react'; +import dynamic from 'next/dynamic'; +import { FC, useContext } from 'react'; +import { Container } from 'react-bootstrap'; + +import { PageHead } from '../../components/Layout/PageHead'; +import { I18nContext } from '../../models/Translation'; + +const DepartmentTree = dynamic(() => import('../../components/Department/Tree'), { ssr: false }); + +const DepartmentPage: FC = observer(() => { + const { t } = useContext(I18nContext); + + return ( + + + +

    {t('organization_structure_chart')}

    + + +
    + ); +}); +export default DepartmentPage; diff --git a/pages/election/[year]/candidate/[recipient]/poster/[position].tsx b/pages/election/[year]/candidate/[recipient]/poster/[position].tsx new file mode 100644 index 0000000..fdde1e1 --- /dev/null +++ b/pages/election/[year]/candidate/[recipient]/poster/[position].tsx @@ -0,0 +1,148 @@ +import { ShareBox } from 'idea-react'; +import { marked } from 'marked'; +import { textJoin } from 'mobx-i18n'; +import { observer } from 'mobx-react'; +import { ObservedComponent } from 'mobx-react-helper'; +import { cache, compose, errorLogger, RouteProps, router } from 'next-ssr-middleware'; +import { QRCodeSVG } from 'qrcode.react'; +import { Col, Container, Row } from 'react-bootstrap'; + +import { LarkImage } from '../../../../../../components/Base/LarkImage'; +import { PageHead } from '../../../../../../components/Layout/PageHead'; +import { Personnel, PersonnelModel } from '../../../../../../models/Personnel'; +import { i18n, I18nContext } from '../../../../../../models/Translation'; +import { API_Host } from '../../../../../../utility/configuration'; + +type PageParams = Record<'year' | 'recipient' | 'position', string>; + +type CandidatePosterProps = RouteProps & Personnel; + +export const getServerSideProps = compose( + errorLogger, + cache(), + router, + async ({ params }) => { + const { year, recipient, position } = params!; + const { + [recipient]: [props], + } = await new PersonnelModel().getGroup({ recipient, position }, ['recipient'], +year); + + return props ? JSON.parse(JSON.stringify({ props })) : { notFound: true }; + }, +); + +export const VoteForm = { + common: 'https://kaiyuanshe.feishu.cn/share/base/form/shrcnVYqyX5w8wTNiCLeH7Ziy1g', + 理事: 'https://kaiyuanshe.feishu.cn/share/base/form/shrcnFARtfFj3P3LrlbKqXYvoxb', + 正式成员: 'https://kaiyuanshe.feishu.cn/share/base/form/shrcnXIXPn0lOt4YomFsvhjnzjf', +}; + +@observer +export default class CandidatePoster extends ObservedComponent { + static contextType = I18nContext; + + rootURL = `${API_Host}/election/${this.props.route.params!.year}`; + sharePath = `/candidate/${this.props.recipient}/poster/${this.props.position}`; + + renderContent(title: string, subTitle: string) { + const { t } = this.observedContext; + const { + overview, + applicants, + recipientAvatar, + position, + reason, + contribution, + proposition, + recommenders, + recommendation1, + recommendation2, + } = this.props; + + return ( + +
    +

    + {title} +
    + {subTitle} +

    + + +
    + {[ + { title: t('nomination_reason'), content: reason as string }, + { + title: t('previous_term_contribution'), + content: contribution as string, + }, + { + title: t('this_term_proposition'), + content: proposition as string, + }, + { + title: `${applicants} ${t('recommendation')}`, + content: recommendation1 as string, + }, + { + title: `${recommenders} ${t('recommendation')}`, + content: recommendation2 as string, + }, + ].map( + ({ title, content }) => + content && ( +
    +

    {title}

    +
    +
    + ), + )} + + + +
    {textJoin(position as string, t('candidate'))}
    + + + +
    {t('vote_for_me')}
    + + {t('press_to_share')} +
    +
    + ); + } + + render() { + const { t } = this.observedContext, + { route, recipient, position } = this.props; + const { year } = route.params!; + const title = `${t('OrgServer')} ${year}`, + subTitle = `${textJoin(position as string, t('candidate'))} ${recipient}`; + + return ( + <> + + + + {this.renderContent(title, subTitle)} + + + ); + } +} diff --git a/pages/election/[year]/index.tsx b/pages/election/[year]/index.tsx new file mode 100644 index 0000000..8a95eec --- /dev/null +++ b/pages/election/[year]/index.tsx @@ -0,0 +1,142 @@ +import { textJoin } from 'mobx-i18n'; +import { observer } from 'mobx-react'; +import { ObservedComponent } from 'mobx-react-helper'; +import { compose, errorLogger, RouteProps, router } from 'next-ssr-middleware'; +import { Button, Col, Container, Row } from 'react-bootstrap'; +import { Day, isEmpty } from 'web-utility'; + +import { ElectorCard } from '../../../components/Election/ElectorCard'; +import { PageHead } from '../../../components/Layout/PageHead'; +import { ElectionTarget, Personnel, PersonnelModel } from '../../../models/Personnel'; +import { i18n, I18nContext } from '../../../models/Translation'; + +type ElectionYearPageProps = RouteProps<{ year: string }> & Pick; + +export const getServerSideProps = compose<{ year: string }, ElectionYearPageProps>( + router, + errorLogger, + async ({ params }) => { + const group = await new PersonnelModel().getGroup({}, ['position', 'award'], +params!.year); + + return { + notFound: isEmpty(group), + props: JSON.parse(JSON.stringify({ group })), + }; + }, +); + +const SectionOrder = [ + '理事', + '正式成员', + '志愿者', + '开源之星', + 'COSCon 之星', + '社区合作之星', + '中国开源先锋 33 人', +]; + +@observer +export default class ElectionYearPage extends ObservedComponent< + ElectionYearPageProps, + typeof i18n +> { + static contextType = I18nContext; + + get sections() { + const { group } = this.props; + + return SectionOrder.map(title => [title, group[title]] as const).filter(([_, list]) => list); + } + + renderGroup(target: string, list: Personnel[]) { + const { t } = this.observedContext, + [startedAt] = list + .map(({ createdAt }) => createdAt as number) + .sort((a, b) => +new Date(a) - +new Date(b)); + const open = +new Date(startedAt) + 16.5 * Day < Date.now(); + + return ( +
    +

    + + {textJoin(t(target as ElectionTarget) || '', t('candidate'))} + +

    + + + {list + .sort( + ( + { score: a0, approvers: a1, rejecters: a2, createdAt: a3 }, + { score: b0, approvers: b1, rejecters: b2, createdAt: b3 }, + ) => + +b0! - +a0! || + (b1 as string[])?.length - (a1 as string[])?.length || + (a2 as string[])?.length - (b2 as string[])?.length || + +new Date(a3 as string) - +new Date(b3 as string), + ) + .map(({ id, approvers, rejecters, ...item }, index) => ( + + + + ))} + +
    + ); + } + + render() { + const { t } = this.observedContext, + { year } = this.props.route.params!; + + const title = `${year} ${t('election')}`, + passed = +year < new Date().getFullYear(); + + return ( + + + +

    {title}

    + +
    + + + + + +
    + + {this.sections.map(([target, list]) => this.renderGroup(target, list))} +
    + ); + } +} diff --git a/pages/election/[year]/vote.tsx b/pages/election/[year]/vote.tsx new file mode 100644 index 0000000..96b20a2 --- /dev/null +++ b/pages/election/[year]/vote.tsx @@ -0,0 +1,95 @@ +import { Loading } from 'idea-react'; +import { computed, when } from 'mobx'; +import { textJoin } from 'mobx-i18n'; +import { observer } from 'mobx-react'; +import { ObservedComponent } from 'mobx-react-helper'; +import { compose, RouteProps, router } from 'next-ssr-middleware'; +import { Breadcrumb, Col, Container, Row } from 'react-bootstrap'; + +import { PageHead } from '../../../components/Layout/PageHead'; +import { ElectionModel } from '../../../models/Personnel/Election'; +import { i18n, I18nContext } from '../../../models/Translation'; +import userStore from '../../../models/User'; +import { prefillForm } from '../../../utility/Lark'; +import { VoteForm } from './candidate/[recipient]/poster/[position]'; + +export const getServerSideProps = compose(router); + +@observer +export default class ElectionVotePage extends ObservedComponent< + RouteProps<{ year: string }>, + typeof i18n +> { + static contextType = I18nContext; + + electionStore = new ElectionModel(); + + electionName = `KYS-administration-${this.props.route.params!.year}`; + + @computed + get formData() { + const { ticketMap } = this.electionStore; + + const list = Object.entries(ticketMap).map(([name, ticket]) => [name, prefillForm(ticket)]); + + return Object.fromEntries(list); + } + + async componentDidMount() { + await when(() => !!userStore.session); + + await this.electionStore.signVoteTicket(`${this.electionName}-director`); + await this.electionStore.signVoteTicket(`${this.electionName}-member`); + } + + renderVoteForm({ + [`${this.electionName}-director`]: director, + [`${this.electionName}-member`]: member, + }: ElectionVotePage['formData']) { + const { t } = this.observedContext; + + return ( + + +

    {t('director_election_voting')}

    + {director && ( +