├── .eslintrc.cjs ├── .github ├── funding.yml ├── issue_template.md └── pull_request_template.md ├── .gitignore ├── .node-version ├── .vars ├── CODE_OF_CONDUCT.md ├── README-zh-CN.md ├── README.md ├── app ├── components │ ├── Ackee.tsx │ ├── BlogPost.tsx │ ├── ClientOnly.tsx │ ├── Comments.tsx │ ├── Container.tsx │ ├── Footer.tsx │ ├── Gitalk │ │ ├── assets │ │ │ └── icon │ │ │ │ ├── arrow_down.svg │ │ │ │ ├── edit.svg │ │ │ │ ├── github.svg │ │ │ │ ├── heart.svg │ │ │ │ ├── heart_on.svg │ │ │ │ ├── reply.svg │ │ │ │ └── tip.svg │ │ ├── component │ │ │ ├── action.tsx │ │ │ ├── avatar.tsx │ │ │ ├── button.tsx │ │ │ ├── comment.tsx │ │ │ └── svg.tsx │ │ ├── const.ts │ │ ├── graphql │ │ │ └── getComments.ts │ │ ├── index.tsx │ │ ├── util.ts │ │ └── zh-CN.json │ ├── Header.tsx │ ├── Pagination.tsx │ ├── PostActions.module.css │ ├── PostActions.tsx │ ├── Scripts.tsx │ ├── TableOfContent.tsx │ ├── TagItem.tsx │ ├── Tags.tsx │ └── Utterances.tsx ├── entry.client.tsx ├── entry.server.tsx ├── layouts │ ├── layout.tsx │ └── search.tsx ├── libs │ ├── cusdisLang.ts │ ├── formatDate.ts │ ├── lang.ts │ ├── locale.tsx │ ├── notion.ts │ ├── notion │ │ ├── filterPublishedPosts.ts │ │ ├── getAllPageIds.ts │ │ ├── getAllPosts.ts │ │ ├── getAllTagsFromPosts.ts │ │ ├── getMetadata.ts │ │ ├── getPageProperties.ts │ │ ├── getPostBlocks.ts │ │ └── preview-images.ts │ ├── rss.ts │ ├── utils.ts │ └── withCache.ts ├── root.tsx ├── routes │ ├── $slug.tsx │ ├── _index.tsx │ ├── action.set-theme.ts │ ├── api.webhook.cusdis.ts │ ├── atom[.]xml.tsx │ ├── page.$pageId..tsx │ └── search.tsx ├── sessions.server.ts ├── styles │ ├── gitalk.css │ ├── globals.css │ ├── notion.css │ └── prism.css └── tailwind.css ├── blog.config.ts ├── cjk.ts ├── desktop.png ├── functions └── [[path]].ts ├── load-context.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── _headers ├── _routes.json ├── apple-touch-icon.png ├── avatar.jpeg ├── favicon copy.ico ├── favicon.ico ├── favicon.png ├── favicon.svg ├── fonts │ ├── IBMPlexSansVar-Italic.woff2 │ ├── IBMPlexSansVar-Roman.woff2 │ ├── SourceSerif-Italic.var.woff2 │ └── SourceSerif.var.woff2 └── polyfill.js ├── scripts └── build.js ├── tailwind.config.ts ├── tsconfig.json ├── vite.config.ts ├── worker-configuration.d.ts └── wrangler.toml.tpl /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: 'latest', 12 | sourceType: 'module', 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | ignorePatterns: ['!**/.server', '!**/.client'], 23 | 24 | // Base config 25 | extends: ['eslint:recommended'], 26 | 27 | overrides: [ 28 | // React 29 | { 30 | files: ['**/*.{js,jsx,ts,tsx}'], 31 | plugins: ['react', 'jsx-a11y'], 32 | extends: [ 33 | 'plugin:react/recommended', 34 | 'plugin:react/jsx-runtime', 35 | 'plugin:react-hooks/recommended', 36 | 'plugin:jsx-a11y/recommended', 37 | ], 38 | settings: { 39 | react: { 40 | version: 'detect', 41 | }, 42 | formComponents: ['Form'], 43 | linkComponents: [ 44 | { name: 'Link', linkAttribute: 'to' }, 45 | { name: 'NavLink', linkAttribute: 'to' }, 46 | ], 47 | 'import/resolver': { 48 | typescript: {}, 49 | }, 50 | }, 51 | }, 52 | 53 | // Typescript 54 | { 55 | files: ['**/*.{ts,tsx}'], 56 | plugins: ['@typescript-eslint', 'import'], 57 | parser: '@typescript-eslint/parser', 58 | settings: { 59 | 'import/internal-regex': '^~/', 60 | 'import/resolver': { 61 | node: { 62 | extensions: ['.ts', '.tsx'], 63 | }, 64 | typescript: { 65 | alwaysTryTypes: true, 66 | }, 67 | }, 68 | }, 69 | extends: [ 70 | 'plugin:@typescript-eslint/recommended', 71 | 'plugin:import/recommended', 72 | 'plugin:import/typescript', 73 | ], 74 | }, 75 | 76 | // Node 77 | { 78 | files: ['.eslintrc.cjs'], 79 | env: { 80 | node: true, 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [ycjcl868] 2 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | .dev.vars 7 | 8 | .wrangler 9 | ~ 10 | .DS_Store 11 | wrangler.toml 12 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.7.0 2 | -------------------------------------------------------------------------------- /.vars: -------------------------------------------------------------------------------- 1 | NOTION_PAGE_ID= 2 | NOTION_ACCESS_TOKEN= 3 | CLOUDFLARE_KV_ID= 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | i#craigary.net. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /README-zh-CN.md: -------------------------------------------------------------------------------- 1 | # 📝 Notion Blog - 零成本实时更新的现代化博客系统 2 | 3 | 中文 | [English](./README.md) 4 | 5 | [![Cloudflare Pages](https://img.shields.io/badge/Deployed_on-Cloudflare_Pages-F38020?logo=cloudflare)](https://developers.cloudflare.com/pages/) 6 | [![Remix Framework](https://img.shields.io/badge/Built_with-Remix-1E1F21?logo=remix)](https://remix.run/) 7 | 8 | 基于 Remix 和 Notion 构建,部署在 Cloudflare Pages ,使用 Edge Function 的无成本、可实时更新的博客站点。 9 | 10 | ![](https://user-images.githubusercontent.com/13595509/221388253-a719a869-c4b9-4387-a513-101caa35df27.png) 11 | 12 | ## 🔥 特性 13 | 14 | - 直接使用你的 Notion 页面创建博客,博客内容访问实时更新 15 | - 支持智能缓存(`stale-while-revalidate`),确保页面快速加载的同时,自动在后台更新内容 16 | - 使用 [Remix](https://remix.run/) 构建,最新的技术栈 17 | - 已经部署到 Cloudflare Pages,无需费用 18 | - 使用 [TailwindCSS](https://tailwindcss.com/) 设计简洁美观的博客 19 | - 支持分类标签和搜索功能 20 | - 支持 RSS Feed 21 | - 主题 Light/Dark 切换 22 | - [ ] 国际化支持,运行时大语言模型翻译 23 | 24 | ## 🚀 性能 25 | 26 | [PageSpeed Insights](https://pagespeed.web.dev/analysis/https-www-rustc-cloud/1zuls2fmg9?hl=zh-cn&form_factor=desktop) 27 | 28 | ![](https://github.com/user-attachments/assets/b505fdf9-1cfa-410d-8f6f-98872263e75b) 29 | 30 | 31 | ## 📦 安装 32 | 33 | 使用以下指令将该项目克隆到你本地 34 | 35 | ``` 36 | git clone https://github.com/ycjcl868/blog 37 | ``` 38 | 39 | 接下来,使用 pnpm 安装依赖 40 | 41 | ``` 42 | pnpm i 43 | ``` 44 | 45 | ## 生成 Notion 数据库 46 | 47 | 复制这个 [Notion 模板](https://ycjcl868.notion.site/b7e25fb9b29a48269e92e36f65a3ffbb),共享生成连接。 48 | 49 | ![](https://github.com/user-attachments/assets/cb894cb4-4e1b-4f1e-adb4-d35ce67e5df4) 50 | 51 | 链接中对应的 `PAGE_ID` 是:`https://www.notion.so/{workspace_name}/{page_id}`,通常是地址后 32 位数字 52 | 53 | ## 🔨 本地开发 54 | 55 | 新建 `.dev.vars`,配置如下: 56 | 57 | ```bash 58 | NOTION_PAGE_ID=xxxx # Notion 共享 ID 59 | NOTION_ACCESS_TOKEN=secret_xxx # 在这里申请一个 TOKEN:https://developers.notion.com/docs/create-a-notion-integration 60 | ``` 61 | 62 | 然后执行启动命令: 63 | 64 | ``` 65 | npm run start 66 | ``` 67 | 68 | 访问 `localhost:3000` 即可访问 69 | 70 | ## 📝 发表你的想法 71 | 72 | 如果你有任何建议,欢迎提交 issue 或者 pull request。 73 | 74 | ## Star History 75 | 76 | [![Star History Chart](https://api.star-history.com/svg?repos=ycjcl868/blog&type=Date)](https://star-history.com/#ycjcl868/blog&Date) 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📝 Notion Blog - A Modern Zero-Cost Blogging System with Real-time Updates 2 | 3 | [中文](./README-zh-CN.md) | English 4 | 5 | [![Cloudflare Pages](https://img.shields.io/badge/Deployed_on-Cloudflare_Pages-F38020?logo=cloudflare)](https://developers.cloudflare.com/pages/) 6 | [![Remix Framework](https://img.shields.io/badge/Built_with-Remix-1E1F21?logo=remix)](https://remix.run/) 7 | 8 | Built with Remix and Notion, deployed on Cloudflare Pages using Edge Functions for a cost-free, real-time updated blog site. 9 | 10 | ![](https://user-images.githubusercontent.com/13595509/221388253-a719a869-c4b9-4387-a513-101caa35df27.png) 11 | 12 | ## 🔥 Features 13 | 14 | - Create blogs directly from your Notion pages with real-time content updates 15 | - Smart caching ensures fast page loads while auto-updating content in the background 16 | - Built with modern tech stack using [Remix](https://remix.run/) 17 | - Zero-cost deployment on Cloudflare Pages 18 | - Clean and elegant design powered by [TailwindCSS](https://tailwindcss.com/) 19 | - Category tags and search functionality 20 | - RSS Feed support 21 | - Light/Dark theme toggle 22 | - [ ] Internationalization support with runtime translation using large language models 23 | 24 | https://github.com/user-attachments/assets/375b1a6a-c564-4717-838e-3285f0b90541 25 | 26 | ## 🚀 Performance 27 | 28 | [PageSpeed Insights](https://pagespeed.web.dev/analysis/https-www-rustc-cloud/1zuls2fmg9?hl=zh-cn&form_factor=desktop) 29 | 30 | ![](https://github.com/user-attachments/assets/b505fdf9-1cfa-410d-8f6f-98872263e75b) 31 | 32 | ## 📦 Installation 33 | 34 | Clone the repository to your local machine: 35 | 36 | ``` 37 | git clone https://github.com/ycjcl868/blog 38 | ``` 39 | 40 | Install dependencies using pnpm: 41 | 42 | ``` 43 | pnpm i 44 | ``` 45 | 46 | ## Generate Notion Database 47 | 48 | Duplicate this [Notion template](https://ycjcl868.notion.site/b7e25fb9b29a48269e92e36f65a3ffbb) and share the page to generate a public link. 49 | 50 | ![](https://github.com/user-attachments/assets/cb894cb4-4e1b-4f1e-adb4-d35ce67e5df4) 51 | 52 | The PAGE_ID can be found in the URL format: `https://www.notion.so/{workspace_name}/{page_id}`(typically the last 32 characters) 53 | 54 | ## 🔨 Local Development 55 | 56 | Create `.dev.vars` file with the following configuration: 57 | 58 | ```bash 59 | NOTION_PAGE_ID=xxxx # Notion page ID 60 | NOTION_ACCESS_TOKEN=secret_xxx # Create integration token at: https://developers.notion.com/docs/create-a-notion-integration 61 | ``` 62 | 63 | Start the development server: 64 | 65 | ``` 66 | npm run start 67 | ``` 68 | 69 | Access the site at `localhost:3000` 70 | 71 | ## 📝 Contribute 72 | 73 | Feel free to submit an issue or pull request if you have any suggestions. 74 | 75 | ## Star History 76 | 77 | [![Star History Chart](https://api.star-history.com/svg?repos=ycjcl868/blog&type=Date)](https://star-history.com/#ycjcl868/blog&Date) 78 | -------------------------------------------------------------------------------- /app/components/Ackee.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation } from '@remix-run/react'; 2 | import useAckee from 'use-ackee'; 3 | 4 | const Ackee = ({ ackeeServerUrl, ackeeDomainId }) => { 5 | const location = useLocation(); 6 | useAckee( 7 | location.pathname, 8 | { server: ackeeServerUrl, domainId: ackeeDomainId }, 9 | { detailed: false, ignoreLocalhost: true } 10 | ); 11 | return null; 12 | }; 13 | 14 | export default Ackee; 15 | -------------------------------------------------------------------------------- /app/components/BlogPost.tsx: -------------------------------------------------------------------------------- 1 | import BLOG from '#/blog.config'; 2 | import { Link } from '@remix-run/react'; 3 | import dayjs from 'dayjs'; 4 | 5 | const BlogPost = ({ post }) => { 6 | const date = post?.date?.start_date || post.createdTime; 7 | return ( 8 | 9 |
10 |
11 |

