├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── actions │ └── setup-js-env │ │ └── action.yaml ├── codecov.yaml ├── labels.yml ├── mergify.yml ├── renovate.json ├── scripts │ ├── milestone.mts │ └── upload-preview.mts └── workflows │ ├── build.yml │ ├── e2e-test.yml │ ├── lint.yml │ ├── preview.yaml │ ├── release.yml │ ├── storybook-build.yml │ ├── todo.yaml │ ├── unit-test.yml │ └── update-types.yml ├── .gitignore ├── .gitmodules ├── .husky └── pre-commit ├── .node-version ├── .npmrc ├── .prettierignore ├── .stylelintignore ├── .stylelintrc.js ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _functions ├── p1 │ └── [[catchall]].js └── readme.md ├── docs ├── code-style.md ├── css-naming-convention.md ├── path-redirect.md └── route.md ├── package.json ├── packages ├── client │ ├── api.yaml │ ├── client.ts │ ├── common.ts │ ├── index.ts │ ├── package.json │ ├── readme.md │ ├── scripts │ │ ├── build.mjs │ │ └── update-openapi.mjs │ ├── topic.ts │ ├── types │ │ ├── index.ts │ │ └── utils.ts │ └── user.ts ├── design │ ├── .gitignore │ ├── .storybook │ │ ├── global.css │ │ ├── main.ts │ │ ├── manager.ts │ │ ├── preview.ts │ │ └── tsconfig.json │ ├── components │ │ ├── Avatar │ │ │ ├── Avatar.stories.tsx │ │ │ ├── __test__ │ │ │ │ └── Avatar.test.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Button │ │ │ ├── __test__ │ │ │ │ ├── Button.test.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── Button.test.tsx.snap │ │ │ ├── button.stories.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── CollapsibleContent │ │ │ ├── CollapsibleContent.stories.tsx │ │ │ ├── __test__ │ │ │ │ ├── CollapsibleContent.test.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── CollapsibleContent.test.tsx.snap │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ └── index.less │ │ ├── Divider │ │ │ ├── Divider.stories.tsx │ │ │ ├── __test__ │ │ │ │ └── Divider.spec.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── EditorForm │ │ │ ├── Editor.tsx │ │ │ ├── EditorForm.stories.tsx │ │ │ ├── Toolbox.tsx │ │ │ ├── __test__ │ │ │ │ ├── Editor.spec.tsx │ │ │ │ ├── EditorForm.test.tsx │ │ │ │ ├── Toolbox.spec.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ ├── Editor.spec.tsx.snap │ │ │ │ │ ├── EditorForm.test.tsx.snap │ │ │ │ │ └── Toolbox.spec.tsx.snap │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Form │ │ │ ├── Form.stories.less │ │ │ ├── Form.stories.tsx │ │ │ ├── __test__ │ │ │ │ ├── Form.spec.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── Form.spec.tsx.snap │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Image │ │ │ ├── Image.stories.tsx │ │ │ ├── __test__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── index.spec.tsx.snap │ │ │ │ └── index.spec.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Input │ │ │ ├── Input.stories.tsx │ │ │ ├── __test__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── index.spec.tsx.snap │ │ │ │ └── index.spec.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Layout │ │ │ ├── Layout.stories.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Menu │ │ │ ├── Menu.stories.tsx │ │ │ ├── MenuItem.tsx │ │ │ ├── __test__ │ │ │ │ └── Menu.spec.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Message │ │ │ ├── Message.stories.tsx │ │ │ ├── __test__ │ │ │ │ ├── Message.spec.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── Message.spec.tsx.snap │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Pagination │ │ │ ├── Pager.tsx │ │ │ ├── Pagination.stories.tsx │ │ │ ├── __test__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── index.spec.tsx.snap │ │ │ │ └── index.spec.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Popover │ │ │ ├── Popover.stories.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ └── index.less │ │ ├── Radio │ │ │ ├── Radio.stories.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Rate │ │ │ ├── Rate.stories.tsx │ │ │ ├── __test__ │ │ │ │ └── Rate.spec.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.ts │ │ ├── RichContent │ │ │ ├── RichContent.stories.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Section │ │ │ ├── Section.stories.tsx │ │ │ ├── __test__ │ │ │ │ └── Section.spec.tsx │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Select │ │ │ ├── Select.stories.tsx │ │ │ ├── __test__ │ │ │ │ ├── Select.spec.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── Select.spec.tsx.snap │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Tab │ │ │ ├── Tab.stories.tsx │ │ │ ├── __test__ │ │ │ │ ├── Tab.spec.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── Tab.spec.tsx.snap │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ ├── Toast │ │ │ ├── Toast.stories.tsx │ │ │ ├── Toast.tsx │ │ │ ├── ToastContainer.tsx │ │ │ ├── __test__ │ │ │ │ ├── Toast.spec.tsx │ │ │ │ └── ToastContainer.spec.tsx │ │ │ ├── index.tsx │ │ │ ├── style │ │ │ │ ├── index.less │ │ │ │ └── index.tsx │ │ │ ├── types.ts │ │ │ └── utils │ │ │ │ └── event-bus.ts │ │ ├── Topic │ │ │ ├── Comment.stories.tsx │ │ │ ├── Comment.tsx │ │ │ ├── CommentActions.stories.tsx │ │ │ ├── CommentActions.tsx │ │ │ ├── CommentInfo.tsx │ │ │ ├── ReplyForm.tsx │ │ │ ├── __test__ │ │ │ │ ├── Comment.spec.tsx │ │ │ │ ├── CommentActions.spec.tsx │ │ │ │ ├── CommentInfo.spec.tsx │ │ │ │ ├── __snapshots__ │ │ │ │ │ ├── Comment.spec.tsx.snap │ │ │ │ │ └── CommentActions.spec.tsx.snap │ │ │ │ └── fixtures │ │ │ │ │ ├── repliesComment.json │ │ │ │ │ ├── singleComment.json │ │ │ │ │ ├── specialComment.json │ │ │ │ │ └── user.json │ │ │ ├── index.tsx │ │ │ └── style │ │ │ │ ├── Comment.less │ │ │ │ ├── CommentActions.less │ │ │ │ ├── CommentInfo.less │ │ │ │ └── index.tsx │ │ └── Typography │ │ │ ├── Link.stories.tsx │ │ │ ├── Link.tsx │ │ │ ├── Text.tsx │ │ │ ├── Typography.stories.tsx │ │ │ ├── __test__ │ │ │ ├── Link.spec.tsx │ │ │ ├── Texts.spec.tsx │ │ │ └── __snapshots__ │ │ │ │ ├── Link.spec.tsx.snap │ │ │ │ └── Texts.spec.tsx.snap │ │ │ ├── index.tsx │ │ │ └── style │ │ │ ├── Link.less │ │ │ ├── Text.less │ │ │ └── index.tsx │ ├── index.tsx │ ├── package.json │ └── theme │ │ ├── base.less │ │ ├── mixins.less │ │ └── variables.less ├── icons │ ├── assets │ │ ├── arrow-down-circle.svg │ │ ├── arrow-down.svg │ │ ├── arrow-path.svg │ │ ├── arrow-right-circle.svg │ │ ├── arrow-up-circle.svg │ │ ├── bold.svg │ │ ├── captcha.svg │ │ ├── close-quote.svg │ │ ├── comment.svg │ │ ├── cursor.svg │ │ ├── delete.svg │ │ ├── empty-star.svg │ │ ├── enter.svg │ │ ├── error.svg │ │ ├── expand.svg │ │ ├── filled-star.svg │ │ ├── friend.svg │ │ ├── half-star.svg │ │ ├── image.svg │ │ ├── italic.svg │ │ ├── link.svg │ │ ├── minus.svg │ │ ├── more.svg │ │ ├── notification.svg │ │ ├── open-quote.svg │ │ ├── original-poster.svg │ │ ├── password.svg │ │ ├── plus-circle.svg │ │ ├── plus.svg │ │ ├── search.svg │ │ ├── setting.svg │ │ ├── size.svg │ │ ├── topic-closed.svg │ │ ├── topic-reopen.svg │ │ ├── topic-silent.svg │ │ ├── underscore.svg │ │ ├── user-login.svg │ │ ├── vertical-left.svg │ │ └── vertical-right.svg │ ├── index.stories.tsx │ ├── index.tsx │ └── package.json ├── server │ └── package.json ├── utils │ ├── bbcode │ │ ├── __test__ │ │ │ ├── __snapshots__ │ │ │ │ ├── html.test.ts.snap │ │ │ │ └── react.test.tsx.snap │ │ │ ├── convert.test.ts │ │ │ ├── html.test.ts │ │ │ ├── parser.test.ts │ │ │ └── react.test.tsx │ │ ├── constants.ts │ │ ├── convert.ts │ │ ├── html.ts │ │ ├── index.ts │ │ ├── parser.ts │ │ ├── react.tsx │ │ └── types.ts │ ├── index.spec.ts │ ├── index.ts │ ├── package.json │ ├── pages.ts │ ├── wiki.spec.ts │ └── wiki.ts └── website │ ├── .env │ ├── .env.production │ ├── .gitignore │ ├── README.md │ ├── e2e │ ├── .gitignore │ ├── common │ │ └── login.ts │ ├── setup │ │ └── auth.setup.ts │ └── specs │ │ ├── 404.spec.ts │ │ ├── group.spec.ts │ │ ├── login.spec.ts │ │ └── main.spec.ts │ ├── index.html │ ├── package.json │ ├── playwright.config.ts │ ├── postcss.config.js │ ├── public │ └── .gitkeep │ ├── src │ ├── App.tsx │ ├── assets │ │ ├── footer-cover.png │ │ ├── logo.svg │ │ ├── musume_1.svg │ │ ├── musume_2.svg │ │ ├── musume_3.svg │ │ └── musume_4.svg │ ├── components │ │ ├── .gitkeep │ │ ├── ErrorBoundary │ │ │ ├── ErrorLayout.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.less │ │ ├── Footer │ │ │ ├── index.tsx │ │ │ └── style.module.less │ │ ├── GlobalLayout │ │ │ ├── index.tsx │ │ │ └── style.module.less │ │ ├── Header │ │ │ ├── SubMenu.module.less │ │ │ ├── SubMenu.tsx │ │ │ ├── index.tsx │ │ │ └── style.module.less │ │ ├── Helmet.tsx │ │ ├── NotFound.tsx │ │ ├── PageRoutes │ │ │ ├── auth.spec.tsx │ │ │ └── auth.tsx │ │ ├── SuspenseRouter.tsx │ │ └── WikiEditor │ │ │ ├── WikiEditor.module.less │ │ │ └── WikiEditor.tsx │ ├── error.ts │ ├── hooks │ │ ├── __snapshots__ │ │ │ └── use-user.spec.tsx.snap │ │ ├── use-group-members.ts │ │ ├── use-group-post.ts │ │ ├── use-group-topic.ts │ │ ├── use-group-topics.ts │ │ ├── use-group.ts │ │ ├── use-navigate.ts │ │ ├── use-notify.tsx │ │ ├── use-pagination.spec.tsx │ │ ├── use-pagination.ts │ │ ├── use-query.ts │ │ ├── use-transition-context.ts │ │ ├── use-user.spec.tsx │ │ └── use-user.tsx │ ├── index.css │ ├── main.tsx │ ├── mocks │ │ ├── fixtures │ │ │ └── p1 │ │ │ │ └── me-GET.json │ │ ├── handlers.ts │ │ ├── server.ts │ │ └── utils.ts │ ├── pages │ │ ├── components │ │ │ ├── UserHome.module.less │ │ │ ├── UserHome.spec.tsx │ │ │ └── UserHome.tsx │ │ ├── index.tsx │ │ ├── index │ │ │ ├── [...slug].tsx │ │ │ ├── group │ │ │ │ ├── [name] │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ ├── fixtures │ │ │ │ │ │ │ ├── recent-topics.json │ │ │ │ │ │ │ ├── sandbox-members.json │ │ │ │ │ │ │ ├── sandbox-mod-member.json │ │ │ │ │ │ │ └── sandbox.json │ │ │ │ │ │ └── index.spec.tsx │ │ │ │ │ ├── components │ │ │ │ │ │ ├── TopicsTable.module.less │ │ │ │ │ │ └── TopicsTable.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── index │ │ │ │ │ │ ├── forum.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── members.tsx │ │ │ │ │ │ └── style.module.less │ │ │ │ │ └── new_topic │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── style.module.less │ │ │ │ ├── components │ │ │ │ │ ├── GroupActions.tsx │ │ │ │ │ ├── GroupHeader.module.less │ │ │ │ │ ├── GroupHeader.tsx │ │ │ │ │ ├── GroupInfo.module.less │ │ │ │ │ ├── GroupInfo.tsx │ │ │ │ │ ├── GroupLayout.module.less │ │ │ │ │ ├── GroupLayout.tsx │ │ │ │ │ ├── GroupNavigation.module.less │ │ │ │ │ ├── GroupNavigation.tsx │ │ │ │ │ ├── TopicForm.module.less │ │ │ │ │ ├── TopicForm.tsx │ │ │ │ │ ├── UserCard.module.less │ │ │ │ │ └── UserCard.tsx │ │ │ │ ├── reply │ │ │ │ │ ├── [id].tsx │ │ │ │ │ └── [id] │ │ │ │ │ │ ├── edit.module.less │ │ │ │ │ │ └── edit.tsx │ │ │ │ └── topic │ │ │ │ │ ├── [id].tsx │ │ │ │ │ └── [id] │ │ │ │ │ ├── components │ │ │ │ │ ├── GroupTopicHeader.module.less │ │ │ │ │ └── GroupTopicHeader.tsx │ │ │ │ │ ├── edit.module.less │ │ │ │ │ ├── edit.tsx │ │ │ │ │ ├── index.module.less │ │ │ │ │ └── index.tsx │ │ │ ├── notifications │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ └── subject │ │ │ │ └── [id] │ │ │ │ ├── components │ │ │ │ ├── WikiBeginnerEditor.module.less │ │ │ │ ├── WikiBeginnerEditor.spec.tsx │ │ │ │ ├── WikiBeginnerEditor.tsx │ │ │ │ ├── WikiLayout.module.less │ │ │ │ ├── WikiLayout.tsx │ │ │ │ └── __snapshots__ │ │ │ │ │ └── WikiBeginnerEditor.spec.tsx.snap │ │ │ │ ├── wiki.tsx │ │ │ │ └── wiki │ │ │ │ ├── common.module.less │ │ │ │ ├── edit.tsx │ │ │ │ ├── edit_detail.tsx │ │ │ │ ├── history.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── upload_img.tsx │ │ └── login │ │ │ ├── assets │ │ │ └── login-logo.svg │ │ │ ├── index.module.less │ │ │ ├── index.spec.tsx │ │ │ └── index.tsx │ ├── shared │ │ ├── index.ts │ │ ├── notifications.ts │ │ └── wiki.ts │ ├── style │ │ ├── animation.less │ │ ├── index.less │ │ └── utils.less │ ├── utils │ │ ├── index.ts │ │ ├── route.ts │ │ └── test-utils.tsx │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tests ├── __mocks__ │ └── svg.js ├── env.d.ts ├── setup.ts └── website.ts ├── tsconfig.json └── vitest.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | !packages/design/.storybook 2 | packages/design/storybook-static 3 | packages/client/types 4 | packages/client/client.ts 5 | node_modules 6 | dist 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml linguist-generated=true 2 | *.snap linguist-generated=true 3 | **/__snapshots__/* linguist-generated=true 4 | -------------------------------------------------------------------------------- /.github/actions/setup-js-env/action.yaml: -------------------------------------------------------------------------------- 1 | name: 'Setup Node and run pnpm i' 2 | description: 'setup node env' 3 | runs: 4 | using: 'composite' 5 | steps: 6 | - name: Setup Node 7 | uses: actions/setup-node@v4 8 | with: 9 | node-version-file: '.node-version' 10 | 11 | - run: | 12 | npm i -g corepack 13 | corepack enable 14 | corepack prepare --activate 15 | pnpm --version 16 | shell: bash 17 | name: install pnpm 18 | 19 | - uses: actions/cache@v4 20 | with: 21 | path: ~/.local/share/pnpm/store/ 22 | key: ${{ runner.os }}-1-node${{ inputs.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 23 | restore-keys: | 24 | ${{ runner.os }}-1-node${{ inputs.node-version }}- 25 | 26 | - name: Install Dependencies 27 | run: pnpm install --frozen-lockfile 28 | shell: bash 29 | -------------------------------------------------------------------------------- /.github/codecov.yaml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | comment: false 5 | 6 | coverage: 7 | precision: 2 8 | round: down 9 | range: '80...100' 10 | status: 11 | default_rules: 12 | flag_coverage_not_uploaded_behavior: exclude 13 | project: 14 | design: 15 | target: auto 16 | threshold: '1%' 17 | paths: 18 | - 'packages/design' 19 | website: 20 | target: auto 21 | threshold: 5 22 | paths: 23 | - 'packages/website' 24 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | XS: 2 | lines: 0 3 | S: 4 | lines: 30 5 | M: 6 | lines: 100 7 | L: 8 | lines: 300 9 | XL: 10 | lines: 500 11 | XXL: 12 | lines: 1000 13 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended", "github>trim21/renovate-config:monthly"], 4 | "timezone": "Asia/Shanghai", 5 | "baseBranches": ["master"], 6 | "separateMajorMinor": true, 7 | "separateMinorPatch": false, 8 | "rangeStrategy": "bump", 9 | "semanticCommits": "enabled", 10 | "prConcurrentLimit": 2, 11 | "labels": ["dependencies"], 12 | "postUpdateOptions": ["pnpmDedupe"], 13 | "lockFileMaintenance": { 14 | "enabled": true 15 | }, 16 | "packageRules": [ 17 | { 18 | "matchManagers": ["npm"], 19 | "semanticCommitType": "build" 20 | }, 21 | { 22 | "matchDepTypes": ["engines"], 23 | "enabled": false 24 | }, 25 | { 26 | "groupName": "eslint", 27 | "matchUpdateTypes": ["minor", "patch"], 28 | "semanticCommitType": "style", 29 | "matchPackageNames": ["@typescript-eslint/**", "eslint", "eslint-**"] 30 | }, 31 | { 32 | "matchManagers": ["github-actions"], 33 | "semanticCommitScope": "", 34 | "semanticCommitType": "ci" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths-ignore: 7 | - '**.md' 8 | pull_request: 9 | branches: [master] 10 | paths-ignore: 11 | - '**.md' 12 | schedule: 13 | # build it every month so we always have valid build in actions 14 | - cron: '15 5 1 * *' 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ./.github/actions/setup-js-env 22 | 23 | - name: Build 24 | run: pnpm build --mode stage 25 | 26 | - name: upload website build artifact 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: sites 30 | path: packages/website/dist 31 | -------------------------------------------------------------------------------- /.github/workflows/e2e-test.yml: -------------------------------------------------------------------------------- 1 | name: E2E Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths-ignore: 7 | - '**.md' 8 | - '.github/scripts/**' 9 | pull_request: 10 | branches: [master] 11 | paths-ignore: 12 | - '**.md' 13 | - '.github/scripts/**' 14 | 15 | jobs: 16 | test-e2e: 17 | runs-on: ubuntu-24.04 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | submodules: recursive 22 | 23 | - uses: ./.github/actions/setup-js-env 24 | 25 | - name: Install Playwright 26 | run: pnpm --filter @bangumi/website run install-playwright-deps 27 | 28 | - name: Run test 29 | if: ${{ false }} 30 | run: pnpm test:e2e 31 | 32 | - name: Upload test results 33 | # if: always() 34 | if: ${{ false }} 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: playwright-report 38 | path: packages/website/playwright-report 39 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Lint 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [master] 10 | pull_request: 11 | branches: [master] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | lint: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | 23 | # Steps represent a sequence of tasks that will be executed as part of the job 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: ./.github/actions/setup-js-env 27 | 28 | - name: Prettier 29 | run: pnpm prettier:check 30 | 31 | - name: Type Checking 32 | run: pnpm type-check 33 | 34 | - name: Lint Code 35 | run: pnpm lint --quiet 36 | 37 | - run: pnpm lint:style 38 | name: Lint Style 39 | -------------------------------------------------------------------------------- /.github/workflows/preview.yaml: -------------------------------------------------------------------------------- 1 | # 把 PR 构建的页面推送到 Netlify 2 | # 由于涉及 secrets,所以不能直接在 build.yml 中配置 3 | name: Upload Preview 4 | 5 | on: 6 | workflow_run: 7 | workflows: ['Build', 'Storybook Build'] 8 | types: 9 | - completed 10 | 11 | jobs: 12 | upload: 13 | concurrency: 14 | group: upload-preview-${{ github.event.workflow_run.head_repository.owner.login }}-${{ github.event.workflow_run.head_branch }} 15 | cancel-in-progress: false 16 | runs-on: ubuntu-latest 17 | if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'push' }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: ./.github/actions/setup-js-env 21 | 22 | - run: echo '${{ toJSON(github.event) }}' 23 | 24 | - run: npm i -g wrangler 25 | 26 | - run: npx tsx ./.github/scripts/upload-preview.mts 27 | env: 28 | workflow_name: '${{ github.event.workflow.name }}' 29 | GH_TOKEN: ${{ github.token }} 30 | RUN_ID: ${{ github.event.workflow_run.id }} 31 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 32 | CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 33 | CLOUDFLARE_PROJECT_NAME: bangumi-frontend 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: ./.github/actions/setup-js-env 14 | 15 | - run: pnpm build 16 | - run: zip -r bangumi-website.zip ./packages/website/dist 17 | - run: openssl dgst -sha256 bangumi-website.zip > bangumi-website.zip.sha256 18 | 19 | - name: Get Tag Name 20 | run: echo "TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV 21 | 22 | - name: Upload Github Release 23 | run: gh release create "$TAG" bangumi-website.zip bangumi-website.zip.sha256 --generate-notes 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | milestone: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: ./.github/actions/setup-js-env 32 | 33 | - name: Update github milestone 34 | run: npx tsx .github/scripts/milestone.mts 35 | env: 36 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/storybook-build.yml: -------------------------------------------------------------------------------- 1 | name: Storybook Build 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths-ignore: 7 | - '**.md' 8 | pull_request: 9 | branches: [master] 10 | paths-ignore: 11 | - '**.md' 12 | 13 | jobs: 14 | storybook-build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: ./.github/actions/setup-js-env 19 | 20 | - name: Build Storybook 21 | run: pnpm design:build-doc 22 | 23 | - name: upload storybook build artifact 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: storybook 27 | path: packages/design/storybook-static 28 | -------------------------------------------------------------------------------- /.github/workflows/todo.yaml: -------------------------------------------------------------------------------- 1 | name: 'Run TODO to Issue' 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | jobs: 8 | build: 9 | runs-on: 'ubuntu-latest' 10 | steps: 11 | - uses: 'actions/checkout@v4' 12 | - name: 'TODO to Issue' 13 | uses: 'alstr/todo-to-issue-action@v5' 14 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | paths-ignore: 7 | - '**.md' 8 | pull_request: 9 | branches: [master] 10 | paths-ignore: 11 | - '**.md' 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | submodules: recursive 20 | 21 | - uses: ./.github/actions/setup-js-env 22 | 23 | - name: Unit Test 24 | run: pnpm test -- --coverage 25 | 26 | - name: Upload Coverage to Codecov 27 | uses: codecov/codecov-action@v5 28 | with: 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/update-types.yml: -------------------------------------------------------------------------------- 1 | name: Update Type Definition 2 | on: 3 | repository_dispatch: 4 | types: [update-types] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | update-types: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | ref: master 14 | fetch-depth: 1 15 | - uses: ./.github/actions/setup-js-env 16 | 17 | - name: Update OpenAPI Definition 18 | run: pnpm client update-openapi 19 | env: 20 | OPENAPI_URL: 'https://github.com/bangumi/dev-docs/raw/master/api.yaml' 21 | 22 | - name: Build Type Definition 23 | run: pnpm client build 24 | 25 | - name: Create Pull Request 26 | uses: peter-evans/create-pull-request@v7 27 | with: 28 | title: 'chore(types): update private API type definition' 29 | # 使用 token 创建 PR 才能触发默认的 ci 30 | push-to-fork: bangumi-bot/frontend 31 | branch: 'update-types' 32 | token: ${{ secrets.PAT }} 33 | author: 'bangumi-bot <124712095+bangumi-bot@users.noreply.github.com>' 34 | committer: 'bangumi-bot <124712095+bangumi-bot@users.noreply.github.com>' 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | .vscode 7 | .idea 8 | .pnpm-debug.log 9 | 10 | coverage 11 | .eslintcache 12 | **/playwright-report 13 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "packages/utils/wiki-syntax-spec"] 2 | path = packages/utils/wiki-syntax-spec 3 | url = https://github.com/bangumi/wiki-syntax-spec 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.13.1 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | message="bump: %s" 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | packages/design/storybook-static 3 | pnpm-lock.yaml 4 | *.snap 5 | # submodules 6 | packages/utils/wiki-syntax-spec/ 7 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | !packages/design/.storybook 2 | packages/design/storybook-static 3 | packages/client/types 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['stylelint-config-standard', 'stylelint-config-css-modules'], 3 | customSyntax: 'postcss-less', 4 | rules: { 5 | 'import-notation': null, 6 | 'selector-class-pattern': null, 7 | 'no-descending-specificity': null, 8 | 'color-function-notation': 'legacy', 9 | 'alpha-value-notation': 'number', 10 | 'function-no-unknown': [true, { ignoreFunctions: ['data-uri'] }], 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bangumi Frontend Project 2 | 3 | Bangumi 新前端项目,基于 React/TypeScript/Vite。 4 | 5 | ## 如何开发 6 | 7 | 项目使用 [pnpm](https://pnpm.io/) 管理依赖。在启动项目之前, 8 | 请确保安装好 pnpm , 请参见 [安装文档](https://pnpm.io/installation) 9 | 10 | 安装依赖 11 | 12 | ```bash 13 | pnpm install 14 | ``` 15 | 16 | 启动开发环境 17 | 18 | ```bash 19 | pnpm dev 20 | ``` 21 | 22 | lint 代码风格 23 | 24 | ```bash 25 | pnpm lint 26 | ``` 27 | 28 | ### 对于使用 Windows 的开发者 29 | 30 | 由于 pnpm 对 exFAT 格式的硬盘支持不佳,见 [issue](https://github.com/pnpm/pnpm/issues/3952), 31 | 在 exFAT 格式的分区上启动项目可能报 `ENOENT: no such file or directory` 错误。 32 | 在上游修复之前,建议在 NTFS 格式的硬盘上存储本项目。 33 | 34 | ## 如何参与开发 35 | 36 | 见[贡献指南](./CONTRIBUTING.md)。 37 | 38 | ## 项目成员 39 | 40 | ### Collaborators 41 | 42 | - [Ayase-252](https://github.com/Ayase-252)<> 43 | - [cokemine](https://github.com/cokemine)<> 44 | - [trim21](https://github.com/trim21)<> 45 | - [FoundTheWOUT](https://github.com/FoundTheWOUT)<> 46 | - [y-young](https://github.com/y-young)<> 47 | -------------------------------------------------------------------------------- /_functions/p1/[[catchall]].js: -------------------------------------------------------------------------------- 1 | export function onRequest(context) { 2 | const url = new URL(context.request.url); 3 | url.hostname = 'next.bgm38.tv'; 4 | return fetch(new Request(url, context.request)); 5 | } 6 | -------------------------------------------------------------------------------- /_functions/readme.md: -------------------------------------------------------------------------------- 1 | 设置 pages 环境下的路由,在生产环境下无效。 2 | 3 | 仅有 master 分支的 `functions` 文件有效,在 PR 中提交的 functions 在合并之前不会起效。 4 | -------------------------------------------------------------------------------- /docs/code-style.md: -------------------------------------------------------------------------------- 1 | # code style 2 | 3 | ## 代码格式化 4 | 5 | 所有的代码都应该使用 `prettier` 进行格式化,确保贡献者们在不同的 ide 和 editor 设置下不会产生无意义的空格修改,方便进行 code review。 6 | 7 | pre-commit hook 会自动运行 `prettier`。 8 | 9 | ## 代码风格 10 | 11 | 请确保 eslint 检查通过,如果认为某条 eslint 规则不合理也可以在 issue/PR 中提出。 12 | 13 | pre-commit hook 会对修改过的文件运行 `eslint --fix`。 14 | 15 | ## 代码注释 16 | 17 | 不需要为每一个函数/变量/类都添加注释。 18 | 19 | 但如果要为含量/变量/类添加注释,需要使用 [`tsdoc`](https://tsdoc.org/) (简单来说就是不需要写类型的 `jsdoc`)。 20 | 21 | 不要使用 `// ...` 注释函数/变量/类,[TypeScript 无法提取这样的注释](https://github.com/bangumi/frontend/pull/542#discussion_r1179033149),导致某些编辑器中无法正常显示提示。 22 | 23 | bad: 24 | 25 | ```ts 26 | // 对于过长的图片名,省略超出部分但保留文件扩展名 27 | function getShortName(name: string): string; 28 | ``` 29 | 30 | good: 31 | 32 | ```ts 33 | /** 34 | * 对于过长的图片名,省略超出部分但保留文件扩展名 35 | */ 36 | function getShortName(name: string): string; 37 | ``` 38 | 39 | 由于我们已经使用了 TypeScript, 所以在注释中不需要也不应该再重复注明类型 40 | 41 | bad: 42 | 43 | ```ts 44 | /** 45 | * 将图片以base64编码 46 | * @param {File} img 需要处理的图片 Blob 47 | * @return {[String, String]} 图片名与base64字符串 48 | */ 49 | const readAsBase64 = async (img: File): Promise<[string, string]>; 50 | ``` 51 | 52 | good: 53 | 54 | ```ts 55 | /** 56 | * 将图片以base64编码 57 | * @param img - 需要处理的图片 Blob 58 | * @return 图片名与base64字符串 59 | */ 60 | const readAsBase64 = async (img: File): Promise<[string, string]>; 61 | ``` 62 | -------------------------------------------------------------------------------- /docs/css-naming-convention.md: -------------------------------------------------------------------------------- 1 | # CSS 编码规范 2 | 3 | ## 组件库 CSS 编码规范 4 | 5 | 1. 组件库 CSS 命名规范由 BEM 派生出来。其中, 6 | 块(block)、元素(element)、修饰符(modifier)的概念在 BEM 中 7 | [定义](http://getbem.com/naming/)。 8 | 9 | 2. 一个组件本身应该成一个块,应使用 `.bgm-${组件名}` 作为容器的类。 10 | 11 | 3. 组件内部可以有任意多个元素,这些元素应使用 `.bgm-${组件名}__${元素}` 作为类。 12 | 13 | 4. 如果块或者元素需要根据状态(如可用态与禁用态)改变样式,则需要使用修饰符,`.${块/元素类名}--${修饰符}` 作为类。 14 | 15 | ## 页面 CSS 编码规范 16 | 17 | 1. 页面及页面级组件应该使用 CSS Module,避免样式的全局污染。 18 | -------------------------------------------------------------------------------- /docs/path-redirect.md: -------------------------------------------------------------------------------- 1 | ### 新旧站路由变化 2 | 3 | ```conf 4 | ; 编辑详细 5 | ^/subject/(\w+)/edit_detail /subject/$1/wiki/edit_detail 6 | ; 修订历史 7 | ^/subject/(\w+)/edit /subject/$1/wiki/history 8 | ; 新增封面 9 | ^/subject/(\w+)/upload_img /subject/$1/wiki/upload_img 10 | ; 关联角色 11 | ^/subject/(\w+)/add_related/character /subject/$1/wiki/relate_character 12 | ; 关联人物 13 | ^/subject/(\w+)/add_related/person /subject/$1/wiki/relate_person 14 | ; 关联条目 15 | ^/subject/(\w+)/add_related/person /subject/$1/wiki/relate_subject 16 | ``` 17 | -------------------------------------------------------------------------------- /docs/route.md: -------------------------------------------------------------------------------- 1 | # 路由 2 | 3 | website 项目使用约定式路由。由 [vite-plugin-pages](https://github.com/hannoeru/vite-plugin-pages) 插件提供路由能力。 4 | 路由约定方式选用了 [next.js 式的路由约定](https://nextjs.org/docs/routing/introduction)。 5 | 6 | 简略来说,`src/pages` 下方的所有组件都会按照路由约定渲染为单独的页面。除了以下情况: 7 | 8 | - `components` 中的组件,例如 `src/pages/a-page/components/AwesomeComponents.tsx` 就不会被当成页面渲染; 9 | - 以 `*.spec.ts` / `*.spec.tsx` / `*.test.ts` / `*.test.tsx` 结尾的测试文件; 10 | 11 | 路由有三种约定: 12 | 13 | - Index Routes: `index.tsx` 文件会被当作所含路由的根看待,例如: 14 | - `src/pages/index.tsx` -> `/` 15 | - `src/pages/subject/index.tsx` -> `/subject/` 16 | - Nested Routes: 如果页面在文件夹中,页面相对 `src/pages` 的路径会被自动当作路由看待,例如: 17 | - `src/pages/foo/bar.tsx` -> `/foo/bar` 18 | - Dynamic Routes: 如果文件或者文件夹名用 `[]` 包裹,它将会被当作动态参数,例如: 19 | - `src/pages/subject/[foo]/bar.tsx` -> `/subject/:foo/bar` 20 | - `src/pages/foo/[bar].tsx` --> `foo/:bar` 21 | - 上述参数可以通过 `react-router-dom` 的 `useParams` 获取到。 22 | 23 | 详细请见 [next.js 式的路由约定](https://nextjs.org/docs/routing/introduction)。 24 | 25 | ### Layout 26 | 27 | 请看 [关于 Layout 的讨论](https://github.com/bangumi/frontend/discussions/126) 28 | -------------------------------------------------------------------------------- /packages/client/common.ts: -------------------------------------------------------------------------------- 1 | import type { components } from './types'; 2 | 3 | export type { SlimUser, Profile } from './client'; 4 | export type Avatar = components['schemas']['Avatar']; 5 | 6 | export interface PaginationQuery { 7 | /** Limit */ 8 | limit: number; 9 | /** Offset */ 10 | offset: number; 11 | } 12 | 13 | export interface Pagination { 14 | /** Total */ 15 | total: number; 16 | } 17 | 18 | export interface ResponseWithPagination extends Pagination { 19 | data: T; 20 | } 21 | -------------------------------------------------------------------------------- /packages/client/index.ts: -------------------------------------------------------------------------------- 1 | export * as ozaClient from './client'; 2 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bangumi/client", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "description": "http client for private api", 6 | "main": "./index.ts", 7 | "private": true, 8 | "scripts": { 9 | "update-openapi": "node scripts/update-openapi.mjs", 10 | "build": "(node scripts/build.mjs) && (oazapfts --useEnumType api.yaml > client.ts) && (prettier -w ./)", 11 | "build2": "(OPENAPI_URL=http://127.0.0.1:4000/p1/openapi.json node scripts/update-openapi.mjs) && (node scripts/build.mjs) && (oazapfts api.yaml > client.ts) && (prettier -w ./)" 12 | }, 13 | "dependencies": { 14 | "@oazapfts/runtime": "^1.0.3" 15 | }, 16 | "devDependencies": { 17 | "oazapfts": "^6.2.1", 18 | "@apidevtools/json-schema-ref-parser": "^11.9.0", 19 | "@faker-js/faker": "^9.4.0", 20 | "change-case": "^5.4.4", 21 | "js-yaml": "^4.1.0", 22 | "msw": "~1.3.5", 23 | "node-fetch": "^3.3.2", 24 | "openapi-types": "^12.1.3", 25 | "openapi-typescript": "^7.6.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/client/readme.md: -------------------------------------------------------------------------------- 1 | # @bangumi/client 2 | 3 | 我们使用 [oazapfts](https://github.com/oazapfts/oazapfts) 从 openapi 定义直接生成 js API client 4 | 5 | 在大多数情况下 ci 会自动对 openapi 进行更新的,不需要手动操作。 6 | 7 | ## 使用 8 | 9 | ```typescript 10 | import { ozaClient } from '@bangumi/client'; 11 | 12 | async function request() { 13 | try { 14 | const res = await ozaClient.$apiCall(...); 15 | if (res.status === 200) { 16 | console.log('操作成功'); 17 | return 18 | } else if (res.status === 400) { 19 | // 在这里 res.data 可以正常进行 type narrow 20 | } else { 21 | console.log("未知错误:", res.data) 22 | } 23 | } catch (e: unknown) { 24 | // http 请求失败 25 | } 26 | } 27 | ``` 28 | 29 | 一般来说一个请求有两个错误需要处理: 30 | 31 | 1. fetch 因为 HTTP 请求未完成时抛出的 `Error` 32 | 2. HTTP 请求完成,但是响应不符合预期。 33 | 34 | 比如,当用户尝试上传封面而调用 `oazClient.uploadSubjectCover` 时,可能会因为用户不是维基人而返回 http code 401,也有可能因为用户上传的图片格式暂不支持而返回 http code 400。 35 | 36 | 这里需要针对响应的 `res.status.code` 进行判断,并且分别进行处理。 37 | 38 | 实际的使用例子: 39 | 40 | https://github.com/bangumi/frontend/blob/6d20bfd22f64b2f2d354d73094fe3aff0d549250/packages/website/src/hooks/use-user.tsx#L89 41 | 42 | 更多其他的 oazapfts 的 api (如 `ok`) 请查看文档 43 | 44 | https://github.com/oazapfts/oazapfts#consuming-the-generated-api 45 | -------------------------------------------------------------------------------- /packages/client/scripts/build.mjs: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | import yaml from 'js-yaml'; 6 | import openapiTS, { astToString } from 'openapi-typescript'; 7 | 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | 10 | /** 11 | * 12 | * @return {Promise} 13 | */ 14 | async function fetchSchema() { 15 | return yaml.load(await fs.readFile(path.resolve(__dirname, '..', 'api.yaml'), 'utf8'), {}); 16 | } 17 | 18 | async function generateType(schema) { 19 | const data = await openapiTS(schema, { additionalProperties: false }); 20 | 21 | await fs.mkdir(path.resolve(__dirname, '../types'), { recursive: true }); 22 | 23 | await fs.writeFile(path.resolve(__dirname, '../types/index.ts'), astToString(data)); 24 | } 25 | 26 | await generateType(await fetchSchema()); 27 | -------------------------------------------------------------------------------- /packages/client/scripts/update-openapi.mjs: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | import fetch from 'node-fetch'; 6 | 7 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 8 | 9 | const openapiURL = process.env.OPENAPI_URL || 'https://bangumi.github.io/dev-docs/api.yaml'; 10 | 11 | async function fetchSchema(url) { 12 | const res = await fetch(url); 13 | const text = await res.text(); 14 | await fs.writeFile(path.resolve(__dirname, '..', 'api.yaml'), text); 15 | } 16 | 17 | await fetchSchema(openapiURL); 18 | -------------------------------------------------------------------------------- /packages/client/topic.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | 3 | export type { Topic, Reply, ReplyBase, GroupTopic } from './client'; 4 | 5 | // https://github.com/drwpow/openapi-typescript/issues/941 6 | // https://github.com/oazapfts/oazapfts/pull/349 7 | export enum State { 8 | Normal = 0, 9 | Closed = 1, 10 | Reopen = 2, 11 | Silent = 5, 12 | DeletedByUser = 6, 13 | DeletedByAdmin = 7, 14 | } 15 | -------------------------------------------------------------------------------- /packages/client/types/utils.ts: -------------------------------------------------------------------------------- 1 | export interface ApiResponse { 2 | status: Status; 3 | ok: Status extends 200 | 204 ? true : false; 4 | headers: Headers; 5 | data: Data; 6 | } 7 | -------------------------------------------------------------------------------- /packages/client/user.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | 3 | // https://github.com/drwpow/openapi-typescript/issues/941 4 | export enum UserGroup { 5 | Admin = 1, 6 | BangumiAdmin = 2, 7 | DoujinAdmin = 3, 8 | MutedUser = 4, 9 | AccessForbiddenUser = 5, 10 | CharacterAdmin = 8, 11 | WikiAdmin = 9, 12 | User = 10, 13 | WikiContributor = 11, 14 | } 15 | 16 | export type { Notice as INotice } from './client'; 17 | -------------------------------------------------------------------------------- /packages/design/.gitignore: -------------------------------------------------------------------------------- 1 | storybook-static -------------------------------------------------------------------------------- /packages/design/.storybook/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 3 | system-ui, 4 | -apple-system, 5 | BlinkMacSystemFont, 6 | 'SF Pro SC', 7 | 'SF Pro Display', 8 | 'PingFang SC', 9 | 'Microsoft YaHei', 10 | 'Lucida Grande', 11 | 'Helvetica Neue', 12 | 'Segoe UI', 13 | Helvetica, 14 | Arial, 15 | Verdana, 16 | sans-serif; 17 | } 18 | -------------------------------------------------------------------------------- /packages/design/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path'; 2 | 3 | import type { StorybookConfig } from '@storybook/react-vite'; 4 | import svgr from 'vite-plugin-svgr'; 5 | 6 | export default { 7 | stories: [ 8 | '../components/**/*.stories.mdx', 9 | '../components/**/*.stories.@(js|jsx|ts|tsx)', 10 | '../../icons/index.stories.tsx', 11 | ], 12 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 13 | core: { 14 | builder: '@storybook/builder-vite', 15 | }, 16 | framework: { 17 | name: '@storybook/react-vite', 18 | options: {}, 19 | }, 20 | docs: { 21 | autodocs: true, 22 | }, 23 | viteFinal: (viteConfig) => { 24 | if (!viteConfig.build) { 25 | viteConfig.build = { sourcemap: true }; 26 | } else { 27 | viteConfig.build.sourcemap = true; 28 | } 29 | 30 | // workaround for vite build 31 | // Refs: https://github.com/eirslett/storybook-builder-vite/issues/55#issuecomment-871800293 32 | viteConfig.root = dirname(require.resolve('@storybook/builder-vite')); 33 | /* 34 | * About auto-generated component docs: 35 | * Please use FC instead of React.FC to declare component. 36 | * https://github.com/styleguidist/react-docgen-typescript/issues/323 37 | * https://github.com/styleguidist/react-docgen-typescript/issues/393 38 | * */ 39 | viteConfig.plugins ??= []; 40 | 41 | viteConfig.plugins.push(svgr()); 42 | return viteConfig; 43 | }, 44 | } satisfies StorybookConfig; 45 | -------------------------------------------------------------------------------- /packages/design/.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api'; 2 | import { themes } from '@storybook/theming'; 3 | 4 | addons.setConfig({ 5 | theme: themes.light, 6 | }); 7 | -------------------------------------------------------------------------------- /packages/design/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import 'reset-css'; 2 | import './global.css'; 3 | 4 | import type { Preview } from '@storybook/react'; 5 | import { themes } from '@storybook/theming'; 6 | 7 | export default { 8 | parameters: { 9 | actions: { argTypesRegex: '^on[A-Z].*' }, 10 | docs: { 11 | theme: themes.light, 12 | }, 13 | controls: { 14 | matchers: { 15 | color: /(background|color)$/i, 16 | date: /Date$/, 17 | }, 18 | }, 19 | }, 20 | } satisfies Preview; 21 | -------------------------------------------------------------------------------- /packages/design/.storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { "module": "ESNext" } 4 | } 5 | -------------------------------------------------------------------------------- /packages/design/components/Avatar/Avatar.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | import React from 'react'; 3 | 4 | import Avatar from '.'; 5 | 6 | const componentMeta: ComponentMeta = { 7 | title: 'modern/Avatar', 8 | component: Avatar, 9 | args: { 10 | src: 'https://lain.bgm.tv/pic/user/l/icon.jpg', 11 | size: 'small', 12 | }, 13 | }; 14 | export default componentMeta; 15 | 16 | const Template: ComponentStory = (args) => ; 17 | 18 | export const Usage = Template.bind({}); 19 | -------------------------------------------------------------------------------- /packages/design/components/Avatar/__test__/Avatar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import Avatar from '..'; 5 | 6 | it('Render a Avatar', () => { 7 | const { container } = render(); 8 | const img = container.children[0]; 9 | expect(img).toBeInTheDocument(); 10 | expect(img?.children[0]).toHaveAttribute('src', 'urlLink'); 11 | }); 12 | 13 | it('Avatar Size', () => { 14 | const { container, rerender } = render(); 15 | const img = container.children[0]; 16 | expect(img).toHaveClass('bgm-avatar'); 17 | expect(img).toHaveClass('bgm-avatar--small'); 18 | 19 | rerender(); 20 | 21 | expect(img).toHaveClass('bgm-avatar--medium'); 22 | }); 23 | -------------------------------------------------------------------------------- /packages/design/components/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import './style'; 2 | 3 | import classNames from 'classnames'; 4 | import type { FC } from 'react'; 5 | import React from 'react'; 6 | 7 | export interface AvatarProps { 8 | /** 头像大小 */ 9 | size?: 'small' | 'medium' | 'large'; 10 | /** 头像的 URL */ 11 | src: string; 12 | /** 替代文本 */ 13 | alt?: string; 14 | /** 自定义最外层类名 */ 15 | wrapperClass?: string; 16 | /** 自定义最外层样式 */ 17 | wrapperStyle?: React.CSSProperties; 18 | } 19 | 20 | const Avatar: FC = ({ size = 'small', src, alt, wrapperClass, wrapperStyle }) => { 21 | return ( 22 |
26 | {alt} 27 |
28 | ); 29 | }; 30 | export default Avatar; 31 | -------------------------------------------------------------------------------- /packages/design/components/Avatar/style/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../theme/base'; 2 | 3 | .bgm-avatar { 4 | display: inline-block; 5 | box-sizing: border-box; 6 | border-radius: 6px; 7 | border: 1px solid @gray-10; 8 | 9 | img { 10 | object-fit: cover; 11 | vertical-align: middle; 12 | border-radius: 6px; 13 | } 14 | 15 | &--small img { 16 | height: 40px; 17 | width: 40px; 18 | } 19 | 20 | &--medium img { 21 | height: 60px; 22 | width: 60px; 23 | } 24 | 25 | &--large img { 26 | height: 75px; 27 | width: 75px; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/design/components/Avatar/style/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | -------------------------------------------------------------------------------- /packages/design/components/Button/style/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | -------------------------------------------------------------------------------- /packages/design/components/CollapsibleContent/__test__/__snapshots__/CollapsibleContent.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`should render 1`] = ` 4 |
7 |
8 | bgm 9 |
10 |
11 | `; 12 | -------------------------------------------------------------------------------- /packages/design/components/CollapsibleContent/style/index.less: -------------------------------------------------------------------------------- 1 | .bgm-collapsible-content { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: flex-start; 5 | gap: 0.5rem; 6 | line-height: 24px; 7 | } 8 | -------------------------------------------------------------------------------- /packages/design/components/Divider/Divider.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentMeta, ComponentStory } from '@storybook/react'; 2 | import React from 'react'; 3 | 4 | import Divider from '.'; 5 | 6 | const componentMeta: ComponentMeta = { 7 | title: 'Grid/Divider', 8 | component: Divider, 9 | decorators: [(story) =>
{story()}
], 10 | }; 11 | 12 | export default componentMeta; 13 | 14 | const Template: ComponentStory = (args) => { 15 | const isListItem = args.isListItem; 16 | const orientation = args.orientation; 17 | 18 | if (isListItem) { 19 | return ( 20 |
    27 |
  • 想看
  • 28 | 29 |
  • 看过
  • 30 |
31 | ); 32 | } 33 | return ( 34 |
35 | 标题 36 | 37 | 文本 38 |
39 | ); 40 | }; 41 | 42 | export const Horizontal = Template.bind({}); 43 | Horizontal.args = { 44 | orientation: 'horizontal', 45 | isListItem: false, 46 | }; 47 | 48 | export const Vertical = Template.bind({}); 49 | Vertical.args = { 50 | orientation: 'vertical', 51 | isListItem: false, 52 | }; 53 | -------------------------------------------------------------------------------- /packages/design/components/Divider/__test__/Divider.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import Divider from '..'; 5 | 6 | describe('', () => { 7 | it('should be horizontal', () => { 8 | const orientation = 'horizontal'; 9 | const { getByRole } = render(); 10 | 11 | expect(getByRole('separator')).toBeInTheDocument(); 12 | }); 13 | 14 | it('should be vertical', () => { 15 | const orientation = 'vertical'; 16 | const { getByRole } = render(); 17 | 18 | expect(getByRole('separator')).toBeInTheDocument(); 19 | expect(getByRole('separator')).toHaveClass('bgm-divider--vertical'); 20 | }); 21 | 22 | it('should be list item', () => { 23 | const orientation = 'horizontal'; 24 | const { getByRole } = render(); 25 | 26 | expect(getByRole('separator')).toContainHTML('
  • 31 | ) : ( 32 |
    33 | )} 34 | 35 | ); 36 | }; 37 | 38 | export default Divider; 39 | -------------------------------------------------------------------------------- /packages/design/components/Divider/style/index.less: -------------------------------------------------------------------------------- 1 | @import '../../../theme/base'; 2 | 3 | .bgm-divider { 4 | height: 1px; 5 | width: 60%; 6 | margin: 0; 7 | border: none; 8 | background-color: @gray-10; 9 | } 10 | 11 | .bgm-divider--vertical { 12 | height: auto; 13 | width: 1px; 14 | } 15 | -------------------------------------------------------------------------------- /packages/design/components/Divider/style/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | -------------------------------------------------------------------------------- /packages/design/components/EditorForm/EditorForm.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryFn } from '@storybook/react'; 2 | import type { FC } from 'react'; 3 | import React, { useState } from 'react'; 4 | 5 | import EditorForm from '.'; 6 | import Editor from './Editor'; 7 | import Toolbox from './Toolbox'; 8 | 9 | const componentMeta: Meta = { 10 | title: 'Modern/EditorForm', 11 | component: EditorForm, 12 | subcomponents: { 13 | Toolbox, 14 | Editor, 15 | } as Record>, 16 | }; 17 | 18 | export default componentMeta; 19 | 20 | const Template: StoryFn = (args) => { 21 | const [value, setValue] = useState(args.value); 22 | return ; 23 | }; 24 | 25 | export const Usage = Template.bind({}); 26 | 27 | Usage.args = { 28 | placeholder: '请输入内容', 29 | }; 30 | -------------------------------------------------------------------------------- /packages/design/components/EditorForm/__test__/Toolbox.spec.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import Toolbox from '../Toolbox'; 5 | 6 | describe('EditorForm > Toolbox', () => { 7 | it('render with default classNames', () => { 8 | const { asFragment } = render(); 9 | expect(asFragment()).toMatchSnapshot(); 10 | }); 11 | 12 | it('Toolbox style props', () => { 13 | const { container } = render(); 14 | expect(container.firstChild).toHaveStyle('display: none'); 15 | }); 16 | 17 | it('Toolbox handleClickEvent props', () => { 18 | const handleClickEvent = vi.fn(); 19 | render(); 20 | for (const type of ['bold', 'italic', 'underscore', 'image', 'link', 'size']) { 21 | fireEvent.click(screen.getByTestId(type)); 22 | expect(handleClickEvent).toHaveBeenCalledWith(type); 23 | } 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /packages/design/components/EditorForm/__test__/__snapshots__/Editor.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`EditorForm > Editor > render correctly 1`] = ` 4 | 5 |
    8 |
    11 |
    14 |
    18 |
    22 |
    26 |
    30 |
    34 |
    38 |
    39 |
    40 |