├── .prettierrc ├── docs ├── issue.png ├── oauth.png ├── repo.png ├── token.png ├── oauth_prod.png ├── oauth_test.png ├── token_premission.png ├── static │ ├── js │ │ ├── 820.f55dda59.js.LICENSE.txt │ │ └── lib-react.80f573d9.js.LICENSE.txt │ └── css │ │ └── 820.b3abc803.css └── index.html ├── .prettierignore ├── src ├── index.tsx ├── i18n │ ├── index.ts │ └── locales │ │ ├── zh.json │ │ └── en.json ├── types │ └── global.d.ts ├── components │ ├── Label.tsx │ ├── AnimatedCard.tsx │ ├── LanguageSwitcher.tsx │ ├── common │ │ ├── IssueLayout.tsx │ │ └── Spotlight.tsx │ ├── SkeletonCard.tsx │ ├── CommentInput.tsx │ ├── Issue.tsx │ ├── About.tsx │ ├── Egg.tsx │ ├── Toolbar.tsx │ └── Interaction.tsx ├── utils │ ├── cache.ts │ ├── request.ts │ └── index.ts ├── config │ └── index.ts ├── gwitter.ts ├── AuthWindow.tsx ├── hooks │ └── useAuth.tsx ├── lib │ └── collapse.js └── App.tsx ├── .gitignore ├── demo ├── npm-demo │ ├── vite.config.ts │ ├── tsconfig.node.json │ ├── src │ │ ├── main.tsx │ │ ├── index.css │ │ ├── config │ │ │ └── gwitter.config.ts │ │ ├── App.css │ │ └── App.tsx │ ├── index.html │ ├── package.json │ └── tsconfig.json ├── README.md └── umd-demo │ └── index.html ├── .npmignore ├── tsconfig.json ├── eslint.config.mjs ├── LICENSE ├── rsbuild.config.mjs ├── package.json ├── public └── index.html ├── rollup.config.js ├── README.zh_CN.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /docs/issue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/Gwitter/HEAD/docs/issue.png -------------------------------------------------------------------------------- /docs/oauth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/Gwitter/HEAD/docs/oauth.png -------------------------------------------------------------------------------- /docs/repo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/Gwitter/HEAD/docs/repo.png -------------------------------------------------------------------------------- /docs/token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/Gwitter/HEAD/docs/token.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Lock files 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /docs/oauth_prod.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/Gwitter/HEAD/docs/oauth_prod.png -------------------------------------------------------------------------------- /docs/oauth_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/Gwitter/HEAD/docs/oauth_test.png -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import gwitter from './gwitter'; 2 | import './i18n'; 3 | 4 | gwitter(); 5 | -------------------------------------------------------------------------------- /docs/token_premission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SimonAKing/Gwitter/HEAD/docs/token_premission.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local 2 | .DS_Store 3 | *.local 4 | *.log* 5 | 6 | # Dist 7 | node_modules 8 | dist/ 9 | 10 | # IDE 11 | .vscode/* 12 | !.vscode/extensions.json 13 | .idea 14 | -------------------------------------------------------------------------------- /demo/npm-demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | server: { 7 | port: 3000, 8 | open: true 9 | } 10 | }) -------------------------------------------------------------------------------- /demo/npm-demo/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } -------------------------------------------------------------------------------- /demo/npm-demo/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) -------------------------------------------------------------------------------- /docs/static/js/820.f55dda59.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** @license React v16.13.1 2 | * react-is.production.min.js 3 | * 4 | * Copyright (c) Facebook, Inc. and its affiliates. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE file in the root directory of this source tree. 8 | */ -------------------------------------------------------------------------------- /demo/npm-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Gwitter NPM Demo 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /demo/npm-demo/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html { 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 7 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 8 | sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | padding: 0; 16 | background-color: #f5f5f5; 17 | color: #333; 18 | line-height: 1.6; 19 | } 20 | 21 | #root { 22 | min-height: 100vh; 23 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Development files 2 | src/ 3 | public/ 4 | docs/ 5 | .git/ 6 | .github/ 7 | node_modules/ 8 | .rsbuild/ 9 | 10 | # Config files 11 | rsbuild.config.mjs 12 | rollup.config.js 13 | tsconfig.json 14 | eslint.config.mjs 15 | .prettierrc 16 | .prettierignore 17 | .gitignore 18 | 19 | # Build scripts 20 | pnpm-lock.yaml 21 | package-lock.json 22 | yarn.lock 23 | 24 | # Test files 25 | test-lib.html 26 | **/*.test.* 27 | **/*.spec.* 28 | 29 | # Development tools 30 | .vscode/ 31 | .idea/ 32 | *.log 33 | *.tmp 34 | *.temp 35 | 36 | # OS files 37 | .DS_Store 38 | Thumbs.db -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import LanguageDetector from 'i18next-browser-languagedetector'; 3 | import { initReactI18next } from 'react-i18next'; 4 | import en from './locales/en.json'; 5 | import zh from './locales/zh.json'; 6 | 7 | i18n 8 | .use(LanguageDetector) 9 | .use(initReactI18next) 10 | .init({ 11 | resources: { 12 | en: { translation: en }, 13 | zh: { translation: zh }, 14 | }, 15 | fallbackLng: 'en', 16 | interpolation: { 17 | escapeValue: false, 18 | }, 19 | }); 20 | 21 | export default i18n; 22 | -------------------------------------------------------------------------------- /demo/npm-demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gwitter-npm-demo", 3 | "version": "1.0.0", 4 | "description": "Demo for using Gwitter with NPM", 5 | "private": true, 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "gwitter": "^1.1.0", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0" 15 | }, 16 | "devDependencies": { 17 | "@types/react": "^18.2.0", 18 | "@types/react-dom": "^18.2.0", 19 | "@vitejs/plugin-react": "^4.0.0", 20 | "typescript": "^5.0.0", 21 | "vite": "^4.4.0" 22 | } 23 | } -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | export interface GwitterConfig { 2 | request?: { 3 | token?: string; 4 | clientID?: string; 5 | clientSecret?: string; 6 | pageSize?: number; 7 | autoProxy?: string; 8 | owner?: string; 9 | repo?: string; 10 | }; 11 | app?: { 12 | onlyShowOwner?: boolean; 13 | enableRepoSwitcher?: boolean; 14 | enableAbout?: boolean; 15 | enableEgg?: boolean; 16 | }; 17 | } 18 | 19 | export interface GwitterOptions { 20 | container?: HTMLElement; 21 | config?: GwitterConfig; 22 | } 23 | 24 | declare global { 25 | interface Window { 26 | gwitter?: (options?: GwitterOptions) => void; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2016", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": false, 17 | "jsx": "react-jsx", 18 | "declaration": true, 19 | "declarationMap": true, 20 | "outDir": "dist" 21 | }, 22 | "include": ["src"] 23 | } -------------------------------------------------------------------------------- /demo/npm-demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules } from '@eslint/compat'; 2 | import js from '@eslint/js'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactJsx from 'eslint-plugin-react/configs/jsx-runtime.js'; 5 | import react from 'eslint-plugin-react/configs/recommended.js'; 6 | import globals from 'globals'; 7 | 8 | export default [ 9 | { languageOptions: { globals: globals.browser } }, 10 | js.configs.recommended, 11 | ...fixupConfigRules([ 12 | { 13 | ...react, 14 | settings: { 15 | react: { version: 'detect' }, 16 | }, 17 | }, 18 | reactJsx, 19 | ]), 20 | { 21 | plugins: { 22 | 'react-hooks': reactHooks, 23 | }, 24 | rules: { 25 | ...reactHooks.configs.recommended.rules, 26 | }, 27 | }, 28 | { ignores: ['dist/'] }, 29 | ]; 30 | -------------------------------------------------------------------------------- /src/components/Label.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { getColorByBgColor } from '../utils'; 3 | 4 | interface LabelProps { 5 | name: string; 6 | color: string; 7 | style?: React.CSSProperties; 8 | } 9 | 10 | const LabelContainer = styled.span<{ bgColor: string }>` 11 | display: inline-block; 12 | line-height: 1; 13 | padding: 5px 6px; 14 | font-size: 0.9em; 15 | font-weight: 600; 16 | border-radius: 3px; 17 | box-shadow: inset 0 -1px 0 rgba(27, 31, 35, 0.12); 18 | background-color: #${(props) => props.bgColor}; 19 | color: ${(props) => getColorByBgColor(props.bgColor)}; 20 | `; 21 | 22 | const Label: React.FC = ({ name, color, style }) => { 23 | return ( 24 | 25 | {name} 26 | 27 | ); 28 | }; 29 | 30 | export default Label; 31 | -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | const LAST_REPO_KEY = 'gwitter_last_repo'; 2 | 3 | export const saveLastRepo = (owner: string, repo: string) => { 4 | try { 5 | const repoData = { owner, repo }; 6 | localStorage.setItem(LAST_REPO_KEY, JSON.stringify(repoData)); 7 | console.log('Saved last repo:', `${owner}/${repo}`); 8 | } catch (error) { 9 | console.warn('Failed to save last repo:', error); 10 | } 11 | }; 12 | 13 | export const loadLastRepo = (): { owner: string; repo: string } | null => { 14 | try { 15 | const cached = localStorage.getItem(LAST_REPO_KEY); 16 | if (cached) { 17 | const repoData = JSON.parse(cached); 18 | console.log('Loaded last repo:', `${repoData.owner}/${repoData.repo}`); 19 | return repoData; 20 | } 21 | return null; 22 | } catch (error) { 23 | console.warn('Failed to load last repo:', error); 24 | return null; 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/AnimatedCard.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | import { ReactNode } from 'react'; 3 | 4 | interface AnimatedCardProps { 5 | children: ReactNode; 6 | id: string; 7 | } 8 | 9 | const AnimatedCard = ({ children, id }: AnimatedCardProps) => { 10 | return ( 11 | 39 | {children} 40 | 41 | ); 42 | }; 43 | 44 | export default AnimatedCard; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 simonma 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/config/index.ts: -------------------------------------------------------------------------------- 1 | import { GwitterConfig } from '../types/global'; 2 | 3 | let config = { 4 | request: { 5 | token: 6 | 'g?i?t?h?u?b?_?p?a?t?_?1?1?A?H?V?6?E?W?Q?0?M?f?C?S?r?0?4?K?A?j?1?F?_?3?7?n?4?U?y?u?S?m?d?z?i?t?D?s?w?i?s?i?u?a?g?N?b?a?k?V?n?L?I?7?U?W?s?s?h?n?K?p?s?H?S?D?S?4?D?K?O?Q?Q?J?S?S?x?q?z?Z?X?M', 7 | clientID: '56af6ab05592f0a2d399', 8 | clientSecret: '5d7e71a1b6130001e84956420ca5b88bc45b7d3c', 9 | pageSize: 6, 10 | autoProxy: 11 | 'https://cors-anywhere.azm.workers.dev/https://github.com/login/oauth/access_token', 12 | owner: 'SimonAKing', 13 | repo: 'weibo', 14 | }, 15 | 16 | app: { 17 | onlyShowOwner: false, 18 | enableRepoSwitcher: true, 19 | enableAbout: false, 20 | enableEgg: false, 21 | }, 22 | }; 23 | 24 | export function setConfig(newConfig: GwitterConfig) { 25 | if (newConfig.request) { 26 | config.request = { 27 | ...config.request, 28 | ...newConfig.request, 29 | }; 30 | } 31 | 32 | if (newConfig.app) { 33 | config.app = { 34 | ...config.app, 35 | ...newConfig.app, 36 | }; 37 | } 38 | } 39 | 40 | export default config; 41 | -------------------------------------------------------------------------------- /docs/static/js/lib-react.80f573d9.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /** 2 | * @license React 3 | * react-dom.production.min.js 4 | * 5 | * Copyright (c) Facebook, Inc. and its affiliates. 6 | * 7 | * This source code is licensed under the MIT license found in the 8 | * LICENSE file in the root directory of this source tree. 9 | */ 10 | 11 | /** 12 | * @license React 13 | * react-jsx-runtime.production.min.js 14 | * 15 | * Copyright (c) Facebook, Inc. and its affiliates. 16 | * 17 | * This source code is licensed under the MIT license found in the 18 | * LICENSE file in the root directory of this source tree. 19 | */ 20 | 21 | /** 22 | * @license React 23 | * react.production.min.js 24 | * 25 | * Copyright (c) Facebook, Inc. and its affiliates. 26 | * 27 | * This source code is licensed under the MIT license found in the 28 | * LICENSE file in the root directory of this source tree. 29 | */ 30 | 31 | /** 32 | * @license React 33 | * scheduler.production.min.js 34 | * 35 | * Copyright (c) Facebook, Inc. and its affiliates. 36 | * 37 | * This source code is licensed under the MIT license found in the 38 | * LICENSE file in the root directory of this source tree. 39 | */ -------------------------------------------------------------------------------- /demo/npm-demo/src/config/gwitter.config.ts: -------------------------------------------------------------------------------- 1 | // Gwitter Configuration 2 | 3 | export interface GwitterConfig { 4 | request: { 5 | token: string; 6 | clientID: string; 7 | clientSecret: string; 8 | owner: string; 9 | repo: string; 10 | pageSize?: number; 11 | autoProxy?: string; 12 | }; 13 | app?: { 14 | onlyShowOwner?: boolean; 15 | enableRepoSwitcher?: boolean; 16 | enableAbout?: boolean; 17 | enableEgg?: boolean; 18 | }; 19 | } 20 | 21 | // Default configuration 22 | export const config: GwitterConfig = { 23 | request: { 24 | token: 25 | 'g?i?t?h?u?b?_?p?a?t?_?1?1?A?H?V?6?E?W?Q?0?M?f?C?S?r?0?4?K?A?j?1?F?_?3?7?n?4?U?y?u?S?m?d?z?i?t?D?s?w?i?s?i?u?a?g?N?b?a?k?V?n?L?I?7?U?W?s?s?h?n?K?p?s?H?S?D?S?4?D?K?O?Q?Q?J?S?S?x?q?z?Z?X?M', 26 | clientID: '56af6ab05592f0a2d399', 27 | clientSecret: '5d7e71a1b6130001e84956420ca5b88bc45b7d3c', 28 | pageSize: 6, 29 | autoProxy: 30 | 'https://cors-anywhere.azm.workers.dev/https://github.com/login/oauth/access_token', 31 | owner: 'SimonAKing', 32 | repo: 'weibo', 33 | }, 34 | app: { 35 | onlyShowOwner: true, 36 | enableAbout: true, 37 | enableRepoSwitcher: false, 38 | enableEgg: true, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/gwitter.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import AuthWindow from './AuthWindow'; 5 | import { setConfig } from './config'; 6 | import './i18n'; 7 | import type { GwitterOptions } from './types/global'; 8 | import { parseUrl } from './utils'; 9 | 10 | let gwitterInstance: ReactDOM.Root | null = null; 11 | 12 | function renderGwitter(container: HTMLElement) { 13 | const params = parseUrl(); 14 | let component = App; 15 | if (params.code) { 16 | component = AuthWindow; 17 | } 18 | 19 | if (gwitterInstance) { 20 | gwitterInstance.unmount(); 21 | } 22 | 23 | const root = ReactDOM.createRoot(container); 24 | root.render(React.createElement(component)); 25 | 26 | gwitterInstance = root; 27 | 28 | return root; 29 | } 30 | 31 | function gwitter(options: GwitterOptions = {}) { 32 | const container = options.container || document.getElementById('gwitter'); 33 | if (!container) { 34 | console.error('Gwitter: Container element not found'); 35 | return; 36 | } 37 | 38 | setConfig(options.config || {}); 39 | 40 | return renderGwitter(container); 41 | } 42 | 43 | if (typeof window !== 'undefined') { 44 | window.gwitter = gwitter; 45 | } 46 | 47 | export default gwitter; 48 | -------------------------------------------------------------------------------- /rsbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@rsbuild/core'; 2 | import { pluginLess } from '@rsbuild/plugin-less'; 3 | import { pluginReact } from '@rsbuild/plugin-react'; 4 | 5 | export default defineConfig({ 6 | html: { 7 | template: './public/index.html', 8 | }, 9 | plugins: [pluginReact(), pluginLess()], 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.ts$/, 14 | exclude: [/node_modules/], 15 | loader: 'builtin:swc-loader', 16 | options: { 17 | jsc: { 18 | parser: { 19 | syntax: 'typescript', 20 | }, 21 | }, 22 | }, 23 | type: 'javascript/auto', 24 | }, 25 | { 26 | test: /\.jsx$/, 27 | use: { 28 | loader: 'builtin:swc-loader', 29 | options: { 30 | jsc: { 31 | parser: { 32 | syntax: 'ecmascript', 33 | jsx: true, 34 | }, 35 | }, 36 | }, 37 | }, 38 | type: 'javascript/auto', 39 | }, 40 | { 41 | test: /\.tsx$/, 42 | use: { 43 | loader: 'builtin:swc-loader', 44 | options: { 45 | jsc: { 46 | parser: { 47 | syntax: 'typescript', 48 | tsx: true, 49 | }, 50 | }, 51 | }, 52 | }, 53 | type: 'javascript/auto', 54 | }, 55 | ], 56 | }, 57 | output: { 58 | assetPrefix: 'https://simonaking.com/Gwitter/', 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/LanguageSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { useTranslation } from 'react-i18next'; 3 | 4 | const SwitcherContainer = styled.div` 5 | display: flex; 6 | align-items: center; 7 | gap: 8px; 8 | `; 9 | 10 | const LanguageButton = styled.button<{ isActive: boolean }>` 11 | background: ${(props) => (props.isActive ? '#1da1f2' : 'transparent')}; 12 | color: ${(props) => (props.isActive ? 'white' : '#657786')}; 13 | border: 1px solid ${(props) => (props.isActive ? '#1da1f2' : '#e1e8ed')}; 14 | padding: 4px 8px; 15 | border-radius: 12px; 16 | cursor: pointer; 17 | font-size: 12px; 18 | font-weight: 500; 19 | transition: all 0.2s; 20 | min-width: 32px; 21 | 22 | &:hover { 23 | background: ${(props) => (props.isActive ? '#1991db' : '#f7f9fa')}; 24 | border-color: ${(props) => (props.isActive ? '#1991db' : '#d1d9e0')}; 25 | } 26 | 27 | &:active { 28 | transform: scale(0.95); 29 | } 30 | `; 31 | 32 | const LanguageSwitcher = () => { 33 | const { i18n } = useTranslation(); 34 | 35 | const changeLanguage = (lng: string) => { 36 | i18n.changeLanguage(lng); 37 | }; 38 | 39 | return ( 40 | 41 | changeLanguage('zh')} 44 | > 45 | 中 46 | 47 | changeLanguage('en')} 50 | > 51 | EN 52 | 53 | 54 | ); 55 | }; 56 | 57 | export default LanguageSwitcher; 58 | -------------------------------------------------------------------------------- /src/components/common/IssueLayout.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const IssueContainer = styled.div` 4 | position: relative; 5 | margin: 0.5em 0; 6 | display: flex; 7 | border-radius: 10px; 8 | `; 9 | 10 | export const IssueContent = styled.div` 11 | flex: 1 1; 12 | padding: 16px 20px 0px; 13 | margin: 6px; 14 | overflow: auto; 15 | background: hsla(0, 0%, 100%, 0.8); 16 | border: 0.5px solid #f1f1f1; 17 | border-radius: 10px; 18 | box-shadow: 0 0.1em 0.2em 0 rgba(234, 234, 234, 0.8); 19 | transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); 20 | position: relative; 21 | overflow: hidden; 22 | font-size: 15px; 23 | z-index: 2; 24 | 25 | /* border: 1px solid rgb(212, 212, 216); */ 26 | /* box-shadow: 27 | rgba(0, 0, 0, 0) 0px 0px 0px 0px, 28 | rgba(0, 0, 0, 0) 0px 0px 0px 0px, 29 | rgba(0, 0, 0, 0.15) 2px 0px 8px 0px; */ 30 | 31 | /* &:hover { */ 32 | /* box-shadow: 0 0.2em 0.3em 0.1em rgba(200, 200, 200, 0.4); */ 33 | /* transform: translateY(-1px); */ 34 | /* } */ 35 | `; 36 | 37 | export const IssueHeader = styled.div` 38 | margin-bottom: 0.7em; 39 | font-size: 1em; 40 | position: relative; 41 | display: flex; 42 | align-items: center; 43 | flex-wrap: wrap; 44 | /* gap: 0.2em; */ 45 | `; 46 | 47 | export const IssueBody = styled.div` 48 | color: #333; 49 | &.markdown-body { 50 | font-size: 1em; 51 | letter-spacing: 0.2px; 52 | word-wrap: break-word; 53 | background-color: transparent; 54 | /* background: hsla(0, 0%, 100%, 0.8); */ 55 | ol { 56 | list-style: decimal !important; 57 | } 58 | ul { 59 | list-style: circle !important; 60 | } 61 | } 62 | `; 63 | 64 | export const IssueFooter = styled.div` 65 | position: relative; 66 | margin-top: 0.8em; 67 | font-size: 1em; 68 | user-select: none; 69 | `; 70 | -------------------------------------------------------------------------------- /src/i18n/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "authorizing": "正在授权中...", 4 | "login": "登录 GitHub", 5 | "logout": "退出", 6 | "loading": "加载中..." 7 | }, 8 | "toolbar": { 9 | "repo": "仓库", 10 | "apply": "应用", 11 | "repoPlaceholder": "用户名/仓库名", 12 | "invalidRepo": "请输入有效的仓库地址(用户名/仓库名)", 13 | "repoNotFound": "仓库未找到或为私有仓库" 14 | }, 15 | "about": { 16 | "title": "Details & Summary", 17 | "gwitter": { 18 | "title": "🎉 关于 Gwitter", 19 | "description": "这是一个基于 GitHub Issues 构建的轻量级微博应用。这里记录着我对技术的思考、对生活的感悟,以及一些有趣的发现,欢迎一起交流。" 20 | }, 21 | "content": { 22 | "title": "✨ 关于内容", 23 | "categories": "共有{{count}}个分类:" 24 | }, 25 | "subscription": { 26 | "title": "🔊 关于订阅", 27 | "watch": "Watch", 28 | "join": "Join", 29 | "repo": "Gwitter 仓库", 30 | "wechat": "微信群", 31 | "telegram": "TG 频道" 32 | } 33 | }, 34 | "egg": { 35 | "message": "感谢浏览!", 36 | "hope": "期待再次相见", 37 | "comment": "// TODO: 添加更多有趣内容", 38 | "runCode": "运行代码" 39 | }, 40 | "interaction": { 41 | "like": "点赞", 42 | "liked": "已点赞", 43 | "comment": "评论", 44 | "comments": "条评论", 45 | "loginRequired": "请先登录后再进行操作", 46 | "loginToLike": "登录后点赞", 47 | "loginToComment": "登录后评论" 48 | }, 49 | "comments": { 50 | "adding": "评论中...", 51 | "saving": "保存中...", 52 | "loading": "加载评论中...", 53 | "empty": "暂无评论", 54 | "add": "评论", 55 | "edit": "编辑", 56 | "delete": "删除", 57 | "cancel": "取消", 58 | "save": "保存", 59 | "placeholder": "写下你的评论...", 60 | "confirmDelete": "确定要删除这条评论吗?", 61 | "confirmDeleteTitle": "删除评论", 62 | "confirmDeleteMessage": "确定要删除这条评论吗?删除后将无法恢复。", 63 | "deleting": "删除中...", 64 | "addSuccess": "评论添加成功", 65 | "updateSuccess": "评论更新成功", 66 | "deleteSuccess": "评论删除成功", 67 | "addFailed": "评论添加失败", 68 | "updateFailed": "评论更新失败", 69 | "deleteFailed": "评论删除失败", 70 | "more": "更多操作", 71 | "like": "点赞", 72 | "liked": "已点赞", 73 | "likes": "个赞" 74 | } 75 | } -------------------------------------------------------------------------------- /src/components/SkeletonCard.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { 3 | IssueBody, 4 | IssueContent, 5 | IssueFooter, 6 | IssueHeader, 7 | } from './common/IssueLayout'; 8 | 9 | const SkeletonBase = styled.div` 10 | background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); 11 | background-size: 200px 100%; 12 | animation: skeleton-loading 3s infinite; 13 | border-radius: 4px; 14 | margin-bottom: 8px; 15 | 16 | @keyframes skeleton-loading { 17 | 0% { 18 | background-position: -200px 0; 19 | } 20 | 100% { 21 | background-position: calc(200px + 100%) 0; 22 | } 23 | } 24 | `; 25 | 26 | const SkeletonAvatar = styled(SkeletonBase)` 27 | width: 2em; 28 | height: 2em; 29 | border-radius: 50%; 30 | margin-right: 0.5em; 31 | margin-bottom: 0; 32 | display: inline-flex; 33 | align-self: center; 34 | `; 35 | 36 | const SkeletonUsername = styled(SkeletonBase)` 37 | width: 120px; 38 | height: 20px; 39 | display: inline-flex; 40 | align-self: center; 41 | margin-bottom: 0; 42 | `; 43 | 44 | const SkeletonDate = styled(SkeletonBase)` 45 | width: 80px; 46 | height: 16px; 47 | display: inline-flex; 48 | align-self: center; 49 | margin-bottom: 0; 50 | margin-left: 20px; 51 | `; 52 | 53 | const SkeletonLabel = styled(SkeletonBase)` 54 | width: 60px; 55 | height: 24px; 56 | position: absolute; 57 | right: 0; 58 | top: 0; 59 | `; 60 | 61 | const SkeletonLine = styled(SkeletonBase)<{ width: string }>` 62 | height: 16px; 63 | margin-top: 12px; 64 | width: ${(props) => props.width}; 65 | `; 66 | 67 | const SkeletonInteractions = styled(SkeletonBase)` 68 | width: 100px; 69 | height: 20px; 70 | margin: 16px 0px; 71 | `; 72 | 73 | export const SkeletonContainer = styled.div` 74 | position: relative; 75 | margin-bottom: 0.5em; 76 | display: flex; 77 | border-radius: 10px; 78 | `; 79 | 80 | export const SkeletonCard = () => ( 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | ); 101 | 102 | export default SkeletonCard; 103 | -------------------------------------------------------------------------------- /src/i18n/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "authorizing": "Authorizing...", 4 | "login": "Login with GitHub", 5 | "logout": "Logout", 6 | "loading": "Loading..." 7 | }, 8 | "toolbar": { 9 | "repo": "Repo", 10 | "apply": "Apply", 11 | "repoPlaceholder": "owner/repo", 12 | "invalidRepo": "Please enter a valid repository (owner/repo)", 13 | "repoNotFound": "Repository not found or private" 14 | }, 15 | "about": { 16 | "title": "Details & Summary", 17 | "gwitter": { 18 | "title": "🎉 About Gwitter", 19 | "description": "This is a lightweight microblogging application built on GitHub Issues. Here I record my thoughts on technology, insights into life, and some interesting discoveries. Welcome to join the discussion." 20 | }, 21 | "content": { 22 | "title": "✨ About Content", 23 | "categories": "There are {{count}} categories:" 24 | }, 25 | "subscription": { 26 | "title": "🔊 About Subscription", 27 | "watch": "Watch", 28 | "join": "Join", 29 | "repo": "Gwitter Repository", 30 | "wechat": "WeChat Group", 31 | "telegram": "TG Channel" 32 | } 33 | }, 34 | "egg": { 35 | "message": "Thanks for visiting!", 36 | "hope": "Hope to see you again", 37 | "comment": "// TODO: Add more interesting content", 38 | "runCode": "Run Code" 39 | }, 40 | "interaction": { 41 | "like": "Like", 42 | "liked": "Liked", 43 | "comment": "Comment", 44 | "comments": "comments", 45 | "loginRequired": "Please login first", 46 | "loginToLike": "Login to like", 47 | "loginToComment": "Login to comment" 48 | }, 49 | "comments": { 50 | "adding": "Commenting...", 51 | "saving": "Saving...", 52 | "loading": "Loading comments...", 53 | "empty": "No comments yet", 54 | "add": "Comment", 55 | "edit": "Edit", 56 | "delete": "Delete", 57 | "cancel": "Cancel", 58 | "save": "Save", 59 | "placeholder": "Write your comment...", 60 | "confirmDelete": "Are you sure you want to delete this comment?", 61 | "confirmDeleteTitle": "Delete Comment", 62 | "confirmDeleteMessage": "Are you sure you want to delete this comment? This action cannot be undone.", 63 | "deleting": "Deleting...", 64 | "addSuccess": "Comment added successfully", 65 | "updateSuccess": "Comment updated successfully", 66 | "deleteSuccess": "Comment deleted successfully", 67 | "addFailed": "Failed to add comment", 68 | "updateFailed": "Failed to update comment", 69 | "deleteFailed": "Failed to delete comment", 70 | "more": "More actions", 71 | "like": "Like", 72 | "liked": "Liked", 73 | "likes": "likes" 74 | } 75 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gwitter", 3 | "version": "1.1.0", 4 | "description": "Turn GitHub Issues into your personal microblog platform", 5 | "main": "dist/gwitter.min.js", 6 | "module": "dist/gwitter.esm.js", 7 | "types": "dist/gwitter.d.ts", 8 | "files": [ 9 | "dist", 10 | "README.md", 11 | "LICENSE" 12 | ], 13 | "keywords": [ 14 | "issues", 15 | "github", 16 | "twitter", 17 | "blogging" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/SimonAKing/Gwitter" 22 | }, 23 | "license": "MIT", 24 | "publishConfig": { 25 | "access": "public" 26 | }, 27 | "private": false, 28 | "type": "module", 29 | "scripts": { 30 | "build": "rsbuild build", 31 | "build:lib": "rollup -c rollup.config.js", 32 | "dev": "rsbuild dev --open", 33 | "format": "prettier --write .", 34 | "lint": "eslint .", 35 | "preview": "rsbuild preview", 36 | "prepublishOnly": "pnpm run build:lib", 37 | "publish:npm": "npm publish" 38 | }, 39 | "dependencies": { 40 | "@emotion/react": "^11.11.3", 41 | "@emotion/styled": "^11.11.0", 42 | "@number-flow/react": "^0.5.9", 43 | "@types/lodash": "^4.17.17", 44 | "@types/react-flip-move": "^2.9.12", 45 | "@types/react-router-dom": "^5.3.3", 46 | "axios": "^1.9.0", 47 | "balloons-js": "^0.0.3", 48 | "date-fns": "^4.1.0", 49 | "framer-motion": "^12.15.0", 50 | "github-markdown-css": "^5.8.1", 51 | "i18next": "^23.7.16", 52 | "i18next-browser-languagedetector": "^8.1.0", 53 | "lodash": "^4.17.21", 54 | "react": "^18.2.0", 55 | "react-dom": "^18.2.0", 56 | "react-flip-move": "^3.0.5", 57 | "react-i18next": "^14.0.5", 58 | "react-router-dom": "^6.21.3" 59 | }, 60 | "devDependencies": { 61 | "@eslint/compat": "^1.2.7", 62 | "@eslint/js": "^9.23.0", 63 | "@rollup/plugin-commonjs": "^28.0.6", 64 | "@rollup/plugin-json": "^6.1.0", 65 | "@rollup/plugin-node-resolve": "^16.0.1", 66 | "@rollup/plugin-replace": "^6.0.2", 67 | "@rollup/plugin-terser": "^0.4.4", 68 | "@rollup/plugin-typescript": "^12.1.4", 69 | "@rsbuild/core": "^1.3.15", 70 | "@rsbuild/plugin-less": "^1.2.4", 71 | "@rsbuild/plugin-react": "^1.3.0", 72 | "@types/node": "^24.0.7", 73 | "@types/react": "^18.2.48", 74 | "@types/react-dom": "^18.2.18", 75 | "eslint": "^9.23.0", 76 | "eslint-plugin-react": "^7.33.2", 77 | "eslint-plugin-react-hooks": "^4.6.0", 78 | "globals": "^16.0.0", 79 | "less": "^4.3.0", 80 | "postcss-less": "^6.0.0", 81 | "prettier": "^3.5.3", 82 | "rollup": "^4.45.0", 83 | "rollup-plugin-postcss": "^4.0.2", 84 | "tslib": "^2.8.1" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /demo/npm-demo/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | /* text-align: center; */ 3 | max-width: 1200px; 4 | margin: 0 auto; 5 | padding: 20px; 6 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; 7 | } 8 | 9 | .App-header { 10 | background-color: #f8f9fa; 11 | padding: 20px; 12 | border-radius: 8px; 13 | margin-bottom: 30px; 14 | } 15 | 16 | .App-header h1 { 17 | color: #2c3e50; 18 | margin: 0 0 10px 0; 19 | font-size: 2.5em; 20 | } 21 | 22 | .App-header p { 23 | color: #666; 24 | margin: 10px 0; 25 | font-size: 1.1em; 26 | line-height: 1.6; 27 | } 28 | 29 | .App-header code { 30 | background-color: #e9ecef; 31 | padding: 2px 6px; 32 | border-radius: 4px; 33 | font-family: 'Monaco', 'Menlo', monospace; 34 | } 35 | 36 | .App-main { 37 | min-height: 400px; 38 | margin: 30px 0; 39 | } 40 | 41 | .demo-info { 42 | background-color: #f8f9fa; 43 | border: 1px solid #e9ecef; 44 | border-radius: 8px; 45 | padding: 20px; 46 | margin: 20px 0; 47 | } 48 | 49 | .demo-info h2 { 50 | margin: 0 0 16px 0; 51 | color: #2c3e50; 52 | font-size: 1.3em; 53 | } 54 | 55 | .config-display { 56 | display: grid; 57 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 58 | gap: 12px; 59 | } 60 | 61 | .config-item { 62 | background-color: white; 63 | padding: 12px; 64 | border-radius: 6px; 65 | border: 1px solid #e9ecef; 66 | font-size: 0.9em; 67 | } 68 | 69 | .config-item strong { 70 | color: #495057; 71 | display: block; 72 | margin-bottom: 4px; 73 | } 74 | 75 | .gwitter-demo-container { 76 | margin: 20px 0; 77 | border: 1px solid #e1e8ed; 78 | border-radius: 8px; 79 | min-height: 400px; 80 | background-color: #fff; 81 | position: relative; 82 | } 83 | 84 | #gwitter-container { 85 | min-height: 400px; 86 | } 87 | 88 | .App-footer { 89 | background-color: #f8f9fa; 90 | padding: 20px; 91 | border-radius: 8px; 92 | margin-top: 30px; 93 | border-top: 1px solid #e1e8ed; 94 | text-align: center; 95 | } 96 | 97 | .footer-links { 98 | display: flex; 99 | justify-content: center; 100 | gap: 20px; 101 | margin-bottom: 12px; 102 | flex-wrap: wrap; 103 | } 104 | 105 | .footer-links a { 106 | color: #1976d2; 107 | text-decoration: none; 108 | font-weight: 500; 109 | padding: 8px 16px; 110 | border-radius: 6px; 111 | background-color: white; 112 | border: 1px solid #e9ecef; 113 | transition: all 0.3s ease; 114 | } 115 | 116 | .footer-links a:hover { 117 | background-color: #1976d2; 118 | color: white; 119 | transform: translateY(-2px); 120 | box-shadow: 0 2px 8px rgba(25, 118, 210, 0.3); 121 | } 122 | 123 | .footer-note { 124 | margin: 0; 125 | color: #666; 126 | font-size: 0.9em; 127 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gwitter 5 | 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 54 | 55 |
56 | 57 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/AuthWindow.tsx: -------------------------------------------------------------------------------- 1 | import { keyframes } from '@emotion/react'; 2 | import styled from '@emotion/styled'; 3 | import { useEffect } from 'react'; 4 | import { useTranslation } from 'react-i18next'; 5 | import { parseUrl } from './utils'; 6 | import { getAccessToken } from './utils/request'; 7 | 8 | const Container = styled.div` 9 | box-sizing: border-box; 10 | * { 11 | box-sizing: border-box; 12 | } 13 | `; 14 | 15 | const InitingWrapper = styled.div` 16 | padding: 1.25em 0; 17 | text-align: center; 18 | `; 19 | 20 | const InitingText = styled.p` 21 | margin: 0.5em auto; 22 | color: #999; 23 | `; 24 | 25 | const squareAnimation = keyframes` 26 | 0% { 27 | transform: rotate(0deg); 28 | } 29 | 25% { 30 | transform: rotate(180deg); 31 | } 32 | 50% { 33 | transform: rotate(180deg); 34 | } 35 | 75% { 36 | transform: rotate(360deg); 37 | } 38 | 100% { 39 | transform: rotate(360deg); 40 | } 41 | `; 42 | 43 | const loaderInnerAnimation = keyframes` 44 | 0% { 45 | height: 0%; 46 | } 47 | 25% { 48 | height: 0%; 49 | } 50 | 50% { 51 | height: 100%; 52 | } 53 | 75% { 54 | height: 100%; 55 | } 56 | 100% { 57 | height: 0%; 58 | } 59 | `; 60 | 61 | const SquareLoader = styled.span` 62 | display: inline-block; 63 | width: 2em; 64 | height: 2em; 65 | position: relative; 66 | border: 4px solid #ccc; 67 | border-radius: 10%; 68 | box-shadow: inset 0px 0px 20px 20px #ebebeb33; 69 | animation: ${squareAnimation} 2s infinite ease; 70 | `; 71 | 72 | const SquareInner = styled.span` 73 | vertical-align: top; 74 | display: inline-block; 75 | width: 100% !important; 76 | background-color: #ccc; 77 | box-shadow: 0 0 5px 0px #ccc; 78 | animation: ${loaderInnerAnimation} 2s infinite ease-in; 79 | `; 80 | 81 | const AuthWindow = () => { 82 | const { t } = useTranslation(); 83 | 84 | useEffect(() => { 85 | const code = parseUrl().code; 86 | if (code) { 87 | getAccessToken(code) 88 | .then((res) => { 89 | window.opener.postMessage( 90 | JSON.stringify({ 91 | result: res, 92 | }), 93 | window.opener.location, 94 | ); 95 | }) 96 | .catch((err) => { 97 | window.opener.postMessage( 98 | JSON.stringify({ 99 | error: err.message, 100 | }), 101 | window.opener.location, 102 | ); 103 | console.error(err); 104 | }); 105 | } 106 | }, []); 107 | 108 | return ( 109 | 110 | 111 | 112 | 113 | 114 | {t('auth.authorizing')} 115 | 116 | 117 | ); 118 | }; 119 | 120 | export default AuthWindow; 121 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Gwitter 5 | 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 54 | 55 |
56 | 57 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/components/common/Spotlight.tsx: -------------------------------------------------------------------------------- 1 | import { motion, SpringOptions, useSpring, useTransform } from 'framer-motion'; 2 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 3 | 4 | type SpotlightProps = { 5 | className?: string; 6 | size?: number; 7 | springOptions?: SpringOptions; 8 | }; 9 | 10 | export const Spotlight = ({ 11 | className, 12 | size = 720, 13 | springOptions = { bounce: 0 }, 14 | }: React.PropsWithChildren) => { 15 | const containerRef = useRef(null); 16 | const [isHovered, setIsHovered] = useState(false); 17 | const [parentElement, setParentElement] = useState(null); 18 | 19 | const mouseX = useSpring(0, springOptions); 20 | const mouseY = useSpring(0, springOptions); 21 | 22 | const spotlightLeft = useTransform(mouseX, (x) => `${x - size / 2}px`); 23 | const spotlightTop = useTransform(mouseY, (y) => `${y - size / 2}px`); 24 | 25 | useEffect(() => { 26 | if (containerRef.current) { 27 | const parent = containerRef.current.parentElement; 28 | if (parent) { 29 | const parentStyle = window.getComputedStyle(parent); 30 | if (parentStyle.position === 'static') { 31 | parent.style.position = 'relative'; 32 | } 33 | parent.style.overflow = 'hidden'; 34 | setParentElement(parent); 35 | } 36 | } 37 | }, []); 38 | 39 | const handleMouseMove = useCallback( 40 | (event: MouseEvent) => { 41 | if (!parentElement) return; 42 | const { left, top } = parentElement.getBoundingClientRect(); 43 | mouseX.set(event.clientX - left); 44 | mouseY.set(event.clientY - top); 45 | }, 46 | [mouseX, mouseY, parentElement], 47 | ); 48 | 49 | useEffect(() => { 50 | if (!parentElement) return; 51 | 52 | parentElement.addEventListener('mousemove', handleMouseMove); 53 | parentElement.addEventListener('mouseenter', () => setIsHovered(true)); 54 | parentElement.addEventListener('mouseleave', () => setIsHovered(false)); 55 | 56 | return () => { 57 | parentElement.removeEventListener('mousemove', handleMouseMove); 58 | try { 59 | parentElement.removeEventListener('mouseenter', () => 60 | setIsHovered(true), 61 | ); 62 | parentElement.removeEventListener('mouseleave', () => 63 | setIsHovered(false), 64 | ); 65 | } catch (e) { 66 | console.warn('Could not remove event listeners from parentElement', e); 67 | } 68 | }; 69 | }, [parentElement, handleMouseMove]); 70 | 71 | const spotlightStyle: React.CSSProperties = { 72 | width: size, 73 | height: size, 74 | left: spotlightLeft.get(), 75 | top: spotlightTop.get(), 76 | position: 'absolute', 77 | pointerEvents: 'none', 78 | borderRadius: '9999px', 79 | backgroundImage: 80 | 'radial-gradient(circle at center, rgba(255, 255, 255, 0.95), rgba(244, 244, 245, 0.8), rgba(228, 228, 231, 0.4), rgba(200, 200, 200, 0.1), transparent 70%)', 81 | filter: 'blur(0.5em)', 82 | // backgroundColor: 'rgba(244, 244, 245, 0.8)', 83 | opacity: isHovered ? 0.9 : 0, 84 | transition: 'opacity 0.15s ease-in-out', 85 | }; 86 | 87 | return ( 88 | 97 | ); 98 | }; 99 | -------------------------------------------------------------------------------- /src/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | ReactNode, 4 | useContext, 5 | useEffect, 6 | useState, 7 | } from 'react'; 8 | import config from '../config'; 9 | import { queryStringify, windowOpen } from '../utils'; 10 | import { getUserInfo } from '../utils/request'; 11 | 12 | interface AuthContextType { 13 | isAuthenticated: boolean; 14 | user: { login: string; avatarUrl: string } | null; 15 | token: string | null; 16 | isLoading: boolean; 17 | login: () => void; 18 | logout: () => void; 19 | } 20 | 21 | const AuthContext = createContext(undefined); 22 | 23 | export const useAuth = () => { 24 | const context = useContext(AuthContext); 25 | if (context === undefined) { 26 | throw new Error('useAuth must be used within an AuthProvider'); 27 | } 28 | return context; 29 | }; 30 | 31 | export const AuthProvider = ({ children }: { children: ReactNode }) => { 32 | const [isAuthenticated, setIsAuthenticated] = useState(false); 33 | const [user, setUser] = useState<{ login: string; avatarUrl: string } | null>( 34 | null, 35 | ); 36 | const [token, setToken] = useState(null); 37 | const [isLoading, setIsLoading] = useState(true); 38 | 39 | useEffect(() => { 40 | const storedToken = localStorage.getItem('github_token'); 41 | const storedUser = localStorage.getItem('github_user'); 42 | 43 | if (storedToken && storedUser) { 44 | setToken(storedToken); 45 | setUser(JSON.parse(storedUser)); 46 | setIsAuthenticated(true); 47 | } 48 | setIsLoading(false); 49 | }, []); 50 | 51 | const handleAuthCallback = async (code: string) => { 52 | setIsLoading(true); 53 | try { 54 | const response = await getUserInfo(code); 55 | const user = { 56 | login: response.login, 57 | avatarUrl: response.avatar_url, 58 | }; 59 | 60 | setToken(code); 61 | setUser(user); 62 | setIsAuthenticated(true); 63 | 64 | localStorage.setItem('github_token', code); 65 | localStorage.setItem('github_user', JSON.stringify(user)); 66 | } catch (error) { 67 | console.error('Auth callback error:', error); 68 | } finally { 69 | setIsLoading(false); 70 | } 71 | }; 72 | 73 | // open window,点击授权,重定向到 auth window,请求 proxy 获取token 74 | const login = () => { 75 | const githubOauthUrl = 'https://github.com/login/oauth/authorize'; 76 | const query = { 77 | client_id: config.request.clientID, 78 | redirect_uri: window.location.href, 79 | scope: 'public_repo', 80 | }; 81 | const loginLink = `${githubOauthUrl}?${queryStringify(query)}`; 82 | setIsLoading(true); 83 | windowOpen(loginLink) 84 | .then((token: unknown) => { 85 | handleAuthCallback(token as string); 86 | }) 87 | .catch((error) => { 88 | console.error('Login error:', error); 89 | setIsLoading(false); 90 | }); 91 | }; 92 | 93 | const logout = () => { 94 | setToken(null); 95 | setUser(null); 96 | setIsAuthenticated(false); 97 | setIsLoading(false); 98 | localStorage.removeItem('github_token'); 99 | localStorage.removeItem('github_user'); 100 | }; 101 | 102 | const value = { 103 | isAuthenticated, 104 | user, 105 | token, 106 | isLoading, 107 | login, 108 | logout, 109 | }; 110 | 111 | return {children}; 112 | }; 113 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import json from '@rollup/plugin-json'; 4 | import resolve from '@rollup/plugin-node-resolve'; 5 | import replace from '@rollup/plugin-replace'; 6 | import terser from '@rollup/plugin-terser'; 7 | import typescript from '@rollup/plugin-typescript'; 8 | import postcss from 'rollup-plugin-postcss'; 9 | 10 | const external = ['react', 'react-dom', 'react-dom/client']; 11 | const NODE_ENV = process.env.NODE_ENV || 'production'; 12 | 13 | const commonPlugins = [ 14 | replace({ 15 | 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), 16 | preventAssignment: true, 17 | }), 18 | resolve({ 19 | browser: true, 20 | preferBuiltins: false, 21 | }), 22 | commonjs(), 23 | json(), 24 | postcss({ 25 | extract: true, 26 | minimize: true, 27 | modules: false, 28 | }), 29 | ]; 30 | 31 | export default [ 32 | // UMD build 33 | { 34 | input: 'src/gwitter.ts', 35 | output: { 36 | file: 'dist/gwitter.min.js', 37 | format: 'umd', 38 | name: 'gwitter', 39 | sourcemap: false, 40 | globals: { 41 | react: 'React', 42 | 'react-dom': 'ReactDOM', 43 | 'react-dom/client': 'ReactDOM', 44 | }, 45 | }, 46 | external, 47 | plugins: [ 48 | ...commonPlugins, 49 | typescript({ 50 | tsconfig: './tsconfig.json', 51 | declaration: false, 52 | declarationMap: false, 53 | outDir: 'dist', 54 | exclude: ['**/*.test.ts', '**/*.test.tsx', 'dist/**/*', 'src/lib/**/*'], 55 | }), 56 | terser(), 57 | ], 58 | }, 59 | // ESM build 60 | { 61 | input: 'src/gwitter.ts', 62 | output: { 63 | file: 'dist/gwitter.esm.js', 64 | format: 'es', 65 | sourcemap: false, 66 | }, 67 | external, 68 | plugins: [ 69 | ...commonPlugins, 70 | typescript({ 71 | tsconfig: './tsconfig.json', 72 | declaration: false, 73 | declarationMap: false, 74 | outDir: 'dist', 75 | exclude: ['**/*.test.ts', '**/*.test.tsx', 'dist/**/*', 'src/lib/**/*'], 76 | }), 77 | terser(), 78 | ], 79 | }, 80 | // Types build 81 | { 82 | input: 'src/gwitter.ts', 83 | output: { 84 | file: 'dist/gwitter.d.ts', 85 | format: 'es', 86 | }, 87 | external: [ 88 | ...external, 89 | '@emotion/react', 90 | '@emotion/styled', 91 | 'react-i18next', 92 | 'i18next', 93 | 'i18next-browser-languagedetector', 94 | 'framer-motion', 95 | 'date-fns', 96 | 'date-fns/locale', 97 | 'axios', 98 | 'balloons-js', 99 | '@number-flow/react', 100 | ], 101 | plugins: [ 102 | replace({ 103 | 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), 104 | preventAssignment: true, 105 | }), 106 | resolve({ 107 | browser: true, 108 | preferBuiltins: false, 109 | }), 110 | commonjs(), 111 | json(), 112 | postcss({ 113 | extract: false, 114 | minimize: false, 115 | modules: false, 116 | }), 117 | typescript({ 118 | tsconfig: './tsconfig.json', 119 | declaration: true, 120 | declarationMap: false, 121 | emitDeclarationOnly: true, 122 | outDir: 'dist', 123 | exclude: ['**/*.test.ts', '**/*.test.tsx', 'dist/**/*', 'src/lib/**/*'], 124 | }), 125 | ], 126 | }, 127 | ]; 128 | -------------------------------------------------------------------------------- /demo/npm-demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import gwitter from 'gwitter'; 2 | import 'gwitter/dist/gwitter.min.css'; 3 | import { useCallback, useEffect, useState } from 'react'; 4 | import './App.css'; 5 | import { GwitterConfig, config } from './config/gwitter.config'; 6 | 7 | function App() { 8 | const [currentConfig] = useState(config); 9 | 10 | const initializeGwitter = useCallback((config: GwitterConfig) => { 11 | setTimeout(() => { 12 | try { 13 | const container = document.getElementById('gwitter-container'); 14 | if (container) { 15 | container.innerHTML = ''; 16 | } 17 | 18 | gwitter({ 19 | container: document.getElementById('gwitter-container'), 20 | config, 21 | }); 22 | } catch (error) { 23 | console.error('Failed to initialize Gwitter:', error); 24 | const container = document.getElementById('gwitter-container'); 25 | if (container) { 26 | container.innerHTML = ` 27 |
28 |

⚠️ Configuration Error

29 |

Please check your GitHub configuration and try again.

30 |

Error: ${ 31 | error instanceof Error ? error.message : 'Unknown error' 32 | }

33 |
34 | `; 35 | } 36 | } 37 | }, 0); 38 | }, []); 39 | 40 | useEffect(() => { 41 | initializeGwitter(currentConfig); 42 | }, []); 43 | 44 | return ( 45 |
46 |
47 |

🐦 Gwitter NPM Demo

48 |

49 | This is a demonstration of Gwitter using NPM installation in a React 50 | project with TypeScript. 51 |

52 |

53 | Try different configuration presets below, then update the GitHub 54 | credentials in 55 | src/config/gwitter.config.ts with your actual repository 56 | details. 57 |

58 |
59 | 60 |
61 |
62 |

📋 Current Configuration

63 |
64 |
65 | Owner: {currentConfig.request.owner} 66 |
67 |
68 | Repository: {currentConfig.request.repo} 69 |
70 |
71 | Page Size: {currentConfig.request.pageSize} 72 |
73 |
74 | Only Show Owner:{' '} 75 | {currentConfig.app?.onlyShowOwner ? 'Yes' : 'No'} 76 |
77 |
78 | Enable About:{' '} 79 | {currentConfig.app?.enableAbout ? 'Yes' : 'No'} 80 |
81 |
82 |
83 | 84 |
85 |
86 | 87 | 98 |
99 | ); 100 | } 101 | 102 | export default App; 103 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Gwitter Demos 2 | 3 | This directory contains demonstration projects showing different ways to use Gwitter in your applications. 4 | 5 | ## Available Demos 6 | 7 | ### 🔧 [NPM Demo](./npm-demo/) 8 | **Best for: Modern React applications with build tools** 9 | 10 | - Full React + TypeScript + Vite setup 11 | - Demonstrates NPM package installation 12 | - Modern development workflow 13 | - Perfect for new React projects or existing applications 14 | 15 | **Features:** 16 | - ✅ Hot reload development 17 | - ✅ TypeScript support 18 | - ✅ Modern build tools (Vite) 19 | - ✅ Component-based architecture 20 | - ✅ Production optimization 21 | 22 | [→ View NPM Demo](./npm-demo/) 23 | 24 | ### 🌐 [UMD Demo](./umd-demo/) 25 | **Best for: Existing websites without build tools** 26 | 27 | - Plain HTML file with script tags 28 | - No build process required 29 | - Works with any existing website 30 | - Perfect for adding Gwitter to blogs, documentation sites, or legacy applications 31 | 32 | **Features:** 33 | - ✅ Zero configuration 34 | - ✅ CDN-based dependencies 35 | - ✅ Copy-paste integration 36 | - ✅ Universal browser support 37 | - ✅ Instant setup 38 | 39 | [→ View UMD Demo](./umd-demo/) 40 | 41 | ## Which Demo Should I Use? 42 | 43 | ### Choose NPM Demo if you: 44 | - Are building a React application 45 | - Use modern build tools (webpack, Vite, etc.) 46 | - Want TypeScript support 47 | - Prefer component-based development 48 | - Need hot reload and development tools 49 | 50 | ### Choose UMD Demo if you: 51 | - Have an existing website without build tools 52 | - Want to add Gwitter to a static site 53 | - Prefer simple HTML/CSS/JS setup 54 | - Need quick integration 55 | - Want to avoid complex toolchains 56 | 57 | ## Quick Comparison 58 | 59 | | Feature | NPM Demo | UMD Demo | 60 | |---------|----------|----------| 61 | | **Setup Complexity** | Medium | Low | 62 | | **Build Tools Required** | Yes (Vite) | No | 63 | | **TypeScript Support** | ✅ Yes | ❌ No | 64 | | **Hot Reload** | ✅ Yes | ❌ No | 65 | | **Bundle Size Control** | ✅ Yes | ❌ No | 66 | | **Integration Ease** | Medium | High | 67 | | **Browser Compatibility** | Modern | Universal | 68 | | **Production Optimization** | ✅ Yes | Limited | 69 | 70 | ## Getting Started 71 | 72 | 1. **Choose your preferred demo** based on your project needs 73 | 2. **Follow the setup instructions** in the respective README files 74 | 3. **Configure GitHub settings** with your repository details 75 | 4. **Customize** the appearance and behavior to match your needs 76 | 77 | ## Prerequisites for Both Demos 78 | 79 | Before using either demo, you'll need to set up: 80 | 81 | 1. **GitHub Repository**: Create a repository for your Issues/content 82 | 2. **GitHub Personal Access Token**: Generate a token with appropriate permissions 83 | 3. **GitHub OAuth App**: Create an OAuth application for authentication 84 | 85 | See the [main Gwitter documentation](../README.md) for detailed setup instructions. 86 | 87 | ## Demo Structure 88 | 89 | ``` 90 | demo/ 91 | ├── npm-demo/ # React + NPM demonstration 92 | │ ├── src/ 93 | │ │ ├── App.tsx # Main React component 94 | │ │ ├── App.css # Component styles 95 | │ │ ├── main.tsx # Application entry point 96 | │ │ └── index.css # Global styles 97 | │ ├── index.html # HTML template 98 | │ ├── package.json # Dependencies and scripts 99 | │ ├── vite.config.ts # Build configuration 100 | │ └── README.md # NPM demo documentation 101 | ├── umd-demo/ # UMD/Browser demonstration 102 | │ ├── index.html # Complete HTML application 103 | │ └── README.md # UMD demo documentation 104 | └── README.md # This file 105 | ``` 106 | 107 | ## Support 108 | 109 | If you encounter issues with either demo: 110 | 111 | 1. Check the specific demo's README file for troubleshooting 112 | 2. Verify your GitHub configuration is correct 113 | 3. Ensure you have the required permissions and tokens 114 | 4. Visit the [main Gwitter repository](https://github.com/SimonAKing/Gwitter) for support 115 | 116 | ## Contributing 117 | 118 | Found an issue with the demos or have suggestions for improvement? 119 | 120 | 1. Check existing issues in the [main repository](https://github.com/SimonAKing/Gwitter/issues) 121 | 2. Create a new issue with details about the demo and the problem 122 | 3. Submit a pull request with your improvements 123 | 124 | ## Learn More 125 | 126 | - [Gwitter Main Documentation](../README.md) 127 | - [Gwitter GitHub Repository](https://github.com/SimonAKing/Gwitter) 128 | - [GitHub Issues API Documentation](https://docs.github.com/en/rest/issues) 129 | - [GitHub OAuth Apps Guide](https://docs.github.com/en/developers/apps/building-oauth-apps) -------------------------------------------------------------------------------- /src/components/CommentInput.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { useRef, useState } from 'react'; 3 | import { useTranslation } from 'react-i18next'; 4 | 5 | interface CommentInputProps { 6 | onSubmit: (content: string) => Promise; 7 | onCancel?: () => void; 8 | initialValue?: string; 9 | placeholder?: string; 10 | submitText?: string; 11 | showCancel?: boolean; 12 | isExpanded?: boolean; 13 | } 14 | 15 | const InputContainer = styled.div<{ $isExpanded?: boolean }>` 16 | background: white; 17 | border-radius: 12px; 18 | border: 1px solid #e1e8ed; 19 | padding: 12px; 20 | transition: 21 | max-height 0.25s cubic-bezier(0.4, 0, 0.2, 1), 22 | border-color 0.2s ease; 23 | overflow: hidden; 24 | max-height: ${(props) => (props.$isExpanded ? '500px' : '80px')}; 25 | will-change: max-height; 26 | contain: layout; 27 | 28 | &:focus-within { 29 | border-color: #1d9bf0; 30 | } 31 | `; 32 | 33 | const TextArea = styled.textarea<{ $isExpanded?: boolean }>` 34 | width: 100%; 35 | min-height: ${(props) => (props.$isExpanded ? '60px' : '40px')}; 36 | padding: 0; 37 | border: none; 38 | font-size: 14px; 39 | line-height: 1.3125; 40 | resize: vertical; 41 | background: transparent; 42 | color: #0f1419; 43 | transition: min-height 0.25s cubic-bezier(0.4, 0, 0.2, 1); 44 | will-change: min-height; 45 | 46 | &:focus { 47 | outline: none; 48 | } 49 | 50 | &::placeholder { 51 | color: #536471; 52 | } 53 | `; 54 | 55 | const ButtonContainer = styled.div<{ $isExpanded?: boolean }>` 56 | display: flex; 57 | gap: 8px; 58 | justify-content: flex-end; 59 | margin-top: 12px; 60 | padding-top: 12px; 61 | border-top: 1px solid #e1e8ed; 62 | opacity: ${(props) => (props.$isExpanded ? 1 : 0)}; 63 | transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1); 64 | pointer-events: ${(props) => (props.$isExpanded ? 'auto' : 'none')}; 65 | will-change: opacity; 66 | `; 67 | 68 | const Button = styled.button<{ variant?: 'primary' | 'secondary' }>` 69 | padding: 4px 12px; 70 | border-radius: 16px; 71 | font-size: 14px; 72 | font-weight: 600; 73 | cursor: pointer; 74 | transition: 75 | background-color 0.2s ease, 76 | border-color 0.2s ease; 77 | border: none; 78 | min-width: 70px; 79 | height: 32px; 80 | display: flex; 81 | align-items: center; 82 | justify-content: center; 83 | 84 | ${(props) => 85 | props.variant === 'primary' 86 | ? ` 87 | background: #1d9bf0; 88 | color: white; 89 | 90 | &:hover:not(:disabled) { 91 | background: #1a8cd8; 92 | } 93 | 94 | &:disabled { 95 | background: #8ecdf8; 96 | cursor: not-allowed; 97 | } 98 | ` 99 | : ` 100 | background: transparent; 101 | color: #0f1419; 102 | border: 1px solid #cfd9de; 103 | 104 | &:hover { 105 | background: #f7f9fa; 106 | border-color: #8b98a5; 107 | } 108 | `} 109 | `; 110 | 111 | const CommentInput: React.FC = ({ 112 | onSubmit, 113 | onCancel, 114 | initialValue = '', 115 | placeholder, 116 | submitText, 117 | showCancel = false, 118 | isExpanded = false, 119 | }) => { 120 | const { t } = useTranslation(); 121 | const [content, setContent] = useState(initialValue); 122 | const [isSubmitting, setIsSubmitting] = useState(false); 123 | const [isFocused, setIsFocused] = useState(false); 124 | const textAreaRef = useRef(null); 125 | 126 | const handleSubmit = async () => { 127 | if (!content.trim() || isSubmitting) return; 128 | 129 | setIsSubmitting(true); 130 | try { 131 | await onSubmit(content.trim()); 132 | setContent(''); 133 | } catch (error) { 134 | console.error('Failed to submit comment:', error); 135 | } finally { 136 | setIsSubmitting(false); 137 | } 138 | }; 139 | 140 | const handleCancel = () => { 141 | setContent(initialValue); 142 | setIsFocused(false); 143 | onCancel?.(); 144 | }; 145 | 146 | const handleFocus = () => { 147 | setIsFocused(true); 148 | }; 149 | 150 | const handleBlur = () => { 151 | // 延迟失焦,避免点击按钮时立即收起 152 | setTimeout(() => { 153 | if (!content.trim()) { 154 | setIsFocused(false); 155 | } 156 | }, 150); 157 | }; 158 | 159 | const shouldExpand = isExpanded || isFocused || content.trim().length > 0; 160 | 161 | return ( 162 | 163 |