12 | {post.title} 13 |

14 | {dayjs(date).isValid() ? ( 15 | 18 | ) : null} 19 |
20 |
21 |

22 | {post.summary} 23 |

24 |
25 |
26 | 27 | ); 28 | }; 29 | 30 | export default BlogPost; 31 | -------------------------------------------------------------------------------- /app/components/ClientOnly.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSyncExternalStore } from 'react'; 3 | 4 | function subscribe() { 5 | // biome-ignore lint/suspicious/noEmptyBlockStatements: Mock function 6 | return () => {}; 7 | } 8 | 9 | /** 10 | * Return a boolean indicating if the JS has been hydrated already. 11 | * When doing Server-Side Rendering, the result will always be false. 12 | * When doing Client-Side Rendering, the result will always be false on the 13 | * first render and true from then on. Even if a new component renders it will 14 | * always start with true. 15 | * 16 | * Example: Disable a button that needs JS to work. 17 | * ```tsx 18 | * let hydrated = useHydrated(); 19 | * return ( 20 | * 23 | * ); 24 | * ``` 25 | */ 26 | export function useHydrated() { 27 | return useSyncExternalStore( 28 | subscribe, 29 | () => true, 30 | () => false 31 | ); 32 | } 33 | 34 | type Props = { 35 | /** 36 | * You are encouraged to add a fallback that is the same dimensions 37 | * as the client rendered children. This will avoid content layout 38 | * shift which is disgusting 39 | */ 40 | children(): React.ReactNode; 41 | fallback?: React.ReactNode; 42 | }; 43 | 44 | /** 45 | * Render the children only after the JS has loaded client-side. Use an optional 46 | * fallback component if the JS is not yet loaded. 47 | * 48 | * Example: Render a Chart component if JS loads, renders a simple FakeChart 49 | * component server-side or if there is no JS. The FakeChart can have only the 50 | * UI without the behavior or be a loading spinner or skeleton. 51 | * ```tsx 52 | * return ( 53 | * }> 54 | * {() => } 55 | * 56 | * ); 57 | * ``` 58 | */ 59 | export function ClientOnly({ children, fallback = null }: Props) { 60 | return useHydrated() ? <>{children()} : <>{fallback}; 61 | } 62 | -------------------------------------------------------------------------------- /app/components/Comments.tsx: -------------------------------------------------------------------------------- 1 | import BLOG from '#/blog.config'; 2 | import { useLocation } from '@remix-run/react'; 3 | import { lazy, Suspense, useEffect } from 'react'; 4 | import { Theme, useTheme } from 'remix-themes'; 5 | import { ClientOnly } from './ClientOnly'; 6 | 7 | const GitalkComponent = lazy(() => import('~/components/Gitalk')); 8 | const UtterancesComponent = lazy(() => import('~/components/Utterances')); 9 | const CusdisComponent = lazy(() => 10 | import('react-cusdis').then((mod) => ({ 11 | default: mod.ReactCusdis, 12 | })) 13 | ); 14 | 15 | const Comments = ({ frontMatter }) => { 16 | const location = useLocation(); 17 | const [theme] = useTheme(); 18 | 19 | const cusdisTheme = theme === Theme.DARK ? Theme.DARK : Theme.LIGHT; 20 | 21 | useEffect(() => { 22 | if (typeof window !== 'undefined' && window.CUSDIS) { 23 | window.CUSDIS.setTheme(cusdisTheme); 24 | } 25 | }, [cusdisTheme]); 26 | 27 | return ( 28 |
29 | {BLOG.comment && BLOG.comment.provider === 'gitalk' && ( 30 | 31 | 45 | 46 | )} 47 | {BLOG.comment && BLOG.comment.provider === 'utterances' && ( 48 | 49 | 50 | 51 | )} 52 | {BLOG.comment && BLOG.comment.provider === 'cusdis' && ( 53 | 54 | {() => ( 55 | 56 | 67 | 68 | )} 69 | 70 | )} 71 |
72 | ); 73 | }; 74 | 75 | export default Comments; 76 | -------------------------------------------------------------------------------- /app/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import BLOG from '#/blog.config'; 2 | import { motion } from 'framer-motion'; 3 | import { useEffect, useState } from 'react'; 4 | import Footer from '~/components/Footer'; 5 | import Header from '~/components/Header'; 6 | 7 | const Container: React.FC = ({ 8 | children, 9 | title, 10 | layout, 11 | fullWidth, 12 | ...customMeta 13 | }) => { 14 | const [isMounted, setIsMounted] = useState(false); 15 | const meta = { 16 | title: title || BLOG.title, 17 | type: 'website', 18 | ...customMeta, 19 | }; 20 | 21 | useEffect(() => { 22 | setIsMounted(true); 23 | }, []); 24 | 25 | return ( 26 |
27 | 37 |
41 | 46 | {children} 47 | 48 |
49 | 50 |
51 | ); 52 | }; 53 | 54 | export default Container; 55 | -------------------------------------------------------------------------------- /app/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import BLOG from '#/blog.config'; 2 | import { forwardRef } from 'react'; 3 | 4 | const Footer = forwardRef(({ fullWidth }, ref) => { 5 | const d = new Date(); 6 | const y = d.getFullYear(); 7 | const from = +BLOG.since; 8 | 9 | return ( 10 |
16 |
17 |
18 |
19 |

20 | ©{' '} 21 | 26 | {BLOG.author} 27 | {' '} 28 | {from === y || !from ? y : `${from} - ${y}`} 29 |

30 |
31 |
32 |
33 | ); 34 | }); 35 | 36 | Footer.displayName = 'Footer'; 37 | 38 | export default Footer; 39 | -------------------------------------------------------------------------------- /app/components/Gitalk/assets/icon/arrow_down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/Gitalk/assets/icon/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/components/Gitalk/assets/icon/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/components/Gitalk/assets/icon/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/components/Gitalk/assets/icon/heart_on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/components/Gitalk/assets/icon/reply.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /app/components/Gitalk/assets/icon/tip.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /app/components/Gitalk/component/action.tsx: -------------------------------------------------------------------------------- 1 | const Action = ({ className, onClick, text }) => ( 2 | 3 | {text} 4 | 5 | ); 6 | 7 | export default Action; 8 | -------------------------------------------------------------------------------- /app/components/Gitalk/component/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import GithubSVG from '../assets/icon/github.svg'; 3 | 4 | const Avatar = ({ src, className, alt, defaultSrc = GithubSVG }) => { 5 | const [imgSrc, setImageSrc] = useState(src || defaultSrc); 6 | return ( 7 |
8 | {`@${alt}`} setImageSrc(defaultSrc)} 14 | /> 15 |
16 | ); 17 | }; 18 | 19 | export default Avatar; 20 | -------------------------------------------------------------------------------- /app/components/Gitalk/component/button.tsx: -------------------------------------------------------------------------------- 1 | const Button = ({ 2 | className, 3 | getRef, 4 | onClick, 5 | onMouseDown, 6 | text, 7 | isLoading, 8 | }) => ( 9 | 18 | ); 19 | 20 | export default Button; 21 | -------------------------------------------------------------------------------- /app/components/Gitalk/component/comment.tsx: -------------------------------------------------------------------------------- 1 | import { formatDistanceToNow, parseISO } from 'date-fns'; 2 | import { de, es, fr, ko, pl, ru, zhCN, zhTW } from 'date-fns/locale'; 3 | import { Component } from 'react'; 4 | import Avatar from './avatar'; 5 | import Svg from './svg'; 6 | 7 | if (typeof window !== 'undefined') { 8 | window.GT_i18n_LocaleMap = { 9 | zh: zhCN, 10 | 'zh-CN': zhCN, 11 | 'zh-TW': zhTW, 12 | 'es-ES': es, 13 | fr: fr, 14 | ru: ru, 15 | pl: pl, 16 | ko: ko, 17 | de: de, 18 | }; 19 | } 20 | 21 | export default class Comment extends Component { 22 | shouldComponentUpdate({ comment }) { 23 | return comment !== this.props.comment; 24 | } 25 | 26 | componentDidMount() { 27 | const comment = this.node; 28 | const emailResponse = comment.querySelector('.email-hidden-toggle>a'); 29 | if (emailResponse) { 30 | emailResponse.addEventListener( 31 | 'click', 32 | (e) => { 33 | e.preventDefault(); 34 | comment 35 | .querySelector('.email-hidden-reply') 36 | .classList.toggle('expanded'); 37 | }, 38 | true 39 | ); 40 | } 41 | } 42 | 43 | render() { 44 | const { 45 | comment, 46 | user, 47 | language, 48 | commentedText = '', 49 | admin = [], 50 | replyCallback, 51 | likeCallback, 52 | } = this.props; 53 | const enableEdit = user && comment.user.login === user.login; 54 | const isAdmin = ~[] 55 | .concat(admin) 56 | .map((a) => a.toLowerCase()) 57 | .indexOf(comment.user.login.toLowerCase()); 58 | const reactions = comment.reactions; 59 | 60 | let reactionTotalCount = ''; 61 | if (reactions && reactions.totalCount) { 62 | reactionTotalCount = reactions.totalCount; 63 | if ( 64 | reactions.totalCount === 100 && 65 | reactions.pageInfo && 66 | reactions.pageInfo.hasNextPage 67 | ) { 68 | reactionTotalCount = '100+'; 69 | } 70 | } 71 | 72 | return ( 73 |
{ 75 | this.node = node; 76 | }} 77 | className={`gt-comment ${isAdmin ? 'gt-comment-admin' : ''}`} 78 | > 79 | 84 | 85 |
86 |
87 |
88 | 92 | {comment.user && comment.user.login} 93 | 94 | {commentedText} 95 | 96 | {formatDistanceToNow(parseISO(comment.created_at), { 97 | addSuffix: true, 98 | locale: window.GT_i18n_LocaleMap[language], 99 | })} 100 | 101 | 102 | {reactions && ( 103 | 108 | {reactions.viewerHasReacted ? ( 109 | 114 | ) : ( 115 | 120 | )} 121 | 122 | )} 123 | 124 | {enableEdit ? ( 125 | 132 | 133 | 134 | ) : ( 135 | 140 | 141 | 142 | )} 143 |
144 |
150 |
151 |
152 | ); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /app/components/Gitalk/component/svg.tsx: -------------------------------------------------------------------------------- 1 | import ArrowDown from '../assets/icon/arrow_down.svg'; 2 | import Edit from '../assets/icon/edit.svg'; 3 | import Github from '../assets/icon/github.svg'; 4 | import Heart from '../assets/icon/heart.svg'; 5 | import HeartOn from '../assets/icon/heart_on.svg'; 6 | import Reply from '../assets/icon/reply.svg'; 7 | import Tip from '../assets/icon/tip.svg'; 8 | 9 | const map = { 10 | arrow_down: ArrowDown, 11 | edit: Edit, 12 | github: Github, 13 | heart: Heart, 14 | heart_on: HeartOn, 15 | reply: Reply, 16 | tip: Tip, 17 | }; 18 | 19 | const SVG = ({ className, text, name }) => { 20 | const Icon = map[name]; 21 | return ( 22 | 23 | 24 | {text && {text}} 25 | 26 | ); 27 | }; 28 | 29 | export default SVG; 30 | -------------------------------------------------------------------------------- /app/components/Gitalk/const.ts: -------------------------------------------------------------------------------- 1 | export const GT_ACCESS_TOKEN = 'GT_ACCESS_TOKEN'; 2 | export const GT_VERSION = '1.7.2'; 3 | export const GT_COMMENT = 'GT_COMMENT'; 4 | -------------------------------------------------------------------------------- /app/components/Gitalk/graphql/getComments.ts: -------------------------------------------------------------------------------- 1 | import { axiosGithub } from '../util'; 2 | 3 | const getQL = (vars, pagerDirection) => { 4 | const cursorDirection = pagerDirection === 'last' ? 'before' : 'after'; 5 | const ql = ` 6 | query getIssueAndComments( 7 | $owner: String!, 8 | $repo: String!, 9 | $id: Int!, 10 | $cursor: String, 11 | $pageSize: Int! 12 | ) { 13 | repository(owner: $owner, name: $repo) { 14 | issue(number: $id) { 15 | title 16 | url 17 | bodyHTML 18 | createdAt 19 | comments(${pagerDirection}: $pageSize, ${cursorDirection}: $cursor) { 20 | totalCount 21 | pageInfo { 22 | ${pagerDirection === 'last' ? 'hasPreviousPage' : 'hasNextPage'} 23 | ${cursorDirection === 'before' ? 'startCursor' : 'endCursor'} 24 | } 25 | nodes { 26 | id 27 | databaseId 28 | author { 29 | avatarUrl 30 | login 31 | url 32 | } 33 | bodyHTML 34 | body 35 | createdAt 36 | reactions(first: 100, content: HEART) { 37 | totalCount 38 | viewerHasReacted 39 | pageInfo{ 40 | hasNextPage 41 | } 42 | nodes { 43 | id 44 | databaseId 45 | user { 46 | login 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | `; 56 | 57 | if (vars.cursor === null) delete vars.cursor; 58 | 59 | return { 60 | operationName: 'getIssueAndComments', 61 | query: ql, 62 | variables: vars, 63 | }; 64 | }; 65 | 66 | function getComments(issue) { 67 | const { owner, repo, perPage, pagerDirection, defaultAuthor } = this.options; 68 | const { cursor, comments } = this.state; 69 | return axiosGithub 70 | .post( 71 | '/graphql', 72 | getQL( 73 | { 74 | owner, 75 | repo, 76 | id: issue.number, 77 | pageSize: perPage, 78 | cursor, 79 | }, 80 | pagerDirection 81 | ), 82 | { 83 | headers: { 84 | Authorization: `bearer ${this.accessToken}`, 85 | }, 86 | } 87 | ) 88 | .then((res) => { 89 | const data = res.data.data.repository.issue.comments; 90 | const items = data.nodes.map((node) => { 91 | const author = node.author || defaultAuthor; 92 | 93 | return { 94 | id: node.databaseId, 95 | gId: node.id, 96 | user: { 97 | avatar_url: author.avatarUrl, 98 | login: author.login, 99 | html_url: author.url, 100 | }, 101 | created_at: node.createdAt, 102 | body_html: node.bodyHTML, 103 | body: node.body, 104 | html_url: `https://github.com/${owner}/${repo}/issues/${issue.number}#issuecomment-${node.databaseId}`, 105 | reactions: node.reactions, 106 | }; 107 | }); 108 | 109 | let cs; 110 | 111 | if (pagerDirection === 'last') { 112 | cs = [...items, ...comments]; 113 | } else { 114 | cs = [...comments, ...items]; 115 | } 116 | 117 | const isLoadOver = 118 | data.pageInfo.hasPreviousPage === false || 119 | data.pageInfo.hasNextPage === false; 120 | this.setState({ 121 | comments: cs, 122 | isLoadOver, 123 | cursor: data.pageInfo.startCursor || data.pageInfo.endCursor, 124 | }); 125 | return cs; 126 | }); 127 | } 128 | 129 | export default getComments; 130 | -------------------------------------------------------------------------------- /app/components/Gitalk/index.tsx: -------------------------------------------------------------------------------- 1 | import autosize from 'autosize'; 2 | import Polyglot from 'node-polyglot'; 3 | import { Component } from 'react'; 4 | import FlipMove from 'react-flip-move'; 5 | 6 | import Action from './component/action'; 7 | import Avatar from './component/avatar'; 8 | import Button from './component/button'; 9 | import Comment from './component/comment'; 10 | import Svg from './component/svg'; 11 | import { GT_ACCESS_TOKEN, GT_COMMENT, GT_VERSION } from './const'; 12 | import QLGetComments from './graphql/getComments'; 13 | import { 14 | axiosGithub, 15 | axiosJSON, 16 | formatErrorMsg, 17 | getMetaContent, 18 | hasClassInParent, 19 | queryParse, 20 | queryStringify, 21 | } from './util'; 22 | import zhCN from './zh-CN.json'; 23 | 24 | const i18n = () => 25 | new Polyglot({ 26 | phrases: zhCN, 27 | locale: 'zh-CN', 28 | }); 29 | 30 | class GitalkComponent extends Component { 31 | state = { 32 | user: null, 33 | issue: null, 34 | comments: [], 35 | localComments: [], 36 | comment: '', 37 | page: 1, 38 | pagerDirection: 'last', 39 | cursor: null, 40 | previewHtml: '', 41 | 42 | isNoInit: false, 43 | isIniting: true, 44 | isCreating: false, 45 | isLoading: false, 46 | isLoadMore: false, 47 | isLoadOver: false, 48 | isIssueCreating: false, 49 | isPopupVisible: false, 50 | isInputFocused: false, 51 | isPreview: false, 52 | 53 | isOccurError: false, 54 | errorMsg: '', 55 | }; 56 | 57 | constructor(props) { 58 | super(props); 59 | this.options = Object.assign( 60 | {}, 61 | { 62 | id: window.location.href, 63 | number: -1, 64 | labels: ['Gitalk'], 65 | title: window.document.title, 66 | body: '', // window.location.href + header.meta[description] 67 | language: window.navigator.language || window.navigator.userLanguage, 68 | perPage: 10, 69 | pagerDirection: 'last', // last or first 70 | createIssueManually: false, 71 | distractionFreeMode: false, 72 | proxy: 73 | 'https://proxy.rustc.cloud/?https://github.com/login/oauth/access_token', 74 | flipMoveOptions: { 75 | staggerDelayBy: 150, 76 | appearAnimation: 'accordionVertical', 77 | enterAnimation: 'accordionVertical', 78 | leaveAnimation: 'accordionVertical', 79 | }, 80 | enableHotKey: true, 81 | 82 | url: window.location.href, 83 | 84 | defaultAuthor: { 85 | avatarUrl: '//avatars1.githubusercontent.com/u/29697133?s=50', 86 | login: 'null', 87 | url: '', 88 | }, 89 | 90 | updateCountCallback: null, 91 | }, 92 | props.options 93 | ); 94 | 95 | this.state.pagerDirection = this.options.pagerDirection; 96 | const storedComment = window.localStorage.getItem(GT_COMMENT); 97 | if (storedComment) { 98 | this.state.comment = decodeURIComponent(storedComment); 99 | window.localStorage.removeItem(GT_COMMENT); 100 | } 101 | 102 | const query = queryParse(); 103 | if (query.code) { 104 | const code = query.code; 105 | delete query.code; 106 | const replacedUrl = `${window.location.origin}${ 107 | window.location.pathname 108 | }?${queryStringify(query)}${window.location.hash}`; 109 | history.replaceState(null, null, replacedUrl); 110 | this.options = Object.assign( 111 | {}, 112 | this.options, 113 | { 114 | url: replacedUrl, 115 | id: replacedUrl, 116 | }, 117 | props.options 118 | ); 119 | 120 | axiosJSON 121 | .post(this.options.proxy, { 122 | code, 123 | client_id: this.options.clientID, 124 | client_secret: this.options.clientSecret, 125 | }) 126 | .then((res) => { 127 | if (res.data && res.data.access_token) { 128 | this.accessToken = res.data.access_token; 129 | 130 | this.getInit() 131 | .then(() => this.setState({ isIniting: false })) 132 | .catch((err) => { 133 | console.log('err:', err); 134 | this.setState({ 135 | isIniting: false, 136 | isOccurError: true, 137 | errorMsg: formatErrorMsg(err), 138 | }); 139 | }); 140 | } else { 141 | // no access_token 142 | console.log('res.data err:', res.data); 143 | this.setState({ 144 | isOccurError: true, 145 | errorMsg: formatErrorMsg(new Error('no access token')), 146 | }); 147 | } 148 | }) 149 | .catch((err) => { 150 | console.log('err: ', err); 151 | this.setState({ 152 | isOccurError: true, 153 | errorMsg: formatErrorMsg(err), 154 | }); 155 | }); 156 | } else { 157 | this.getInit() 158 | .then(() => this.setState({ isIniting: false })) 159 | .catch((err) => { 160 | console.log('err:', err); 161 | this.setState({ 162 | isIniting: false, 163 | isOccurError: true, 164 | errorMsg: formatErrorMsg(err), 165 | }); 166 | }); 167 | } 168 | 169 | this.i18n = i18n(this.options.language); 170 | } 171 | 172 | componentDidUpdate() { 173 | this.commentEL && autosize(this.commentEL); 174 | } 175 | 176 | get accessToken() { 177 | return this._accessToke || window.localStorage.getItem(GT_ACCESS_TOKEN); 178 | } 179 | 180 | set accessToken(token) { 181 | window.localStorage.setItem(GT_ACCESS_TOKEN, token); 182 | this._accessToken = token; 183 | } 184 | 185 | get loginLink() { 186 | const githubOauthUrl = 'https://github.com/login/oauth/authorize'; 187 | const { clientID } = this.options; 188 | const query = { 189 | client_id: clientID, 190 | redirect_uri: window.location.href, 191 | scope: 'public_repo', 192 | }; 193 | return `${githubOauthUrl}?${queryStringify(query)}`; 194 | } 195 | 196 | get isAdmin() { 197 | const { admin } = this.options; 198 | const { user } = this.state; 199 | 200 | return ( 201 | user && 202 | ~[] 203 | .concat(admin) 204 | .map((a) => a.toLowerCase()) 205 | .indexOf(user.login.toLowerCase()) 206 | ); 207 | } 208 | 209 | getInit() { 210 | return this.getUserInfo() 211 | .then(() => this.getIssue()) 212 | .then((issue) => this.getComments(issue)); 213 | } 214 | 215 | getUserInfo() { 216 | if (!this.accessToken) { 217 | return new Promise((resolve) => { 218 | resolve(); 219 | }); 220 | } 221 | return axiosGithub 222 | .get('/user', { 223 | headers: { 224 | Authorization: `token ${this.accessToken}`, 225 | }, 226 | }) 227 | .then((res) => { 228 | this.setState({ user: res.data }); 229 | }) 230 | .catch((err) => { 231 | this.logout(); 232 | }); 233 | } 234 | 235 | getIssueById() { 236 | const { owner, repo, number, clientID, clientSecret } = this.options; 237 | const getUrl = `/repos/${owner}/${repo}/issues/${number}`; 238 | return new Promise((resolve, reject) => { 239 | axiosGithub 240 | .get(`${getUrl}${encodeURIComponent(`?t=${Date.now()}`)}`, { 241 | auth: { 242 | username: clientID, 243 | password: clientSecret, 244 | }, 245 | // params: { 246 | // t: Date.now() 247 | // } 248 | }) 249 | .then((res) => { 250 | let issue = null; 251 | 252 | if (res && res.data && res.data.number === number) { 253 | issue = res.data; 254 | 255 | this.setState({ issue, isNoInit: false }); 256 | } 257 | resolve(issue); 258 | }) 259 | .catch((err) => { 260 | // When the status code is 404, promise will be resolved with null 261 | if (err.response.status === 404) resolve(null); 262 | reject(err); 263 | }); 264 | }); 265 | } 266 | 267 | getIssueByLabels() { 268 | const { owner, repo, id, labels, clientID, clientSecret } = this.options; 269 | 270 | return axiosGithub 271 | .get( 272 | `/repos/${owner}/${repo}/issues${encodeURIComponent( 273 | `?labels=${labels.concat(id).join(',')}&t=${Date.now()}` 274 | )}`, 275 | { 276 | auth: { 277 | username: clientID, 278 | password: clientSecret, 279 | }, 280 | // params: { 281 | // labels: labels.concat(id).join(','), 282 | // t: Date.now() 283 | // } 284 | } 285 | ) 286 | .then((res) => { 287 | const { createIssueManually } = this.options; 288 | let isNoInit = false; 289 | let issue = null; 290 | if (!(res && res.data && res.data.length)) { 291 | if (!createIssueManually && this.isAdmin) { 292 | return this.createIssue(); 293 | } 294 | 295 | isNoInit = true; 296 | } else { 297 | issue = res.data[0]; 298 | } 299 | this.setState({ issue, isNoInit }); 300 | return issue; 301 | }); 302 | } 303 | 304 | getIssue() { 305 | const { number } = this.options; 306 | const { issue } = this.state; 307 | if (issue) { 308 | this.setState({ isNoInit: false }); 309 | console.log('issue', issue); 310 | return Promise.resolve(issue); 311 | } 312 | 313 | if (typeof number === 'number' && number > 0) { 314 | return this.getIssueById().then((resIssue) => { 315 | if (!resIssue) return this.getIssueByLabels(); 316 | return resIssue; 317 | }); 318 | } 319 | return this.getIssueByLabels(); 320 | } 321 | 322 | createIssue() { 323 | const { owner, repo, title, body, id, labels, url } = this.options; 324 | return axiosGithub 325 | .post( 326 | `/repos/${owner}/${repo}/issues`, 327 | { 328 | title, 329 | labels: labels.concat(id), 330 | body: 331 | body || 332 | `${url} \n\n ${ 333 | getMetaContent('description') || 334 | getMetaContent('description', 'og:description') || 335 | '' 336 | }`, 337 | }, 338 | { 339 | headers: { 340 | Authorization: `token ${this.accessToken}`, 341 | }, 342 | } 343 | ) 344 | .then((res) => { 345 | this.setState({ issue: res.data }); 346 | return res.data; 347 | }); 348 | } 349 | 350 | // Get comments via v3 api, don't require login, but sorting feature is disable 351 | getCommentsV3 = (issue) => { 352 | const { perPage, clientID, clientSecret } = this.options; 353 | const { page } = this.state; 354 | 355 | return this.getIssue().then((issue) => { 356 | if (!issue) return; 357 | 358 | console.log('issueissue', issue); 359 | const path = issue.comments_url?.replace('https://api.github.com', ''); 360 | 361 | return axiosGithub 362 | .get( 363 | `${path}${encodeURIComponent(`?per_page=${perPage}&page=${page}`)}`, 364 | { 365 | headers: { 366 | Accept: 'application/vnd.github.v3.full+json', 367 | }, 368 | auth: { 369 | username: clientID, 370 | password: clientSecret, 371 | }, 372 | // params: { 373 | // per_page: perPage, 374 | // page 375 | // } 376 | } 377 | ) 378 | .then((res) => { 379 | const { comments, issue } = this.state; 380 | let isLoadOver = false; 381 | const cs = comments.concat(res.data); 382 | if (cs.length >= issue?.comments || res.data.length < perPage) { 383 | isLoadOver = true; 384 | } 385 | this.setState({ 386 | comments: cs, 387 | isLoadOver, 388 | page: page + 1, 389 | }); 390 | return cs; 391 | }); 392 | }); 393 | }; 394 | 395 | getComments(issue) { 396 | if (!issue) return; 397 | // Get comments via v4 graphql api, login required and sorting feature is available 398 | if (this.accessToken) return QLGetComments.call(this, issue); 399 | return this.getCommentsV3(issue); 400 | } 401 | 402 | createComment() { 403 | const { comment, localComments, comments } = this.state; 404 | 405 | return this.getIssue() 406 | .then((issue) => 407 | axiosGithub.post( 408 | issue.comments_url, 409 | { 410 | body: comment, 411 | }, 412 | { 413 | headers: { 414 | Accept: 'application/vnd.github.v3.full+json', 415 | Authorization: `token ${this.accessToken}`, 416 | }, 417 | } 418 | ) 419 | ) 420 | .then((res) => { 421 | this.setState({ 422 | comment: '', 423 | comments: comments.concat(res.data), 424 | localComments: localComments.concat(res.data), 425 | }); 426 | }); 427 | } 428 | 429 | logout() { 430 | this.setState({ user: null }); 431 | window.localStorage.removeItem(GT_ACCESS_TOKEN); 432 | } 433 | 434 | getRef = (e) => { 435 | this.publicBtnEL = e; 436 | }; 437 | 438 | reply = (replyComment) => () => { 439 | const { comment } = this.state; 440 | const replyCommentBody = replyComment.body; 441 | let replyCommentArray = replyCommentBody.split('\n'); 442 | replyCommentArray.unshift(`@${replyComment.user.login}`); 443 | replyCommentArray = replyCommentArray.map((t) => `> ${t}`); 444 | replyCommentArray.push(''); 445 | replyCommentArray.push(''); 446 | if (comment) replyCommentArray.unshift(''); 447 | this.setState({ comment: comment + replyCommentArray.join('\n') }, () => { 448 | autosize.update(this.commentEL); 449 | this.commentEL.focus(); 450 | }); 451 | }; 452 | 453 | like(comment) { 454 | const { owner, repo } = this.options; 455 | const { user } = this.state; 456 | let { comments } = this.state; 457 | 458 | axiosGithub 459 | .post( 460 | `/repos/${owner}/${repo}/issues/comments/${comment.id}/reactions`, 461 | { 462 | content: 'heart', 463 | }, 464 | { 465 | headers: { 466 | Authorization: `token ${this.accessToken}`, 467 | Accept: 'application/vnd.github.squirrel-girl-preview', 468 | }, 469 | } 470 | ) 471 | .then((res) => { 472 | comments = comments.map((c) => { 473 | if (c.id === comment.id) { 474 | if (c.reactions) { 475 | if ( 476 | !~c.reactions.nodes.findIndex( 477 | (n) => n.user.login === user.login 478 | ) 479 | ) { 480 | c.reactions.totalCount += 1; 481 | } 482 | } else { 483 | c.reactions = { nodes: [] }; 484 | c.reactions.totalCount = 1; 485 | } 486 | 487 | c.reactions.nodes.push(res.data); 488 | c.reactions.viewerHasReacted = true; 489 | return Object.assign({}, c); 490 | } 491 | return c; 492 | }); 493 | 494 | this.setState({ 495 | comments, 496 | }); 497 | }); 498 | } 499 | 500 | unLike(comment) { 501 | const { user } = this.state; 502 | let { comments } = this.state; 503 | 504 | // const { user } = this.state 505 | // let id 506 | // comment.reactions.nodes.forEach(r => { 507 | // if (r.user.login = user.login) id = r.databaseId 508 | // }) 509 | // return axiosGithub.delete(`/reactions/${id}`, { 510 | // headers: { 511 | // Authorization: `token ${this.accessToken}`, 512 | // Accept: 'application/vnd.github.squirrel-girl-preview' 513 | // } 514 | // }).then(res => { 515 | // console.log('res:', res) 516 | // }) 517 | 518 | const getQL = (id) => ({ 519 | operationName: 'RemoveReaction', 520 | query: ` 521 | mutation RemoveReaction{ 522 | removeReaction (input:{ 523 | subjectId: "${id}", 524 | content: HEART 525 | }) { 526 | reaction { 527 | content 528 | } 529 | } 530 | } 531 | `, 532 | }); 533 | 534 | axiosGithub 535 | .post('/graphql', getQL(comment.gId), { 536 | headers: { 537 | Authorization: `bearer ${this.accessToken}`, 538 | }, 539 | }) 540 | .then((res) => { 541 | if (res.data) { 542 | comments = comments.map((c) => { 543 | if (c.id === comment.id) { 544 | const index = c.reactions.nodes.findIndex( 545 | (n) => n.user.login === user.login 546 | ); 547 | if (~index) { 548 | c.reactions.totalCount -= 1; 549 | c.reactions.nodes.splice(index, 1); 550 | } 551 | c.reactions.viewerHasReacted = false; 552 | return Object.assign({}, c); 553 | } 554 | return c; 555 | }); 556 | 557 | this.setState({ 558 | comments, 559 | }); 560 | } 561 | }); 562 | } 563 | 564 | handlePopup = (e) => { 565 | e.preventDefault(); 566 | e.stopPropagation(); 567 | const isVisible = !this.state.isPopupVisible; 568 | const hideHandle = (e1) => { 569 | if (hasClassInParent(e1.target, 'gt-user', 'gt-popup')) { 570 | return; 571 | } 572 | window.document.removeEventListener('click', hideHandle); 573 | this.setState({ isPopupVisible: false }); 574 | }; 575 | this.setState({ isPopupVisible: isVisible }); 576 | if (isVisible) { 577 | window.document.addEventListener('click', hideHandle); 578 | } else { 579 | window.document.removeEventListener('click', hideHandle); 580 | } 581 | }; 582 | 583 | handleLogin = () => { 584 | const { comment } = this.state; 585 | window.localStorage.setItem(GT_COMMENT, encodeURIComponent(comment)); 586 | window.location.href = this.loginLink; 587 | }; 588 | 589 | handleIssueCreate = () => { 590 | this.setState({ isIssueCreating: true }); 591 | this.createIssue() 592 | .then((issue) => { 593 | this.setState({ 594 | isIssueCreating: false, 595 | isOccurError: false, 596 | }); 597 | return this.getComments(issue); 598 | }) 599 | .catch((err) => { 600 | this.setState({ 601 | isIssueCreating: false, 602 | isOccurError: true, 603 | errorMsg: formatErrorMsg(err), 604 | }); 605 | }) 606 | .then((res) => { 607 | if (res) { 608 | this.setState({ 609 | isNoInit: false, 610 | }); 611 | } 612 | }); 613 | }; 614 | 615 | handleCommentCreate = (e) => { 616 | if (!this.state.comment.length) { 617 | e && e.preventDefault(); 618 | this.commentEL.focus(); 619 | return; 620 | } 621 | this.setState((state) => { 622 | if (state.isCreating) return; 623 | 624 | this.createComment() 625 | .then(() => 626 | this.setState({ 627 | isCreating: false, 628 | isOccurError: false, 629 | }) 630 | ) 631 | .catch((err) => { 632 | this.setState({ 633 | isCreating: false, 634 | isOccurError: true, 635 | errorMsg: formatErrorMsg(err), 636 | }); 637 | }); 638 | return { isCreating: true }; 639 | }); 640 | }; 641 | 642 | handleCommentPreview = (e) => { 643 | this.setState({ 644 | isPreview: !this.state.isPreview, 645 | }); 646 | if (!this.state.isPreview) { 647 | return; 648 | } 649 | axiosGithub 650 | .post( 651 | '/markdown', 652 | { 653 | text: this.state.comment, 654 | }, 655 | { 656 | headers: this.accessToken && { 657 | Authorization: `token ${this.accessToken}`, 658 | }, 659 | } 660 | ) 661 | .then((res) => { 662 | this.setState({ 663 | previewHtml: res.data, 664 | }); 665 | }) 666 | .catch((err) => { 667 | this.setState({ 668 | isOccurError: true, 669 | errorMsg: formatErrorMsg(err), 670 | }); 671 | }); 672 | }; 673 | 674 | handleCommentLoad = () => { 675 | const { issue, isLoadMore } = this.state; 676 | if (isLoadMore) return; 677 | this.setState({ isLoadMore: true }); 678 | console.log('issue', issue); 679 | this.getComments(issue).then(() => this.setState({ isLoadMore: false })); 680 | }; 681 | 682 | handleCommentChange = (e) => this.setState({ comment: e.target.value }); 683 | handleLogout = () => { 684 | this.logout(); 685 | window.location.reload(); 686 | }; 687 | 688 | handleCommentFocus = (e) => { 689 | const { distractionFreeMode } = this.options; 690 | if (!distractionFreeMode) return e.preventDefault(); 691 | this.setState({ isInputFocused: true }); 692 | }; 693 | 694 | handleCommentBlur = (e) => { 695 | const { distractionFreeMode } = this.options; 696 | if (!distractionFreeMode) return e.preventDefault(); 697 | this.setState({ isInputFocused: false }); 698 | }; 699 | 700 | handleSort = (direction) => (e) => { 701 | this.setState({ pagerDirection: direction }); 702 | }; 703 | 704 | handleCommentKeyDown = (e) => { 705 | const { enableHotKey } = this.options; 706 | if (enableHotKey && (e.metaKey || e.ctrlKey) && e.keyCode === 13) { 707 | this.publicBtnEL && this.publicBtnEL.focus(); 708 | this.handleCommentCreate(); 709 | } 710 | }; 711 | 712 | initing() { 713 | return ( 714 |
715 | 716 |

{this.i18n.t('init')}

717 |
718 | ); 719 | } 720 | 721 | noInit() { 722 | const { user, isIssueCreating } = this.state; 723 | const { owner, repo, admin } = this.options; 724 | return ( 725 |
726 |

Issues`, 730 | }), 731 | }} 732 | /> 733 |

734 | {this.i18n.t('please-contact', { 735 | user: [] 736 | .concat(admin) 737 | .map((u) => `@${u}`) 738 | .join(' '), 739 | })} 740 |

741 | {this.isAdmin ? ( 742 |

743 |

758 | ); 759 | } 760 | 761 | header() { 762 | const { user, comment, isCreating, previewHtml, isPreview } = this.state; 763 | return ( 764 |
765 | {user ? ( 766 | 771 | ) : ( 772 | 773 | 774 | 775 | )} 776 |
777 |