├── .eslintignore ├── .env.example ├── .prettierrc.cjs ├── standalone-build.sh ├── .eslintrc.cjs ├── .vscode └── settings.json ├── src ├── constants │ └── env.ts ├── types │ ├── events.ts │ ├── global.d.ts │ ├── api │ │ ├── error.ts │ │ ├── index.ts │ │ ├── user.ts │ │ ├── aggregate.ts │ │ └── post.ts │ └── types.d.ts ├── assets │ └── styles │ │ ├── main.css │ │ ├── theme.css │ │ ├── juejin.less │ │ ├── color.css │ │ └── markdown │ │ └── smart-blue.css ├── services │ ├── index.ts │ ├── api │ │ ├── aggregate.ts │ │ ├── post.ts │ │ └── user.ts │ ├── tools.ts │ └── server.ts ├── components │ ├── universal │ │ ├── Icons │ │ │ ├── icon.module.less │ │ │ ├── TabIcon.tsx │ │ │ └── dark-mode.tsx │ │ ├── Card │ │ │ ├── __test__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── index.spec.tsx.snap │ │ │ │ └── index.spec.tsx │ │ │ └── index.tsx │ │ ├── Divider │ │ │ ├── __test__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── index.spec.tsx.snap │ │ │ │ └── index.spec.tsx │ │ │ └── index.tsx │ │ ├── Markdown │ │ │ ├── codeBlock │ │ │ │ └── index.tsx │ │ │ ├── renderers │ │ │ │ └── heading.tsx │ │ │ ├── index.tsx │ │ │ └── components.tsx │ │ ├── Image │ │ │ ├── __test__ │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── index.spec.tsx.snap │ │ │ │ └── index.spec.tsx │ │ │ └── index.tsx │ │ ├── Mermaid │ │ │ └── index.tsx │ │ ├── Toc │ │ │ ├── index.module.less │ │ │ ├── item.tsx │ │ │ └── index.tsx │ │ ├── CodeHighlighter │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ └── Logo │ │ │ └── index.tsx │ ├── in-page │ │ ├── Post │ │ │ └── aside │ │ │ │ ├── author.module.less │ │ │ │ ├── related.module.less │ │ │ │ ├── toc.tsx │ │ │ │ ├── author.tsx │ │ │ │ └── related.tsx │ │ └── Home │ │ │ ├── aside │ │ │ ├── author.module.less │ │ │ ├── appDownload.module.less │ │ │ ├── advertisement.module.less │ │ │ ├── appDownload.tsx │ │ │ ├── author.tsx │ │ │ └── advertisement.tsx │ │ │ ├── navbar.module.less │ │ │ ├── navbar.tsx │ │ │ ├── list.module.less │ │ │ └── list.tsx │ ├── layouts │ │ ├── ArticleLayout │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── BasicLayout │ │ │ ├── index.tsx │ │ │ └── Header │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ └── AppLayout.tsx │ ├── app │ │ ├── Error │ │ │ ├── no-config.tsx │ │ │ ├── no-data.tsx │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── WrapperNextPage │ │ │ └── index.tsx │ │ ├── LoadableView │ │ │ └── index.tsx │ │ └── ErrorBoundary │ │ │ └── index.tsx │ ├── widgets │ │ └── Author │ │ │ ├── index.module.less │ │ │ └── index.tsx │ └── biz │ │ ├── Meta │ │ └── head.tsx │ │ └── Seo │ │ └── index.tsx ├── store │ ├── index.ts │ ├── collections │ │ └── post.ts │ ├── root-store.ts │ ├── types.ts │ ├── helper │ │ ├── base.ts │ │ └── structure.ts │ └── app.ts ├── utils │ ├── env.ts │ ├── console.ts │ ├── cookie.ts │ ├── utils.ts │ ├── event-emitter.ts │ ├── load-script.ts │ ├── time.ts │ └── spring.ts ├── hooks │ ├── use-is-client.ts │ ├── use-resize-scroll-event.ts │ ├── use-check-old-browser.ts │ └── use-media-toggle.ts ├── test │ └── mountTest.tsx ├── context │ ├── initial-data.tsx │ └── root-store.tsx └── pages │ ├── _document.tsx │ ├── index.tsx │ ├── _error.tsx │ ├── _app.tsx │ └── post │ └── [id].tsx ├── .commitlintrc.cjs ├── public ├── favicon.ico └── juejin.svg ├── .husky ├── pre-commit └── commit-msg ├── .npmrc ├── next-env.d.ts ├── ecosystem.config.js ├── .github ├── renovate.json └── workflows │ ├── pull.yml │ ├── build.yml │ └── release.yml ├── vitest.config.ts ├── .gitignore ├── next.config.js ├── LICENSE ├── tsconfig.json ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | **/dist/** -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=http://127.0.0.1:7498 -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@suemor/prettier") -------------------------------------------------------------------------------- /standalone-build.sh: -------------------------------------------------------------------------------- 1 | npm run build 2 | zip -r release.zip .next/* 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require("@suemor/eslint-config-react-ts") -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["juejin"] 3 | } 4 | -------------------------------------------------------------------------------- /src/constants/env.ts: -------------------------------------------------------------------------------- 1 | export const API_URL = process.env.NEXT_PUBLIC_API_URL 2 | -------------------------------------------------------------------------------- /src/types/events.ts: -------------------------------------------------------------------------------- 1 | export enum CustomEventTypes { 2 | TOC = 'toc', 3 | } 4 | -------------------------------------------------------------------------------- /.commitlintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Bocchi-Developers/juejin/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- lint-staged -------------------------------------------------------------------------------- /src/assets/styles/main.css: -------------------------------------------------------------------------------- 1 | @import './color.css'; 2 | @import './theme.css'; 3 | @import './markdown/smart-blue.css'; -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | import { userApi } from './api/user' 2 | 3 | export const api = { 4 | ...userApi, 5 | } 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /src/components/universal/Icons/icon.module.less: -------------------------------------------------------------------------------- 1 | .icon { 2 | fill: var(--juejin-font-1); 3 | transition: fill 0.1s; 4 | } 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # https://zenn.dev/haxibami/scraps/083718c1beec04 2 | strict-peer-dependencies=false 3 | 4 | registry=https://registry.npmjs.org 5 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.module.less' { 2 | const classes: { readonly [key: string]: string } 3 | export default classes 4 | } 5 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { store, useRootStore } from '~/context/root-store' 2 | 3 | export const useStore = useRootStore 4 | export * from './root-store' 5 | export { store } 6 | -------------------------------------------------------------------------------- /src/types/api/error.ts: -------------------------------------------------------------------------------- 1 | export declare class RequestError extends Error { 2 | status: number 3 | path: string 4 | raw: any 5 | constructor(message: string, status: number, path: string, raw: any) 6 | } 7 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/types/api/index.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError } from 'axios' 2 | 3 | export type ApiResponse = Promise 4 | export type AxiosErrorConfig = AxiosError<{ 5 | code: number 6 | success: boolean 7 | message: string 8 | }> 9 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | export const isClientSide = () => { 2 | return typeof window !== 'undefined' 3 | } 4 | export const isServerSide = () => { 5 | return !isClientSide() 6 | } 7 | 8 | export const isDev = process.env.NODE_ENV === 'development' 9 | -------------------------------------------------------------------------------- /src/components/in-page/Post/aside/author.module.less: -------------------------------------------------------------------------------- 1 | .introduce { 2 | color: var(--juejin-font-1); 3 | font-size: 1.167rem; 4 | font-weight: 400; 5 | line-height: 25px; 6 | white-space: nowrap; 7 | overflow: hidden; 8 | text-overflow: ellipsis; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/universal/Card/__test__/__snapshots__/index.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`Card > snapshot 1`] = ` 4 |
5 |
8 |
11 |
12 |
13 | `; 14 | -------------------------------------------------------------------------------- /src/components/universal/Divider/__test__/__snapshots__/index.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`Card > snapshot 1`] = ` 4 |
5 | 11 | `; 12 | -------------------------------------------------------------------------------- /src/hooks/use-is-client.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | import { isClientSide } from '~/utils/env' 4 | 5 | export const useIsClient = () => { 6 | const [isClient, setIsClient] = useState(isClientSide()) 7 | 8 | useEffect(() => { 9 | setIsClient(true) 10 | }, []) 11 | return isClient 12 | } 13 | -------------------------------------------------------------------------------- /src/components/universal/Divider/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { DividerProps } from '@arco-design/web-react' 4 | import { Divider as ArcoDivider } from '@arco-design/web-react' 5 | 6 | export const Divider: FC = (props) => { 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'juejin', 5 | script: 'npx next start -p 7496', 6 | instances: 1, 7 | autorestart: true, 8 | watch: false, 9 | max_memory_restart: '180M', 10 | env: { 11 | NODE_ENV: 'production', 12 | }, 13 | }, 14 | ], 15 | } 16 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":automergePatch", 5 | ":automergeTesters", 6 | ":automergeLinters", 7 | ":rebaseStalePrs" 8 | ], 9 | "packageRules": [ 10 | { 11 | "updateTypes": ["major"], 12 | "labels": ["UPDATE-MAJOR"] 13 | } 14 | ], 15 | "ignoreDeps": ["next", "axios"] 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/console.ts: -------------------------------------------------------------------------------- 1 | export function printToConsole() { 2 | try { 3 | console.log( 4 | '%c 稀土掘金 %c https://github.com/Bocchi-Developers/juejin', 5 | 'color: #fff; margin: 1em 0; padding: 5px 0; background: #3498db;', 6 | 'margin: 1em 0; padding: 5px 0; background: #efefef;', 7 | ) 8 | } catch { 9 | /* empty */ 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/components/layouts/ArticleLayout/index.module.less: -------------------------------------------------------------------------------- 1 | .main-content { 2 | margin: 10rem auto 2rem auto; 3 | display: flex; 4 | gap: 1.5rem; 5 | max-width: 940px; 6 | .card-list { 7 | flex: 1; 8 | overflow: hidden; 9 | height: 100%; 10 | } 11 | 12 | .sidebar { 13 | display: flex; 14 | flex-direction: column; 15 | gap: 1.5rem; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/store/collections/post.ts: -------------------------------------------------------------------------------- 1 | import { PostApi } from '~/services/api/post' 2 | import type { IPostModel } from '~/types/api/post' 3 | 4 | import { Store } from '../helper/base' 5 | 6 | export class PostStore extends Store { 7 | async fetchBySlug(id: string) { 8 | const post = await PostApi.postById(id) 9 | this.add(post) 10 | return post 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/in-page/Post/aside/related.module.less: -------------------------------------------------------------------------------- 1 | .list { 2 | line-height: 22px; 3 | display: flex; 4 | flex-direction: column; 5 | gap: 1.2rem; 6 | 7 | a { 8 | color: var(--color-text); 9 | font-size: 1.2rem; 10 | font-weight: 500; 11 | transition: color 0.2s ease-in-out; 12 | 13 | &:hover { 14 | color: var(--juejin-font-brand2-hover); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/in-page/Home/aside/author.module.less: -------------------------------------------------------------------------------- 1 | 2 | .wrapper { 3 | :hover { 4 | background-color: var(--juejin-gray-1-4); 5 | } 6 | cursor: pointer; 7 | 8 | .introduce { 9 | color: var(--juejin-font-3); 10 | font-size: 1.167rem; 11 | font-weight: 400; 12 | line-height: 25px; 13 | white-space: nowrap; 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/in-page/Home/aside/appDownload.module.less: -------------------------------------------------------------------------------- 1 | .app { 2 | background-color: var(--juejin-item-background); 3 | display: flex; 4 | align-items: center; 5 | padding: 12px; 6 | gap: 16px; 7 | 8 | :first-child { 9 | color: var(--juejin-font-1); 10 | font-weight: 500; 11 | font-size: 14px; 12 | } 13 | 14 | :last-child { 15 | font-size: 12px; 16 | margin-top: 6px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/store/root-store.ts: -------------------------------------------------------------------------------- 1 | import AppUIStore from './app' 2 | import { PostStore } from './collections/post' 3 | 4 | export interface RootStore { 5 | appUIStore: AppUIStore 6 | postStore: PostStore 7 | } 8 | export class RootStore { 9 | constructor() { 10 | this.appUIStore = new AppUIStore() 11 | this.postStore = new PostStore() 12 | } 13 | 14 | get appStore() { 15 | return this.appUIStore 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/universal/Markdown/codeBlock/index.tsx: -------------------------------------------------------------------------------- 1 | import { Mermaid } from '~/components/universal/Mermaid' 2 | 3 | import { HighLighter } from '../../CodeHighlighter' 4 | 5 | export const CodeBlock = (props: { 6 | lang: string | undefined 7 | content: string 8 | }) => { 9 | if (props.lang === 'mermaid') { 10 | return 11 | } else { 12 | return 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/app/Error/no-config.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { ErrorView } from '.' 4 | 5 | export const NoConfigErrorView: FC = () => { 6 | return ( 7 | 12 |

出现这个错误表示未获取到配置文件

13 |

可能是 API 接口地址配置不正确,或者是配置文件不存在

14 | 15 | } 16 | /> 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/components/universal/Image/__test__/__snapshots__/index.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1 2 | 3 | exports[`Card > snapshot 1`] = ` 4 |
5 |
9 | 14 |
17 |
18 |
19 | `; 20 | -------------------------------------------------------------------------------- /src/components/universal/Card/__test__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import { render } from '@testing-library/react' 4 | 5 | import mountTest from '~/test/mountTest' 6 | 7 | import { Card } from '..' 8 | 9 | mountTest(Card) 10 | 11 | describe('Card', () => { 12 | it('snapshot', () => { 13 | const markdown = render() 14 | expect(markdown.container).matchSnapshot() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/universal/Image/__test__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import { render } from '@testing-library/react' 4 | 5 | import mountTest from '~/test/mountTest' 6 | 7 | import { Image } from '..' 8 | 9 | mountTest(Image) 10 | 11 | describe('Card', () => { 12 | it('snapshot', () => { 13 | const markdown = render() 14 | expect(markdown.container).matchSnapshot() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/universal/Divider/__test__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | 3 | import { render } from '@testing-library/react' 4 | 5 | import mountTest from '~/test/mountTest' 6 | 7 | import { Divider } from '..' 8 | 9 | mountTest(Divider) 10 | 11 | describe('Card', () => { 12 | it('snapshot', () => { 13 | const markdown = render() 14 | expect(markdown.container).matchSnapshot() 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/utils/cookie.ts: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie' 2 | 3 | export const TokenKey = 'juejin-token' 4 | 5 | /** 6 | * 带了 bearer 7 | */ 8 | export function getToken(): string | null { 9 | const token = Cookies.get(TokenKey) 10 | return token ? `bearer ${token}` : null 11 | } 12 | 13 | export function setToken(token: string) { 14 | if (typeof token !== 'string') { 15 | return 16 | } 17 | return Cookies.set(TokenKey, token, { 18 | expires: 30, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/universal/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { CardProps } from '@arco-design/web-react' 4 | import { Card as ArcoCard } from '@arco-design/web-react' 5 | 6 | export const Card: FC = (props) => { 7 | return ( 8 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/services/api/aggregate.ts: -------------------------------------------------------------------------------- 1 | import type { ApiResponse } from '~/types/api' 2 | import type { IAggregate, IAggregateHome } from '~/types/api/aggregate' 3 | 4 | import { Get } from '../server' 5 | 6 | function aggregateInfoRequest(): ApiResponse { 7 | return Get('/aggregate') 8 | } 9 | 10 | function aggregateHomeRequest(size: number): ApiResponse { 11 | return Get(`/aggregate/home/${size}`) 12 | } 13 | 14 | export const AggregateApi = { 15 | aggregateInfoRequest, 16 | aggregateHomeRequest, 17 | } 18 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | import react from '@vitejs/plugin-react' 5 | 6 | export default defineConfig({ 7 | // @ts-ignore 8 | plugins: [react()], 9 | test: { 10 | environment: 'jsdom', 11 | passWithNoTests: true, 12 | exclude: ['**/node_modules/**', '**/dist/**'], 13 | threads: true, 14 | }, 15 | resolve: { 16 | alias: { 17 | '~': path.resolve(__dirname, './src'), 18 | }, 19 | }, 20 | assetsInclude: ['**/*.md'], 21 | }) 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # next.js build output 5 | .next 6 | 7 | # typescript build output 8 | dist 9 | .env 10 | 11 | #for debugging components 12 | /src/pages/test.tsx 13 | /src/assets/styles/test.module.css 14 | # pwa 15 | sw.js 16 | sw.js.* 17 | workbox-*.js 18 | workbox-*.js.* 19 | pages/debug.tsx 20 | /temp 21 | 22 | pages/dev 23 | 24 | patch/dist 25 | tmp 26 | out 27 | release.zip 28 | 29 | run 30 | .idea 31 | .DS_Store 32 | 33 | src/pages/dev 34 | 35 | public/md.md 36 | 37 | json-schema.json -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const withLess = require('next-with-less') 2 | 3 | const bundleAnalyzer = require('@next/bundle-analyzer')({ 4 | enabled: process.env.ANALYZE === 'true', 5 | }) 6 | 7 | const plugins = [bundleAnalyzer, withLess] 8 | 9 | /** @type {import('next').NextConfig} */ 10 | const bookstairsConfig = { 11 | reactStrictMode: false, 12 | images: { 13 | domains: ['qiniu.suemor.com', 'y.suemor.com'], 14 | }, 15 | } 16 | 17 | module.exports = plugins.reduce( 18 | (config, plugin) => plugin(config), 19 | bookstairsConfig, 20 | ) 21 | -------------------------------------------------------------------------------- /src/components/app/Error/no-data.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import { API_URL } from '~/constants/env' 4 | 5 | import { ErrorView } from '.' 6 | 7 | export const NoDataErrorView: FC = () => { 8 | return ( 9 | 15 |

出现这个错误表示未获取到初始数据

16 |

可能是 API 接口地址配置不正确,或者后端服务出现异常

17 |

API 地址:{API_URL}

18 | 19 | } 20 | /> 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/widgets/Author/index.module.less: -------------------------------------------------------------------------------- 1 | .author { 2 | display: flex; 3 | gap: 0.8em; 4 | &-info { 5 | min-width: 0; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | > a { 10 | color: var(--juejin-font-1); 11 | font-weight: 500; 12 | display: inline-block; 13 | vertical-align: top; 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | white-space: nowrap; 17 | font-size: 1.333rem; 18 | font-weight: 500; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/services/api/post.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IPostListParam, 3 | IPostListResponse, 4 | IPostModel, 5 | } from '~/types/api/post' 6 | 7 | import type { ApiResponse } from '../../types/api' 8 | import { Get } from '../server' 9 | 10 | function postListRequest( 11 | param: IPostListParam, 12 | ): ApiResponse { 13 | return Get('/post', param) 14 | } 15 | 16 | function postById(id: string): ApiResponse { 17 | return Get(`/post/${id}`) 18 | } 19 | 20 | export const PostApi = { 21 | postListRequest, 22 | postById, 23 | } 24 | -------------------------------------------------------------------------------- /src/test/mountTest.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { describe, expect, it } from 'vitest' 3 | 4 | import { render } from '@testing-library/react' 5 | 6 | export default function mountTest( 7 | Component: React.ComponentType | React.ComponentType, 8 | ) { 9 | describe(`mount and unmount`, () => { 10 | it(`component could be updated and unmounted without errors`, () => { 11 | const wrapper = render() 12 | expect(() => { 13 | wrapper.rerender() 14 | wrapper.unmount() 15 | }).not.toThrow() 16 | }) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/context/initial-data.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, PropsWithChildren } from 'react' 2 | import { createContext, memo, useMemo } from 'react' 3 | 4 | import type { IAggregate } from '~/types/api/aggregate' 5 | 6 | export const InitialContext = createContext({} as IAggregate) 7 | 8 | export const InitialContextProvider: FC< 9 | PropsWithChildren<{ value: IAggregate }> 10 | > = memo((props) => { 11 | return ( 12 | ({ ...props.value }), [props.value])} 14 | > 15 | {props.children} 16 | 17 | ) 18 | }) 19 | -------------------------------------------------------------------------------- /src/components/in-page/Home/aside/advertisement.module.less: -------------------------------------------------------------------------------- 1 | .ad { 2 | position: relative; 3 | div:last-child { 4 | position: absolute; 5 | right: 10px; 6 | bottom: 10px; 7 | font-size: 0.5rem; 8 | z-index: 1; 9 | padding: 0.5px 5px; 10 | color: #fff; 11 | background-color: rgba(0, 0, 0, 0.2); 12 | border: 0.5px solid #fff; 13 | border-radius: 2px; 14 | 15 | :first-child { 16 | display: none; 17 | } 18 | &:hover :first-child { 19 | display: inline; 20 | } 21 | &:hover { 22 | background-color: rgba(0, 0, 0, 0.4); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/in-page/Post/aside/toc.tsx: -------------------------------------------------------------------------------- 1 | import dynamic from 'next/dynamic' 2 | import type { FC } from 'react' 3 | 4 | import { Card } from '../../../universal/Card' 5 | import type { TocProps } from '../../../universal/Toc' 6 | 7 | const Toc = dynamic( 8 | () => import('~/components/universal/Toc').then((m) => m.Toc), 9 | { 10 | ssr: false, 11 | }, 12 | ) 13 | 14 | export const MarkdownToc: FC = (props) => { 15 | return ( 16 | 23 | 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/store/types.ts: -------------------------------------------------------------------------------- 1 | import type { UrlObject } from 'url' 2 | 3 | export declare enum LayoutType { 4 | Post, 5 | Note, 6 | Page, 7 | Home, 8 | Project, 9 | Music, 10 | Bangumi, 11 | Custom, 12 | } 13 | 14 | export interface MenuModel { 15 | title: string 16 | type?: keyof typeof LayoutType 17 | path: string 18 | subMenu?: MenuModel[] 19 | icon?: JSX.Element | string 20 | as?: string | UrlObject 21 | independent?: boolean 22 | } 23 | 24 | export interface ViewportRecord { 25 | w: number 26 | h: number 27 | mobile: boolean 28 | pad: boolean 29 | hpad: boolean 30 | wider: boolean 31 | widest: boolean 32 | } 33 | -------------------------------------------------------------------------------- /public/juejin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/services/tools.ts: -------------------------------------------------------------------------------- 1 | import { message } from 'react-message-popup' 2 | 3 | import { getToken } from '~/utils/cookie' 4 | 5 | export const handleConfigureAuth = (config: any) => { 6 | config.headers['authorization'] = getToken() || '' 7 | return config 8 | } 9 | 10 | export const handleNetworkError = ( 11 | success?: boolean, 12 | msg?: string | string[], 13 | ): void => { 14 | if (success) { 15 | message.error('未知错误') 16 | return 17 | } 18 | if (typeof msg == 'string') { 19 | message.error(msg) 20 | } else if (typeof msg == 'object') { 21 | message.error(msg.map((item: string) => item).join(' ')) 22 | } else { 23 | message.error('服务器异常') 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/universal/Mermaid/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { useInsertionEffect } from 'react' 3 | 4 | import { loadScript } from '~/utils/load-script' 5 | 6 | export const Mermaid: FC<{ content: string }> = (props) => { 7 | useInsertionEffect(() => { 8 | loadScript( 9 | 'https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/mermaid/8.9.0/mermaid.min.js', 10 | ).then(() => { 11 | if (window.mermaid) { 12 | window.mermaid.initialize({ 13 | theme: 'default', 14 | startOnLoad: false, 15 | }) 16 | window.mermaid.init(undefined, '.mermaid') 17 | } 18 | }) 19 | }, []) 20 | return
{props.content}
21 | } 22 | -------------------------------------------------------------------------------- /src/components/universal/Toc/index.module.less: -------------------------------------------------------------------------------- 1 | .toc { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 10px; 5 | .toc-link { 6 | position: relative; 7 | padding: 8px 0; 8 | color: var(--juejin-font-1); 9 | border-radius: 4px; 10 | &:hover { 11 | background-color: var(--juejin-toc-hover); 12 | } 13 | } 14 | 15 | .last { 16 | background-color: var(--juejin-toc-hover); 17 | color: var(--juejin-toc-blue); 18 | } 19 | 20 | .active::before { 21 | content: ''; 22 | position: absolute; 23 | top: 4px; 24 | left: 0; 25 | margin-top: 7px; 26 | width: 4px; 27 | height: 16px; 28 | background: var(--juejin-toc-blue); 29 | border-radius: 0 4px 4px 0; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const resolveUrl = (pathname: string | undefined, base: string) => { 2 | return base.replace(/\/$/, '').concat(pathname || '') 3 | } 4 | 5 | export const escapeHTMLTag = (html: string) => { 6 | const lt = //g, 8 | ap = /'/g, 9 | ic = /"/g 10 | return html 11 | .toString() 12 | .replace(lt, '<') 13 | .replace(gt, '>') 14 | .replace(ap, ''') 15 | .replace(ic, '"') 16 | } 17 | 18 | const _noop = /* @__PURE__ */ {} 19 | export const noop = /* @__PURE__ */ new Proxy(_noop, { 20 | get() { 21 | return noop 22 | }, 23 | apply() { 24 | // eslint-disable-next-line prefer-rest-params 25 | return Reflect.apply(noop, this, arguments) 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /src/types/api/user.ts: -------------------------------------------------------------------------------- 1 | export interface LoginReponseType { 2 | username: string 3 | token: string 4 | expiresIn: number 5 | } 6 | 7 | export interface LoginRequestType { 8 | username: string 9 | password: string 10 | } 11 | 12 | export interface UserModel { 13 | _id: string 14 | username: string 15 | avatar: string 16 | introduce: string 17 | socialIds: SocialIds 18 | } 19 | 20 | interface SocialIds { 21 | bilibili: string 22 | twitter: string 23 | qq: string 24 | github: string 25 | } 26 | 27 | export interface UserForm { 28 | username: string 29 | password: string 30 | confirmPassword: string 31 | } 32 | 33 | export interface UserFormDetail { 34 | avatar?: string 35 | mail?: string 36 | introduce?: string 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/components/app/Error/index.module.less: -------------------------------------------------------------------------------- 1 | .error { 2 | color: currentColor; 3 | height: calc(100vh - 16rem); 4 | text-align: center; 5 | display: flex; 6 | flex-direction: column; 7 | align-items: center; 8 | justify-content: center; 9 | } 10 | 11 | .error h1 { 12 | display: inline-block; 13 | border-right: 1px solid rgb(0 0 0 / 30%); 14 | margin: 0 20px 0 0; 15 | padding: 0px 23px 0px 0; 16 | font-size: 24px; 17 | font-weight: 500; 18 | vertical-align: top; 19 | } 20 | 21 | .error .desc { 22 | display: inline-block; 23 | text-align: left; 24 | line-height: 39px; 25 | height: 49px; 26 | vertical-align: middle; 27 | } 28 | 29 | .error .desc h2 { 30 | font-size: 14px; 31 | font-weight: normal; 32 | line-height: inherit; 33 | margin: 0; 34 | padding: 0; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/universal/CodeHighlighter/index.module.less: -------------------------------------------------------------------------------- 1 | .code-wrap { 2 | position: relative; 3 | font-size: .9em; 4 | } 5 | 6 | .copy-tip { 7 | position: absolute; 8 | right: 2em; 9 | top: 4em; 10 | opacity: 0.4; 11 | font-size: 0.6em; 12 | font-weight: 600; 13 | text-transform: uppercase; 14 | cursor: pointer; 15 | transition: opacity 0.5s; 16 | will-change: opacity; 17 | user-select: none; 18 | 19 | &:hover { 20 | opacity: 1; 21 | } 22 | 23 | &::after { 24 | content: ''; 25 | bottom: -3px; 26 | position: absolute; 27 | left: 3px; 28 | right: 3px; 29 | height: 1px; 30 | background-color: currentColor; 31 | } 32 | } 33 | 34 | .language-tip { 35 | position: absolute; 36 | right: 1em; 37 | transform: translate(-0.5em, 1.2em); 38 | font-size: 0.8em; 39 | opacity: 0.7; 40 | z-index: 1; 41 | } 42 | -------------------------------------------------------------------------------- /src/components/in-page/Post/aside/author.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | 3 | import { SidebarContext } from '~/components/layouts/ArticleLayout' 4 | import { Card } from '~/components/universal/Card' 5 | import { Author } from '~/components/widgets/Author' 6 | import { useStore } from '~/store' 7 | 8 | import styles from './author.module.less' 9 | 10 | export const PostAuthor = () => { 11 | const { postStore } = useStore() 12 | const sideBarContext = useContext(SidebarContext) 13 | const post = sideBarContext?.postId && postStore.get(sideBarContext.postId) 14 | if (!post) { 15 | return
render error
16 | } 17 | return ( 18 | 19 | {post?.user.introduce} 23 | } 24 | /> 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/components/in-page/Home/navbar.module.less: -------------------------------------------------------------------------------- 1 | .view-nav { 2 | position: fixed; 3 | left: 0; 4 | background-color: var(--juejin-layer-1); 5 | top: 5rem; 6 | width: 100%; 7 | height: 3.833rem; 8 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 5%); 9 | z-index: 200; 10 | transition: all 0.2s; 11 | } 12 | 13 | 14 | .nav-list{ 15 | position: relative; 16 | max-width: 960px; 17 | overflow-x: auto; 18 | height: 100%; 19 | margin: auto; 20 | display: flex; 21 | align-items: center; 22 | line-height: 1; 23 | word-break:keep-all; 24 | .nav-item{ 25 | display: flex; 26 | align-items: center; 27 | height: 100%; 28 | padding: 0 1rem; 29 | font-size: 1.167rem; 30 | color: var(--juejin-text-1); 31 | cursor: pointer; 32 | &.active{ 33 | color: var(--juejin-brand-2-hover); 34 | } 35 | &:hover{ 36 | color: var(--juejin-brand-2-hover); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/components/in-page/Post/aside/related.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { useContext } from 'react' 3 | 4 | import { SidebarContext } from '~/components/layouts/ArticleLayout' 5 | import { Card } from '~/components/universal/Card' 6 | import { useStore } from '~/store' 7 | 8 | import style from './related.module.less' 9 | 10 | export const RelatedPost = () => { 11 | const { postStore } = useStore() 12 | const sideBarContext = useContext(SidebarContext) 13 | const post = sideBarContext?.postId && postStore.get(sideBarContext.postId) 14 | if (!post) { 15 | return
render error
16 | } 17 | 18 | return ( 19 | 20 |
21 | {post.related.map(({ title, _id }) => ( 22 | 23 | {title} 24 | 25 | ))} 26 |
27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/components/biz/Meta/head.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import type { FC } from 'react' 3 | import React, { memo, useContext } from 'react' 4 | 5 | import { API_URL } from '~/constants/env' 6 | import { InitialContext } from '~/context/initial-data' 7 | import { isDev } from '~/utils/env' 8 | 9 | export const DynamicHeadMeta: FC = memo(() => { 10 | const { seo } = useContext(InitialContext) 11 | return ( 12 | 13 | 14 | 18 | {!isDev ? ( 19 | 23 | ) : null} 24 | 25 | {seo.keywords && ( 26 | 27 | )} 28 | 29 | ) 30 | }) 31 | -------------------------------------------------------------------------------- /src/components/app/WrapperNextPage/index.tsx: -------------------------------------------------------------------------------- 1 | // import { AxiosError } from 'axios' 2 | import isNumber from 'lodash-es/isNumber' 3 | import type { NextPage } from 'next' 4 | import { wrapperNextPage as wrapper } from 'next-suspense' 5 | 6 | import { Spin } from '@arco-design/web-react' 7 | 8 | import { RequestError } from '~/services/server' 9 | 10 | import { ErrorView } from '../Error' 11 | 12 | export function wrapperNextPage(Page: NextPage) { 13 | return wrapper(Page, { 14 | LoadingComponent: () => , 15 | ErrorComponent: ({ error }) => { 16 | let code: any 17 | if (error instanceof RequestError) { 18 | code = isNumber(error.response?.status) ? error.response?.status : 408 19 | } 20 | 21 | return ( 22 | 27 | ) 28 | }, 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/components/universal/Image/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite' 2 | import NextImage from 'next/image' 3 | import type { ImageProps as NextImageProps } from 'next/image' 4 | import type { FC } from 'react' 5 | import { createElement } from 'react' 6 | 7 | import type { ImageProps } from '@arco-design/web-react' 8 | import { Image as ArcoImage } from '@arco-design/web-react' 9 | 10 | import { useStore } from '~/store' 11 | 12 | interface OtherImageProps extends ImageProps { 13 | dark?: boolean 14 | } 15 | 16 | export const Image: FC = ({ dark, ...rest }) => { 17 | return createElement(ArcoImage, { 18 | style: { opacity: dark ? '0.85' : '1' }, 19 | ...rest, 20 | }) 21 | } 22 | 23 | export const ImageNext: FC = observer(({ ...rest }) => { 24 | const { appStore } = useStore() 25 | return createElement(NextImage, { 26 | style: { opacity: appStore.colorMode == 'dark' ? '0.85' : '1' }, 27 | ...rest, 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/types/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | 3 | import type { InitialDataType } from '~/context/initial-data' 4 | 5 | import 'react-dom/next' 6 | 7 | declare global { 8 | export interface History { 9 | backPath: string[] 10 | } 11 | export interface Window { 12 | [key: string]: any 13 | 14 | data?: InitialDataType 15 | 16 | umami?: Umami 17 | } 18 | 19 | export type IdProps = { id: string } 20 | export type PageOnlyProps = FC 21 | 22 | interface Umami { 23 | (event: string): void 24 | trackEvent( 25 | event_value: string, 26 | event_type: string, 27 | url?: string, 28 | website_id?: string, 29 | ): void 30 | 31 | trackView(url?: string, referrer?: string, website_id?: string): void 32 | } 33 | } 34 | 35 | declare module 'react' { 36 | export interface HTMLAttributes extends AriaAttributes, DOMAttributes { 37 | 'data-hide-print'?: boolean 38 | 'aria-hidden'?: boolean 39 | } 40 | } 41 | 42 | export {} 43 | -------------------------------------------------------------------------------- /src/components/universal/Icons/TabIcon.tsx: -------------------------------------------------------------------------------- 1 | import style from './icon.module.less' 2 | 3 | export const Expand = () => { 4 | return ( 5 | 13 | 17 | 18 | ) 19 | } 20 | export const Collapse = () => { 21 | return ( 22 | 30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/services/api/user.ts: -------------------------------------------------------------------------------- 1 | import type { ApiResponse } from '../../types/api' 2 | import type { 3 | LoginReponseType, 4 | LoginRequestType, 5 | UserForm, 6 | UserFormDetail, 7 | UserModel, 8 | } from '../../types/api/user' 9 | import { Get, Patch, Post } from '../server' 10 | 11 | function userInfoRequest(): ApiResponse { 12 | return Get('/user') 13 | } 14 | 15 | function loginRequest( 16 | user: LoginRequestType, 17 | ): ApiResponse { 18 | return Post('/user/login', user) 19 | } 20 | 21 | function authToken(): ApiResponse { 22 | return Get('/user/check_logged') 23 | } 24 | 25 | function registerRequest( 26 | user: Omit, 27 | ): ApiResponse { 28 | return Post('/user/register', user) 29 | } 30 | 31 | function updateDetailRequest(user: UserFormDetail): ApiResponse { 32 | return Patch('/user', user) 33 | } 34 | 35 | export const userApi = { 36 | userInfoRequest, 37 | loginRequest, 38 | authToken, 39 | registerRequest, 40 | updateDetailRequest, 41 | } 42 | -------------------------------------------------------------------------------- /src/components/layouts/BasicLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite' 2 | import type { FC, PropsWithChildren } from 'react' 3 | import React, { useEffect } from 'react' 4 | 5 | import { ConfigProvider } from '@arco-design/web-react' 6 | 7 | import { store } from '~/store' 8 | import { springScrollToElement } from '~/utils/spring' 9 | 10 | import Header from './Header' 11 | 12 | export const BasicLayout: FC = observer(({ children }) => { 13 | useEffect(() => { 14 | store.appUIStore.updateViewport() 15 | 16 | if (location.hash) { 17 | const id = location.hash.replace(/^#/, '') 18 | setTimeout(() => { 19 | const $el = document.getElementById(decodeURIComponent(id)) 20 | $el && springScrollToElement($el, 1000, -window.innerHeight / 2 + 100) 21 | }, 1050) 22 | } 23 | window.onresize = () => { 24 | store.appUIStore.updateViewport() 25 | } 26 | }, []) 27 | return ( 28 | 29 |
30 |
{children}
31 | 32 | ) 33 | }) 34 | -------------------------------------------------------------------------------- /src/components/app/LoadableView/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite' 2 | import type { NextPage } from 'next' 3 | import React, { Fragment, memo, useEffect } from 'react' 4 | 5 | import { Spin } from '@arco-design/web-react' 6 | 7 | import type { Store } from '~/store/helper/base' 8 | 9 | export const LoadableView = memo<{ 10 | data?: null | undefined | object 11 | children?: any 12 | }>(({ data, ...props }) => { 13 | if (!data) { 14 | return 15 | } 16 | 17 | return React.createElement(Fragment, props.children) 18 | }) 19 | 20 | type Props = { id: string; [k: string]: any } 21 | export function buildStoreDataLoadableView( 22 | store: Store, 23 | View: PageOnlyProps, 24 | ): NextPage { 25 | return observer((props: Props) => { 26 | const post = store.get(props.id) 27 | // eslint-disable-next-line react-hooks/rules-of-hooks 28 | useEffect(() => { 29 | store.add(props) 30 | }, [props.id]) 31 | 32 | if (!post) { 33 | return 34 | } 35 | return 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/types/api/aggregate.ts: -------------------------------------------------------------------------------- 1 | export interface IAggregate { 2 | user: User 3 | seo: Seo 4 | tab: TabModule[] 5 | } 6 | 7 | interface Seo { 8 | _id: string 9 | title: string 10 | description: string 11 | keywords: string[] 12 | created: string 13 | } 14 | 15 | interface User { 16 | _id: string 17 | username: string 18 | admin: boolean 19 | created: string 20 | avatar: string 21 | introduce: string 22 | } 23 | 24 | export interface TabModule { 25 | _id: string 26 | title: string 27 | created: string 28 | tag?: string 29 | slug: string 30 | updatedAt: string 31 | } 32 | 33 | export interface IAggregateHome { 34 | category: Category[] 35 | ad: Ad 36 | authorRank: AuthorRank[] 37 | } 38 | 39 | interface AuthorRank { 40 | _id: string 41 | username: string 42 | avatar: string 43 | introduce: string 44 | } 45 | 46 | interface Ad { 47 | _id: string 48 | adHref: string 49 | phoUrl: string 50 | putAdHref: string 51 | created: string 52 | } 53 | 54 | interface Category { 55 | _id?: string 56 | name: string 57 | slug: string 58 | created?: string 59 | } 60 | -------------------------------------------------------------------------------- /src/components/in-page/Home/aside/appDownload.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import Link from 'next/link' 3 | import { useContext } from 'react' 4 | 5 | import { SidebarContext } from '~/components/layouts/ArticleLayout' 6 | import { ImageNext } from '~/components/universal/Image' 7 | 8 | import styles from './appDownload.module.less' 9 | 10 | export const AppDownload = () => { 11 | const sidebarContex = useContext(SidebarContext) 12 | 13 | return ( 14 | 23 |
24 | 31 | 32 |
33 |

下载稀土掘金APP

34 |

一个帮助开发者成长的社区

35 |
36 |
37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Bocchi-Developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types/api/post.ts: -------------------------------------------------------------------------------- 1 | export interface IPaginate { 2 | pageCurrent: number 3 | pageSize: number 4 | } 5 | 6 | export type Sort = 7 | | 'newest' 8 | | 'three_days_hottest' 9 | | 'weekly_hottest' 10 | | 'monthly_hottest' 11 | | 'hottest' 12 | 13 | export interface IPostListParam extends IPaginate { 14 | categoryId?: string 15 | tag?: string 16 | sort?: Sort 17 | category?: string 18 | } 19 | 20 | export interface IPostListResponse { 21 | postList: IPostList[] 22 | totalCount: number 23 | totalPages: number 24 | } 25 | 26 | export interface IPostList { 27 | _id: string 28 | id: string 29 | title: string 30 | tags: string[] 31 | category: Category 32 | user: User 33 | cover?: string 34 | created: Date 35 | content: string 36 | ad: boolean 37 | read: number 38 | } 39 | 40 | export interface IPostModel extends IPostList { 41 | related: IPostList[] 42 | } 43 | 44 | interface Category { 45 | _id: string 46 | name: string 47 | slug: string 48 | created: Date 49 | } 50 | 51 | interface User { 52 | _id: string 53 | username: string 54 | admin: boolean 55 | created: Date 56 | avatar: string 57 | introduce: string 58 | } 59 | -------------------------------------------------------------------------------- /src/components/layouts/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite' 2 | import { NextSeo } from 'next-seo' 3 | import type { FC, PropsWithChildren } from 'react' 4 | import { useContext, useEffect } from 'react' 5 | 6 | import { InitialContext } from '~/context/initial-data' 7 | import { useCheckOldBrowser } from '~/hooks/use-check-old-browser' 8 | import { useResizeScrollEvent } from '~/hooks/use-resize-scroll-event' 9 | import { printToConsole } from '~/utils/console' 10 | import { isDev } from '~/utils/env' 11 | 12 | import { DynamicHeadMeta } from '../biz/Meta/head' 13 | 14 | export const Content: FC = observer((props) => { 15 | const { check: checkBrowser } = useCheckOldBrowser() 16 | const { seo } = useContext(InitialContext) 17 | useResizeScrollEvent() 18 | useEffect(() => { 19 | checkBrowser() 20 | printToConsole() 21 | }, []) 22 | 23 | return ( 24 | <> 25 | 26 | 开发测试版本!!!!!!!!!' : '' 29 | }`} 30 | description={seo.description} 31 | /> 32 | 33 | 34 | ) 35 | }) 36 | -------------------------------------------------------------------------------- /src/components/in-page/Home/aside/author.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | 3 | import styles from '~/components/in-page/Home/aside/author.module.less' 4 | import { Card } from '~/components/universal/Card' 5 | import { Author } from '~/components/widgets/Author' 6 | import { HomeSidebarContext } from '~/pages' 7 | 8 | export const AuthorRecommend = () => { 9 | const homeContext = useContext(HomeSidebarContext) 10 | 11 | return ( 12 | 13 | {homeContext?.authorRank.map((user) => ( 14 |
window.open('', '_ blank')} 20 | > 21 | {user.introduce} 25 | } 26 | usernameStyle={{ fontSize: '1.16rem' }} 27 | style={{ padding: '1rem' }} 28 | /> 29 |
30 | ))} 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "experimentalDecorators": true, 5 | "noEmit": true, 6 | "strict": true, 7 | "target": "esnext", 8 | "module": "esnext", 9 | "jsx": "preserve", 10 | "allowJs": true, 11 | "moduleResolution": "node", 12 | "allowSyntheticDefaultImports": true, 13 | "removeComments": false, 14 | "preserveConstEnums": true, 15 | "sourceMap": true, 16 | "skipLibCheck": true, 17 | "baseUrl": "./src", 18 | "noImplicitAny": false, 19 | "useDefineForClassFields": true, 20 | "typeRoots": [ 21 | "./node_modules/@types" 22 | ], 23 | "lib": [ 24 | "dom" 25 | ], 26 | "outDir": ".next", 27 | "forceConsistentCasingInFileNames": true, 28 | "esModuleInterop": true, 29 | "resolveJsonModule": true, 30 | "isolatedModules": true, 31 | "incremental": true, 32 | "paths": { 33 | "~/*": [ 34 | "./*" 35 | ], 36 | "~": [ 37 | "." 38 | ], 39 | } 40 | }, 41 | "exclude": [ 42 | ".next", 43 | "server/**/*.*", 44 | ], 45 | "include": [ 46 | "next-env.d.ts", 47 | "**/*.ts", 48 | "**/*.tsx" 49 | , "src/utils/md2html.ts" ] 50 | } -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Checker 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | 9 | strategy: 10 | matrix: 11 | node-version: [16.x, 18.x] 12 | os: [ubuntu-latest, macos-latest] 13 | 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Cache pnpm modules 23 | uses: actions/cache@v3 24 | env: 25 | cache-name: cache-pnpm-modules 26 | with: 27 | path: ~/.pnpm-store 28 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 29 | restore-keys: | 30 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}- 31 | - uses: pnpm/action-setup@v2.2.4 32 | with: 33 | version: latest 34 | run_install: true 35 | - name: Install dependencies 36 | run: pnpm i --no-optional 37 | - name: Build project 38 | run: | 39 | npm run build 40 | - name: Test project 41 | run: | 42 | pnpm test -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import { createContext } from 'react' 3 | 4 | import { Advertisement } from '~/components/in-page/Home/aside/advertisement' 5 | import { AppDownload } from '~/components/in-page/Home/aside/appDownload' 6 | import { AuthorRecommend } from '~/components/in-page/Home/aside/author' 7 | import { List } from '~/components/in-page/Home/list' 8 | import { Navbar } from '~/components/in-page/Home/navbar' 9 | import { ArticleLayout } from '~/components/layouts/ArticleLayout' 10 | import { AggregateApi } from '~/services/api/aggregate' 11 | import type { IAggregateHome } from '~/types/api/aggregate' 12 | 13 | const sidebar = [Advertisement, AppDownload, AuthorRecommend] 14 | 15 | const Home: NextPage = (props) => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | Home.getInitialProps = async () => { 27 | const aggregate = await AggregateApi.aggregateHomeRequest(3) 28 | return aggregate 29 | } 30 | 31 | export const HomeSidebarContext = createContext(null) 32 | 33 | export default Home 34 | -------------------------------------------------------------------------------- /src/components/in-page/Home/aside/advertisement.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import Link from 'next/link' 3 | import { useContext } from 'react' 4 | 5 | import { SidebarContext } from '~/components/layouts/ArticleLayout' 6 | import { ImageNext } from '~/components/universal/Image' 7 | import { HomeSidebarContext } from '~/pages' 8 | 9 | import styles from './advertisement.module.less' 10 | 11 | export const Advertisement = () => { 12 | const homeContext = useContext(HomeSidebarContext) 13 | const sidebarContex = useContext(SidebarContext) 14 | if (!homeContext?.ad.phoUrl) return null 15 | return ( 16 | 24 |
25 | 32 | 33 |
window.open(homeContext.ad.putAdHref, '_ blank')}> 34 | 投放 35 | 广告 36 |
37 |
38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/components/widgets/Author/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite' 2 | import Link from 'next/link' 3 | import type { CSSProperties, FC, HTMLAttributes, ReactNode } from 'react' 4 | 5 | import { Avatar } from '@arco-design/web-react' 6 | 7 | import { useStore } from '~/store' 8 | import type { UserModel } from '~/types/api/user' 9 | 10 | import styles from './index.module.less' 11 | 12 | interface IAuthorProps extends HTMLAttributes { 13 | description?: ReactNode 14 | user: Pick 15 | usernameStyle?: CSSProperties 16 | } 17 | 18 | export const Author: FC = observer( 19 | ({ description, user, usernameStyle, ...rest }) => { 20 | const { appStore } = useStore() 21 | return ( 22 |
23 | 24 | 28 | avatar 29 | 30 | 31 |
32 | 33 | {user.username} 34 | 35 | {description} 36 |
37 |
38 | ) 39 | }, 40 | ) 41 | -------------------------------------------------------------------------------- /src/components/biz/Seo/index.tsx: -------------------------------------------------------------------------------- 1 | import merge from 'lodash-es/merge' 2 | import { observer } from 'mobx-react-lite' 3 | import type { NextSeoProps } from 'next-seo' 4 | import { NextSeo } from 'next-seo' 5 | import type { OpenGraph } from 'next-seo/lib/types' 6 | import type { FC } from 'react' 7 | import { useContext } from 'react' 8 | 9 | import { InitialContext } from '~/context/initial-data' 10 | 11 | type SEOProps = { 12 | title: string 13 | description?: string 14 | openGraph?: { type?: 'website' | 'article' } & OpenGraph 15 | } & NextSeoProps 16 | 17 | export const SEO: FC = observer((props) => { 18 | const { title, description, openGraph, ...rest } = props 19 | const Title = `${title}` 20 | const { seo } = useContext(InitialContext) 21 | return ( 22 | 41 | ) 42 | }) 43 | 44 | export const Seo = SEO 45 | -------------------------------------------------------------------------------- /src/utils/event-emitter.ts: -------------------------------------------------------------------------------- 1 | import type { CustomEventTypes } from '~/types/events' 2 | 3 | export class EventEmitter { 4 | private observers: Record = {} 5 | 6 | on(event: CustomEventTypes, handler: any): void 7 | on(event: string, handler: (...rest: any) => void) { 8 | const queue = this.observers[event] 9 | if (!queue) { 10 | this.observers[event] = [handler] 11 | return 12 | } 13 | const isExist = queue.some((func) => { 14 | return func === handler 15 | }) 16 | if (!isExist) { 17 | this.observers[event].push(handler) 18 | } 19 | } 20 | 21 | emit(event: CustomEventTypes, payload?: any): void 22 | emit(event: string, payload?: any) { 23 | const queue = this.observers[event] 24 | if (!queue) { 25 | return 26 | } 27 | for (const func of queue) { 28 | func.call(this, payload) 29 | } 30 | } 31 | 32 | off(event: CustomEventTypes, handler?: (...rest: any) => void) 33 | off(event: string, handler?: (...rest: any) => void) { 34 | const queue = this.observers[event] 35 | if (!queue) { 36 | return 37 | } 38 | 39 | if (handler) { 40 | const index = queue.findIndex((func) => { 41 | return func === handler 42 | }) 43 | if (index !== -1) { 44 | queue.splice(index, 1) 45 | } 46 | } else { 47 | queue.length = 0 48 | } 49 | } 50 | } 51 | export const eventBus = new EventEmitter() 52 | -------------------------------------------------------------------------------- /src/hooks/use-resize-scroll-event.ts: -------------------------------------------------------------------------------- 1 | import debounce from 'lodash-es/debounce' 2 | import throttle from 'lodash-es/throttle' 3 | import { useEffect, useRef } from 'react' 4 | 5 | import { useStore } from '~/store' 6 | 7 | export const useResizeScrollEvent = () => { 8 | const _currentY = useRef(0) 9 | const { appStore: app } = useStore() 10 | 11 | useEffect(() => { 12 | const handleScroll = throttle( 13 | () => { 14 | const currentY = document.documentElement.scrollTop 15 | const shouldUpdateDirection = 16 | Math.abs(_currentY.current - currentY) > 100 17 | 18 | if (shouldUpdateDirection) { 19 | const direction = _currentY.current > currentY ? 'up' : 'down' 20 | app.updatePosition(direction, currentY) 21 | _currentY.current = currentY 22 | } else { 23 | app.updatePosition(app.scrollDirection, currentY) 24 | } 25 | }, 26 | 16, 27 | { leading: false }, 28 | ) 29 | 30 | const resizeHandler = debounce( 31 | () => { 32 | app.updateViewport() 33 | }, 34 | 500, 35 | { leading: true }, 36 | ) 37 | window.onresize = resizeHandler 38 | app.updateViewport() 39 | 40 | if (typeof document !== 'undefined') { 41 | document.addEventListener('scroll', handleScroll) 42 | } 43 | return () => { 44 | window.onresize = null 45 | document.removeEventListener('scroll', handleScroll) 46 | } 47 | }, []) 48 | } 49 | -------------------------------------------------------------------------------- /src/context/root-store.tsx: -------------------------------------------------------------------------------- 1 | import { configure } from 'mobx' 2 | import { enableStaticRendering } from 'mobx-react-lite' 3 | import type { ReactNode } from 'react' 4 | import React, { createContext, useContext } from 'react' 5 | 6 | import { isClientSide, isDev, isServerSide } from '~/utils/env' 7 | 8 | import { RootStore } from '../store/root-store' 9 | 10 | enableStaticRendering(isServerSide()) 11 | 12 | configure({ 13 | useProxies: 'always', 14 | }) 15 | 16 | let $store: RootStore 17 | const StoreContext = createContext(undefined) 18 | StoreContext.displayName = 'StoreContext' 19 | 20 | export function useRootStore() { 21 | const context = useContext(StoreContext) 22 | if (context === undefined) { 23 | throw new Error('useRootStore must be used within RootStoreProvider') 24 | } 25 | 26 | return context 27 | } 28 | export const store = initializeStore() 29 | export function RootStoreProvider({ children }: { children: ReactNode }) { 30 | if (isDev && isClientSide() && !window.store) { 31 | Object.defineProperty(window, 'store', { 32 | get() { 33 | return store 34 | }, 35 | }) 36 | } 37 | 38 | return {children} 39 | } 40 | 41 | function initializeStore(): RootStore { 42 | const _store = $store ?? new RootStore() 43 | 44 | // For SSG and SSR always create a new store 45 | if (typeof window === 'undefined') return _store 46 | // Create the store once in the client 47 | if (!$store) $store = _store 48 | 49 | return _store 50 | } 51 | -------------------------------------------------------------------------------- /src/components/app/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | import Router from 'next/router' 2 | import React, { PureComponent, createElement } from 'react' 3 | 4 | export class ErrorBoundary extends PureComponent<{ 5 | children: React.ReactNode 6 | fallbackComponent?: React.ComponentType 7 | [k: string]: any 8 | }> { 9 | state: any = { 10 | error: null, 11 | errorInfo: null, 12 | } 13 | 14 | componentDidCatch(error, errorInfo) { 15 | console.error(error, 'render error') 16 | console.error(errorInfo) 17 | 18 | this.setState({ 19 | error, 20 | errorInfo, 21 | }) 22 | } 23 | eventsRef: any = () => void 0 24 | componentDidMount(): void { 25 | this.eventsRef = () => { 26 | this.setState({ 27 | error: null, 28 | errorInfo: null, 29 | }) 30 | } 31 | Router.events.on('routeChangeStart', this.eventsRef) 32 | } 33 | 34 | componentWillUnmount(): void { 35 | Router.events.off('routeChangeStart', this.eventsRef) 36 | } 37 | 38 | render() { 39 | const { errorInfo } = this.state 40 | const { children, ...restProps } = this.props 41 | 42 | if (errorInfo) { 43 | return ( 44 | // @ts-ignore 45 | this.props.fallbackComponent ? ( 46 | createElement(this.props.fallbackComponent, { 47 | error: errorInfo.error, 48 | errorInfo, 49 | }) 50 | ) : ( 51 |
渲染报错
52 | ) 53 | ) 54 | } 55 | 56 | // @ts-ignore 57 | return React.cloneElement(children, { 58 | ...restProps, 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/universal/Markdown/renderers/heading.tsx: -------------------------------------------------------------------------------- 1 | import type { DOMAttributes, FC } from 'react' 2 | import React, { 3 | Fragment, 4 | createElement, 5 | useEffect, 6 | useMemo, 7 | useRef, 8 | useState, 9 | } from 'react' 10 | import { useInView } from 'react-intersection-observer' 11 | 12 | import { CustomEventTypes } from '~/types/events' 13 | import { eventBus } from '~/utils/event-emitter' 14 | 15 | interface HeadingProps { 16 | id: string 17 | className?: string 18 | children: React.ReactNode 19 | level: number 20 | } 21 | export const MHeading: () => FC = () => { 22 | let index = 0 23 | const RenderHeading = (props: HeadingProps) => { 24 | const currentIndex = useMemo(() => index++, []) 25 | const [id, setId] = useState('') 26 | 27 | useEffect(() => { 28 | if (!$titleRef.current) { 29 | return 30 | } 31 | 32 | setId($titleRef.current.textContent || '') 33 | 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | }, []) 36 | 37 | const { ref } = useInView({ 38 | rootMargin: '0% 0% -85% 0%', 39 | onChange(inView) { 40 | if (inView) { 41 | eventBus.emit(CustomEventTypes.TOC, currentIndex) 42 | } 43 | }, 44 | }) 45 | 46 | const $titleRef = useRef(null) 47 | 48 | return ( 49 | 50 | {createElement, HTMLHeadingElement>( 51 | `h${props.level}`, 52 | { 53 | id, 54 | ref: $titleRef, 55 | 'data-index': currentIndex, 56 | } as any, 57 | 58 | {props.children} 59 | 60 | , 61 | )} 62 | 63 | ) 64 | } 65 | 66 | return RenderHeading 67 | } 68 | -------------------------------------------------------------------------------- /src/assets/styles/theme.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | transition: color 0.1s, background 0.1s, border 0.1s, box-shadow 0.1s ease-in; 4 | } 5 | 6 | :focus { 7 | outline: auto blue; 8 | } 9 | 10 | /* 11 | * TODO 优化滚动行为 12 | */ 13 | article { 14 | scroll-behavior: smooth; 15 | } 16 | html { 17 | font-size: 12px; 18 | word-break: break-word; 19 | text-rendering: optimizeLegibility; 20 | -webkit-font-smoothing: unset !important; 21 | font-family: -apple-system, system-ui, Segoe UI, Roboto, Ubuntu, Cantarell, 22 | Noto Sans, sans-serif, BlinkMacSystemFon; 23 | /* for firefox */ 24 | scrollbar-color: var(--juejin-scrollbar) transparent; 25 | scrollbar-width: thin; 26 | overflow: overlay; 27 | } 28 | 29 | body { 30 | color: var(--juejin-font-1); 31 | background-color: var(--juejin-background); 32 | } 33 | 34 | ::-webkit-scrollbar { 35 | width: 6px; 36 | height: 6px; 37 | background: var(--juejin-gray-0); 38 | } 39 | 40 | ::-webkit-scrollbar-thumb { 41 | background: var(--juejin-scrollbar); 42 | border-radius: 6px; 43 | } 44 | 45 | ::-webkit-scrollbar-corner { 46 | background: var(--juejin-gray-0); 47 | } 48 | 49 | a { 50 | text-decoration: none; 51 | color: var(--juejin-font-3); 52 | } 53 | 54 | ul, 55 | li { 56 | list-style: none; 57 | padding: 0px; 58 | margin: 0px; 59 | } 60 | 61 | h1 { 62 | margin: 0; 63 | bottom: 0; 64 | } 65 | 66 | .arco-card { 67 | transition: color 0.1s, background 0.1s, border 0.1s, box-shadow 0.1s ease-in !important; 68 | } 69 | 70 | .nav-move { 71 | transform: translate3d(0, -5rem, 0); 72 | } 73 | 74 | p { 75 | margin: 0; 76 | padding: 0; 77 | } 78 | 79 | .sidebar-show { 80 | position: fixed; 81 | width: 240px; 82 | top: 67px; 83 | opacity: 1 !important; 84 | transition: all 0.2s ease-in-out; 85 | } 86 | 87 | .sidebar-opacity { 88 | opacity: 0; 89 | } 90 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Cache pnpm modules 28 | uses: actions/cache@v3 29 | env: 30 | cache-name: cache-pnpm-modules 31 | with: 32 | path: ~/.pnpm-store 33 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 34 | restore-keys: | 35 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 36 | - uses: actions/cache@v3 37 | with: 38 | path: | 39 | ${{ github.workspace }}/.next/cache 40 | key: ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 41 | restore-keys: | 42 | ${{ runner.os }}-nextjs-${{ hashFiles('**/pnpm-lock.yaml') }}- 43 | - uses: pnpm/action-setup@v2.2.4 44 | with: 45 | version: 7.x.x 46 | run_install: true 47 | - name: Build project 48 | run: | 49 | pnpm build 50 | - name: Test project 51 | run: | 52 | pnpm test 53 | env: 54 | CI: true 55 | -------------------------------------------------------------------------------- /src/components/in-page/Home/navbar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { observer } from 'mobx-react-lite' 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/router' 5 | import { useContext, useEffect, useMemo, useState } from 'react' 6 | 7 | import { SEO } from '~/components/biz/Seo' 8 | import { HomeSidebarContext } from '~/pages' 9 | import { useStore } from '~/store' 10 | 11 | import styles from './navbar.module.less' 12 | 13 | export const Navbar = observer(() => { 14 | const router = useRouter() 15 | const [showCategoryTitle, setShowCategoryTitle] = useState(false) 16 | const homeContext = useContext(HomeSidebarContext) 17 | const { appStore } = useStore() 18 | const title = useMemo( 19 | () => 20 | homeContext?.category.find((item) => item.slug == router.query.category) 21 | ?.name || (homeContext?.category[0].name as string), 22 | [router.query.category], 23 | ) 24 | 25 | useEffect(() => { 26 | setShowCategoryTitle(!!router.query.category) 27 | }, [router.query.category]) 28 | return ( 29 | <> 30 | 31 | 32 | 56 | 57 | ) 58 | }) 59 | -------------------------------------------------------------------------------- /src/components/universal/Icons/dark-mode.tsx: -------------------------------------------------------------------------------- 1 | export function IconSun() { 2 | return ( 3 | 12 | 18 | 25 | 26 | ) 27 | } 28 | 29 | export function IconNight() { 30 | return ( 31 | 40 | 47 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /src/pages/_error.tsx: -------------------------------------------------------------------------------- 1 | // import type { AxiosError } from 'axios' 2 | import isNumber from 'lodash-es/isNumber' 3 | import type { NextPage } from 'next' 4 | import { NextSeo } from 'next-seo' 5 | 6 | import { ErrorView, errorToText } from '~/components/app/Error' 7 | import { useIsClient } from '~/hooks/use-is-client' 8 | import { RequestError } from '~/services/server' 9 | 10 | const ErrorPage: NextPage<{ statusCode: number; err: any }> = ({ 11 | statusCode = 500, 12 | err, 13 | }) => { 14 | const isClient = useIsClient() 15 | // FIXME error page hydrate error, cause error data not equal to server side. 16 | return isClient ? ( 17 | 18 | ) : ( 19 | 20 | ) 21 | } 22 | 23 | const getCode = (err, res): number => { 24 | if (!err && !res) { 25 | return 500 26 | } 27 | if (err instanceof RequestError) { 28 | // @see: https://github.com/axios/axios/pull/3645 29 | 30 | return isNumber(err.response?.status) ? isNumber(err.response?.status) : 408 31 | } 32 | if (res?.statusCode === 500 && err?.statusCode === 500) { 33 | return 500 34 | } else if (res && res.statusCode !== 500) { 35 | return res.statusCode || 500 36 | } else if (err && err.statusCode !== 500) { 37 | return err.statusCode || 500 38 | } 39 | return 500 40 | } 41 | 42 | ErrorPage.getInitialProps = async ({ res, err }: any) => { 43 | const statusCode = +getCode(err, res) || 500 44 | res && (res.statusCode = statusCode) 45 | if (statusCode === 404) { 46 | return { statusCode: 404, err } 47 | } 48 | const serializeErr: any = (() => { 49 | try { 50 | return JSON.parse(JSON.stringify(err)) 51 | } catch (e: any) { 52 | return err 53 | } 54 | })() 55 | serializeErr['_message'] = 56 | (err as any)?.raw?.response?.data?.message || 57 | err.message || 58 | err.response?.data?.message 59 | 60 | return { statusCode, err: serializeErr } as { 61 | statusCode: number 62 | err: any 63 | } 64 | } 65 | 66 | export default ErrorPage 67 | -------------------------------------------------------------------------------- /src/hooks/use-check-old-browser.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react' 2 | import { message } from 'react-message-popup' 3 | 4 | export const useCheckOldBrowser = () => { 5 | const checkBrowser = useCallback(() => { 6 | const { isOld, msg: errMsg } = checkOldBrowser() 7 | if (isOld) { 8 | const msg = '欧尼酱, 乃的浏览器太老了, 更新一下啦(o´゚□゚`o)' 9 | alert(msg) 10 | message.warn(msg, Infinity) 11 | class BrowserTooOldError extends Error { 12 | constructor() { 13 | super(errMsg) 14 | } 15 | } 16 | 17 | throw new BrowserTooOldError() 18 | } 19 | }, []) 20 | 21 | return { 22 | check: checkBrowser, 23 | } 24 | } 25 | 26 | function checkOldBrowser() { 27 | const ua = window.navigator.userAgent 28 | const isIE = (function () { 29 | const msie = ua.indexOf('MSIE') // IE 10 or older 30 | const trident = ua.indexOf('Trident/') // IE 11 31 | 32 | return msie > 0 || trident > 0 33 | })() 34 | const isOld: boolean = (() => { 35 | if (isIE) { 36 | alert( 37 | '欧尼酱, 乃真的要使用 IE 浏览器吗, 不如换个 Chrome 好不好嘛(o´゚□゚`o)', 38 | ) 39 | location.href = 'https://www.google.cn/chrome/' 40 | return true 41 | } 42 | // check build-in methods 43 | const ObjectMethods = ['fromEntries', 'entries'] 44 | const ArrayMethods = ['flat'] 45 | if ( 46 | !window.Reflect || 47 | !( 48 | ObjectMethods.every((m) => Reflect.has(Object, m)) && 49 | ArrayMethods.every((m) => Reflect.has(Array.prototype, m)) 50 | ) || 51 | !window.requestAnimationFrame || 52 | !window.Proxy || 53 | !window.IntersectionObserver || 54 | !window.ResizeObserver || 55 | !window.Intl || 56 | typeof globalThis === 'undefined' || 57 | typeof Set === 'undefined' || 58 | typeof Map === 'undefined' 59 | ) { 60 | return true 61 | } 62 | 63 | return false 64 | })() 65 | if (isOld) { 66 | window.alert('欧尼酱, 乃的浏览器太老了, 更新一下啦(o´゚□゚`o)') 67 | return { 68 | isOld: true, 69 | msg: `User browser is too old. UA: ${ua}`, 70 | } 71 | } 72 | 73 | return { isOld: false, msg: '' } 74 | } 75 | -------------------------------------------------------------------------------- /src/store/helper/base.ts: -------------------------------------------------------------------------------- 1 | import { 2 | action, 3 | computed, 4 | makeObservable, 5 | observable, 6 | runInAction, 7 | toJS, 8 | } from 'mobx' 9 | 10 | import type { Id } from './structure' 11 | import { KeyValueCollection } from './structure' 12 | 13 | type Identifiable = { id: Id } 14 | export class Store extends KeyValueCollection< 15 | Id, 16 | T & { isDeleted?: boolean } 17 | > { 18 | constructor() { 19 | super() 20 | 21 | makeObservable(this, { 22 | data: observable, 23 | set: action, 24 | delete: action, 25 | softDelete: action, 26 | clear: action, 27 | size: computed, 28 | add: action, 29 | remove: action, 30 | addAndPatch: action, 31 | list: computed, 32 | first: computed, 33 | }) 34 | } 35 | 36 | get raw() { 37 | return toJS(this.data) 38 | } 39 | 40 | add(id: string, data: T | T[]): this 41 | add(data: T | T[]): this 42 | add(...args: any[]): this { 43 | const add = (id: string, data: T | T[]) => { 44 | if (Array.isArray(data)) { 45 | runInAction(() => { 46 | data.forEach((d) => { 47 | this.add(d) 48 | }) 49 | }) 50 | return 51 | } 52 | this.set(id, { ...data }) 53 | } 54 | 55 | if (typeof args[0] === 'string') { 56 | const id = args[0] 57 | const data = args[1] 58 | add(id, data) 59 | } else { 60 | const data = args[0] 61 | add(data.id, data) 62 | } 63 | 64 | return this 65 | } 66 | 67 | // same as add, but ignores `undefined` 68 | addAndPatch(data: T | T[]): this { 69 | if (Array.isArray(data)) { 70 | data.forEach((d) => { 71 | this.addAndPatch(d) 72 | }) 73 | return this 74 | } 75 | if (this.has(data.id)) { 76 | const exist = this.get(data.id) 77 | this.set(data.id, { ...exist, ...data }) 78 | } else { 79 | this.set(data.id, data) 80 | } 81 | return this 82 | } 83 | 84 | remove(id: Id): this { 85 | this.delete(id) 86 | return this 87 | } 88 | 89 | hydrate(data?: any) { 90 | if (data) { 91 | this.data = data 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@arco-design/web-react/dist/css/arco.css' 2 | import '~/assets/styles/main.css' 3 | 4 | // import '~/assets/styles/juejin.less' 5 | 6 | import NextApp from 'next/app' 7 | import type { AppProps } from 'next/app' 8 | import { memo, useMemo } from 'react' 9 | import type { FC, PropsWithChildren } from 'react' 10 | 11 | import { NoDataErrorView } from '~/components/app/Error/no-data' 12 | import { Content } from '~/components/layouts/AppLayout' 13 | import { BasicLayout } from '~/components/layouts/BasicLayout' 14 | import { InitialContextProvider } from '~/context/initial-data' 15 | import { RootStoreProvider } from '~/context/root-store' 16 | import { AggregateApi } from '~/services/api/aggregate' 17 | import type { IAggregate } from '~/types/api/aggregate' 18 | 19 | const App: FC> = ({ 20 | Component, 21 | pageProps, 22 | initData, 23 | }) => { 24 | const Inner = useMemo(() => { 25 | // 兜底页 26 | return initData.user ? ( 27 | 28 | 29 | 30 | ) : ( 31 | 32 | ) 33 | }, [Component, initData, pageProps]) 34 | return ( 35 | 36 | {Inner} 37 | 38 | ) 39 | } 40 | 41 | const Wrapper: FC = memo((props) => { 42 | return ( 43 | 44 | {props.children} 45 | 46 | ) 47 | }) 48 | 49 | // @ts-ignore 50 | App.getInitialProps = async (props: AppContext) => { 51 | const ctx = props.ctx 52 | 53 | const data = await AggregateApi.aggregateInfoRequest() 54 | const appProps = await (async () => { 55 | try { 56 | return await NextApp.getInitialProps(props) 57 | } catch (e) { 58 | if (!data?.user) { 59 | throw e 60 | } 61 | // 这里捕获,为了走全局无数据页 62 | if (ctx.res) { 63 | ctx.res.statusCode = 466 64 | ctx.res.statusMessage = 'No Data' 65 | } 66 | return null 67 | } 68 | })() 69 | 70 | return { 71 | ...appProps, 72 | initData: data, 73 | } 74 | } 75 | 76 | export default App 77 | -------------------------------------------------------------------------------- /src/components/app/Error/index.tsx: -------------------------------------------------------------------------------- 1 | import isNumber from 'lodash-es/isNumber' 2 | import type { NextPage } from 'next' 3 | import { useRouter } from 'next/router' 4 | 5 | import { Button, Space } from '@arco-design/web-react' 6 | 7 | import { Seo } from '~/components/biz/Seo' 8 | import { isServerSide } from '~/utils/env' 9 | 10 | import styles from './index.module.less' 11 | 12 | export const errorToText = (statusCode: number) => { 13 | switch (statusCode) { 14 | case 404: 15 | return '抱歉啦,页面走丢了' 16 | case 403: 17 | return '不要做一些不允许的事情啦' 18 | case 401: 19 | return '这是主人的小秘密哦,你是我的主人吗' 20 | case 408: 21 | return isServerSide() 22 | ? '上游服务器连接超时' 23 | : '连接超时,请检查一下网络哦!' 24 | case 406: 25 | case 418: 26 | return '茶壶出现错误' 27 | case 666: 28 | return '你在干什么呀' 29 | case 500: 30 | default: 31 | return '抱歉,出了点小问题' 32 | } 33 | } 34 | export const ErrorView: NextPage<{ 35 | statusCode: number | string 36 | showBackButton?: boolean 37 | showRefreshButton?: boolean 38 | description?: string | JSX.Element | number 39 | 40 | // 适用于无数据状态 41 | noSeo?: boolean 42 | }> = ({ 43 | statusCode = 500, 44 | showBackButton = true, 45 | showRefreshButton = true, 46 | description, 47 | noSeo = false, 48 | }) => { 49 | const router = useRouter() 50 | 51 | const message = errorToText( 52 | isNumber(statusCode) ? (statusCode as number) : 500, 53 | ) 54 | return ( 55 |
56 | {!noSeo && } 57 |
58 |

{statusCode}

59 |
60 | {description ??

{message}

} 61 |
62 |
63 | {(showBackButton || showBackButton) && ( 64 | 65 | {showBackButton && ( 66 | 69 | )} 70 | {showRefreshButton && ( 71 | 74 | )} 75 | 76 | )} 77 |
78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/components/universal/Markdown/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | import type { MarkdownToJSX } from 'markdown-to-jsx' 3 | import type { FC, PropsWithChildren } from 'react' 4 | import React, { memo, useCallback, useMemo } from 'react' 5 | 6 | import { ErrorBoundary } from '~/components/app/ErrorBoundary' 7 | 8 | import { MarkdownToc } from '../../in-page/Post/aside/toc' 9 | import { CodeBlock } from './codeBlock' 10 | import { Markdown as JuejinMarkdown } from './components' 11 | import type { MdProps } from './components' 12 | import { MHeading } from './renderers/heading' 13 | 14 | const Noop = () => null 15 | 16 | export interface KamiMarkdownProps extends MdProps { 17 | toc?: boolean 18 | } 19 | export const Markdown: FC< 20 | PropsWithChildren 21 | > = memo((props) => { 22 | const { 23 | value, 24 | renderers, 25 | 26 | extendsRules, 27 | 28 | ...rest 29 | } = props 30 | const Heading = useMemo(() => { 31 | return MHeading() 32 | }, [value, props.children]) 33 | 34 | const RenderError = useCallback( 35 | () => ( 36 |
37 | Markdown RenderError, Raw:
{value || props.children} 38 |
39 | ), 40 | [props.children, value], 41 | ) 42 | 43 | return ( 44 | 45 | 55 | {output(node.content, state!)} 56 | 57 | ) 58 | }, 59 | }, 60 | codeBlock: { 61 | react(node, output, state) { 62 | return ( 63 | 68 | ) 69 | }, 70 | }, 71 | ...extendsRules, 72 | ...renderers, 73 | }} 74 | > 75 | {props.children} 76 | 77 | 78 | ) 79 | }) 80 | -------------------------------------------------------------------------------- /src/store/app.ts: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable } from 'mobx' 2 | 3 | import { isClientSide } from '~/utils/env' 4 | 5 | import type { ViewportRecord } from './types' 6 | 7 | export default class AppUIStore { 8 | constructor() { 9 | makeAutoObservable(this) 10 | } 11 | 12 | viewport: ViewportRecord = {} as any 13 | 14 | private position = 0 15 | scrollDirection: 'up' | 'down' | null = null 16 | 17 | colorMode: 'light' | 'dark' = 'light' 18 | mediaType: 'screen' | 'print' = 'screen' 19 | 20 | shareData: { title: string; text?: string; url: string } | null = null 21 | 22 | updatePosition(direction: 'up' | 'down' | null, y: number) { 23 | if (typeof document !== 'undefined') { 24 | this.position = y 25 | this.scrollDirection = direction 26 | } 27 | } 28 | 29 | get headerOpacity() { 30 | const threshold = 50 31 | return this.position >= threshold 32 | ? 1 33 | : Math.floor((this.position / threshold) * 100) / 100 34 | } 35 | 36 | get isOverFirstScreenHeight() { 37 | if (!isClientSide()) { 38 | return 39 | } 40 | return this.position > window.innerHeight || this.position > screen.height 41 | } 42 | 43 | get isOverNar() { 44 | if (!isClientSide()) { 45 | return 46 | } 47 | 48 | return this.position > 300 49 | } 50 | 51 | updateViewport() { 52 | const innerHeight = window.innerHeight 53 | const width = document.documentElement.getBoundingClientRect().width 54 | const { hpad, pad, mobile } = this.viewport 55 | 56 | // 忽略移动端浏览器 上下滚动 导致的视图大小变化 57 | if ( 58 | this.viewport.h && 59 | // chrome mobile delta == 56 60 | Math.abs(innerHeight - this.viewport.h) < 80 && 61 | width === this.viewport.w && 62 | (hpad || pad || mobile) 63 | ) { 64 | return 65 | } 66 | 67 | this.viewport = { 68 | w: width, 69 | h: innerHeight, 70 | mobile: window.screen.width <= 568 || window.innerWidth <= 568, 71 | pad: window.innerWidth <= 768 && window.innerWidth > 568, 72 | hpad: window.innerWidth <= 1100 && window.innerWidth > 768, 73 | wider: window.innerWidth > 1100 && window.innerWidth < 1920, 74 | widest: window.innerWidth >= 1920, 75 | } 76 | } 77 | 78 | get isPadOrMobile() { 79 | return this.viewport.pad || this.viewport.mobile 80 | } 81 | 82 | /** 83 | * < 1100 84 | */ 85 | get isNarrowThanLaptop() { 86 | return this.isPadOrMobile || this.viewport.hpad 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/layouts/ArticleLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { observer } from 'mobx-react-lite' 3 | import type { 4 | CSSProperties, 5 | Dispatch, 6 | FC, 7 | HTMLAttributes, 8 | PropsWithChildren, 9 | SetStateAction, 10 | } from 'react' 11 | import { createContext, useEffect, useState } from 'react' 12 | import { useInView } from 'react-intersection-observer' 13 | 14 | import { Card } from '~/components/universal/Card' 15 | import { useStore } from '~/store' 16 | 17 | import styles from './index.module.less' 18 | 19 | export interface ArticleLayoutProps 20 | extends PropsWithChildren> { 21 | padding?: CSSProperties['padding'] 22 | aside?: FC[] 23 | asideWidth?: CSSProperties['width'] 24 | postId?: string 25 | } 26 | 27 | export const SidebarContext = createContext<{ 28 | setSidebar: Dispatch[] | undefined>> 29 | postId?: string 30 | asideLeave: boolean 31 | leaveShow: boolean 32 | } | null>(null) 33 | 34 | export const ArticleLayout: FC = observer( 35 | ({ children, aside, asideWidth, padding, postId, ...props }) => { 36 | const [sidebar, setSidebar] = useState(aside) 37 | const [asideLeave, setAsideLeave] = useState(false) 38 | const [leaveShow, setLeaveShow] = useState(false) 39 | const { appStore } = useStore() 40 | const { ref } = useInView({ 41 | threshold: 0, 42 | onChange: (inView) => { 43 | setAsideLeave(!inView) 44 | }, 45 | }) 46 | 47 | useEffect(() => { 48 | setTimeout(() => { 49 | setLeaveShow(asideLeave) 50 | }, 50) 51 | }, [asideLeave]) 52 | 53 | return ( 54 | 57 |
58 | 63 | {children} 64 | 65 | {!appStore.isNarrowThanLaptop && ( 66 |
77 |
78 | ) 79 | }, 80 | ) 81 | -------------------------------------------------------------------------------- /src/utils/load-script.ts: -------------------------------------------------------------------------------- 1 | import { isDev } from './env' 2 | 3 | const isLoadScriptMap: Record = {} 4 | const loadingQueueMap: Record = {} 5 | export function loadScript(url: string) { 6 | return new Promise((resolve, reject) => { 7 | const status = isLoadScriptMap[url] 8 | if (status === 'loaded') { 9 | return resolve(null) 10 | } else if (status === 'loading') { 11 | loadingQueueMap[url] = !loadingQueueMap[url] 12 | ? [[resolve, reject]] 13 | : [...loadingQueueMap[url], [resolve, reject]] 14 | return 15 | } 16 | 17 | const script = document.createElement('script') 18 | script.src = url 19 | script.crossOrigin = 'anonymous' 20 | 21 | isLoadScriptMap[url] = 'loading' 22 | script.onload = function () { 23 | isLoadScriptMap[url] = 'loaded' 24 | resolve(null) 25 | if (loadingQueueMap[url]) { 26 | loadingQueueMap[url].forEach(([resolve]) => { 27 | resolve(null) 28 | }) 29 | delete loadingQueueMap[url] 30 | } 31 | } 32 | 33 | if (isDev) { 34 | console.log('load script: ', url) 35 | } 36 | 37 | script.onerror = function (e) { 38 | // this.onload = null here is necessary 39 | // because even IE9 works not like others 40 | this.onerror = this.onload = null 41 | delete isLoadScriptMap[url] 42 | loadingQueueMap[url].forEach(([_, reject]) => { 43 | reject(e) 44 | }) 45 | delete loadingQueueMap[url] 46 | reject(e) 47 | } 48 | 49 | document.head.appendChild(script) 50 | }) 51 | } 52 | 53 | const cssMap = new Map() 54 | 55 | export function loadStyleSheet(href: string) { 56 | if (cssMap.has(href)) { 57 | const $link = cssMap.get(href)! 58 | return { 59 | $link, 60 | remove: () => { 61 | $link.parentNode && $link.parentNode.removeChild($link) 62 | cssMap.delete(href) 63 | }, 64 | } 65 | } 66 | const $link = document.createElement('link') 67 | $link.href = href 68 | $link.rel = 'stylesheet' 69 | $link.type = 'text/css' 70 | $link.crossOrigin = 'anonymous' 71 | cssMap.set(href, $link) 72 | 73 | $link.onerror = () => { 74 | $link.onerror = null 75 | cssMap.delete(href) 76 | } 77 | 78 | document.head.appendChild($link) 79 | 80 | return { 81 | remove: () => { 82 | $link.parentNode && $link.parentNode.removeChild($link) 83 | cssMap.delete(href) 84 | }, 85 | $link, 86 | } 87 | } 88 | 89 | export function appendStyle(style: string) { 90 | const $style = document.createElement('style') 91 | $style.innerHTML = style 92 | document.head.appendChild($style) 93 | return { 94 | remove: () => { 95 | $style.parentNode && $style.parentNode.removeChild($style) 96 | }, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/utils/time.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | 3 | import 'dayjs/locale/zh-cn' 4 | 5 | import customParseFormat from 'dayjs/plugin/customParseFormat' 6 | import LocalizedFormat from 'dayjs/plugin/localizedFormat' 7 | 8 | // import relativeTime from 'dayjs/plugin/relativeTime' 9 | dayjs.extend(customParseFormat) 10 | // dayjs.extend(relativeTime) 11 | dayjs.extend(LocalizedFormat) 12 | dayjs.locale('zh-cn') 13 | 14 | export enum DateFormat { 15 | 'MMM DD YYYY', 16 | 'HH:mm', 17 | 'LLLL', 18 | 'H:mm:ss A', 19 | 'YYYY-MM-DD', 20 | 'YYYY-MM-DD dddd', 21 | 'YYYY-MM-DD ddd', 22 | 'MM-DD ddd', 23 | 'YYYY年MM月DD日 HH:mm', 24 | } 25 | 26 | export const parseDate = ( 27 | time: string | Date, 28 | format: keyof typeof DateFormat, 29 | ) => dayjs(time).format(format) 30 | 31 | export const relativeTimeFromNow = ( 32 | time: Date | number, 33 | current = new Date(), 34 | ) => { 35 | if (!time) { 36 | return '' 37 | } 38 | time = new Date(time) 39 | const msPerMinute = 60 * 1000 40 | const msPerHour = msPerMinute * 60 41 | const msPerDay = msPerHour * 24 42 | const msPerMonth = msPerDay * 30 43 | const msPerYear = msPerDay * 365 44 | 45 | const elapsed = +current - +time 46 | 47 | if (elapsed < msPerMinute) { 48 | const gap = Math.ceil(elapsed / 1000) 49 | return gap <= 0 ? '刚刚' : `${gap}秒前` 50 | } else if (elapsed < msPerHour) { 51 | return `${Math.round(elapsed / msPerMinute)}分钟前` 52 | } else if (elapsed < msPerDay) { 53 | return `${Math.round(elapsed / msPerHour)}小时前` 54 | } else if (elapsed < msPerMonth) { 55 | return `${Math.round(elapsed / msPerDay)}天前` 56 | } else if (elapsed < msPerYear) { 57 | return `${Math.round(elapsed / msPerMonth)}个月前` 58 | } else { 59 | return `${Math.round(elapsed / msPerYear)}年前` 60 | } 61 | } 62 | export const dayOfYear = () => { 63 | const now = new Date() 64 | const start = new Date(now.getFullYear(), 0, 0) 65 | const diff = now.getTime() - start.getTime() 66 | const oneDay = 1000 * 60 * 60 * 24 67 | const day = Math.floor(diff / oneDay) 68 | return day 69 | } 70 | 71 | export function daysOfYear(year?: number) { 72 | return isLeapYear(year ?? new Date().getFullYear()) ? 366 : 365 73 | } 74 | 75 | export function isLeapYear(year: number) { 76 | return year % 400 === 0 || (year % 100 !== 0 && year % 4 === 0) 77 | } 78 | 79 | export const secondOfDay = () => { 80 | const dt = new Date() 81 | const secs = dt.getSeconds() + 60 * (dt.getMinutes() + 60 * dt.getHours()) 82 | return secs 83 | } 84 | 85 | export const secondOfDays = 86400 86 | 87 | export function hms(seconds: number): string { 88 | // @ts-ignore 89 | // return [3600, 60] // 00:00:00 90 | return [60] 91 | .reduceRight( 92 | // @ts-ignore 93 | (p, b) => (r) => [Math.floor(r / b)].concat(p(r % b)), 94 | (r) => [r], 95 | )(seconds) 96 | .map((a) => a.toString().padStart(2, '0')) 97 | .join(':') 98 | } 99 | -------------------------------------------------------------------------------- /src/components/universal/Toc/item.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import type { FC, RefObject } from 'react' 3 | import { memo, useCallback, useEffect, useMemo, useRef } from 'react' 4 | 5 | import { springScrollToElement } from '~/utils/spring' 6 | 7 | import styles from './index.module.less' 8 | 9 | export const TocItem: FC<{ 10 | title: string 11 | depth: number 12 | active: boolean 13 | rootDepth: number 14 | onClick: (i: number) => void 15 | index: number 16 | lastPostion: number 17 | containerRef?: RefObject 18 | }> = memo((props) => { 19 | const { 20 | index, 21 | active, 22 | depth, 23 | title, 24 | rootDepth, 25 | onClick, 26 | containerRef, 27 | lastPostion, 28 | } = props 29 | const $ref = useRef(null) 30 | useEffect(() => { 31 | if (active) { 32 | const state = history.state 33 | history.replaceState(state, '', `#${title}`) 34 | } 35 | }, [active, title]) 36 | 37 | useEffect(() => { 38 | if (!$ref.current || !active || !containerRef?.current) { 39 | return 40 | } 41 | // NOTE animation group will wrap a element as a scroller container 42 | const $scoller = containerRef.current.children?.item(0) 43 | 44 | if (!$scoller) { 45 | return 46 | } 47 | const itemHeight = $ref.current.offsetHeight 48 | const currentScrollerTop = $scoller.scrollTop 49 | const scollerContainerHeight = $scoller.clientHeight 50 | const thisItemTop = index * itemHeight 51 | 52 | if ( 53 | currentScrollerTop - thisItemTop >= 0 || 54 | (thisItemTop >= currentScrollerTop && 55 | thisItemTop >= scollerContainerHeight) 56 | ) { 57 | $scoller.scrollTop = thisItemTop 58 | } 59 | }, [active, containerRef, index]) 60 | 61 | const renderDepth = useMemo(() => { 62 | const result = depth - rootDepth 63 | 64 | return result 65 | }, [depth, rootDepth]) 66 | return ( 67 | ({ 78 | paddingLeft: 79 | depth >= rootDepth ? `${1.2 + renderDepth * 0.6}rem` : undefined, 80 | }), 81 | [depth, renderDepth, rootDepth], 82 | )} 83 | data-depth={depth} 84 | onClick={useCallback( 85 | (e) => { 86 | e.preventDefault() 87 | onClick(index) 88 | const $el = document.getElementById(title) 89 | if ($el) { 90 | springScrollToElement($el, undefined, -100) 91 | } 92 | }, 93 | [index, title, onClick], 94 | )} 95 | > 96 | {title} 97 | 98 | ) 99 | }) 100 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Sequence of patterns matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 6 | 7 | name: Release 8 | 9 | jobs: 10 | build: 11 | name: Upload Release Asset 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | lfs: true 20 | - name: Checkout LFS objects 21 | run: git lfs checkout 22 | 23 | - uses: actions/setup-node@v3 24 | with: 25 | node-version: 16.x 26 | 27 | - name: Cache pnpm modules 28 | uses: actions/cache@v3 29 | env: 30 | cache-name: cache-pnpm-modules 31 | with: 32 | path: ~/.pnpm-store 33 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 34 | restore-keys: | 35 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }} 36 | 37 | - uses: pnpm/action-setup@v2.2.4 38 | with: 39 | version: 7.x.x 40 | run_install: true 41 | 42 | - name: Build project 43 | run: | 44 | sh standalone-build.sh 45 | 46 | - name: Create Release 47 | id: create_release 48 | uses: actions/create-release@v1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | with: 52 | tag_name: ${{ github.ref }} 53 | release_name: Release ${{ github.ref }} 54 | draft: false 55 | prerelease: false 56 | - run: npx changelogithub 57 | continue-on-error: true 58 | env: 59 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 60 | - name: Upload Release Asset 61 | id: upload-release-asset 62 | uses: actions/upload-release-asset@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 67 | asset_path: ./release.zip 68 | asset_name: release-ubuntu.zip 69 | asset_content_type: application/zip 70 | deploy: 71 | name: Deploy To Remote Server 72 | runs-on: ubuntu-latest 73 | needs: [build] 74 | steps: 75 | - name: Exec deploy script with SSH 76 | uses: appleboy/ssh-action@master 77 | with: 78 | host: ${{ secrets.HOST }} 79 | username: ${{ secrets.USER }} 80 | password: ${{ secrets.PASSWORD }} 81 | script_stop: true 82 | script: | 83 | cd /home/ubuntu/code/juejin/juejin 84 | sudo su 85 | git pull 86 | pnpm i 87 | pnpm build 88 | pnpm prod:pm2 89 | -------------------------------------------------------------------------------- /src/pages/post/[id].tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite' 2 | 3 | import { buildStoreDataLoadableView } from '~/components/app/LoadableView' 4 | import { wrapperNextPage } from '~/components/app/WrapperNextPage' 5 | import { Seo } from '~/components/biz/Seo' 6 | import { PostAuthor } from '~/components/in-page/Post/aside/author' 7 | import { RelatedPost } from '~/components/in-page/Post/aside/related' 8 | import { ArticleLayout } from '~/components/layouts/ArticleLayout' 9 | import { Image } from '~/components/universal/Image' 10 | import { Markdown } from '~/components/universal/Markdown' 11 | import { Author } from '~/components/widgets/Author' 12 | import { RequestError } from '~/services/server' 13 | import { store, useStore } from '~/store' 14 | import type { IPostModel } from '~/types/api/post' 15 | import { parseDate } from '~/utils/time' 16 | import { noop } from '~/utils/utils' 17 | 18 | const sidebar = [PostAuthor, RelatedPost] 19 | 20 | const PostView: PageOnlyProps = observer((props) => { 21 | const { postStore, appStore } = useStore() 22 | const post: IPostModel = postStore.get(props.id) || noop 23 | return ( 24 | <> 25 | 37 | 44 |
45 |

51 | {post.title} 52 |

53 | 58 | 59 |   ·  阅读 {post?.read} 60 |
61 | } 62 | /> 63 | {post.cover && ( 64 | {'cover'} 70 | )} 71 | 72 | 73 | 74 | 75 | 76 | ) 77 | }) 78 | 79 | const PP = buildStoreDataLoadableView(store.postStore, PostView) 80 | 81 | PP.getInitialProps = async (ctx) => { 82 | const { query } = ctx 83 | const { id } = query as any 84 | // FIXME: 韭菜写法 85 | const data = (await store.postStore.fetchBySlug(id)) as any 86 | if (data.status && data.status !== 200) { 87 | throw new RequestError(data.status, data.response) 88 | } 89 | return data 90 | } 91 | 92 | export default wrapperNextPage(PP) 93 | -------------------------------------------------------------------------------- /src/utils/spring.ts: -------------------------------------------------------------------------------- 1 | import spring, { toString } from 'css-spring' 2 | 3 | import { isClientSide } from './env' 4 | 5 | const cache = new Map() 6 | export type SpringOption = { 7 | precision?: number 8 | preset?: 'stiff' | 'gentle' | 'wobbly' | 'noWobble' 9 | stiffness?: number 10 | damping?: number 11 | } 12 | 13 | export const genSpringKeyframes = ( 14 | name: string, 15 | from: any, 16 | to: any, 17 | options: SpringOption = { preset: 'gentle' }, 18 | ) => { 19 | if (!isClientSide() || cache.has(name)) { 20 | return [name, null, null] as const 21 | } 22 | 23 | const transformProperty = [ 24 | 'translateX', 25 | 'translateY', 26 | 'translateZ', 27 | 'scale', 28 | 'rotate', 29 | ] 30 | const keyframes = toString(spring(from, to, options), (property, value) => 31 | transformProperty.includes(property) 32 | ? `transform: ${property}(${value});` 33 | : `${property}:${value};`, 34 | ) 35 | 36 | const css = document.createElement('style') 37 | css.innerHTML = ` 38 | @keyframes ${name} {${keyframes}}` 39 | document.head.appendChild(css) 40 | 41 | cache.set(name, keyframes) 42 | 43 | return [name, css, keyframes] as const 44 | } 45 | export const springScrollToTop = () => springScrollTo(0) 46 | export const springScrollTo = ( 47 | to: number, 48 | duration = 1000, 49 | container?: HTMLElement, 50 | ) => { 51 | if (!isClientSide()) { 52 | return 53 | } 54 | const scrollContainer = container || document.documentElement 55 | const res = spring( 56 | { top: scrollContainer.scrollTop }, 57 | { top: to }, 58 | { stiffness: 300, damping: 55 }, 59 | ) 60 | 61 | let raf: any 62 | const cancelScroll = () => { 63 | raf = cancelAnimationFrame(raf) 64 | 65 | scrollContainer.removeEventListener('wheel', cancelScroll) 66 | scrollContainer.removeEventListener('touchstart', cancelScroll) 67 | } 68 | scrollContainer.addEventListener('wheel', cancelScroll) 69 | scrollContainer.addEventListener('touchstart', cancelScroll, { 70 | passive: true, 71 | }) 72 | 73 | setTimeout(() => { 74 | cancelScroll() 75 | }, duration + 50) 76 | const ts = +new Date() 77 | raf = requestAnimationFrame(function an() { 78 | const current = +new Date() 79 | const percent = Math.floor(((current - ts) / duration) * 100) 80 | 81 | if (percent > 100) { 82 | scrollContainer.scrollTop = res[`${100}%`].top 83 | return 84 | } 85 | raf = requestAnimationFrame(an) 86 | if (res[`${percent}%`]?.top) { 87 | scrollContainer.scrollTop = res[`${percent}%`].top 88 | } 89 | }) 90 | } 91 | 92 | export const springScrollToElement = ( 93 | el: HTMLElement, 94 | duration = 1000, 95 | offset = 0, 96 | container?: HTMLElement, 97 | ) => { 98 | if (!isClientSide()) { 99 | return 100 | } 101 | const height = calculateElementTop(el) + offset 102 | 103 | return springScrollTo(height, duration, container) 104 | } 105 | 106 | const calculateElementTop = (el: HTMLElement) => { 107 | let top = 0 108 | while (el) { 109 | top += el.offsetTop 110 | el = el.offsetParent as HTMLElement 111 | } 112 | return top 113 | } 114 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "juejin", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "private": "false", 6 | "bugs": { 7 | "url": "https://github.com/Bocchi-Developers/juejin/issues" 8 | }, 9 | "homepage": "https://github.com/Bocchi-Developers/juejin#readme", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/Bocchi-Developers/juejin.git" 13 | }, 14 | "engines": { 15 | "node": ">=14" 16 | }, 17 | "lint-staged": { 18 | "**/*.{js,jsx,tsx,ts}": [ 19 | "npm run lint", 20 | "prettier --ignore-path ./.gitignore --write " 21 | ] 22 | }, 23 | "config": { 24 | "commitizen": { 25 | "path": "node_modules/cz-git", 26 | "useEmoji": true 27 | } 28 | }, 29 | "bump": { 30 | "leading": [ 31 | "pnpm build" 32 | ], 33 | "publish": false, 34 | "tag": true, 35 | "commit_message": "chore(release): bump @Bocchi-Developers/juejin to v${NEW_VERSION}" 36 | }, 37 | "scripts": { 38 | "prepare": "husky install", 39 | "dev": "cross-env NODE_ENV=development next dev -p 7496", 40 | "build": "cross-env NODE_ENV=production next build", 41 | "analyze": "cross-env NODE_ENV=production ANALYZE=true BUNDLE_ANALYZE=browser next build", 42 | "start": "next start -p 7497", 43 | "lint": "eslint --ext .ts,.tsx --ignore-path .gitignore . --fix", 44 | "prod:pm2": "cross-env NODE_ENV=production pm2 restart ecosystem.config.js", 45 | "prod:stop": "pm2 stop ecosystem.config.js", 46 | "prod:reload": "pm2 reload ecosystem.config.js", 47 | "release": "vv", 48 | "test": "vitest", 49 | "test:watch": "vitest watch", 50 | "test:updateSnap": "vitest -u" 51 | }, 52 | "dependencies": { 53 | "@arco-design/web-react": "^2.44.0", 54 | "@next/font": "13.1.6", 55 | "@types/react": "18.0.27", 56 | "@types/react-dom": "18.0.10", 57 | "axios": "^1.2.5", 58 | "clsx": "^1.2.1", 59 | "css-spring": "^4.1.0", 60 | "dayjs": "^1.11.7", 61 | "js-cookie": "^3.0.1", 62 | "less": "^4.1.3", 63 | "less-loader": "^11.1.0", 64 | "lodash-es": "^4.17.21", 65 | "markdown-to-jsx": "npm:@innei/markdown-to-jsx@7.1.3-beta.2", 66 | "mobx": "^6.7.0", 67 | "mobx-react-lite": "^3.4.0", 68 | "next": "13.1.2", 69 | "next-seo": "^5.15.0", 70 | "next-suspense": "^0.1.3", 71 | "next-with-less": "^2.0.5", 72 | "react": "18.2.0", 73 | "react-dom": "18.2.0", 74 | "react-infinite-scroller": "^1.2.6", 75 | "react-intersection-observer": "^9.4.1", 76 | "react-message-popup": "1.0.0", 77 | "typescript": "4.9.5", 78 | "unified": "^10.1.2" 79 | }, 80 | "devDependencies": { 81 | "@commitlint/cli": "^17.4.2", 82 | "@commitlint/config-conventional": "^17.4.2", 83 | "@innei/bump-version": "^1.5.6", 84 | "@next/bundle-analyzer": "^13.1.2", 85 | "@suemor/eslint-config-react-ts": "^1.1.0", 86 | "@suemor/prettier": "^1.1.0", 87 | "@testing-library/react": "^14.0.0", 88 | "@types/node": "18.14.6", 89 | "@vitejs/plugin-react": "^3.1.0", 90 | "commitlint": "^17.4.2", 91 | "cross-env": "^7.0.3", 92 | "cz-git": "^1.4.1", 93 | "husky": "^8.0.3", 94 | "jsdom": "^21.1.0", 95 | "lint-staged": "^13.1.0", 96 | "next-compose-plugins": "^2.2.1", 97 | "vite": "^4.1.3", 98 | "vitest": "^0.29.0" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/services/server.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosError, AxiosResponse } from 'axios' 2 | import axios, { CanceledError } from 'axios' 3 | import message from 'react-message-popup' 4 | 5 | import { API_URL } from '~/constants/env' 6 | import type { ApiResponse } from '~/types/api' 7 | import { isClientSide } from '~/utils/env' 8 | 9 | import { handleConfigureAuth } from './tools' 10 | 11 | axios.interceptors.request.use((config) => { 12 | config.baseURL = API_URL 13 | config = handleConfigureAuth(config) 14 | return config 15 | }) 16 | 17 | axios.interceptors.response.use( 18 | undefined, 19 | (error: AxiosError | undefined>) => { 20 | if (error instanceof CanceledError) { 21 | return Promise.reject(error) 22 | } 23 | 24 | if (process.env.NODE_ENV === 'development') { 25 | console.error(error.message) 26 | } 27 | if ( 28 | !error.response || 29 | error.response.status === 408 || 30 | error.code === 'ECONNABORTED' 31 | ) { 32 | if (isClientSide()) { 33 | message.error('请求超时,请检查一下网络哦!') 34 | } else { 35 | const msg = '上游服务器请求超时' 36 | message.error(msg) 37 | console.error(msg, error.message) 38 | } 39 | } 40 | const response = error.response 41 | if (response) { 42 | const data = response.data 43 | 44 | // eslint-disable-next-line no-empty 45 | if (response.status == 401) { 46 | } else if (data && data.message) { 47 | message.error( 48 | typeof data.message == 'string' 49 | ? data.message 50 | : Array.isArray(data.message) 51 | ? data.message[0] 52 | : '请求错误', 53 | ) 54 | } 55 | } 56 | const status = response ? response.status : 408 57 | 58 | return Promise.reject(new RequestError(status, response)) 59 | }, 60 | ) 61 | 62 | export class RequestError extends Error { 63 | response: AxiosError['response'] 64 | status: number 65 | constructor(status: number, response: AxiosResponse | undefined) { 66 | const message = response 67 | ? response.data?.message || 'Unknown Error' 68 | : 'Request timeout' 69 | super(message) 70 | this.status = status 71 | this.response = response 72 | } 73 | } 74 | 75 | export const Get = (url: string, params?: {}): ApiResponse => 76 | new Promise((resolve) => { 77 | axios 78 | .get(url, { params }) 79 | .then((result) => { 80 | resolve(result ? result.data : result) 81 | }) 82 | .catch((err) => { 83 | resolve(err) 84 | }) 85 | }) 86 | 87 | export const Post = ( 88 | url: string, 89 | data: unknown, 90 | params?: {}, 91 | ): ApiResponse => { 92 | return new Promise((resolve) => { 93 | axios 94 | .post(url, data, { params }) 95 | .then((result) => { 96 | resolve(result ? result.data : result) 97 | }) 98 | .catch((err) => { 99 | resolve(err) 100 | }) 101 | }) 102 | } 103 | 104 | export const Patch = ( 105 | url: string, 106 | data: unknown, 107 | params?: {}, 108 | ): ApiResponse => { 109 | return new Promise((resolve) => { 110 | axios 111 | .patch(url, data, { params }) 112 | .then((result) => { 113 | resolve(result ? result.data : result) 114 | }) 115 | .catch((err) => { 116 | resolve(err) 117 | }) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /src/components/universal/CodeHighlighter/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite' 2 | import type { FC } from 'react' 3 | import React, { useCallback, useInsertionEffect, useRef } from 'react' 4 | import { message } from 'react-message-popup' 5 | 6 | import { loadScript, loadStyleSheet } from '~/utils/load-script' 7 | 8 | import { useStore } from '../../../store' 9 | import styles from './index.module.less' 10 | 11 | interface Props { 12 | lang: string | undefined 13 | content: string 14 | } 15 | 16 | export const HighLighter: FC = observer((props) => { 17 | const { lang: language, content: value } = props 18 | const { appUIStore } = useStore() 19 | const { colorMode } = appUIStore 20 | const handleCopy = useCallback(() => { 21 | navigator.clipboard.writeText(value) 22 | message.success('COPIED!') 23 | }, [value]) 24 | const isPrintMode = appUIStore.mediaType === 'print' 25 | 26 | const prevThemeCSS = useRef>() 27 | 28 | useInsertionEffect(() => { 29 | const css = loadStyleSheet( 30 | `https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/prism-themes/1.9.0/prism-one-${ 31 | isPrintMode ? 'light' : colorMode 32 | }.css`, 33 | ) 34 | 35 | if (prevThemeCSS.current) { 36 | const $prev = prevThemeCSS.current 37 | css.$link.onload = () => { 38 | $prev.remove() 39 | } 40 | } 41 | 42 | prevThemeCSS.current = css 43 | }, [colorMode, isPrintMode]) 44 | useInsertionEffect(() => { 45 | loadStyleSheet( 46 | 'https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/prism/1.23.0/plugins/line-numbers/prism-line-numbers.min.css', 47 | ) 48 | 49 | Promise.all([ 50 | loadScript( 51 | 'https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/prism/1.23.0/components/prism-core.min.js', 52 | ), 53 | ]) 54 | .then(() => 55 | Promise.all([ 56 | loadScript( 57 | 'https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/prism/1.23.0/plugins/autoloader/prism-autoloader.min.js', 58 | ), 59 | loadScript( 60 | 'https://lf3-cdn-tos.bytecdntp.com/cdn/expire-1-M/prism/1.23.0/plugins/line-numbers/prism-line-numbers.min.js', 61 | ), 62 | ]), 63 | ) 64 | .then(() => { 65 | if (ref.current) { 66 | requestAnimationFrame(() => { 67 | window.Prism?.highlightElement(ref.current) 68 | 69 | requestAnimationFrame(() => { 70 | window.Prism?.highlightElement(ref.current) 71 | }) 72 | }) 73 | } else { 74 | requestAnimationFrame(() => { 75 | window.Prism?.highlightAll() 76 | // highlightAll twice 77 | 78 | requestAnimationFrame(() => { 79 | window.Prism?.highlightAll() 80 | }) 81 | }) 82 | } 83 | }) 84 | }, []) 85 | 86 | const ref = useRef(null) 87 | return ( 88 |
89 | 90 | {language?.toUpperCase()} 91 | 92 | 93 |
 94 |         
 95 |           {value}
 96 |         
 97 |       
98 | 99 |
100 | Copy 101 |
102 |
103 | ) 104 | }) 105 | -------------------------------------------------------------------------------- /src/components/in-page/Home/list.module.less: -------------------------------------------------------------------------------- 1 | .divider { 2 | padding: 0 20px; 3 | // background-color: var(--juejin-background); 4 | } 5 | 6 | .skeleton { 7 | padding: 30px 20px; 8 | } 9 | .list { 10 | padding: 10px 20px 0; 11 | color: var(--juejin-font-3); 12 | background-color: var(--juejin-item-background); 13 | &.hover:hover { 14 | background-color: var(--juejin-gray-1-4); 15 | } 16 | } 17 | .bg-wrapper { 18 | background-color: var(--juejin-item-background); 19 | } 20 | .bar-container { 21 | display: flex; 22 | padding: 5px 0 0 0; 23 | justify-self: center; 24 | align-items: center; 25 | .bar-author { 26 | // width: 1rem; 27 | width: fit-content; 28 | color: var(--juejin-font-2); 29 | // padding: 0 0.5rem; 30 | &:hover { 31 | color: var(--juejin-brand-2-hover); 32 | } 33 | } 34 | } 35 | .bar-date { 36 | // width: 1rem; 37 | width: fit-content; 38 | // &:not(:last-child)::after, 39 | &:before { 40 | content: ''; 41 | display: inline-block; 42 | width: 1px; 43 | height: 1rem; 44 | // border-radius: 50%; 45 | background-color: var(--juejin-font-5); 46 | margin: 0 0.6rem; 47 | // color: var(--juejin-brand-2-hover); 48 | } 49 | } 50 | .bar-tag { 51 | &:before { 52 | content: ''; 53 | display: inline-block; 54 | width: 1px; 55 | height: 1rem; 56 | // border-radius: 50%; 57 | background-color: var(--juejin-font-5); 58 | margin: 0 0.6rem; 59 | // color: var(--juejin-brand-2-hover); 60 | } 61 | a { 62 | position: relative; 63 | margin-right: 1rem; 64 | &:hover { 65 | color: var(--juejin-brand-2-hover); 66 | } 67 | } 68 | a:not(:last-child)::after { 69 | content: ''; 70 | display: inline-block; 71 | position: absolute; 72 | bottom: calc(0.5rem + 1px); 73 | width: 2px; 74 | height: 2px; 75 | border-radius: 50%; 76 | left: calc(100% + 0.5rem); 77 | background-color: var(--juejin-font-2); 78 | // margin: 0 0.5rem; 79 | } 80 | } 81 | .bar-ad { 82 | margin-left: auto; 83 | border: 1px solid var(--juejin-font-4); 84 | box-sizing: border-box; 85 | border-radius: 2px; 86 | padding: 0 0.4em; 87 | line-height: 18px; 88 | margin-bottom: 0.2em; 89 | text-align: center; 90 | color: var(--juejin-font-4); 91 | font-size: 13px; 92 | } 93 | .not-read { 94 | color: var(--juejin-font-1); 95 | } 96 | .ellipsis { 97 | margin-right: 1em; 98 | display: -webkit-box; 99 | overflow: hidden; 100 | text-overflow: ellipsis; 101 | -webkit-box-orient: vertical; 102 | -webkit-line-clamp: 1; 103 | } 104 | 105 | .description { 106 | margin-top: 0.7em; 107 | } 108 | .title { 109 | margin-top: 0.6em; 110 | font-weight: 700; 111 | font-size: 16px; 112 | line-height: 24px; 113 | // color: #1d2129; 114 | display: -webkit-box; 115 | overflow: hidden; 116 | text-overflow: ellipsis; 117 | -webkit-box-orient: vertical; 118 | -webkit-line-clamp: 1; 119 | } 120 | .nav { 121 | padding: 10px 0 0 20px; 122 | background-color: var(--juejin-item-background); 123 | } 124 | .nav-item { 125 | &.active { 126 | color: var(--juejin-brand-2-hover); 127 | } 128 | margin-right: 25px; 129 | &:hover { 130 | color: var(--juejin-brand-2-hover); 131 | } 132 | &:not(:last-child)::after { 133 | content: ''; 134 | display: inline-block; 135 | width: 1px; 136 | height: 1rem; 137 | // border-radius: 50%; 138 | background-color: var(--juejin-font-5); 139 | // margin: 0 20px; 140 | transform: translate(12px, 0); 141 | // color: var(--juejin-brand-2-hover); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/components/universal/Logo/index.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react' 2 | import { memo } from 'react' 3 | 4 | export const Logo: FC = memo((props) => { 5 | return ( 6 | 14 | 20 | 21 | ) 22 | }) 23 | 24 | export const JuejinFont: FC = memo((props) => { 25 | return ( 26 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ) 46 | }) 47 | -------------------------------------------------------------------------------- /src/components/universal/Markdown/components.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | 3 | import { clsx } from 'clsx' 4 | import range from 'lodash-es/range' 5 | import type { MarkdownToJSX } from 'markdown-to-jsx' 6 | import { compiler } from 'markdown-to-jsx' 7 | import { observer } from 'mobx-react-lite' 8 | import type { FC, PropsWithChildren } from 'react' 9 | import React, { 10 | createElement, 11 | useContext, 12 | useEffect, 13 | useMemo, 14 | useRef, 15 | useState, 16 | } from 'react' 17 | 18 | import { SidebarContext } from '~/components/layouts/ArticleLayout' 19 | import { Image } from '~/components/universal/Image' 20 | import { useStore } from '~/store' 21 | 22 | export interface MdProps { 23 | value?: string 24 | style?: React.CSSProperties 25 | readonly renderers?: { [key: string]: Partial } 26 | wrapperProps?: React.DetailedHTMLProps< 27 | React.HTMLAttributes, 28 | HTMLDivElement 29 | > 30 | codeBlockFully?: boolean 31 | className?: string 32 | tocSlot?: (props: { headings: HTMLElement[] }) => JSX.Element | null 33 | } 34 | 35 | export const Markdown: FC> = 36 | observer((props) => { 37 | const { 38 | value, 39 | renderers, 40 | style, 41 | wrapperProps = {}, 42 | className, 43 | overrides, 44 | extendsRules, 45 | additionalParserRules, 46 | ...rest 47 | } = props 48 | const sideBarContext = useContext(SidebarContext) 49 | const ref = useRef(null) 50 | const [headings, setHeadings] = useState([]) 51 | const { appStore } = useStore() 52 | useEffect(() => { 53 | if (!ref.current) { 54 | return 55 | } 56 | 57 | const $headings = ref.current.querySelectorAll( 58 | range(1, 6) 59 | .map((i) => `h${i}`) 60 | .join(','), 61 | ) as NodeListOf 62 | 63 | setHeadings(Array.from($headings)) 64 | 65 | return () => { 66 | setHeadings([]) 67 | } 68 | }, [value, props.children]) 69 | 70 | const node = useMemo(() => { 71 | if (!value && typeof props.children != 'string') return null 72 | const mdElement = compiler(`${value || props.children}`, { 73 | wrapper: null, 74 | 75 | overrides: { 76 | img: (props) => ( 77 | 78 | ), 79 | ...overrides, 80 | }, 81 | 82 | extendsRules: { 83 | ...extendsRules, 84 | ...renderers, 85 | }, 86 | additionalParserRules: { 87 | ...additionalParserRules, 88 | }, 89 | ...rest, 90 | }) 91 | if (headings.length > 0 && sideBarContext?.setSidebar) { 92 | const { setSidebar } = sideBarContext 93 | setSidebar((sidebars) => { 94 | if (!sidebars) return 95 | if (sidebars.length >= 3) return sidebars 96 | return [ 97 | ...sidebars, 98 | () => 99 | props.tocSlot ? createElement(props.tocSlot, { headings }) : null, 100 | ] 101 | }) 102 | } 103 | return mdElement 104 | }, [ 105 | value, 106 | props.children, 107 | overrides, 108 | extendsRules, 109 | renderers, 110 | additionalParserRules, 111 | rest, 112 | appStore.colorMode, 113 | ]) 114 | 115 | return ( 116 |
123 | {className ?
{node}
: node} 124 |
125 | ) 126 | }) 127 | -------------------------------------------------------------------------------- /src/assets/styles/juejin.less: -------------------------------------------------------------------------------- 1 | @monospace-font: Menlo, Monaco, Consolas, 'Courier New', monospace; 2 | @line-space: 22px; 3 | 4 | .markdown-body { 5 | word-break: break-word; 6 | line-height: 1.75; 7 | font-weight: 400; 8 | font-size: 16px; 9 | overflow-x: hidden; 10 | color: #333; 11 | 12 | h1, 13 | h2, 14 | h3, 15 | h4, 16 | h5, 17 | h6 { 18 | line-height: 1.5; 19 | margin-top: 35px; 20 | margin-bottom: 10px; 21 | padding-bottom: 5px; 22 | } 23 | 24 | h1 { 25 | font-size: 24px; 26 | margin-bottom: 5px; 27 | } 28 | h2, h3, h4, h5, h6 { 29 | font-size: 20px; 30 | } 31 | h2 { 32 | padding-bottom: 12px; 33 | border-bottom: 1px solid #ececec; 34 | } 35 | h3 { 36 | font-size: 18px; 37 | padding-bottom: 0; 38 | } 39 | h6 { 40 | margin-top: 5px; 41 | } 42 | 43 | p { 44 | line-height: inherit; 45 | margin-top: @line-space; 46 | margin-bottom: @line-space; 47 | } 48 | 49 | img { 50 | max-width: 100%; 51 | } 52 | 53 | hr { 54 | border-top: 1px solid #ddd; 55 | border-bottom: none; 56 | border-left: none; 57 | border-right: none; 58 | margin-top: 32px; 59 | margin-bottom: 32px; 60 | } 61 | 62 | // code { 63 | // font-family: @monospace-font; 64 | // word-break: break-word; 65 | // border-radius: 2px; 66 | // overflow-x: auto; 67 | // background-color: #fff5f5; 68 | // color: #ff502c; 69 | // font-size: 0.87em; 70 | // padding: 0.065em 0.4em; 71 | // } 72 | 73 | // pre { 74 | // font-family: @monospace-font; 75 | // overflow: auto; 76 | // position: relative; 77 | // line-height: 1.75; 78 | // > code { 79 | // font-size: 12px; 80 | // padding: 15px 12px; 81 | // margin: 0; 82 | // word-break: normal; 83 | // display: block; 84 | // overflow-x: auto; 85 | // color: #333; 86 | // background: #f8f8f8; 87 | // } 88 | // } 89 | 90 | a { 91 | text-decoration: none; 92 | color: #0269c8; 93 | border-bottom: 1px solid #d1e9ff; 94 | &:hover, 95 | &:active { 96 | color: #275b8c; 97 | } 98 | } 99 | 100 | table { 101 | display: inline-block !important; 102 | font-size: 12px; 103 | width: auto; 104 | max-width: 100%; 105 | overflow: auto; 106 | border: solid 1px #f6f6f6; 107 | } 108 | thead { 109 | background: #f6f6f6; 110 | color: #000; 111 | text-align: left; 112 | } 113 | tr:nth-child(2n) { 114 | background-color: #fcfcfc; 115 | } 116 | th, 117 | td { 118 | padding: 12px 7px; 119 | line-height: 24px; 120 | } 121 | td { 122 | min-width: 120px; 123 | } 124 | 125 | blockquote { 126 | color: #666; 127 | padding: 1px 23px; 128 | margin: 22px 0; 129 | border-left: 4px solid #cbcbcb; 130 | background-color: #f8f8f8; 131 | &::after { 132 | display: block; 133 | content: ''; 134 | } 135 | & > p { 136 | margin: 10px 0; 137 | } 138 | } 139 | 140 | ol, 141 | ul { 142 | padding-left: 28px; 143 | li { 144 | margin-bottom: 0; 145 | list-style: inherit; 146 | & .task-list-item { 147 | list-style: none; 148 | ul, 149 | ol { 150 | margin-top: 0; 151 | } 152 | } 153 | } 154 | 155 | ul, 156 | ol { 157 | margin-top: 3px; 158 | } 159 | } 160 | ol li { 161 | padding-left: 6px; 162 | } 163 | 164 | .contains-task-list { 165 | padding-left: 0; 166 | } 167 | 168 | .task-list-item { 169 | list-style: none; 170 | } 171 | 172 | @media (max-width: 720px) { 173 | h1 { 174 | font-size: 24px; 175 | } 176 | h2 { 177 | font-size: 20px; 178 | } 179 | h3 { 180 | font-size: 18px; 181 | } 182 | } 183 | } -------------------------------------------------------------------------------- /src/components/layouts/BasicLayout/Header/index.module.less: -------------------------------------------------------------------------------- 1 | .main-header { 2 | background: var(--juejin-item-background); 3 | border-bottom: 1px solid var(--juejin-gray-2); 4 | position: fixed; 5 | height: 5rem; 6 | top: 0; 7 | left: 0; 8 | right: 0; 9 | z-index: 250; 10 | transition: transform 0.2s; 11 | 12 | .wrapper { 13 | margin: auto; 14 | max-width: 1440px; 15 | display: flex; 16 | align-items: center; 17 | justify-content: space-between; 18 | padding: 0 1rem; 19 | } 20 | } 21 | .icon { 22 | cursor: pointer; 23 | transition: transform 0.2s ease-in; 24 | &:hover { 25 | transform: scale(1.1); 26 | } 27 | * { 28 | transition: none; 29 | } 30 | } 31 | .logo { 32 | display: flex; 33 | flex-direction: row; 34 | align-items: center; 35 | gap: 0.3rem; 36 | span { 37 | font-size: 1.5rem; 38 | font-weight: 500; 39 | } 40 | } 41 | .header-container { 42 | height: 100%; 43 | display: flex; 44 | user-select: none; 45 | } 46 | .tab-item { 47 | line-height: 5rem; 48 | font-size: 1.25rem; 49 | height: 100%; 50 | height: 5rem; 51 | color: var(--juejin-text-1); 52 | position: relative; 53 | &::before { 54 | content: attr(data-promote) ''; 55 | height: 16px; 56 | line-height: 16px; 57 | white-space: nowrap; 58 | font-size: 16px; 59 | padding: 0.25rem 0.55rem; 60 | color: var(--juejin-font-white); 61 | width: -moz-fit-content; 62 | width: fit-content; 63 | background-color: var(--juejin-coupon_1_button); 64 | border-radius: calc(16px + 0.25rem); 65 | position: absolute; 66 | font-weight: 500; 67 | top: 10%; 68 | left: -12.5%; 69 | transform: scale(0.5); 70 | // opacity: if($width<0.25rem, 1, $width); 71 | } 72 | &:hover { 73 | // color: var(--juejin-font-brand2-hover); 74 | &::after { 75 | content: ''; 76 | width: 80%; 77 | left: 10%; 78 | height: 5%; 79 | position: absolute; 80 | bottom: 0; 81 | background-color: var(--juejin-font-brand1-normal); 82 | } 83 | } 84 | } 85 | .active { 86 | color: var(--juejin-font-brand2-hover); 87 | } 88 | .tab { 89 | display: flex; 90 | flex-direction: row; 91 | @media screen and (min-width: 1100px) { 92 | gap: 1.8em; 93 | margin-left: 2rem; 94 | } 95 | } 96 | .tab-mobile { 97 | flex-direction: column; 98 | position: absolute; 99 | width: 12.9rem; 100 | height: fit-content; 101 | background-color: var(--juejin-item-background); 102 | box-shadow: 0 8px 24px rgb(81 87 103 / 16%); 103 | top: 5rem; 104 | text-align: center; 105 | padding: 0 4em; 106 | left: -1em; 107 | @media screen and (min-width: 568px) { 108 | left: 6em; 109 | } 110 | } 111 | .hidden { 112 | display: none; 113 | } 114 | .after-hidden { 115 | &::after { 116 | display: none; 117 | } 118 | } 119 | .before-hidden { 120 | &::before { 121 | display: none; 122 | } 123 | } 124 | .tab-more { 125 | line-height: 5rem; 126 | font-size: 1.25rem; 127 | height: 100%; 128 | height: 5rem; 129 | color: var(--juejin-text-1); 130 | cursor: pointer; 131 | position: relative; 132 | } 133 | .tab-first { 134 | line-height: 5rem; 135 | margin-left: 1rem; 136 | font-size: 1.5rem; 137 | color: var(--juejin-font-brand1-normal); 138 | cursor: pointer; 139 | // left:; 140 | &::after { 141 | content: ''; 142 | width: 0; 143 | position: absolute; 144 | top: 50%; 145 | margin-left: 5px; 146 | height: 0; 147 | border-top: 5px solid var(--juejin-popover); 148 | border-right: 5px solid transparent; 149 | border-left: 5px solid transparent; 150 | transition: all 0.2s; 151 | } 152 | } 153 | .tab-first-active { 154 | &::after { 155 | border-top: 5px solid var(--juejin-font-brand2-hover); 156 | transform: rotate(180deg); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/components/layouts/BasicLayout/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { observer } from 'mobx-react-lite' 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/router' 5 | import type { FC } from 'react' 6 | import { useContext, useState } from 'react' 7 | 8 | import { Collapse, Expand } from '~/components/universal/Icons/TabIcon' 9 | import { IconNight, IconSun } from '~/components/universal/Icons/dark-mode' 10 | import { JuejinFont, Logo as JuejinLogo } from '~/components/universal/Logo' 11 | import { InitialContext } from '~/context/initial-data' 12 | import { useMediaToggle } from '~/hooks/use-media-toggle' 13 | import { useStore } from '~/store' 14 | import type { TabModule } from '~/types/api/aggregate' 15 | 16 | import styles from './index.module.less' 17 | 18 | export const SwitchTheme = () => { 19 | const { toggle, value } = useMediaToggle() 20 | return ( 21 |
22 | {value ? : } 23 |
24 | ) 25 | } 26 | const TabItem: FC = ({ slug, title, tag }) => { 27 | const router = useRouter() 28 | return ( 29 | 41 | {title} 42 | 43 | ) 44 | } 45 | const Header = observer(() => { 46 | const { 47 | appStore: { isNarrowThanLaptop, viewport }, 48 | } = useStore() 49 | const [hidden, setHidden] = useState(true) 50 | const [tabExpand, setTabExpand] = useState(false) 51 | const { tab } = useContext(InitialContext) 52 | const { appStore } = useStore() 53 | return ( 54 |
57 |
58 |
59 | 60 | 61 |
62 | {!viewport.mobile && ( 63 | 66 | )} 67 |
68 | 69 | {isNarrowThanLaptop && ( 70 |
{ 76 | setHidden((pre) => !pre) 77 | }} 78 | > 79 | {tab[0].title} 80 |
81 | )} 82 | {!(isNarrowThanLaptop ? hidden : false) && ( 83 |
89 | {tab 90 | .slice(0, isNarrowThanLaptop || tabExpand ? undefined : 9) 91 | .map((tab) => ( 92 | 93 | ))} 94 | {tab.length > 9 && !isNarrowThanLaptop && ( 95 |
{ 98 | setTabExpand(!tabExpand) 99 | }} 100 | title={tabExpand ? '收起' : '展开'} 101 | > 102 | {tabExpand ? : } 103 |
104 | )} 105 |
106 | )} 107 |
108 | 109 |
110 |
111 | ) 112 | }) 113 | 114 | export default Header 115 | -------------------------------------------------------------------------------- /src/hooks/use-media-toggle.ts: -------------------------------------------------------------------------------- 1 | import { runInAction } from 'mobx' 2 | import { useEffect, useState } from 'react' 3 | 4 | import { useStore } from '~/store' 5 | import { isServerSide } from '~/utils/env' 6 | 7 | interface DarkModeConfig { 8 | classNameDark?: string // A className to set "dark mode". Default = "dark-mode". 9 | classNameLight?: string // A className to set "light mode". Default = "light-mode". 10 | element?: HTMLElement | undefined | null // The element to apply the className. Default = `document.body` 11 | storageKey?: string // Specify the `localStorage` key. Default = "darkMode". Sewt to `null` to disable persistent storage. 12 | } 13 | 14 | const useDarkMode = ( 15 | initialState: boolean | undefined, 16 | options: DarkModeConfig, 17 | ) => { 18 | const { 19 | classNameDark = 'dark', 20 | classNameLight = 'light', 21 | storageKey, 22 | element, 23 | } = options 24 | 25 | const [darkMode, setDarkMode] = useState(initialState) 26 | 27 | useEffect(() => { 28 | const presentedDarkMode = storageKey 29 | ? isServerSide() 30 | ? null 31 | : localStorage.getItem(storageKey) 32 | : null 33 | 34 | if (presentedDarkMode !== null) { 35 | if (presentedDarkMode === 'true') { 36 | setDarkMode(true) 37 | } else if (presentedDarkMode === 'false') { 38 | setDarkMode(false) 39 | } 40 | } else if (typeof initialState === 'undefined') { 41 | setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches) 42 | } 43 | }, [storageKey]) 44 | 45 | useEffect(() => { 46 | const handler = (e: MediaQueryListEvent) => { 47 | const storageValue = localStorage.getItem(storageKey || 'darkMode') 48 | if (storageValue === null) { 49 | setDarkMode(e.matches) 50 | } 51 | } 52 | 53 | const focusHandler = () => { 54 | const storageValue = localStorage.getItem(storageKey || 'darkMode') 55 | if (storageValue === null) { 56 | setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches) 57 | } 58 | } 59 | 60 | window.addEventListener('focus', focusHandler) 61 | window 62 | .matchMedia('(prefers-color-scheme: dark)') 63 | .addEventListener('change', handler) 64 | 65 | return () => { 66 | window.removeEventListener('focus', focusHandler) 67 | window 68 | .matchMedia('(prefers-color-scheme: dark)') 69 | .removeEventListener('change', handler) 70 | } 71 | }, [storageKey]) 72 | 73 | useEffect(() => { 74 | if (isServerSide()) { 75 | return 76 | } 77 | 78 | if (darkMode) { 79 | document.body.setAttribute('arco-theme', 'dark') 80 | } else { 81 | document.body.removeAttribute('arco-theme') 82 | } 83 | }, [classNameDark, classNameLight, darkMode, element]) 84 | 85 | if (isServerSide()) { 86 | return { 87 | toggle: () => {}, 88 | value: false, 89 | } 90 | } 91 | 92 | return { 93 | value: darkMode, 94 | toggle: () => { 95 | setDarkMode((d) => { 96 | if (storageKey && !isServerSide()) { 97 | localStorage.setItem(storageKey, String(!d)) 98 | } 99 | 100 | return !d 101 | }) 102 | }, 103 | } 104 | } 105 | 106 | const noop = () => {} 107 | 108 | const mockElement = { 109 | classList: { 110 | add: noop, 111 | remove: noop, 112 | }, 113 | } 114 | const darkModeKey = 'darkMode' 115 | export const useMediaToggle = () => { 116 | const { appStore: app } = useStore() 117 | const { toggle, value } = useDarkMode(undefined, { 118 | classNameDark: 'dark', 119 | classNameLight: 'light', 120 | storageKey: darkModeKey, 121 | element: (globalThis.document && document.documentElement) || mockElement, 122 | }) 123 | 124 | useEffect(() => { 125 | runInAction(() => { 126 | app.colorMode = value ? 'dark' : 'light' 127 | }) 128 | }, [app, value]) 129 | 130 | useEffect(() => { 131 | const handler = () => { 132 | if (window.matchMedia('(prefers-color-scheme: dark)').matches === value) { 133 | localStorage.removeItem(darkModeKey) 134 | } 135 | } 136 | window.addEventListener('beforeunload', handler) 137 | 138 | return () => { 139 | window.removeEventListener('beforeunload', handler) 140 | } 141 | }, [value]) 142 | 143 | return { 144 | toggle, 145 | value, 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/components/universal/Toc/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import throttle from 'lodash-es/throttle' 3 | import type { FC } from 'react' 4 | import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' 5 | 6 | import { CustomEventTypes } from '~/types/events' 7 | import { eventBus } from '~/utils/event-emitter' 8 | 9 | import styles from './index.module.less' 10 | import { TocItem } from './item' 11 | 12 | export type TocProps = { 13 | headings: HTMLElement[] 14 | 15 | useAsWeight?: boolean 16 | } 17 | 18 | type Headings = { 19 | depth: number 20 | index: number 21 | title: string 22 | }[] 23 | export const Toc: FC = memo( 24 | ({ headings: $headings, useAsWeight }) => { 25 | const containerRef = useRef(null) 26 | const headings: Headings = useMemo(() => { 27 | return Array.from($headings).map((el) => { 28 | const depth = +el.tagName.slice(1) 29 | const title = el.id || el.textContent || '' 30 | 31 | const index = Number(el.dataset['index']) 32 | return { 33 | depth, 34 | index: isNaN(index) ? -1 : index, 35 | title, 36 | } 37 | }) 38 | }, [$headings]) 39 | const [index, setIndex] = useState(-1) 40 | useEffect(() => { 41 | const handler = (index: number) => { 42 | setIndex(index) 43 | } 44 | eventBus.on(CustomEventTypes.TOC, handler) 45 | return () => { 46 | eventBus.off(CustomEventTypes.TOC, handler) 47 | } 48 | }, []) 49 | 50 | useEffect(() => { 51 | if (useAsWeight) { 52 | return 53 | } 54 | const setMaxWidth = throttle(() => { 55 | if (containerRef.current) { 56 | containerRef.current.style.maxWidth = `${ 57 | document.documentElement.getBoundingClientRect().width - 58 | containerRef.current.getBoundingClientRect().x - 59 | 60 60 | }px` 61 | } 62 | }, 14) 63 | window.addEventListener('resize', setMaxWidth) 64 | setMaxWidth() 65 | 66 | return () => { 67 | window.removeEventListener('resize', setMaxWidth) 68 | } 69 | }, [useAsWeight]) 70 | 71 | const handleItemClick = useCallback((i) => { 72 | setTimeout(() => { 73 | setIndex(i) 74 | }, 350) 75 | }, []) 76 | const rootDepth = useMemo( 77 | () => 78 | headings?.length 79 | ? (headings.reduce( 80 | (d: number, cur) => Math.min(d, cur.depth), 81 | headings[0]?.depth || 0, 82 | ) as any as number) 83 | : 0, 84 | [headings], 85 | ) 86 | return ( 87 |
88 |
89 | {headings && 90 | headings.map((heading) => { 91 | return ( 92 | 101 | ) 102 | })} 103 |
104 |
105 | ) 106 | }, 107 | ) 108 | const MemoedItem = memo<{ 109 | isActive: boolean 110 | heading: Headings[0] 111 | rootDepth: number 112 | lastPostion: number 113 | onClick: (i: number) => void 114 | containerRef: any 115 | }>( 116 | (props) => { 117 | const { heading, isActive, onClick, rootDepth, containerRef, lastPostion } = 118 | props 119 | 120 | return ( 121 | 132 | ) 133 | }, 134 | (a, b) => { 135 | // FUCK react transition group alway inject onExited props into Child element, but this props alway change, so ignore it. 136 | 137 | return ( 138 | a.heading === b.heading && 139 | a.isActive === b.isActive && 140 | a.onClick === b.onClick && 141 | a.rootDepth === b.rootDepth 142 | ) 143 | }, 144 | ) 145 | 146 | MemoedItem.displayName = 'MemoedItem' 147 | -------------------------------------------------------------------------------- /src/assets/styles/color.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --juejin-layer-golden-1: #faf3e5; 3 | --juejin-layer-golden-2: #f6e7cb; 4 | --juejin-component-hover: #e4e6eb; 5 | --juejin-gradientgold_normal_start: #fde8c3; 6 | --juejin-gradientgold_normal_end: #edd3a7; 7 | --juejin-gradientgold_hover_start: #f1dfc0; 8 | --juejin-gradientgold_hover_end: #e6c99b; 9 | --juejin-gradientgold_click_start: #e9d5b3; 10 | --juejin-gradientgold_click_end: #dac29a; 11 | --juejin-layer_loading_start: rgba(228, 230, 235, 0); 12 | --juejin-layer_loading_end: rgba(228, 230, 235, 0.5); 13 | --juejin-layer_golden_2: #faf3e5; 14 | --juejin-font_golden_4: #7e5d25; 15 | --juejin-font-golden-1: #7e5d25; 16 | --juejin-font-golden-2: #8a795c; 17 | --juejin-font-golden-3: #d6b885; 18 | --juejin-gray-0: #fff; 19 | --juejin-gray-1-1: #e4e6eb; 20 | --juejin-gray-1-2: rgba(228, 230, 235, 0.5); 21 | --juejin-gray-1-3: #e4e6eb; 22 | --juejin-gray-1-4: #fafafa; 23 | --juejin-gray-2: #f2f3f5; 24 | --juejin-gray-3: #f7f8fa; 25 | --juejin-background: #f2f3f5; 26 | --juejin-layer-1: #fff; 27 | --juejin-layer-2-1: #f7f8fa; 28 | --juejin-layer-2-2: rgba(247, 248, 250, 0.7); 29 | --juejin-layer-3-fill: #f2f3f5; 30 | --juejin-layer-3-border: #e4e6eb; 31 | --juejin-layer-4-dropdown: #fff; 32 | --juejin-layer-5: #fff; 33 | --juejin-brand-1-normal: #1e80ff; 34 | --juejin-brand-2-hover: #1171ee; 35 | --juejin-brand-3-click: #0060dd; 36 | --juejin-brand-4-disable: #abcdff; 37 | --juejin-brand-5-light: #eaf2ff; 38 | --juejin-mask-1: rgba(0, 0, 0, 0.4); 39 | --juejin-mask-2: #fff; 40 | --juejin-mask-3: none; 41 | --juejin-brand-fill1-normal: rgba(30, 128, 255, 0.05); 42 | --juejin-brand-fill2-hover: rgba(30, 128, 255, 0.1); 43 | --juejin-brand-fill3-click: rgba(30, 128, 255, 0.2); 44 | --juejin-brand-stroke1-normal: rgba(30, 128, 255, 0.3); 45 | --juejin-brand-stroke2-hover: rgba(30, 128, 255, 0.45); 46 | --juejin-brand-stroke3-click: rgba(30, 128, 255, 0.6); 47 | --juejin-font_danger: #ff5132; 48 | --juejin-font-1: #252933; 49 | --juejin-font-2: #515767; 50 | --juejin-font-3: #86909c; 51 | --juejin-font-4: #c2c8d1; 52 | --juejin-font-5: #e5e6eb; 53 | --juejin-font-brand1-normal: #1e80ff; 54 | --juejin-font-brand2-hover: #1171ee; 55 | --juejin-font-brand3-click: #0060dd; 56 | --juejin-font-brand4-disable: #abcdff; 57 | --juejin-font-success: #2bb91b; 58 | --juejin-font-warning: #ff8412; 59 | --juejin-font-danger: #ff5132; 60 | --juejin-font-white-disable: #fff; 61 | --juejin-font-white: #fff; 62 | --juejin-success-1-normal: #00b453; 63 | --juejin-success-2-deep: #00964e; 64 | --juejin-success-3-light: #e2faed; 65 | --juejin-warning-1-normal: #ff7426; 66 | --juejin-warning-2-deep: #e05e00; 67 | --juejin-warning-3-light: #fff3e5; 68 | --juejin-danger-1-normal: #f64242; 69 | --juejin-danger-2-deep: #cb2634; 70 | --juejin-danger-3-light: #fff2ff; 71 | --juejin-sub-1-purple: #9f54ff; 72 | --juejin-sub-2-blue: #57a0ff; 73 | --juejin-sub-3-cyan: #5ad7ff; 74 | --juejin-sub-4-green: #33d790; 75 | --juejin-sub-5-yellow: #ffcc15; 76 | --juejin-sub-6-orange: #ff834e; 77 | --juejin-sub-7-red: #ff5e54; 78 | --juejin-coupon_1_button: #f64242; 79 | --juejin-coupon_1_button_disable: #faa0a0; 80 | --juejin-coupon_2_card: rgba(255, 245, 244, 0.7); 81 | --juejin-coupon_3_stroke: rgba(246, 66, 66, 0.2); 82 | --juejin-navigation: #fff; 83 | --juejin-item-background: #fff; 84 | --juejin-shade-1: rgba(0, 0, 0, 0.4); 85 | --juejin-shade-2: rgba(0, 0, 0, 0.6); 86 | --juejin-popup: #fff; 87 | --juejin-popover: rgba(0, 0, 0, 0.8); 88 | --juejin-sheets: #f7f8fa; 89 | --juejin-coupon-button: #f64242; 90 | --juejin-coupon-button-disable: #faa0a0; 91 | --juejin-coupon-card: rgba(255, 245, 244, 0.7); 92 | --juejin-layer-loading-start: #e4e6eb; 93 | --juejin-layer-loading-end: rgba(228, 230, 235, 0.5); 94 | --juejin-font-priv-hint: #916be1; 95 | --juejin-background: #f4f5f5; 96 | --juejin-background-jscore-radar: #232323; 97 | --juejin-scrollbar: #c1c1c1; 98 | --juejin-toc-text-click: #007fff; 99 | --juejin-toc-hover: #f7f8fa; 100 | --juejin-toc-text-color: #000; 101 | --juejin-toc-blue: #1e80ff; 102 | } 103 | 104 | [arco-theme='dark'] { 105 | --juejin-item-background: #121212; 106 | --juejin-font-1: #f3f8ff; 107 | --juejin-font-2: #e5e9ef; 108 | --juejin-font-5: #333333; 109 | --juejin-layer-1: #121212; 110 | --juejin-background: #222323; 111 | --juejin-gray-1-4: #1f1f1f; 112 | --juejin-navigation: #121212; 113 | --juejin-gray-0: #222323; 114 | --juejin-gray-2: #191919; 115 | --juejin-toc-blue: #2885ff; 116 | --juejin-toc-hover: #202022; 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | juejin 4 | 5 |

6 | 7 |
8 | 9 | # 10 | 11 |
12 | 13 |
14 | 15 | # 仿掘金站点 16 | 17 | 🎉 字节青训营《基于 Nextjs 开发仿掘金站点》 🎉 18 | 19 |
20 | 21 |
22 | 23 |
24 | 25 |

26 | 27 | license 28 | 29 | 30 | release 31 | 32 | 33 | issues 34 | 35 | 36 | pulls 37 | 38 | 39 | action 40 | 41 |

42 | 43 |

44 | 预览 45 | · 46 | 开发 47 | · 48 | 部署 49 |

50 | 51 | ## 技术栈 52 | 53 | - 语言:TypeScript 54 | - 框架:Nextjs 55 | - 测试:Vitest、@testing-library/react 56 | - 构建工具:Webpack 57 | - 包管理器:pnpm 58 | - 样式:Less、Less Module 59 | - UI:Arco Design 60 | - 状态管理:Mobx 61 | - 代码风格:Eslint、Prettier 62 | - 代码提交:Husky、Commitlint、lint-staged、cz-git、bump-version 63 | 64 | ## 预览 65 | 66 | ![首页](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/juejin1.jpg) 67 | ![文章1](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/juejin2.jpg) 68 | ![文章2](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/juejin3.jpg) 69 | ![文章3](https://fastly.jsdelivr.net/gh/suemor233/static@main/img/juejin5.jpg) 70 | 71 | ## 开发 72 | 73 | ```bash 74 | git clone https://github.com/Bocchi-Developers/juejin.git 75 | cd juejin 76 | pnpm i 77 | pnpm dev 78 | ``` 79 | 80 | ## 部署 81 | 82 | ```bash 83 | git clone https://github.com/Bocchi-Developers/juejin.git 84 | cd juejin 85 | 编辑 env 86 | pnpm i 87 | pnpm build 88 | pnpm prod:pm2 89 | ``` 90 | 91 | ## 维护者 92 | 93 | 94 | 95 | 96 | 109 | 122 | 135 | 136 | 149 | 150 | 151 |
97 | 98 | suemor 103 |
104 | 105 | Suemor 106 | 107 |
108 |
110 | 111 | wexx 116 |
117 | 118 | wexx 119 | 120 |
121 |
123 | 124 | GrittyB 129 |
130 | 131 | GrittyB 132 | 133 |
134 |
137 | 138 | jiang 143 |
144 | 145 | jiang 146 | 147 |
148 |
152 | 153 | ## 其它 154 | 155 | - [后端](https://github.com/Bocchi-Developers/juejin-core) - 基于 NestJS 开发 156 | -------------------------------------------------------------------------------- /src/components/in-page/Home/list.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { observer } from 'mobx-react-lite' 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/router' 5 | import { useEffect, useState } from 'react' 6 | import InfiniteScroll from 'react-infinite-scroller' 7 | 8 | import { List as ArcoList, Skeleton } from '@arco-design/web-react' 9 | 10 | import { Divider } from '~/components/universal/Divider' 11 | import { Image } from '~/components/universal/Image' 12 | import { PostApi } from '~/services/api/post' 13 | import { useStore } from '~/store' 14 | import type { IPostList, Sort } from '~/types/api/post' 15 | import { relativeTimeFromNow } from '~/utils/time' 16 | 17 | import style from './list.module.less' 18 | 19 | const tab = [ 20 | { 21 | name: '推荐', 22 | }, 23 | { 24 | name: '最新', 25 | query: { 26 | sort: 'newest', 27 | }, 28 | }, 29 | { 30 | name: '热榜', 31 | query: { 32 | sort: 'three_days_hottest', 33 | }, 34 | }, 35 | ] 36 | 37 | const TagBar = (props: IPostList) => { 38 | const { user, created, tags, ad } = props 39 | return ( 40 |
41 | 42 | {user.username} 43 | 44 | {relativeTimeFromNow(created)} 45 | {tags && ( 46 | 47 | {tags?.map((tag) => ( 48 | 49 | {tag} 50 | 51 | ))} 52 | 53 | )} 54 | {ad && ( 55 | 56 | 广告 57 | 58 | )} 59 |
60 | ) 61 | } 62 | const ListItem = observer(({ item }: { item: IPostList }) => ( 63 |
  • 64 | 65 | 66 | 71 | {item.title} 79 |
  • 80 | ) 81 | } 82 | > 83 |
    87 | {item.title} 88 |
    89 |
    93 | {item.content} 94 |
    95 | 96 | 97 | 98 | 99 | )) 100 | const PostNav = () => { 101 | const router = useRouter() 102 | 103 | const { category } = router.query 104 | 105 | return ( 106 | 128 | ) 129 | } 130 | 131 | export const List = () => { 132 | const [postList, setPostList] = useState([]) 133 | const router = useRouter() 134 | const [lastRouterName, setLastRouterName] = useState(router.asPath) 135 | const [load, setLoad] = useState(true) 136 | const [hasMore, sethasMore] = useState(true) 137 | const fetchList = async (currentPage: number) => { 138 | const { sort } = router.query 139 | const postListData = await PostApi.postListRequest({ 140 | pageCurrent: currentPage, 141 | pageSize: 15, 142 | sort: sort as Sort, 143 | category: router.query.category as string, 144 | }) 145 | setPostList((list) => { 146 | return [...list, ...postListData.postList] 147 | }) 148 | setLoad(false) 149 | if (currentPage >= postListData?.totalPages) { 150 | sethasMore(false) 151 | } 152 | } 153 | 154 | useEffect(() => { 155 | if (router.asPath != lastRouterName || postList.length == 0) { 156 | setLoad(true) 157 | sethasMore(true) 158 | setPostList([]) 159 | fetchList(1) 160 | setLastRouterName(router.asPath) 161 | } 162 | }, [router.query]) 163 | 164 | return ( 165 |
    166 | 167 | 168 | 169 | {load ? ( 170 | 171 | ) : ( 172 | fetchList(page + 1)} 174 | hasMore={hasMore} 175 | > 176 | {postList.map((item) => ( 177 | 178 | ))} 179 | 180 | )} 181 |
    182 | ) 183 | } 184 | -------------------------------------------------------------------------------- /src/store/helper/structure.ts: -------------------------------------------------------------------------------- 1 | export type Id = string 2 | 3 | type VoidCallback = (val: V, key: K, map: Map) => void 4 | type BoolCallback = (val: V, key: K, map: Map) => boolean 5 | type ValueCallback = (val: V, key: K, map: Map) => Y 6 | type ReduceCallback = ( 7 | previousValue: V, 8 | currentValue: V, 9 | currentIndex: K, 10 | map: Map, 11 | ) => V 12 | type TypedReduceCallback = ( 13 | previousValue: V, 14 | currentValue: V, 15 | currentIndex: K, 16 | map: Map, 17 | ) => U 18 | 19 | export class KeyValueCollection extends Map< 20 | K, 21 | V 22 | > { 23 | constructor() { 24 | super() 25 | } 26 | 27 | // for mobx 28 | data: Map = new Map() 29 | 30 | forEach(callbackfn: VoidCallback, thisArg?: any): void { 31 | return this.data.forEach(callbackfn, thisArg) 32 | } 33 | 34 | clear(): void { 35 | this.data.clear() 36 | } 37 | 38 | delete(key: K): boolean { 39 | return this.data.delete(key) 40 | } 41 | 42 | softDelete(key: K) { 43 | const data = this.data.get(key) 44 | if (!data) { 45 | return false 46 | } 47 | 48 | ;(data as any).isDeleted = true 49 | return true 50 | } 51 | 52 | get(key: K): V | undefined { 53 | return this.data.get(key) 54 | } 55 | 56 | has(key: K): boolean { 57 | return this.data.has(key) 58 | } 59 | 60 | set(key: K, value: V): this { 61 | this.data.set(key, value) 62 | return this 63 | } 64 | 65 | get size(): number { 66 | return this.data.size 67 | } 68 | 69 | [Symbol.iterator](): IterableIterator<[K, V]> { 70 | return this.entries() 71 | } 72 | 73 | entries(): IterableIterator<[K, V]> { 74 | return this.data.entries() 75 | } 76 | 77 | keys(): IterableIterator { 78 | return this.data.keys() 79 | } 80 | 81 | values(): IterableIterator { 82 | return this.data.values() 83 | } 84 | 85 | find(fn: BoolCallback, thisArg?: this) { 86 | if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg) 87 | 88 | for (const [key, val] of this) { 89 | if (fn(val, key, this)) return val 90 | } 91 | return undefined 92 | } 93 | 94 | findKey(fn: BoolCallback, thisArg?: this) { 95 | if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg) 96 | 97 | for (const [key, val] of this) { 98 | if (fn(val, key, this)) return key 99 | } 100 | return undefined 101 | } 102 | 103 | filter(fn: BoolCallback, thisArg?: this): KeyValueCollection { 104 | if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg) 105 | const results = new this.constructor[Symbol.species]() 106 | for (const [key, val] of this) { 107 | if (fn(val, key, this)) results.set(key, val) 108 | } 109 | return results 110 | } 111 | 112 | map(fn: ValueCallback, thisArg?: this): T[] { 113 | if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg) 114 | const arr = new Array(this.size) 115 | let i = 0 116 | 117 | for (const [key, val] of this) arr[i++] = fn(val, key, this) 118 | return arr 119 | } 120 | 121 | some(fn: BoolCallback, thisArg?: this) { 122 | if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg) 123 | 124 | for (const [key, val] of this) { 125 | if (fn(val, key, this)) return true 126 | } 127 | return false 128 | } 129 | 130 | every(fn: BoolCallback, thisArg?: this) { 131 | if (typeof thisArg !== 'undefined') fn = fn.bind(thisArg) 132 | 133 | for (const [key, val] of this) { 134 | if (!fn(val, key, this)) return false 135 | } 136 | return true 137 | } 138 | 139 | reduce(fn: ReduceCallback, initialValue?: undefined): V 140 | reduce(fn: ReduceCallback, initialValue: V): V 141 | reduce(fn: TypedReduceCallback, initialValue: U): U 142 | reduce(fn, initialValue) { 143 | let accumulator 144 | if (typeof initialValue !== 'undefined') { 145 | accumulator = initialValue 146 | for (const [key, val] of this) 147 | accumulator = fn(accumulator, val, key, this) 148 | } else { 149 | let first = true 150 | for (const [key, val] of this) { 151 | if (first) { 152 | accumulator = val 153 | first = false 154 | continue 155 | } 156 | accumulator = fn(accumulator, val, key, this) 157 | } 158 | } 159 | return accumulator 160 | } 161 | 162 | each(fn: VoidCallback, thisArg?: this) { 163 | this.forEach(fn, thisArg) 164 | return this 165 | } 166 | 167 | clone(): this { 168 | return new this.constructor[Symbol.species](this) 169 | } 170 | 171 | merge(...collections: this[]) { 172 | // eslint-disable-next-line 173 | for (const coll of collections) { 174 | // eslint-disable-next-line 175 | for (const [key, val] of coll) this.set(key, val) 176 | } 177 | return this 178 | } 179 | 180 | concat(...collections: this[]) { 181 | const newColl = this.clone() 182 | // eslint-disable-next-line 183 | for (const coll of collections) { 184 | // eslint-disable-next-line 185 | for (const [key, val] of coll) newColl.set(key, val) 186 | } 187 | return newColl 188 | } 189 | 190 | sort(compareFunction = (x: V, y: V) => +(x > y) || +(x === y) - 1): this { 191 | return new this.constructor[Symbol.species]( 192 | [...this.entries()].sort((a, b) => compareFunction(a[1], b[1])), 193 | ) 194 | } 195 | 196 | get first() { 197 | return this.data.values().next().value 198 | } 199 | 200 | get list() { 201 | return this.map((v) => v) 202 | } 203 | 204 | static get [Symbol.species]() { 205 | return KeyValueCollection 206 | } 207 | 208 | get [Symbol.toStringTag]() { 209 | return 'KeyValueCollection' 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/assets/styles/markdown/smart-blue.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --juejin-md-font: #595959; 3 | --juejin-md-heading: #135ce0; 4 | --juejin-md-spcial-font: #036aca; 5 | --juejin-md-quote-bg: #fff9f9; 6 | --juejin-md-quote-font: #666; 7 | --juejin-md-quote-left: #b2aec5; 8 | --juejin-md-code-bg: #fff5f5; 9 | --juejin-md-code-font: #ff502c; 10 | --juejin-md-bg-linear: rgba(60, 10, 30, 0.04) 3%, rgba(0, 0, 0, 0) 3%; 11 | } 12 | [arco-theme='dark'] { 13 | --juejin-md-font: #b1b1b1; 14 | --juejin-md-heading: #7e9eff; 15 | --juejin-md-spcial-font: #65a1ff; 16 | --juejin-md-quote-bg: #221e1e; 17 | --juejin-md-quote-font: #828181; 18 | --juejin-md-quote-left: #666471; 19 | --juejin-md-code-bg: #261f1f; 20 | --juejin-md-code-font: hsl(8, 81%, 60%); 21 | --juejin-md-bg-linear: rgba(52, 50, 51, 0.263) 3%, rgba(0, 0, 0, 0) 3%; 22 | } 23 | .markdown-body { 24 | color: var(--juejin-md-font); 25 | font-size: 15px; 26 | font-family: -apple-system, system-ui, BlinkMacSystemFont, Helvetica Neue, 27 | PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif; 28 | background-image: linear-gradient(90deg, var(--juejin-md-bg-linear)), 29 | linear-gradient(360deg, var(--juejin-md-bg-linear)); 30 | background-size: 20px 20px; 31 | background-position: center center; 32 | } 33 | 34 | /* 段落 */ 35 | .markdown-body p { 36 | color: var(--juejin-md-font); 37 | font-size: 15px; 38 | line-height: 2; 39 | font-weight: 400; 40 | } 41 | 42 | /* 段落间距控制 */ 43 | .markdown-body p + p { 44 | margin-top: 16px; 45 | } 46 | 47 | /* 标题的通用设置 */ 48 | .markdown-body h1, 49 | .markdown-body h2, 50 | .markdown-body h3, 51 | .markdown-body h4, 52 | .markdown-body h5, 53 | .markdown-body h6 { 54 | padding: 30px 0; 55 | margin: 0; 56 | color: var(--juejin-md-heading); 57 | } 58 | 59 | /* 一级标题 */ 60 | .markdown-body h1 { 61 | position: relative; 62 | text-align: center; 63 | font-size: 22px; 64 | margin: 50px 0; 65 | } 66 | 67 | /* 一级标题前缀,用来放背景图,支持透明度控制 */ 68 | .markdown-body h1:before { 69 | position: absolute; 70 | content: ''; 71 | top: -10px; 72 | left: 50%; 73 | width: 32px; 74 | height: 32px; 75 | transform: translateX(-50%); 76 | background-size: 100% 100%; 77 | opacity: 0.36; 78 | background-repeat: no-repeat; 79 | background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAABfVBMVEX///8Ad/8AgP8AgP8AgP8Aff8AgP8Af/8AgP8AVf8Af/8Af/8AgP8AgP8Af/8Afv8AAP8Afv8Afv8Aef8AgP8AdP8Afv8AgP8AgP8Acf8Ae/8AgP8Af/8AgP8Af/8Af/8AfP8Afv8AgP8Af/8Af/8Afv8Afv8AgP8Afv8AgP8Af/8Af/8AgP8AgP8Afv8AgP8Af/8AgP8AgP8AgP8Ae/8Afv8Af/8AgP8Af/8AgP8Af/8Af/8Aff8Af/8Abf8AgP8Af/8AgP8Af/8Af/8Afv8AgP8AgP8Afv8Afv8AgP8Af/8Aff8AgP8Afv8AgP8Aff8AgP8AfP8AgP8Ae/8AgP8Af/8AgP8AgP8AgP8Afv8AgP8AgP8AgP8Afv8AgP8AgP8AgP8AgP8AgP8Af/8AgP8Af/8Af/8Aev8Af/8AgP8Aff8Afv8AgP8AgP8AgP8Af/8AgP8Af/8Af/8AgP8Afv8AgP8AgP8AgP8AgP8Af/8AeP8Af/8Af/8Af//////rzEHnAAAAfXRSTlMAD7CCAivatxIDx5EMrP19AXdLEwgLR+6iCR/M0yLRzyFF7JupSXn8cw6v60Q0QeqzKtgeG237HMne850/6Qeq7QaZ+WdydHtj+OM3qENCMRYl1B3K2U7wnlWE/mhlirjkODa9FN/BF7/iNV/2kASNZpX1Wlf03C4stRGxgUPclqoAAAABYktHRACIBR1IAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEaBzgZ4yeM3AAAAT9JREFUOMvNUldbwkAQvCAqsSBoABE7asSOBRUVVBQNNuy9996789+9cMFAMHnVebmdm+/bmdtbQv4dOFOW2UjPzgFyLfo6nweKfIMOBYWwFtmMPGz2Yj2pJI0JDq3udJW6VVbmKa9I192VQFV1ktXUAl5NB0cd4KpnORqsEO2ZIRpF9gJfE9Dckqq0KuZt7UAH5+8EPF3spjsRpCeQNO/tA/qDwIDA+OCQbBoKA8NOdjMySgcZGVM6jwcgRuUiSs0nlPFNSrEpJfU0jTLD6llqbvKxei7OzvkFNQohi0vAsj81+MoqsCaoPOQFgus/1LyxichW+hS2JWCHZ7VlF9jb187pIAYcHiViHAMnp5mTjJ8B5xeEXF4B1ze/fTh/C0h398DDI9HB07O8ci+vRBdvdGnfP4gBuM8vw7X/G3wDmFhFZEdxzjMAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTgtMDEtMjZUMDc6NTY6MjUrMDE6MDA67pVWAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE4LTAxLTI2VDA3OjU2OjI1KzAxOjAwS7Mt6gAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAWdEVYdFRpdGxlAGp1ZWppbl9sb2dvIGNvcHlxapmKAAAAV3pUWHRSYXcgcHJvZmlsZSB0eXBlIGlwdGMAAHic4/IMCHFWKCjKT8vMSeVSAAMjCy5jCxMjE0uTFAMTIESANMNkAyOzVCDL2NTIxMzEHMQHy4BIoEouAOoXEXTyQjWVAAAAAElFTkSuQmCC); 80 | } 81 | 82 | /* 二级标题 */ 83 | .markdown-body h2 { 84 | position: relative; 85 | font-size: 20px; 86 | border-left: 4px solid; 87 | padding: 0 0 0 10px; 88 | margin: 30px 0; 89 | } 90 | 91 | /* 三级标题 */ 92 | .markdown-body h3 { 93 | font-size: 16px; 94 | } 95 | 96 | .markdown-body code { 97 | word-break: break-word; 98 | border-radius: 2px; 99 | overflow-x: auto; 100 | background-color: var(--juejin-md-code-bg); 101 | color: var(--juejin-md-code-font); 102 | font-size: 15px; 103 | padding: 0.065em 0.4em; 104 | } 105 | 106 | /* 无序列表 */ 107 | .markdown-body ul { 108 | list-style: disc outside; 109 | margin-left: 2em; 110 | margin-top: 1em; 111 | } 112 | 113 | /* 无序列表内容 */ 114 | .markdown-body li { 115 | line-height: 2; 116 | color: #595959; 117 | margin-bottom: 0; 118 | list-style: inherit; 119 | } 120 | 121 | .markdown-body img { 122 | max-width: 100%; 123 | } 124 | 125 | /* 已加载图片 */ 126 | .markdown-body img.loaded { 127 | margin: 0 auto; 128 | display: block; 129 | } 130 | 131 | /* 引用 */ 132 | .markdown-body blockquote { 133 | background: var(--juejin-md-quote-bg); 134 | margin: 2em 0; 135 | padding: 15px 20px; 136 | border-left: 4px solid var(--juejin-md-quote-left); 137 | } 138 | 139 | /* 引用文字 */ 140 | .markdown-body blockquote p { 141 | color: var(--juejin-md-quote-font); 142 | line-height: 2; 143 | } 144 | 145 | /* 链接 */ 146 | .markdown-body a { 147 | color: var(--juejin-md-spcial-font); 148 | border-bottom: 1px solid rgba(3, 106, 202, 0.8); 149 | font-weight: 400; 150 | text-decoration: none; 151 | } 152 | 153 | /* 加粗 */ 154 | .markdown-body strong { 155 | color: var(--juejin-md-spcial-font); 156 | } 157 | 158 | /* 加粗斜体 */ 159 | .markdown-body em strong { 160 | color: var(--juejin-md-spcial-font); 161 | } 162 | 163 | /* 分隔线 */ 164 | .markdown-body hr { 165 | border-top: 1px solid #135ce0; 166 | } 167 | 168 | /* 代码 */ 169 | .markdown-body pre { 170 | overflow: auto; 171 | } 172 | 173 | /* 表格 */ 174 | .markdown-body table { 175 | border-collapse: collapse; 176 | margin: 1rem 0; 177 | overflow-x: auto; 178 | } 179 | 180 | .markdown-body table th, 181 | .markdown-body table td { 182 | border: 1px solid #dfe2e5; 183 | padding: 0.6em 1em; 184 | } 185 | 186 | .markdown-body table tr { 187 | border-top: 1px solid #dfe2e5; 188 | } 189 | 190 | .markdown-body table tr:nth-child(2n) { 191 | background-color: #f6f8fa; 192 | } 193 | --------------------------------------------------------------------------------