├── .gitignore
├── frontend
├── package.json.md5
├── .prettierignore
├── src
│ ├── vite-env.d.ts
│ ├── tailwind.css
│ ├── assets
│ │ ├── images
│ │ │ └── logo-universal.png
│ │ └── fonts
│ │ │ ├── nunito-v16-latin-regular.woff2
│ │ │ └── OFL.txt
│ ├── main.tsx
│ ├── css
│ │ └── mixins.scss
│ ├── style.css
│ ├── components
│ │ ├── GitInstallSidebar.tsx
│ │ ├── ContributionCalendar.module.scss
│ │ ├── RemoteRepoModal.tsx
│ │ ├── CharacterSelector.tsx
│ │ ├── GitPathSettings.tsx
│ │ ├── LoginModal.tsx
│ │ └── CalendarControls.tsx
│ ├── App.tsx
│ ├── App.css
│ ├── i18n.tsx
│ └── data
│ │ └── characterPatterns.ts
├── commitlint.config.js
├── postcss.config.cjs
├── tailwind.config.js
├── .prettierrc.json
├── tsconfig.node.json
├── vite.config.ts
├── index.html
├── wailsjs
│ ├── runtime
│ │ ├── package.json
│ │ ├── runtime.js
│ │ └── runtime.d.ts
│ └── go
│ │ ├── main
│ │ ├── App.d.ts
│ │ └── App.js
│ │ └── models.ts
├── tsconfig.json
├── eslint.config.js
└── package.json
├── docs
├── images
│ ├── app.png
│ ├── cat.png
│ ├── app_zh.png
│ ├── appnew.png
│ ├── cailg.png
│ ├── darkcat.jpg
│ ├── darkhw.png
│ ├── token1.png
│ ├── token2.png
│ ├── token3.png
│ ├── token4.png
│ ├── appnew_en.png
│ ├── darkandroid.png
│ └── privatesetting.png
├── githubtoken.md
└── githubtoken_en.md
├── .husky
├── pre-commit
└── commit-msg
├── cmd_nonwindows.go
├── cmd_windows.go
├── wails.json
├── main.go
├── open_directory.go
├── LICENSE
├── go.mod
├── README_zh.md
├── README.md
├── go.sum
├── .github
└── workflows
│ └── build.yml
└── app.go
/.gitignore:
--------------------------------------------------------------------------------
1 | build/bin
2 | node_modules
3 | frontend/dist
--------------------------------------------------------------------------------
/frontend/package.json.md5:
--------------------------------------------------------------------------------
1 | cf7eeb812cadce6dc9108a9563da0e8c
--------------------------------------------------------------------------------
/frontend/.prettierignore:
--------------------------------------------------------------------------------
1 | dist/
2 | wailsjs/
3 | package-lock.json
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/docs/images/app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/app.png
--------------------------------------------------------------------------------
/docs/images/cat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/cat.png
--------------------------------------------------------------------------------
/docs/images/app_zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/app_zh.png
--------------------------------------------------------------------------------
/docs/images/appnew.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/appnew.png
--------------------------------------------------------------------------------
/docs/images/cailg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/cailg.png
--------------------------------------------------------------------------------
/docs/images/darkcat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/darkcat.jpg
--------------------------------------------------------------------------------
/docs/images/darkhw.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/darkhw.png
--------------------------------------------------------------------------------
/docs/images/token1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/token1.png
--------------------------------------------------------------------------------
/docs/images/token2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/token2.png
--------------------------------------------------------------------------------
/docs/images/token3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/token3.png
--------------------------------------------------------------------------------
/docs/images/token4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/token4.png
--------------------------------------------------------------------------------
/frontend/src/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/docs/images/appnew_en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/appnew_en.png
--------------------------------------------------------------------------------
/docs/images/darkandroid.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/darkandroid.png
--------------------------------------------------------------------------------
/docs/images/privatesetting.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/docs/images/privatesetting.png
--------------------------------------------------------------------------------
/frontend/commitlint.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | extends: ['@commitlint/config-conventional'],
3 | };
4 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | cd "$(dirname "$0")/../frontend"
5 | npx --no-install lint-staged
6 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | cd "$(dirname "$0")/../frontend"
5 | npx --no-install commitlint --edit "$1"
6 |
--------------------------------------------------------------------------------
/frontend/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/src/assets/images/logo-universal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/frontend/src/assets/images/logo-universal.png
--------------------------------------------------------------------------------
/cmd_nonwindows.go:
--------------------------------------------------------------------------------
1 | //go:build !windows
2 |
3 | package main
4 |
5 | import "os/exec"
6 |
7 | func configureCommand(cmd *exec.Cmd, hideWindow bool) {}
8 |
--------------------------------------------------------------------------------
/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zmrlft/GreenWall/HEAD/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./src/**/*.{js,jsx,ts,tsx}'],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/frontend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "jsxSingleQuote": false,
4 | "semi": true,
5 | "trailingComma": "es5",
6 | "printWidth": 100,
7 | "tabWidth": 2,
8 | "endOfLine": "lf"
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 | import eslint from 'vite-plugin-eslint';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react(), eslint()],
8 | });
9 |
--------------------------------------------------------------------------------
/cmd_windows.go:
--------------------------------------------------------------------------------
1 | //go:build windows
2 |
3 | package main
4 |
5 | import (
6 | "os/exec"
7 | "syscall"
8 | )
9 |
10 | // configureCommand applies platform specific process settings.
11 | func configureCommand(cmd *exec.Cmd, hideWindow bool) {
12 | cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: hideWindow}
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | green-wall
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 | import './style.css';
4 | import './tailwind.css';
5 | import App from './App';
6 |
7 | const container = document.getElementById('root');
8 |
9 | const root = createRoot(container!);
10 |
11 | root.render(
12 |
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/wails.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://wails.io/schemas/config.v2.json",
3 | "name": "green-wall",
4 | "outputfilename": "green-wall",
5 | "frontend:install": "npm install",
6 | "frontend:build": "npm run build",
7 | "frontend:dev:watcher": "npm run dev",
8 | "frontend:dev:serverUrl": "auto",
9 | "author": {
10 | "name": "zmrlft",
11 | "email": "2643895326@qq.com"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/css/mixins.scss:
--------------------------------------------------------------------------------
1 | @mixin mobile-layout {
2 | @media screen and (max-width: 768px) {
3 | @content;
4 | }
5 | }
6 |
7 | @mixin pc-layout {
8 | @media screen and (min-width: 768px) {
9 | @content;
10 | }
11 | }
12 |
13 | @mixin tablet-layout {
14 | @media screen and (max-width: 1200px) {
15 | @content;
16 | }
17 | }
18 |
19 | /* 手机屏下的文字区域左右边距 */
20 | $margin-mobile: 12px;
21 |
22 | /* 限制最大内容宽度,人的视角有限,文字内容太长不好阅读。*/
23 | $max-content: 900px;
24 |
--------------------------------------------------------------------------------
/docs/githubtoken.md:
--------------------------------------------------------------------------------
1 | # 如何获取你的GitHub Personal Access Token (classic)
2 |
3 | > English: [How to get your PAT](githubtoken_en.md)
4 |
5 | 打开:https://github.com/settings/tokens/new
6 | 按下图设置, Note随意命名,Expiration选择No Expiration,以免过期。给repo和user权限。
7 | 
8 | 
9 |
10 | 点击Generate token按钮生成
11 | 
12 | 复制Token到登录页面中的PAT输入框,点击登录。
13 | 注意:PAT 正是 Personal Access Token 的缩写,保管好PAT 请不要告诉任何人!
14 | 
15 |
--------------------------------------------------------------------------------
/frontend/wailsjs/runtime/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@wailsapp/runtime",
3 | "version": "2.0.0",
4 | "description": "Wails Javascript runtime library",
5 | "main": "runtime.js",
6 | "types": "runtime.d.ts",
7 | "scripts": {
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/wailsapp/wails.git"
12 | },
13 | "keywords": [
14 | "Wails",
15 | "Javascript",
16 | "Go"
17 | ],
18 | "author": "Lea Anthony ",
19 | "license": "MIT",
20 | "bugs": {
21 | "url": "https://github.com/wailsapp/wails/issues"
22 | },
23 | "homepage": "https://github.com/wailsapp/wails#readme"
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [
21 | {
22 | "path": "./tsconfig.node.json"
23 | }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/style.css:
--------------------------------------------------------------------------------
1 | html {
2 | background-color: white;
3 | text-align: center;
4 | color: black;
5 | }
6 |
7 | body {
8 | margin: 0;
9 | color: black;
10 | font-family:
11 | 'Nunito',
12 | -apple-system,
13 | BlinkMacSystemFont,
14 | 'Segoe UI',
15 | 'Roboto',
16 | 'Oxygen',
17 | 'Ubuntu',
18 | 'Cantarell',
19 | 'Fira Sans',
20 | 'Droid Sans',
21 | 'Helvetica Neue',
22 | sans-serif;
23 | }
24 |
25 | @font-face {
26 | font-family: 'Nunito';
27 | font-style: normal;
28 | font-weight: 400;
29 | src:
30 | local(''),
31 | url('assets/fonts/nunito-v16-latin-regular.woff2') format('woff2');
32 | }
33 |
34 | #app {
35 | height: 100vh;
36 | text-align: center;
37 | }
38 |
--------------------------------------------------------------------------------
/docs/githubtoken_en.md:
--------------------------------------------------------------------------------
1 | # How to Generate Your GitHub Personal Access Token (classic)
2 |
3 | > 中文: [如何获取 PAT](githubtoken.md)
4 |
5 | 1. Open .
6 | 2. Configure the form as shown below:
7 | - `Note` can be any name you like.
8 | - Set `Expiration` to **No expiration** so the token never expires unexpectedly.
9 | - Select the **repo** and **user** scopes.
10 |
11 | 
12 | 
13 |
14 | 3. Click **Generate token**.
15 |
16 | 
17 |
18 | 4. Copy the generated token into the PAT input field on the login screen and click **Log in**.
19 |
20 | 
21 |
22 | > **Note:** PAT stands for *Personal Access Token*. Store it securely and never share it with anyone.
23 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 |
6 | "github.com/wailsapp/wails/v2"
7 | "github.com/wailsapp/wails/v2/pkg/options"
8 | "github.com/wailsapp/wails/v2/pkg/options/assetserver"
9 | )
10 |
11 | //go:embed all:frontend/dist
12 | var assets embed.FS
13 |
14 | func main() {
15 | // Create an instance of the app structure
16 | app := NewApp()
17 |
18 | // Create application with options
19 | err := wails.Run(&options.App{
20 | Title: "green-wall",
21 | // Give the contribution canvas extra breathing room so wide layouts don't get cramped.
22 | Width: 1290,
23 | Height: 750,
24 | AssetServer: &assetserver.Options{
25 | Assets: assets,
26 | },
27 | BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
28 | OnStartup: app.startup,
29 | Bind: []interface{}{
30 | app,
31 | },
32 | })
33 |
34 | if err != nil {
35 | println("Error:", err.Error())
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/wailsjs/go/main/App.d.ts:
--------------------------------------------------------------------------------
1 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2 | // This file is automatically generated. DO NOT EDIT
3 | import {main} from '../models';
4 |
5 | export function AuthenticateWithToken(arg1:main.GithubAuthRequest):Promise;
6 |
7 | export function CheckGitInstalled():Promise;
8 |
9 | export function ExportContributions(arg1:main.ExportContributionsRequest):Promise;
10 |
11 | export function GenerateRepo(arg1:main.GenerateRepoRequest):Promise;
12 |
13 | export function GetGithubLoginStatus():Promise;
14 |
15 | export function ImportContributions():Promise;
16 |
17 | export function LogoutGithub():Promise;
18 |
19 | export function SetGitPath(arg1:main.SetGitPathRequest):Promise;
20 |
--------------------------------------------------------------------------------
/open_directory.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os/exec"
6 | "path/filepath"
7 | "runtime"
8 | )
9 |
10 | // openDirectory attempts to reveal the given directory in the default file explorer.
11 | func openDirectory(path string) error {
12 | if path == "" {
13 | return fmt.Errorf("no path provided")
14 | }
15 |
16 | absPath, err := filepath.Abs(path)
17 | if err != nil {
18 | return fmt.Errorf("resolve path: %w", err)
19 | }
20 |
21 | var cmd *exec.Cmd
22 | switch runtime.GOOS {
23 | case "windows":
24 | cmd = exec.Command("explorer", absPath)
25 | case "darwin":
26 | cmd = exec.Command("open", absPath)
27 | default:
28 | cmd = exec.Command("xdg-open", absPath)
29 | }
30 |
31 | hideWindow := runtime.GOOS != "windows" // keep Explorer visible on Windows
32 | configureCommand(cmd, hideWindow)
33 |
34 | if err := cmd.Start(); err != nil {
35 | return fmt.Errorf("launch file explorer: %w", err)
36 | }
37 |
38 | return nil
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import eslintConfigPrettier from 'eslint-config-prettier';
3 | import pluginReact from 'eslint-plugin-react';
4 | import reactHooksPlugin from 'eslint-plugin-react-hooks';
5 | import { defineConfig } from 'eslint/config';
6 | import globals from 'globals';
7 | import tseslint from 'typescript-eslint';
8 |
9 | export default defineConfig([
10 | {
11 | ignores: ['wailsjs/**', 'dist/**', 'postcss.config.cjs'],
12 | },
13 | {
14 | files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
15 | plugins: { js },
16 | extends: ['js/recommended'],
17 | languageOptions: { globals: globals.browser },
18 | },
19 | tseslint.configs.recommended,
20 | { settings: { react: { version: 'detect' } } },
21 | pluginReact.configs.flat.recommended,
22 | reactHooksPlugin.configs.flat.recommended,
23 | {
24 | rules: {
25 | semi: 2,
26 | eqeqeq: [2, 'always'],
27 | quotes: [2, 'single'],
28 | },
29 | },
30 | eslintConfigPrettier,
31 | ]);
32 |
--------------------------------------------------------------------------------
/frontend/wailsjs/go/main/App.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
3 | // This file is automatically generated. DO NOT EDIT
4 |
5 | export function AuthenticateWithToken(arg1) {
6 | return window['go']['main']['App']['AuthenticateWithToken'](arg1);
7 | }
8 |
9 | export function CheckGitInstalled() {
10 | return window['go']['main']['App']['CheckGitInstalled']();
11 | }
12 |
13 | export function ExportContributions(arg1) {
14 | return window['go']['main']['App']['ExportContributions'](arg1);
15 | }
16 |
17 | export function GenerateRepo(arg1) {
18 | return window['go']['main']['App']['GenerateRepo'](arg1);
19 | }
20 |
21 | export function GetGithubLoginStatus() {
22 | return window['go']['main']['App']['GetGithubLoginStatus']();
23 | }
24 |
25 | export function ImportContributions() {
26 | return window['go']['main']['App']['ImportContributions']();
27 | }
28 |
29 | export function LogoutGithub() {
30 | return window['go']['main']['App']['LogoutGithub']();
31 | }
32 |
33 | export function SetGitPath(arg1) {
34 | return window['go']['main']['App']['SetGitPath'](arg1);
35 | }
36 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 橡皮膏
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 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview",
10 | "prepare": "git config core.hooksPath .husky",
11 | "lint-staged": "lint-staged --allow-empty",
12 | "lint-staged:js": "eslint --ext .js,.jsx,.ts,.tsx"
13 | },
14 | "lint-staged": {
15 | "**/*.{js,jsx,ts,tsx}": "npm run lint-staged:js",
16 | "**/*.{js,jsx,tsx,ts,less,md,json}": [
17 | "prettier --write"
18 | ]
19 | },
20 | "dependencies": {
21 | "clsx": "^2.1.1",
22 | "react": "^18.2.0",
23 | "react-dom": "^18.2.0"
24 | },
25 | "devDependencies": {
26 | "@commitlint/cli": "^20.1.0",
27 | "@commitlint/config-conventional": "^20.0.0",
28 | "@eslint/js": "^9.38.0",
29 | "@types/react": "^18.0.17",
30 | "@types/react-dom": "^18.0.6",
31 | "@vitejs/plugin-react": "^2.0.1",
32 | "autoprefixer": "^10.4.21",
33 | "eslint": "^9.38.0",
34 | "eslint-config-prettier": "^10.1.8",
35 | "eslint-plugin-react": "^7.37.5",
36 | "eslint-plugin-react-hooks": "^7.0.1",
37 | "globals": "^16.4.0",
38 | "husky": "^9.1.7",
39 | "lint-staged": "^16.2.6",
40 | "prettier": "^3.6.2",
41 | "sass": "^1.93.2",
42 | "tailwindcss": "^3.4.18",
43 | "typescript": "^4.6.4",
44 | "typescript-eslint": "^8.46.2",
45 | "vite": "^3.0.7",
46 | "vite-plugin-eslint": "^1.8.1"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module green-wall
2 |
3 | go 1.24.0
4 |
5 | require github.com/wailsapp/wails/v2 v2.10.2
6 |
7 | require (
8 | github.com/bep/debounce v1.2.1 // indirect
9 | github.com/go-ole/go-ole v1.3.0 // indirect
10 | github.com/godbus/dbus/v5 v5.1.0 // indirect
11 | github.com/google/uuid v1.6.0 // indirect
12 | github.com/gorilla/websocket v1.5.3 // indirect
13 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
14 | github.com/labstack/echo/v4 v4.13.3 // indirect
15 | github.com/labstack/gommon v0.4.2 // indirect
16 | github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
17 | github.com/leaanthony/gosod v1.0.4 // indirect
18 | github.com/leaanthony/slicer v1.6.0 // indirect
19 | github.com/leaanthony/u v1.1.1 // indirect
20 | github.com/mattn/go-colorable v0.1.13 // indirect
21 | github.com/mattn/go-isatty v0.0.20 // indirect
22 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
23 | github.com/pkg/errors v0.9.1 // indirect
24 | github.com/rivo/uniseg v0.4.7 // indirect
25 | github.com/samber/lo v1.49.1 // indirect
26 | github.com/tkrajina/go-reflector v0.5.8 // indirect
27 | github.com/valyala/bytebufferpool v1.0.0 // indirect
28 | github.com/valyala/fasttemplate v1.2.2 // indirect
29 | github.com/wailsapp/go-webview2 v1.0.19 // indirect
30 | github.com/wailsapp/mimetype v1.4.1 // indirect
31 | golang.org/x/crypto v0.45.0 // indirect
32 | golang.org/x/net v0.47.0 // indirect
33 | golang.org/x/sys v0.38.0 // indirect
34 | golang.org/x/text v0.31.0 // indirect
35 | )
36 |
37 | // replace github.com/wailsapp/wails/v2 v2.10.2 => C:\Users\admin\go\pkg\mod
38 |
--------------------------------------------------------------------------------
/README_zh.md:
--------------------------------------------------------------------------------
1 | # README (中文)
2 |
3 | > English: [README (English)](README.md)
4 |
5 | ## 项目里程碑:
6 |
7 | 11月初,本项目获阮一峰大佬的推荐,正式收录于[科技爱好者周刊372期](https://www.ruanyifeng.com/blog/2025/11/weekly-issue-372.html);11月中旬,获知名大V“it咖啡馆”推荐,正式收录于[Github一周热点93期](https://youtu.be/pjQftatKpjc?si=5pMK1bAyFXfp6oyF);12月,以“有趣的项目”身份顺利入选知名开源社区“你好Github”
8 |
9 | ## 如何使用
10 |
11 | 请确保你的电脑已经安装了 git。
12 |
13 | 
14 |
15 | 下载软件,打开后,首先要获取你的PAT来登录github,你可以参考这个:[如何获取你的github访问令牌](docs/githubtoken.md)
16 |
17 | 登录成功左上角会显示你的头像和名字。拖动鼠标在日历上尽情画画,发挥你的艺术才能!画完后点击创建远程仓库,你可以自定义仓库名称和描述,选择仓库是否公开,确认无误后点击生成并且推送,软件会自动在你的GitHub上创建对应的仓库。
18 |
19 | 注意: GitHub 可能需要 5 分钟至两天才会显示你的贡献度图案。你可以把仓库设置为私人仓库,并在贡献统计中允许显示私人仓库的贡献,这样他人看不到仓库内容但可以看到贡献记录。
20 | 
21 |
22 | ### 快速提示
23 |
24 | - 绘画过程中右键可以切换画笔和橡皮擦
25 | - 可以调节画笔的强度
26 | - **复制粘贴功能**:点击"复制模式"按钮进入复制模式,在日历上拖选一块区域后按 `Ctrl+C` 复制,软件会弹出"复制成功"提示。复制后,被选中区域的图案会跟随鼠标移动作为预览,你可以左键点击或按 `Ctrl+V` 粘贴到目标位置,右键取消粘贴预览。按`Ctrl+V`可以快速恢复上次复制的图案
27 |
28 | ### Windows/Linux
29 |
30 | 下载后直接点击运行即可。软件开源,报毒正常
31 |
32 | ### macOS
33 |
34 | 由于本应用暂时未进行签名服务,首次运行时可能会遇到安全限制。按以下步骤解决:
35 |
36 | ```bash
37 | cd 你的green-wall.app存在的目录
38 | sudo xattr -cr ./green-wall.app
39 | sudo xattr -r -d com.apple.quarantine ./green-wall.app
40 | ```
41 |
42 | **提示:** 这些指令并不需要全部执行,从上往下依次尝试,如果某条指令解决了问题就无需继续执行。
43 |
44 | **警告:** 命令执行后不会自动弹出应用界面,需要手动双击应用来启动(命令只是改变了文件属性)。
45 |
46 | ## 效果图
47 |
48 | 
49 | 
50 | 
51 | 
52 | 
53 |
54 | ## 开发指南
55 |
56 | - 环境准备
57 |
58 | 安装 Go 1.23+
59 |
60 | 安装 Node.js (v22+)
61 |
62 | 安装 git
63 |
64 | - 安装依赖工具
65 |
66 | ```
67 | go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2
68 | ```
69 |
70 | - 项目操作
71 |
72 | 克隆仓库并进入目录:
73 |
74 | ```
75 | git clone https://github.com/zmrlft/GreenWall.git
76 | cd GreenWall
77 | ```
78 |
79 | 安装前端依赖:
80 |
81 | ```
82 | cd frontend && npm install
83 | ```
84 |
85 | 启动开发环境
86 |
87 | ```
88 | wails dev
89 | ```
90 |
91 | 构建
92 |
93 | ```
94 | wails build
95 | ```
96 |
97 | 输出路径:build/bin/
98 |
99 | ## 未来的功能
100 |
101 | 我们可能会增加创建自定义语言仓库的功能,例如生成一个 Java 仓库并在你的主页语言占比中统计它。
102 |
103 | ## Star History
104 |
105 | [](https://www.star-history.com/#zmrlft/GreenWall&type=date&legend=top-left)
106 |
107 | ## 免责
108 |
109 | 免责声明:本项目仅用于教育、演示及研究 GitHub 贡献机制,如用于求职造假,所造成后果自负。
110 |
--------------------------------------------------------------------------------
/frontend/src/components/GitInstallSidebar.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useTranslations } from '../i18n';
3 |
4 | interface GitInstallSidebarProps {
5 | onCheckAgain: () => void;
6 | }
7 |
8 | const GitInstallSidebar: React.FC = ({ onCheckAgain }) => {
9 | const { t } = useTranslations();
10 | const [isExpanded, setIsExpanded] = useState(false);
11 |
12 | const isMac = navigator.platform.toLowerCase().includes('mac');
13 | const isLinux =
14 | navigator.platform.toLowerCase().includes('linux') ||
15 | navigator.platform.toLowerCase().includes('x11');
16 |
17 | const getInstructions = () => {
18 | if (isMac) return t('gitInstall.instructions.mac');
19 | if (isLinux) return t('gitInstall.instructions.linux');
20 | return t('gitInstall.instructions.windows');
21 | };
22 |
23 | const getDownloadUrl = () => {
24 | if (isMac) return 'https://git-scm.com/download/mac';
25 | if (isLinux) return 'https://git-scm.com/download/linux';
26 | return 'https://git-scm.com/download/win';
27 | };
28 |
29 | return (
30 |
31 | {/* 展开的侧边栏 */}
32 | {isExpanded && (
33 |
34 |
35 |
{t('gitInstall.title')}
36 |
setIsExpanded(false)}
38 | className="text-gray-500 hover:text-gray-700 transition-colors"
39 | aria-label={t('gitInstall.close')}
40 | >
41 |
42 |
47 |
48 |
49 |
50 |
51 |
73 |
74 | )}
75 |
76 | {/* 提示按钮 */}
77 |
setIsExpanded(!isExpanded)}
79 | className="flex items-center gap-2 rounded-lg bg-yellow-500 px-4 py-2 text-sm font-medium text-black shadow-md transition-all hover:bg-yellow-600"
80 | aria-label={t('gitInstall.notInstalledLabel')}
81 | >
82 |
83 |
89 |
90 | {t('gitInstall.notInstalledLabel')}
91 |
97 |
98 |
99 |
100 |
101 | );
102 | };
103 |
104 | export default GitInstallSidebar;
105 |
--------------------------------------------------------------------------------
/frontend/src/assets/fonts/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
2 |
3 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
4 | This license is copied below, and is also available with a FAQ at:
5 | http://scripts.sil.org/OFL
6 |
7 |
8 | -----------------------------------------------------------
9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10 | -----------------------------------------------------------
11 |
12 | PREAMBLE
13 | The goals of the Open Font License (OFL) are to stimulate worldwide
14 | development of collaborative font projects, to support the font creation
15 | efforts of academic and linguistic communities, and to provide a free and
16 | open framework in which fonts may be shared and improved in partnership
17 | with others.
18 |
19 | The OFL allows the licensed fonts to be used, studied, modified and
20 | redistributed freely as long as they are not sold by themselves. The
21 | fonts, including any derivative works, can be bundled, embedded,
22 | redistributed and/or sold with any software provided that any reserved
23 | names are not used by derivative works. The fonts and derivatives,
24 | however, cannot be released under any other type of license. The
25 | requirement for fonts to remain under this license does not apply
26 | to any document created using the fonts or their derivatives.
27 |
28 | DEFINITIONS
29 | "Font Software" refers to the set of files released by the Copyright
30 | Holder(s) under this license and clearly marked as such. This may
31 | include source files, build scripts and documentation.
32 |
33 | "Reserved Font Name" refers to any names specified as such after the
34 | copyright statement(s).
35 |
36 | "Original Version" refers to the collection of Font Software components as
37 | distributed by the Copyright Holder(s).
38 |
39 | "Modified Version" refers to any derivative made by adding to, deleting,
40 | or substituting -- in part or in whole -- any of the components of the
41 | Original Version, by changing formats or by porting the Font Software to a
42 | new environment.
43 |
44 | "Author" refers to any designer, engineer, programmer, technical
45 | writer or other person who contributed to the Font Software.
46 |
47 | PERMISSION & CONDITIONS
48 | Permission is hereby granted, free of charge, to any person obtaining
49 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
50 | redistribute, and sell modified and unmodified copies of the Font
51 | Software, subject to the following conditions:
52 |
53 | 1) Neither the Font Software nor any of its individual components,
54 | in Original or Modified Versions, may be sold by itself.
55 |
56 | 2) Original or Modified Versions of the Font Software may be bundled,
57 | redistributed and/or sold with any software, provided that each copy
58 | contains the above copyright notice and this license. These can be
59 | included either as stand-alone text files, human-readable headers or
60 | in the appropriate machine-readable metadata fields within text or
61 | binary files as long as those fields can be easily viewed by the user.
62 |
63 | 3) No Modified Version of the Font Software may use the Reserved Font
64 | Name(s) unless explicit written permission is granted by the corresponding
65 | Copyright Holder. This restriction only applies to the primary font name as
66 | presented to the users.
67 |
68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69 | Software shall not be used to promote, endorse or advertise any
70 | Modified Version, except to acknowledge the contribution(s) of the
71 | Copyright Holder(s) and the Author(s) or with their explicit written
72 | permission.
73 |
74 | 5) The Font Software, modified or unmodified, in part or in whole,
75 | must be distributed entirely under this license, and must not be
76 | distributed under any other license. The requirement for fonts to
77 | remain under this license does not apply to any document created
78 | using the Font Software.
79 |
80 | TERMINATION
81 | This license becomes null and void if any of the above conditions are
82 | not met.
83 |
84 | DISCLAIMER
85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93 | OTHER DEALINGS IN THE FONT SOFTWARE.
94 |
--------------------------------------------------------------------------------
/frontend/src/components/ContributionCalendar.module.scss:
--------------------------------------------------------------------------------
1 | @import '../css/mixins.scss';
2 | .container {
3 | --cell: 14px;
4 | --gap: 4px;
5 | --tile-extra: 2px;
6 |
7 | display: grid;
8 | grid-template-columns: auto repeat(53, var(--cell));
9 | grid-template-rows: auto repeat(7, var(--cell)) auto;
10 | gap: var(--gap);
11 |
12 | width: fit-content;
13 | font-size: 12px;
14 | padding: 0px;
15 |
16 | border-radius: 0px;
17 | margin: 0 auto;
18 | background: #ffffff;
19 | box-shadow: none;
20 |
21 | // 当窗口更大(例如最大化)时,自动放大格子与字体
22 | @media (min-width: 1280px) {
23 | --cell: 14px;
24 | --gap: 4px;
25 | --tile-extra: 2px;
26 | font-size: 14px;
27 | }
28 |
29 | @media (min-width: 1600px) {
30 | --cell: 16px;
31 | --gap: 5px;
32 | --tile-extra: 2px;
33 | font-size: 15px;
34 | }
35 |
36 | // 最大化窗口时进一步放大
37 | &.maximized {
38 | --cell: 20px;
39 | --gap: 6px;
40 | --tile-extra: 2px;
41 | font-size: 16px;
42 | }
43 |
44 | // 作为备用:单独的 maximized 类,确保样式可导出与应用
45 | .maximized {
46 | --cell: 20px;
47 | --gap: 6px;
48 | --tile-extra: 2px;
49 | font-size: 16px;
50 | }
51 |
52 | @include mobile-layout {
53 | display: none; /* 太长手机显示不下 */
54 | }
55 | }
56 | .month {
57 | grid-row: 1/2;
58 | margin-bottom: -3px;
59 | color: #000000;
60 | font-weight: 500;
61 | }
62 |
63 | .week {
64 | grid-row: 3;
65 | grid-column: 1/2;
66 | line-height: var(--cell);
67 | margin-right: 3px;
68 | color: #000000;
69 | font-weight: 500;
70 |
71 | & + .week {
72 | grid-row: 5;
73 | }
74 |
75 | & + .week + .week {
76 | grid-row: 7;
77 | }
78 | }
79 |
80 | .tiles {
81 | grid-column: 2/55;
82 | grid-row: 2/9;
83 |
84 | display: grid;
85 | grid-auto-flow: column;
86 | grid-template-columns: subgrid;
87 | grid-template-rows: subgrid;
88 | }
89 |
90 | .tile {
91 | display: block;
92 | width: calc(var(--cell) + var(--tile-extra));
93 | height: calc(var(--cell) + var(--tile-extra));
94 | border-radius: calc((var(--cell) + var(--tile-extra)) / 4);
95 |
96 | outline: 1px solid rgba(27, 35, 36, 0.06);
97 | outline-offset: -1px;
98 | cursor: pointer;
99 | transition: all 0.15s ease-in-out;
100 | border: 1px solid transparent;
101 |
102 | &[data-level='0'] {
103 | background: #ebedf0;
104 | border-color: rgba(235, 237, 240, 0.3);
105 | }
106 | &[data-level='1'] {
107 | background: #9be9a8;
108 | border-color: rgba(155, 233, 168, 0.3);
109 | }
110 | &[data-level='2'] {
111 | background: #40c463;
112 | border-color: rgba(64, 196, 99, 0.3);
113 | }
114 | &[data-level='3'] {
115 | background: #30a14e;
116 | border-color: rgba(48, 161, 78, 0.3);
117 | }
118 | &[data-level='4'] {
119 | background: #216e39;
120 | border-color: rgba(33, 110, 57, 0.3);
121 | }
122 |
123 | &[data-future='true'] {
124 | background: #000000 !important;
125 | border-color: #000000 !important;
126 | cursor: not-allowed;
127 | outline: none;
128 | outline-offset: 0;
129 | transform: none;
130 | box-shadow: none;
131 |
132 | &:hover {
133 | outline: none;
134 | transform: none;
135 | box-shadow: none;
136 | }
137 | }
138 |
139 | // Enhanced hover effects
140 | &:hover {
141 | outline: 2px solid #000000;
142 | outline-offset: 1px;
143 | transform: scale(1.2);
144 | box-shadow: none;
145 | z-index: 10;
146 | }
147 |
148 | // 预览状态样式
149 | &.preview {
150 | background: #216e39 !important;
151 | border-color: rgba(33, 110, 57, 0.8) !important;
152 | outline: 2px solid #ff6b35 !important;
153 | outline-offset: 1px;
154 | animation: pulse 1.5s infinite;
155 | }
156 |
157 | // 选择状态样式 - 只加边框,保持原有颜色
158 | &.selection {
159 | outline: 2px solid #2563eb !important;
160 | outline-offset: 1px;
161 | }
162 |
163 | @keyframes pulse {
164 | 0% {
165 | opacity: 1;
166 | }
167 | 50% {
168 | opacity: 0.7;
169 | }
170 | 100% {
171 | opacity: 1;
172 | }
173 | }
174 | }
175 |
176 | .total {
177 | grid-column: 2/30;
178 | margin-top: 4px;
179 | color: #000000 !important;
180 | font-size: 16px;
181 | font-weight: bold;
182 | }
183 |
184 | .legend {
185 | grid-column: 30/53;
186 | margin-top: 4px;
187 |
188 | display: flex;
189 | gap: 6px;
190 | justify-content: right;
191 | align-items: center;
192 | font-size: 11px;
193 | font-weight: 500;
194 | text-transform: uppercase;
195 | letter-spacing: 0.5px;
196 | color: #000000 !important;
197 |
198 | .tile {
199 | cursor: default;
200 | transition: none;
201 |
202 | // Disable hover effects for legend tiles
203 | &:hover {
204 | outline: none;
205 | transform: none;
206 | box-shadow: none;
207 | }
208 | }
209 | }
210 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # README (English)
2 |
3 | > 中文: [README (中文)](README_zh.md)
4 |
5 | ## Project Milestones
6 |
7 | In early November, this project was recommended by YiFeng Ruan(阮一峰), and officially featured in [Tech Enthusiast Weekly Issue 372](https://www.ruanyifeng.com/blog/2025/11/weekly-issue-372.html); in mid-November, it was recommended by the well-known influencer "it咖啡馆" and featured in [GitHub Weekly Hotspots Issue 93](https://youtu.be/pjQftatKpjc?si=5pMK1bAyFXfp6oyF); in December, it was successfully selected as an "interesting project" by the renowned open-source community HelloGitHub
8 |
9 | ## How to use
10 |
11 | Make sure Git is installed on your computer.
12 |
13 | 
14 |
15 | Download the app, open it, and first grab a Personal Access Token (PAT) so you can sign in to GitHub. You can follow this guide: [how to get your PAT](docs/githubtoken_en.md).
16 |
17 | Once you’re logged in you’ll see your avatar and name in the upper-left corner. Drag across the calendar to paint your design. When you’re satisfied, click **Create Remote Repo**. You can edit the repo name and description, choose whether it’s public or private, and then press **Generate & Push** to let the app create and push the repository for you automatically.
18 |
19 | > **Heads-up:** GitHub may take anywhere from 5 minutes to 2 days to show the contributions on your profile. You can keep the repo private and enable “Include private contributions” in your profile settings so others can’t see the repo content but the contribution streak still counts.
20 |
21 | 
22 |
23 | ### Quick Tips
24 |
25 | - Right-click while painting to toggle between the brush and the eraser.
26 | - Use the brush intensity control to switch between different shades of green.
27 | - **Copy and Paste Feature**: Click the "Copy Mode" button to enter copy mode. Drag to select an area on the calendar and press `Ctrl+C` to copy. The app will show a "Copy successful" message. After copying, the selected pattern will follow the mouse as a preview. Left-click or press `Ctrl+V` to paste to the target location, right-click to cancel the paste preview. Press `Ctrl+V` to quickly restore the last copied pattern.
28 |
29 | ### Windows/Linux
30 |
31 | Download and run the application directly.
32 |
33 | ### macOS
34 |
35 | Since this application is not yet signed, you may encounter security restrictions on first launch. Follow these steps to resolve:
36 |
37 | ```bash
38 | cd the-directory-where-green-wall.app-is-located
39 | sudo xattr -cr ./green-wall.app
40 | sudo xattr -r -d com.apple.quarantine ./green-wall.app
41 | ```
42 |
43 | **Tip:** You don't need to execute all of these commands. Try them in order from top to bottom, and stop once one resolves the issue.
44 |
45 | **Warning:** The commands will not automatically launch the application. You need to manually double-click the app to start it (the commands only modify file attributes).
46 |
47 | ## Rendering
48 |
49 | 
50 | 
51 | 
52 | 
53 | 
54 |
55 | ## Development Guide
56 |
57 | - Environmental Preparation
58 |
59 | Install Go 1.23+
60 |
61 | Install Node.js (v22+)
62 |
63 | Install Git
64 |
65 | - Install dependent tools
66 |
67 | ```
68 | go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2
69 | ```
70 |
71 | - Project operation
72 |
73 | Clone the repository and enter the directory:
74 |
75 | ```
76 | git clone https://github.com/zmrlft/GreenWall.git
77 | cd GreenWall
78 | ```
79 |
80 | Install front-end dependencies:
81 |
82 | ```
83 | cd frontend && npm install
84 | ```
85 |
86 | Start the development environment
87 |
88 | ```
89 | wails dev
90 | ```
91 |
92 | Construction
93 |
94 | ```
95 | wails build
96 | ```
97 |
98 | Output path: build/bin/
99 |
100 | ## Future features
101 |
102 | We may add support for creating repositories in custom languages. For example, if you want a Java repository, the tool would generate one and it would be reflected in your GitHub language statistics.
103 |
104 | ## Star History
105 |
106 | [](https://www.star-history.com/#zmrlft/GreenWall&type=date&legend=top-left)
107 |
108 | ## Disclaimer
109 |
110 | This project is provided for educational, demonstration, and research purposes related to GitHub contribution mechanics. Misuse (for example to falsify job applications) is the user's responsibility.
111 |
--------------------------------------------------------------------------------
/frontend/src/components/RemoteRepoModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTranslations } from '../i18n';
3 |
4 | export type RemoteRepoPayload = {
5 | name: string;
6 | description: string;
7 | isPrivate: boolean;
8 | };
9 |
10 | type RemoteRepoModalProps = {
11 | open: boolean;
12 | defaultName: string;
13 | defaultDescription?: string;
14 | defaultPrivate?: boolean;
15 | isSubmitting?: boolean;
16 | onSubmit: (payload: RemoteRepoPayload) => void;
17 | onClose: () => void;
18 | };
19 |
20 | const repoNamePattern = /^[a-zA-Z0-9._-]{1,100}$/;
21 |
22 | const RemoteRepoModal: React.FC = ({
23 | open,
24 | defaultName,
25 | defaultDescription = '',
26 | defaultPrivate = true,
27 | isSubmitting = false,
28 | onSubmit,
29 | onClose,
30 | }) => {
31 | const { dictionary } = useTranslations();
32 | const labels = dictionary.remoteModal;
33 |
34 | const [name, setName] = React.useState(defaultName);
35 | const [description, setDescription] = React.useState(defaultDescription);
36 | const [isPrivate, setIsPrivate] = React.useState(defaultPrivate);
37 | const [error, setError] = React.useState(null);
38 |
39 | React.useEffect(() => {
40 | if (open) {
41 | setName(defaultName);
42 | setDescription(defaultDescription);
43 | setIsPrivate(defaultPrivate);
44 | setError(null);
45 | }
46 | }, [open, defaultName, defaultDescription, defaultPrivate]);
47 |
48 | if (!open) {
49 | return null;
50 | }
51 |
52 | const handleSubmit = (event: React.FormEvent) => {
53 | event.preventDefault();
54 | const trimmedName = name.trim();
55 | if (!trimmedName) {
56 | setError(labels.nameRequired);
57 | return;
58 | }
59 | if (!repoNamePattern.test(trimmedName)) {
60 | setError(labels.nameInvalid);
61 | return;
62 | }
63 | setError(null);
64 | onSubmit({
65 | name: trimmedName,
66 | description: description.trim(),
67 | isPrivate,
68 | });
69 | };
70 |
71 | return (
72 |
73 |
74 |
75 |
76 |
{labels.title}
77 |
{labels.description}
78 |
79 |
85 | ×
86 |
87 |
88 |
89 |
158 |
159 |
160 | );
161 | };
162 |
163 | export default RemoteRepoModal;
164 |
--------------------------------------------------------------------------------
/frontend/src/components/CharacterSelector.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { getPatternsByCategory, type CharacterPattern } from '../data/characterPatterns';
4 | import { useTranslations } from '../i18n';
5 |
6 | type CharacterTab = CharacterPattern['category'];
7 |
8 | type Props = {
9 | onSelect: (char: string) => void;
10 | onClose: () => void;
11 | };
12 |
13 | /**
14 | * 字符选择弹窗组件
15 | * 显示A-Z、a-z、0-9和符号的选择界面,每个字符显示为像素图案预览
16 | */
17 | export const CharacterSelector: React.FC = ({ onSelect, onClose }) => {
18 | const { t } = useTranslations();
19 | const [activeTab, setActiveTab] = React.useState('uppercase');
20 |
21 | // 获取当前标签页的字符图案
22 | const currentPatterns = React.useMemo(() => {
23 | return getPatternsByCategory(activeTab);
24 | }, [activeTab]);
25 |
26 | const handleCharacterClick = (char: string) => {
27 | onSelect(char);
28 | onClose();
29 | };
30 |
31 | return (
32 |
33 |
34 | {/* 标题栏 */}
35 |
36 |
{t('characterSelector.title')}
37 |
42 |
43 |
49 |
50 |
51 |
52 |
53 | {/* 标签页 */}
54 |
55 | setActiveTab('uppercase')}
57 | className={clsx(
58 | 'rounded-t-lg px-6 py-2 text-sm font-medium transition-all',
59 | activeTab === 'uppercase'
60 | ? 'bg-black text-white'
61 | : 'bg-white text-black hover:bg-gray-100'
62 | )}
63 | >
64 | {t('characterSelector.tabUppercase')}
65 |
66 | setActiveTab('lowercase')}
68 | className={clsx(
69 | 'rounded-t-lg px-6 py-2 text-sm font-medium transition-all',
70 | activeTab === 'lowercase'
71 | ? 'bg-black text-white'
72 | : 'bg-white text-black hover:bg-gray-100'
73 | )}
74 | >
75 | {t('characterSelector.tabLowercase')}
76 |
77 | setActiveTab('numbers')}
79 | className={clsx(
80 | 'rounded-t-lg px-6 py-2 text-sm font-medium transition-all',
81 | activeTab === 'numbers'
82 | ? 'bg-black text-white'
83 | : 'bg-white text-black hover:bg-gray-100'
84 | )}
85 | >
86 | {t('characterSelector.tabNumbers')}
87 |
88 | setActiveTab('symbols')}
90 | className={clsx(
91 | 'rounded-t-lg px-6 py-2 text-sm font-medium transition-all',
92 | activeTab === 'symbols'
93 | ? 'bg-black text-white'
94 | : 'bg-white text-black hover:bg-gray-100'
95 | )}
96 | >
97 | {t('characterSelector.tabSymbols')}
98 |
99 |
100 |
101 | {/* 字符网格 */}
102 |
103 |
104 | {currentPatterns.map((pattern) => {
105 | return (
106 |
handleCharacterClick(pattern.id)}
109 | className="group flex flex-col items-center gap-2 rounded-lg border-2 border-gray-200 bg-white p-3 transition-all hover:border-black hover:shadow-lg"
110 | title={`${t('characterSelector.selectCharacter')} ${pattern.name}`}
111 | >
112 | {/* 字符标签 */}
113 | {pattern.name}
114 |
115 | {/* 像素预览 */}
116 |
117 | {pattern.grid.map((row, y) =>
118 | row.map((pixel, x) => (
119 |
131 | ))
132 | )}
133 |
134 |
135 | );
136 | })}
137 |
138 |
139 |
140 |
141 | );
142 | };
143 |
--------------------------------------------------------------------------------
/frontend/src/components/GitPathSettings.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useTranslations } from '../i18n';
3 |
4 | interface GitPathSettingsProps {
5 | onClose: () => void;
6 | onCheckAgain: () => void;
7 | }
8 |
9 | const GitPathSettings: React.FC = ({ onClose, onCheckAgain }) => {
10 | const { t } = useTranslations();
11 | const [customGitPath, setCustomGitPath] = useState('');
12 | const [isSettingPath, setIsSettingPath] = useState(false);
13 | const [setPathResult, setSetPathResult] = useState<{ success: boolean; message: string } | null>(
14 | null
15 | );
16 |
17 | const handleSetGitPath = async () => {
18 | if (!customGitPath.trim()) {
19 | return;
20 | }
21 |
22 | setIsSettingPath(true);
23 | setSetPathResult(null);
24 |
25 | try {
26 | const { SetGitPath } = await import('../../wailsjs/go/main/App');
27 | const result = await SetGitPath({ gitPath: customGitPath });
28 |
29 | setSetPathResult({
30 | success: result.success,
31 | message: result.success
32 | ? t('gitPathSettings.setSuccess')
33 | : t('gitPathSettings.setError', { message: result.message }),
34 | });
35 |
36 | if (result.success) {
37 | // 成功设置后,清空输入框并重新检查git状态
38 | setCustomGitPath('');
39 | setTimeout(() => {
40 | onCheckAgain();
41 | onClose();
42 | }, 500);
43 | }
44 | } catch (error) {
45 | console.error('Failed to set git path:', error);
46 | setSetPathResult({
47 | success: false,
48 | message: t('gitPathSettings.setError', { message: (error as Error).message }),
49 | });
50 | } finally {
51 | setIsSettingPath(false);
52 | }
53 | };
54 |
55 | const handleResetGitPath = async () => {
56 | try {
57 | const { SetGitPath } = await import('../../wailsjs/go/main/App');
58 | const result = await SetGitPath({ gitPath: '' });
59 |
60 | setSetPathResult({
61 | success: result.success,
62 | message: result.success
63 | ? t('gitPathSettings.resetSuccess')
64 | : t('gitPathSettings.resetError', { message: result.message }),
65 | });
66 |
67 | if (result.success) {
68 | setCustomGitPath('');
69 | setTimeout(() => {
70 | onCheckAgain();
71 | }, 500);
72 | }
73 | } catch (error) {
74 | console.error('Failed to reset git path:', error);
75 | setSetPathResult({
76 | success: false,
77 | message: t('gitPathSettings.resetError', { message: (error as Error).message }),
78 | });
79 | }
80 | };
81 |
82 | return (
83 |
84 |
85 |
86 |
{t('gitPathSettings.title')}
87 |
92 |
93 |
99 |
100 |
101 |
102 |
103 |
104 |
{t('gitPathSettings.description')}
105 |
106 |
107 |
108 | {t('gitPathSettings.label')}
109 |
110 | {
114 | setCustomGitPath(e.target.value);
115 | setSetPathResult(null);
116 | }}
117 | placeholder={t('gitPathSettings.placeholder')}
118 | className="w-full border border-black px-3 py-2 text-sm text-black focus:outline-none focus:ring-1 focus:ring-black"
119 | />
120 |
121 |
122 | {setPathResult && (
123 |
124 | {setPathResult.message}
125 |
126 | )}
127 |
128 |
129 |
134 | {isSettingPath ? t('gitPathSettings.setting') : t('gitPathSettings.setPath')}
135 |
136 |
140 | {t('gitPathSettings.reset')}
141 |
142 |
143 |
144 |
145 |
146 | {t('gitPathSettings.noteTitle')}
147 |
148 |
149 | {t('gitPathSettings.noteEmpty')}
150 | {t('gitPathSettings.noteCustom')}
151 | {t('gitPathSettings.noteManualCheck')}
152 |
153 |
154 |
155 |
156 |
157 | );
158 | };
159 |
160 | export default GitPathSettings;
161 |
--------------------------------------------------------------------------------
/frontend/wailsjs/runtime/runtime.js:
--------------------------------------------------------------------------------
1 | /*
2 | _ __ _ __
3 | | | / /___ _(_) /____
4 | | | /| / / __ `/ / / ___/
5 | | |/ |/ / /_/ / / (__ )
6 | |__/|__/\__,_/_/_/____/
7 | The electron alternative for Go
8 | (c) Lea Anthony 2019-present
9 | */
10 |
11 | export function LogPrint(message) {
12 | window.runtime.LogPrint(message);
13 | }
14 |
15 | export function LogTrace(message) {
16 | window.runtime.LogTrace(message);
17 | }
18 |
19 | export function LogDebug(message) {
20 | window.runtime.LogDebug(message);
21 | }
22 |
23 | export function LogInfo(message) {
24 | window.runtime.LogInfo(message);
25 | }
26 |
27 | export function LogWarning(message) {
28 | window.runtime.LogWarning(message);
29 | }
30 |
31 | export function LogError(message) {
32 | window.runtime.LogError(message);
33 | }
34 |
35 | export function LogFatal(message) {
36 | window.runtime.LogFatal(message);
37 | }
38 |
39 | export function EventsOnMultiple(eventName, callback, maxCallbacks) {
40 | return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
41 | }
42 |
43 | export function EventsOn(eventName, callback) {
44 | return EventsOnMultiple(eventName, callback, -1);
45 | }
46 |
47 | export function EventsOff(eventName, ...additionalEventNames) {
48 | return window.runtime.EventsOff(eventName, ...additionalEventNames);
49 | }
50 |
51 | export function EventsOnce(eventName, callback) {
52 | return EventsOnMultiple(eventName, callback, 1);
53 | }
54 |
55 | export function EventsEmit(eventName) {
56 | let args = [eventName].slice.call(arguments);
57 | return window.runtime.EventsEmit.apply(null, args);
58 | }
59 |
60 | export function WindowReload() {
61 | window.runtime.WindowReload();
62 | }
63 |
64 | export function WindowReloadApp() {
65 | window.runtime.WindowReloadApp();
66 | }
67 |
68 | export function WindowSetAlwaysOnTop(b) {
69 | window.runtime.WindowSetAlwaysOnTop(b);
70 | }
71 |
72 | export function WindowSetSystemDefaultTheme() {
73 | window.runtime.WindowSetSystemDefaultTheme();
74 | }
75 |
76 | export function WindowSetLightTheme() {
77 | window.runtime.WindowSetLightTheme();
78 | }
79 |
80 | export function WindowSetDarkTheme() {
81 | window.runtime.WindowSetDarkTheme();
82 | }
83 |
84 | export function WindowCenter() {
85 | window.runtime.WindowCenter();
86 | }
87 |
88 | export function WindowSetTitle(title) {
89 | window.runtime.WindowSetTitle(title);
90 | }
91 |
92 | export function WindowFullscreen() {
93 | window.runtime.WindowFullscreen();
94 | }
95 |
96 | export function WindowUnfullscreen() {
97 | window.runtime.WindowUnfullscreen();
98 | }
99 |
100 | export function WindowIsFullscreen() {
101 | return window.runtime.WindowIsFullscreen();
102 | }
103 |
104 | export function WindowGetSize() {
105 | return window.runtime.WindowGetSize();
106 | }
107 |
108 | export function WindowSetSize(width, height) {
109 | window.runtime.WindowSetSize(width, height);
110 | }
111 |
112 | export function WindowSetMaxSize(width, height) {
113 | window.runtime.WindowSetMaxSize(width, height);
114 | }
115 |
116 | export function WindowSetMinSize(width, height) {
117 | window.runtime.WindowSetMinSize(width, height);
118 | }
119 |
120 | export function WindowSetPosition(x, y) {
121 | window.runtime.WindowSetPosition(x, y);
122 | }
123 |
124 | export function WindowGetPosition() {
125 | return window.runtime.WindowGetPosition();
126 | }
127 |
128 | export function WindowHide() {
129 | window.runtime.WindowHide();
130 | }
131 |
132 | export function WindowShow() {
133 | window.runtime.WindowShow();
134 | }
135 |
136 | export function WindowMaximise() {
137 | window.runtime.WindowMaximise();
138 | }
139 |
140 | export function WindowToggleMaximise() {
141 | window.runtime.WindowToggleMaximise();
142 | }
143 |
144 | export function WindowUnmaximise() {
145 | window.runtime.WindowUnmaximise();
146 | }
147 |
148 | export function WindowIsMaximised() {
149 | return window.runtime.WindowIsMaximised();
150 | }
151 |
152 | export function WindowMinimise() {
153 | window.runtime.WindowMinimise();
154 | }
155 |
156 | export function WindowUnminimise() {
157 | window.runtime.WindowUnminimise();
158 | }
159 |
160 | export function WindowSetBackgroundColour(R, G, B, A) {
161 | window.runtime.WindowSetBackgroundColour(R, G, B, A);
162 | }
163 |
164 | export function ScreenGetAll() {
165 | return window.runtime.ScreenGetAll();
166 | }
167 |
168 | export function WindowIsMinimised() {
169 | return window.runtime.WindowIsMinimised();
170 | }
171 |
172 | export function WindowIsNormal() {
173 | return window.runtime.WindowIsNormal();
174 | }
175 |
176 | export function BrowserOpenURL(url) {
177 | window.runtime.BrowserOpenURL(url);
178 | }
179 |
180 | export function Environment() {
181 | return window.runtime.Environment();
182 | }
183 |
184 | export function Quit() {
185 | window.runtime.Quit();
186 | }
187 |
188 | export function Hide() {
189 | window.runtime.Hide();
190 | }
191 |
192 | export function Show() {
193 | window.runtime.Show();
194 | }
195 |
196 | export function ClipboardGetText() {
197 | return window.runtime.ClipboardGetText();
198 | }
199 |
200 | export function ClipboardSetText(text) {
201 | return window.runtime.ClipboardSetText(text);
202 | }
203 |
204 | /**
205 | * Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
206 | *
207 | * @export
208 | * @callback OnFileDropCallback
209 | * @param {number} x - x coordinate of the drop
210 | * @param {number} y - y coordinate of the drop
211 | * @param {string[]} paths - A list of file paths.
212 | */
213 |
214 | /**
215 | * OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
216 | *
217 | * @export
218 | * @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
219 | * @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
220 | */
221 | export function OnFileDrop(callback, useDropTarget) {
222 | return window.runtime.OnFileDrop(callback, useDropTarget);
223 | }
224 |
225 | /**
226 | * OnFileDropOff removes the drag and drop listeners and handlers.
227 | */
228 | export function OnFileDropOff() {
229 | return window.runtime.OnFileDropOff();
230 | }
231 |
232 | export function CanResolveFilePaths() {
233 | return window.runtime.CanResolveFilePaths();
234 | }
235 |
236 | export function ResolveFilePaths(files) {
237 | return window.runtime.ResolveFilePaths(files);
238 | }
--------------------------------------------------------------------------------
/frontend/src/components/LoginModal.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { main } from '../../wailsjs/go/models';
3 | import { AuthenticateWithToken } from '../../wailsjs/go/main/App';
4 | import { BrowserOpenURL } from '../../wailsjs/runtime/runtime';
5 | import { useTranslations } from '../i18n';
6 |
7 | type LoginModalProps = {
8 | open: boolean;
9 | onClose: () => void;
10 | onSuccess: (user: main.GithubUserProfile) => void;
11 | };
12 |
13 | const LoginModal: React.FC = ({ open, onClose, onSuccess }) => {
14 | const [token, setToken] = React.useState('');
15 | const [remember, setRemember] = React.useState(false);
16 | const [isSubmitting, setIsSubmitting] = React.useState(false);
17 | const [error, setError] = React.useState(null);
18 | const [profile, setProfile] = React.useState(null);
19 | const [successMessage, setSuccessMessage] = React.useState('');
20 |
21 | const { t } = useTranslations();
22 | const tokenGuideUrl = 'https://github.com/zmrlft/GreenWall/blob/main/docs/githubtoken_en.md';
23 | const labels = React.useMemo(
24 | () => ({
25 | title: t('loginModal.title'),
26 | tokenLabel: t('loginModal.tokenLabel'),
27 | tokenPlaceholder: t('loginModal.tokenPlaceholder'),
28 | remember: t('loginModal.remember'),
29 | helpLink: t('loginModal.helpLink'),
30 | submit: t('loginModal.submit'),
31 | submitting: t('loginModal.submitting'),
32 | close: t('loginModal.close'),
33 | hint: t('loginModal.hint'),
34 | success: t('loginModal.success'),
35 | emailFallback: t('loginModal.emailFallback'),
36 | missingUser: t('loginModal.missingUser'),
37 | }),
38 | [t]
39 | );
40 |
41 | const handleOpenTokenGuide = React.useCallback(() => {
42 | BrowserOpenURL(tokenGuideUrl);
43 | }, [tokenGuideUrl]);
44 |
45 | React.useEffect(() => {
46 | if (!open) {
47 | setToken('');
48 | setRemember(false);
49 | setIsSubmitting(false);
50 | setError(null);
51 | setProfile(null);
52 | setSuccessMessage('');
53 | }
54 | }, [open]);
55 |
56 | if (!open) {
57 | return null;
58 | }
59 |
60 | const handleSubmit = async (event: React.FormEvent) => {
61 | event.preventDefault();
62 | if (isSubmitting) {
63 | return;
64 | }
65 | setIsSubmitting(true);
66 | setError(null);
67 | setSuccessMessage('');
68 | try {
69 | const response = await AuthenticateWithToken({ token, remember });
70 | if (!response.user) {
71 | throw new Error(labels.missingUser);
72 | }
73 | setProfile(response.user);
74 | setSuccessMessage(labels.success);
75 | onSuccess(response.user);
76 | } catch (err) {
77 | setProfile(null);
78 | setError(err instanceof Error ? err.message : String(err));
79 | } finally {
80 | setIsSubmitting(false);
81 | }
82 | };
83 |
84 | const displayName = profile?.name?.trim() || profile?.login || '';
85 |
86 | return (
87 |
88 |
89 |
90 |
{labels.title}
91 |
92 |
99 | ?
100 |
101 |
107 | ×
108 |
109 |
110 |
111 |
112 |
113 | {labels.tokenLabel}
114 | setToken(event.target.value)}
118 | placeholder={labels.tokenPlaceholder}
119 | autoComplete="off"
120 | spellCheck={false}
121 | autoFocus
122 | required
123 | />
124 |
125 |
126 |
127 | setRemember(event.target.checked)}
131 | />
132 | {labels.remember}
133 |
134 |
{labels.hint}
135 |
136 | {error && {error}
}
137 | {profile && successMessage && (
138 |
139 |
{successMessage}
140 |
141 | {profile.avatarUrl ? (
142 |
148 | ) : (
149 |
150 | {displayName.slice(0, 1).toUpperCase()}
151 |
152 | )}
153 |
154 |
{displayName}
155 |
@{profile.login}
156 |
{profile.email || labels.emailFallback}
157 |
158 |
159 |
160 | )}
161 |
162 |
163 | {labels.close}
164 |
165 |
170 | {isSubmitting ? labels.submitting : labels.submit}
171 |
172 |
173 |
174 |
175 |
176 | );
177 | };
178 |
179 | export default LoginModal;
180 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
2 | github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
6 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
7 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
8 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
9 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
10 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
11 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
12 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
13 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
14 | github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
15 | github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
16 | github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
17 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
18 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
19 | github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
20 | github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
21 | github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
22 | github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
23 | github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
24 | github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
25 | github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
26 | github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
27 | github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
28 | github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
29 | github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
30 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
31 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
32 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
33 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
34 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
35 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
36 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
37 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
38 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
39 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
40 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
41 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
42 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
43 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
44 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
45 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
46 | github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
47 | github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
48 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
49 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
50 | github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
51 | github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
52 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
53 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
54 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
55 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
56 | github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU=
57 | github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
58 | github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
59 | github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
60 | github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk=
61 | github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4=
62 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
63 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
64 | golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
65 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
66 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
67 | golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
68 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
69 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
70 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
71 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
72 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
73 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
74 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
75 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
76 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
77 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
78 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
79 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
80 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
81 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
82 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './App.css';
3 | import ContributionCalendar, { OneDay } from './components/ContributionCalendar';
4 | import GitInstallSidebar from './components/GitInstallSidebar';
5 | import GitPathSettings from './components/GitPathSettings';
6 | import LoginModal from './components/LoginModal';
7 | import { TranslationProvider, useTranslations, Language } from './i18n';
8 | import { BrowserOpenURL, EventsOn } from '../wailsjs/runtime/runtime';
9 | import type { main } from '../wailsjs/go/models';
10 |
11 | function App() {
12 | const generateEmptyYearData = (year: number): OneDay[] => {
13 | const data: OneDay[] = [];
14 | const d = new Date(Date.UTC(year, 0, 1));
15 |
16 | while (d.getUTCFullYear() === year) {
17 | data.push({
18 | date: d.toISOString().slice(0, 10),
19 | count: 0,
20 | level: 0,
21 | });
22 | d.setUTCDate(d.getUTCDate() + 1);
23 | }
24 | return data;
25 | };
26 |
27 | const generateMultiYearData = (): OneDay[] => {
28 | const data: OneDay[] = [];
29 | const currentYear = new Date().getFullYear();
30 |
31 | for (let year = 2008; year <= currentYear; year++) {
32 | data.push(...generateEmptyYearData(year));
33 | }
34 | return data;
35 | };
36 |
37 | const multiYearData: OneDay[] = generateMultiYearData();
38 |
39 | return (
40 |
41 |
42 |
43 | );
44 | }
45 |
46 | type AppLayoutProps = {
47 | contributions: OneDay[];
48 | };
49 |
50 | const AppLayout: React.FC = ({ contributions }) => {
51 | const { language, setLanguage, t } = useTranslations();
52 | const [isGitInstalled, setIsGitInstalled] = React.useState(null);
53 | const [isGitPathSettingsOpen, setIsGitPathSettingsOpen] = React.useState(false);
54 | const [isLoginModalOpen, setIsLoginModalOpen] = React.useState(false);
55 | const [githubUser, setGithubUser] = React.useState(null);
56 |
57 | const checkGit = React.useCallback(async () => {
58 | try {
59 | const { CheckGitInstalled } = await import('../wailsjs/go/main/App');
60 | const response = await CheckGitInstalled();
61 | setIsGitInstalled(response.installed);
62 | } catch (error) {
63 | console.error('Failed to check Git installation:', error);
64 | setIsGitInstalled(false);
65 | }
66 | }, []);
67 |
68 | React.useEffect(() => {
69 | checkGit();
70 | }, [checkGit]);
71 |
72 | React.useEffect(() => {
73 | (async () => {
74 | try {
75 | const { GetGithubLoginStatus } = await import('../wailsjs/go/main/App');
76 | const status = await GetGithubLoginStatus();
77 | if (status.authenticated && status.user) {
78 | setGithubUser(status.user);
79 | } else {
80 | setGithubUser(null);
81 | }
82 | } catch (error) {
83 | console.error('Failed to fetch GitHub login status:', error);
84 | }
85 | })();
86 | }, []);
87 |
88 | React.useEffect(() => {
89 | const unsubscribe = EventsOn('github:auth-changed', (status: main.GithubLoginStatus) => {
90 | if (status && status.authenticated && status.user) {
91 | setGithubUser(status.user);
92 | return;
93 | }
94 | setGithubUser(null);
95 | });
96 |
97 | return () => {
98 | unsubscribe();
99 | };
100 | }, []);
101 |
102 | const handleCheckAgain = React.useCallback(() => {
103 | checkGit();
104 | }, [checkGit]);
105 |
106 | const languageOptions = React.useMemo(
107 | () => [
108 | { value: 'en' as Language, label: t('languageSwitcher.english') },
109 | { value: 'zh' as Language, label: t('languageSwitcher.chinese') },
110 | ],
111 | [t]
112 | );
113 |
114 | const loginLabel = language === 'zh' ? '登录' : 'Log in';
115 | const logoutLabel = language === 'zh' ? '退出' : 'Log out';
116 | const handleLogout = React.useCallback(async () => {
117 | try {
118 | const { LogoutGithub } = await import('../wailsjs/go/main/App');
119 | await LogoutGithub();
120 | setGithubUser(null);
121 | } catch (error) {
122 | console.error('Failed to log out from GitHub:', error);
123 | }
124 | }, []);
125 | const handleAuthSuccess = React.useCallback((user: main.GithubUserProfile) => {
126 | setGithubUser(user);
127 | }, []);
128 | const displayName = githubUser?.name?.trim() || githubUser?.login || '';
129 |
130 | const openRepository = React.useCallback(() => {
131 | BrowserOpenURL('https://github.com/zmrlft/GreenWall');
132 | }, []);
133 |
134 | return (
135 |
136 |
216 |
217 | {isGitInstalled === false &&
}
218 |
219 | {isGitPathSettingsOpen && (
220 |
setIsGitPathSettingsOpen(false)}
222 | onCheckAgain={handleCheckAgain}
223 | />
224 | )}
225 | {isLoginModalOpen && (
226 | setIsLoginModalOpen(false)}
229 | onSuccess={handleAuthSuccess}
230 | />
231 | )}
232 |
233 | );
234 | };
235 |
236 | export default App;
237 |
--------------------------------------------------------------------------------
/frontend/wailsjs/go/models.ts:
--------------------------------------------------------------------------------
1 | export namespace main {
2 |
3 | export class CheckGitInstalledResponse {
4 | installed: boolean;
5 | version: string;
6 |
7 | static createFrom(source: any = {}) {
8 | return new CheckGitInstalledResponse(source);
9 | }
10 |
11 | constructor(source: any = {}) {
12 | if ('string' === typeof source) source = JSON.parse(source);
13 | this.installed = source["installed"];
14 | this.version = source["version"];
15 | }
16 | }
17 | export class ContributionDay {
18 | date: string;
19 | count: number;
20 |
21 | static createFrom(source: any = {}) {
22 | return new ContributionDay(source);
23 | }
24 |
25 | constructor(source: any = {}) {
26 | if ('string' === typeof source) source = JSON.parse(source);
27 | this.date = source["date"];
28 | this.count = source["count"];
29 | }
30 | }
31 | export class ExportContributionsRequest {
32 | contributions: ContributionDay[];
33 |
34 | static createFrom(source: any = {}) {
35 | return new ExportContributionsRequest(source);
36 | }
37 |
38 | constructor(source: any = {}) {
39 | if ('string' === typeof source) source = JSON.parse(source);
40 | this.contributions = this.convertValues(source["contributions"], ContributionDay);
41 | }
42 |
43 | convertValues(a: any, classs: any, asMap: boolean = false): any {
44 | if (!a) {
45 | return a;
46 | }
47 | if (a.slice && a.map) {
48 | return (a as any[]).map(elem => this.convertValues(elem, classs));
49 | } else if ("object" === typeof a) {
50 | if (asMap) {
51 | for (const key of Object.keys(a)) {
52 | a[key] = new classs(a[key]);
53 | }
54 | return a;
55 | }
56 | return new classs(a);
57 | }
58 | return a;
59 | }
60 | }
61 | export class ExportContributionsResponse {
62 | filePath: string;
63 |
64 | static createFrom(source: any = {}) {
65 | return new ExportContributionsResponse(source);
66 | }
67 |
68 | constructor(source: any = {}) {
69 | if ('string' === typeof source) source = JSON.parse(source);
70 | this.filePath = source["filePath"];
71 | }
72 | }
73 | export class RemoteRepoOptions {
74 | enabled: boolean;
75 | name: string;
76 | private: boolean;
77 | description: string;
78 |
79 | static createFrom(source: any = {}) {
80 | return new RemoteRepoOptions(source);
81 | }
82 |
83 | constructor(source: any = {}) {
84 | if ('string' === typeof source) source = JSON.parse(source);
85 | this.enabled = source["enabled"];
86 | this.name = source["name"];
87 | this.private = source["private"];
88 | this.description = source["description"];
89 | }
90 | }
91 | export class GenerateRepoRequest {
92 | year: number;
93 | githubUsername: string;
94 | githubEmail: string;
95 | repoName: string;
96 | contributions: ContributionDay[];
97 | remoteRepo?: RemoteRepoOptions;
98 |
99 | static createFrom(source: any = {}) {
100 | return new GenerateRepoRequest(source);
101 | }
102 |
103 | constructor(source: any = {}) {
104 | if ('string' === typeof source) source = JSON.parse(source);
105 | this.year = source["year"];
106 | this.githubUsername = source["githubUsername"];
107 | this.githubEmail = source["githubEmail"];
108 | this.repoName = source["repoName"];
109 | this.contributions = this.convertValues(source["contributions"], ContributionDay);
110 | this.remoteRepo = this.convertValues(source["remoteRepo"], RemoteRepoOptions);
111 | }
112 |
113 | convertValues(a: any, classs: any, asMap: boolean = false): any {
114 | if (!a) {
115 | return a;
116 | }
117 | if (a.slice && a.map) {
118 | return (a as any[]).map(elem => this.convertValues(elem, classs));
119 | } else if ("object" === typeof a) {
120 | if (asMap) {
121 | for (const key of Object.keys(a)) {
122 | a[key] = new classs(a[key]);
123 | }
124 | return a;
125 | }
126 | return new classs(a);
127 | }
128 | return a;
129 | }
130 | }
131 | export class GenerateRepoResponse {
132 | repoPath: string;
133 | commitCount: number;
134 | remoteUrl?: string;
135 |
136 | static createFrom(source: any = {}) {
137 | return new GenerateRepoResponse(source);
138 | }
139 |
140 | constructor(source: any = {}) {
141 | if ('string' === typeof source) source = JSON.parse(source);
142 | this.repoPath = source["repoPath"];
143 | this.commitCount = source["commitCount"];
144 | this.remoteUrl = source["remoteUrl"];
145 | }
146 | }
147 | export class GithubAuthRequest {
148 | token: string;
149 | remember: boolean;
150 |
151 | static createFrom(source: any = {}) {
152 | return new GithubAuthRequest(source);
153 | }
154 |
155 | constructor(source: any = {}) {
156 | if ('string' === typeof source) source = JSON.parse(source);
157 | this.token = source["token"];
158 | this.remember = source["remember"];
159 | }
160 | }
161 | export class GithubUserProfile {
162 | login: string;
163 | name: string;
164 | email: string;
165 | avatarUrl: string;
166 |
167 | static createFrom(source: any = {}) {
168 | return new GithubUserProfile(source);
169 | }
170 |
171 | constructor(source: any = {}) {
172 | if ('string' === typeof source) source = JSON.parse(source);
173 | this.login = source["login"];
174 | this.name = source["name"];
175 | this.email = source["email"];
176 | this.avatarUrl = source["avatarUrl"];
177 | }
178 | }
179 | export class GithubAuthResponse {
180 | user?: GithubUserProfile;
181 | remembered: boolean;
182 |
183 | static createFrom(source: any = {}) {
184 | return new GithubAuthResponse(source);
185 | }
186 |
187 | constructor(source: any = {}) {
188 | if ('string' === typeof source) source = JSON.parse(source);
189 | this.user = this.convertValues(source["user"], GithubUserProfile);
190 | this.remembered = source["remembered"];
191 | }
192 |
193 | convertValues(a: any, classs: any, asMap: boolean = false): any {
194 | if (!a) {
195 | return a;
196 | }
197 | if (a.slice && a.map) {
198 | return (a as any[]).map(elem => this.convertValues(elem, classs));
199 | } else if ("object" === typeof a) {
200 | if (asMap) {
201 | for (const key of Object.keys(a)) {
202 | a[key] = new classs(a[key]);
203 | }
204 | return a;
205 | }
206 | return new classs(a);
207 | }
208 | return a;
209 | }
210 | }
211 | export class GithubLoginStatus {
212 | authenticated: boolean;
213 | user?: GithubUserProfile;
214 |
215 | static createFrom(source: any = {}) {
216 | return new GithubLoginStatus(source);
217 | }
218 |
219 | constructor(source: any = {}) {
220 | if ('string' === typeof source) source = JSON.parse(source);
221 | this.authenticated = source["authenticated"];
222 | this.user = this.convertValues(source["user"], GithubUserProfile);
223 | }
224 |
225 | convertValues(a: any, classs: any, asMap: boolean = false): any {
226 | if (!a) {
227 | return a;
228 | }
229 | if (a.slice && a.map) {
230 | return (a as any[]).map(elem => this.convertValues(elem, classs));
231 | } else if ("object" === typeof a) {
232 | if (asMap) {
233 | for (const key of Object.keys(a)) {
234 | a[key] = new classs(a[key]);
235 | }
236 | return a;
237 | }
238 | return new classs(a);
239 | }
240 | return a;
241 | }
242 | }
243 |
244 | export class ImportContributionsResponse {
245 | contributions: ContributionDay[];
246 |
247 | static createFrom(source: any = {}) {
248 | return new ImportContributionsResponse(source);
249 | }
250 |
251 | constructor(source: any = {}) {
252 | if ('string' === typeof source) source = JSON.parse(source);
253 | this.contributions = this.convertValues(source["contributions"], ContributionDay);
254 | }
255 |
256 | convertValues(a: any, classs: any, asMap: boolean = false): any {
257 | if (!a) {
258 | return a;
259 | }
260 | if (a.slice && a.map) {
261 | return (a as any[]).map(elem => this.convertValues(elem, classs));
262 | } else if ("object" === typeof a) {
263 | if (asMap) {
264 | for (const key of Object.keys(a)) {
265 | a[key] = new classs(a[key]);
266 | }
267 | return a;
268 | }
269 | return new classs(a);
270 | }
271 | return a;
272 | }
273 | }
274 |
275 | export class SetGitPathRequest {
276 | gitPath: string;
277 |
278 | static createFrom(source: any = {}) {
279 | return new SetGitPathRequest(source);
280 | }
281 |
282 | constructor(source: any = {}) {
283 | if ('string' === typeof source) source = JSON.parse(source);
284 | this.gitPath = source["gitPath"];
285 | }
286 | }
287 | export class SetGitPathResponse {
288 | success: boolean;
289 | message: string;
290 | version: string;
291 |
292 | static createFrom(source: any = {}) {
293 | return new SetGitPathResponse(source);
294 | }
295 |
296 | constructor(source: any = {}) {
297 | if ('string' === typeof source) source = JSON.parse(source);
298 | this.success = source["success"];
299 | this.message = source["message"];
300 | this.version = source["version"];
301 | }
302 | }
303 |
304 | }
305 |
306 |
--------------------------------------------------------------------------------
/frontend/wailsjs/runtime/runtime.d.ts:
--------------------------------------------------------------------------------
1 | /*
2 | _ __ _ __
3 | | | / /___ _(_) /____
4 | | | /| / / __ `/ / / ___/
5 | | |/ |/ / /_/ / / (__ )
6 | |__/|__/\__,_/_/_/____/
7 | The electron alternative for Go
8 | (c) Lea Anthony 2019-present
9 | */
10 |
11 | export interface Position {
12 | x: number;
13 | y: number;
14 | }
15 |
16 | export interface Size {
17 | w: number;
18 | h: number;
19 | }
20 |
21 | export interface Screen {
22 | isCurrent: boolean;
23 | isPrimary: boolean;
24 | width : number
25 | height : number
26 | }
27 |
28 | // Environment information such as platform, buildtype, ...
29 | export interface EnvironmentInfo {
30 | buildType: string;
31 | platform: string;
32 | arch: string;
33 | }
34 |
35 | // [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
36 | // emits the given event. Optional data may be passed with the event.
37 | // This will trigger any event listeners.
38 | export function EventsEmit(eventName: string, ...data: any): void;
39 |
40 | // [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
41 | export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
42 |
43 | // [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
44 | // sets up a listener for the given event name, but will only trigger a given number times.
45 | export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
46 |
47 | // [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
48 | // sets up a listener for the given event name, but will only trigger once.
49 | export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
50 |
51 | // [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
52 | // unregisters the listener for the given event name.
53 | export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
54 |
55 | // [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
56 | // unregisters all listeners.
57 | export function EventsOffAll(): void;
58 |
59 | // [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
60 | // logs the given message as a raw message
61 | export function LogPrint(message: string): void;
62 |
63 | // [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
64 | // logs the given message at the `trace` log level.
65 | export function LogTrace(message: string): void;
66 |
67 | // [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
68 | // logs the given message at the `debug` log level.
69 | export function LogDebug(message: string): void;
70 |
71 | // [LogError](https://wails.io/docs/reference/runtime/log#logerror)
72 | // logs the given message at the `error` log level.
73 | export function LogError(message: string): void;
74 |
75 | // [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
76 | // logs the given message at the `fatal` log level.
77 | // The application will quit after calling this method.
78 | export function LogFatal(message: string): void;
79 |
80 | // [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
81 | // logs the given message at the `info` log level.
82 | export function LogInfo(message: string): void;
83 |
84 | // [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
85 | // logs the given message at the `warning` log level.
86 | export function LogWarning(message: string): void;
87 |
88 | // [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
89 | // Forces a reload by the main application as well as connected browsers.
90 | export function WindowReload(): void;
91 |
92 | // [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
93 | // Reloads the application frontend.
94 | export function WindowReloadApp(): void;
95 |
96 | // [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
97 | // Sets the window AlwaysOnTop or not on top.
98 | export function WindowSetAlwaysOnTop(b: boolean): void;
99 |
100 | // [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
101 | // *Windows only*
102 | // Sets window theme to system default (dark/light).
103 | export function WindowSetSystemDefaultTheme(): void;
104 |
105 | // [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
106 | // *Windows only*
107 | // Sets window to light theme.
108 | export function WindowSetLightTheme(): void;
109 |
110 | // [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
111 | // *Windows only*
112 | // Sets window to dark theme.
113 | export function WindowSetDarkTheme(): void;
114 |
115 | // [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
116 | // Centers the window on the monitor the window is currently on.
117 | export function WindowCenter(): void;
118 |
119 | // [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
120 | // Sets the text in the window title bar.
121 | export function WindowSetTitle(title: string): void;
122 |
123 | // [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
124 | // Makes the window full screen.
125 | export function WindowFullscreen(): void;
126 |
127 | // [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
128 | // Restores the previous window dimensions and position prior to full screen.
129 | export function WindowUnfullscreen(): void;
130 |
131 | // [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
132 | // Returns the state of the window, i.e. whether the window is in full screen mode or not.
133 | export function WindowIsFullscreen(): Promise;
134 |
135 | // [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
136 | // Sets the width and height of the window.
137 | export function WindowSetSize(width: number, height: number): void;
138 |
139 | // [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
140 | // Gets the width and height of the window.
141 | export function WindowGetSize(): Promise;
142 |
143 | // [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
144 | // Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
145 | // Setting a size of 0,0 will disable this constraint.
146 | export function WindowSetMaxSize(width: number, height: number): void;
147 |
148 | // [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
149 | // Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
150 | // Setting a size of 0,0 will disable this constraint.
151 | export function WindowSetMinSize(width: number, height: number): void;
152 |
153 | // [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
154 | // Sets the window position relative to the monitor the window is currently on.
155 | export function WindowSetPosition(x: number, y: number): void;
156 |
157 | // [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
158 | // Gets the window position relative to the monitor the window is currently on.
159 | export function WindowGetPosition(): Promise;
160 |
161 | // [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
162 | // Hides the window.
163 | export function WindowHide(): void;
164 |
165 | // [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
166 | // Shows the window, if it is currently hidden.
167 | export function WindowShow(): void;
168 |
169 | // [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
170 | // Maximises the window to fill the screen.
171 | export function WindowMaximise(): void;
172 |
173 | // [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
174 | // Toggles between Maximised and UnMaximised.
175 | export function WindowToggleMaximise(): void;
176 |
177 | // [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
178 | // Restores the window to the dimensions and position prior to maximising.
179 | export function WindowUnmaximise(): void;
180 |
181 | // [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
182 | // Returns the state of the window, i.e. whether the window is maximised or not.
183 | export function WindowIsMaximised(): Promise;
184 |
185 | // [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
186 | // Minimises the window.
187 | export function WindowMinimise(): void;
188 |
189 | // [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
190 | // Restores the window to the dimensions and position prior to minimising.
191 | export function WindowUnminimise(): void;
192 |
193 | // [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
194 | // Returns the state of the window, i.e. whether the window is minimised or not.
195 | export function WindowIsMinimised(): Promise;
196 |
197 | // [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
198 | // Returns the state of the window, i.e. whether the window is normal or not.
199 | export function WindowIsNormal(): Promise;
200 |
201 | // [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
202 | // Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
203 | export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
204 |
205 | // [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
206 | // Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
207 | export function ScreenGetAll(): Promise;
208 |
209 | // [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
210 | // Opens the given URL in the system browser.
211 | export function BrowserOpenURL(url: string): void;
212 |
213 | // [Environment](https://wails.io/docs/reference/runtime/intro#environment)
214 | // Returns information about the environment
215 | export function Environment(): Promise;
216 |
217 | // [Quit](https://wails.io/docs/reference/runtime/intro#quit)
218 | // Quits the application.
219 | export function Quit(): void;
220 |
221 | // [Hide](https://wails.io/docs/reference/runtime/intro#hide)
222 | // Hides the application.
223 | export function Hide(): void;
224 |
225 | // [Show](https://wails.io/docs/reference/runtime/intro#show)
226 | // Shows the application.
227 | export function Show(): void;
228 |
229 | // [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
230 | // Returns the current text stored on clipboard
231 | export function ClipboardGetText(): Promise;
232 |
233 | // [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
234 | // Sets a text on the clipboard
235 | export function ClipboardSetText(text: string): Promise;
236 |
237 | // [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
238 | // OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
239 | export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
240 |
241 | // [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
242 | // OnFileDropOff removes the drag and drop listeners and handlers.
243 | export function OnFileDropOff() :void
244 |
245 | // Check if the file path resolver is available
246 | export function CanResolveFilePaths(): boolean;
247 |
248 | // Resolves file paths for an array of files
249 | export function ResolveFilePaths(files: File[]): void
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
2 |
3 | #app {
4 | min-height: 100vh;
5 | text-align: center;
6 | }
7 |
8 | /* Additional responsive styles */
9 |
10 | body {
11 | font-family:
12 | 'Inter',
13 | -apple-system,
14 | BlinkMacSystemFont,
15 | 'Segoe UI',
16 | 'Roboto',
17 | sans-serif;
18 | color: black !important;
19 | }
20 |
21 | /* Custom scrollbar for small screens */
22 | ::-webkit-scrollbar {
23 | width: 6px;
24 | height: 6px;
25 | }
26 |
27 | ::-webkit-scrollbar-track {
28 | background: #f1f1f1;
29 | border-radius: 3px;
30 | }
31 |
32 | ::-webkit-scrollbar-thumb {
33 | background: #c1c1c1;
34 | border-radius: 3px;
35 | }
36 |
37 | ::-webkit-scrollbar-thumb:hover {
38 | background: #a1a1a1;
39 | }
40 |
41 | #logo {
42 | display: block;
43 | width: 50%;
44 | height: 50%;
45 | margin: auto;
46 | padding: 10% 0 0;
47 | background-position: center;
48 | background-repeat: no-repeat;
49 | background-size: 100% 100%;
50 | background-origin: content-box;
51 | }
52 |
53 | .result {
54 | height: 20px;
55 | line-height: 20px;
56 | margin: 1.5rem auto;
57 | }
58 |
59 | .input-box .btn {
60 | width: 60px;
61 | height: 30px;
62 | line-height: 30px;
63 | border-radius: 3px;
64 | border: none;
65 | margin: 0 0 0 20px;
66 | padding: 0 8px;
67 | cursor: pointer;
68 | }
69 |
70 | .input-box .btn:hover {
71 | background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%);
72 | color: #333333;
73 | }
74 |
75 | .input-box .input {
76 | border: none;
77 | border-radius: 3px;
78 | outline: none;
79 | height: 30px;
80 | line-height: 30px;
81 | padding: 0 10px;
82 | background-color: rgba(240, 240, 240, 1);
83 | -webkit-font-smoothing: antialiased;
84 | }
85 |
86 | .input-box .input:hover {
87 | border: none;
88 | background-color: rgba(255, 255, 255, 1);
89 | }
90 |
91 | .input-box .input:focus {
92 | border: none;
93 | background-color: rgba(255, 255, 255, 1);
94 | }
95 |
96 | .app-shell {
97 | min-height: 100vh;
98 | background: radial-gradient(circle at top, #f4f8fb, #e5ecf5);
99 | padding: 32px;
100 | display: flex;
101 | justify-content: center;
102 | }
103 |
104 | .app-shell__surface {
105 | width: 100%;
106 | max-width: 1400px;
107 | display: flex;
108 | flex-direction: column;
109 | gap: 10px;
110 | }
111 |
112 | .app-shell__topbar {
113 | display: flex;
114 | align-items: center;
115 | justify-content: space-between;
116 | background: #ffffff;
117 | border: 1px solid #e4e7ec;
118 | border-radius: 10px;
119 | padding: 20px 28px;
120 | box-shadow: 0 20px 50px rgba(15, 23, 42, 0.08);
121 | }
122 |
123 | .app-shell__identity {
124 | display: flex;
125 | align-items: center;
126 | gap: 12px;
127 | }
128 |
129 | .app-shell__login {
130 | border-radius: 999px;
131 | border: 1px solid #cbd5f5;
132 | background: #ffffff;
133 | color: #0f172a;
134 | padding: 10px 22px;
135 | font-size: 0.95rem;
136 | font-weight: 600;
137 | cursor: pointer;
138 | transition: box-shadow 0.2s ease, transform 0.2s ease;
139 | }
140 |
141 | .app-shell__login:hover {
142 | box-shadow: 0 8px 16px rgba(15, 23, 42, 0.1);
143 | transform: translateY(-1px);
144 | }
145 |
146 | .app-shell__user {
147 | display: inline-flex;
148 | align-items: center;
149 | gap: 10px;
150 | border-radius: 999px;
151 | border: 1px solid #cbd5f5;
152 | background: #ffffff;
153 | color: #0f172a;
154 | padding: 8px 16px;
155 | font-size: 0.95rem;
156 | font-weight: 600;
157 | cursor: pointer;
158 | transition: box-shadow 0.2s ease, transform 0.2s ease;
159 | }
160 |
161 | .app-shell__user:hover {
162 | box-shadow: 0 8px 16px rgba(15, 23, 42, 0.1);
163 | transform: translateY(-1px);
164 | }
165 |
166 | .app-shell__avatar {
167 | width: 36px;
168 | height: 36px;
169 | border-radius: 999px;
170 | object-fit: cover;
171 | border: 1px solid #e2e8f0;
172 | background: #f8fafc;
173 | display: inline-flex;
174 | align-items: center;
175 | justify-content: center;
176 | font-weight: 600;
177 | color: #475569;
178 | }
179 |
180 | .app-shell__avatar--fallback {
181 | font-size: 0.95rem;
182 | }
183 |
184 | .app-shell__user-name {
185 | max-width: 160px;
186 | overflow: hidden;
187 | text-overflow: ellipsis;
188 | white-space: nowrap;
189 | }
190 |
191 | .app-shell__logout {
192 | border-radius: 12px;
193 | border: 1px solid #e2e8f0;
194 | background: #f8fafc;
195 | padding: 8px 14px;
196 | font-size: 0.85rem;
197 | font-weight: 500;
198 | cursor: pointer;
199 | color: #0f172a;
200 | transition: background 0.2s ease, border 0.2s ease;
201 | }
202 |
203 | .app-shell__logout:hover {
204 | background: #e2e8f0;
205 | border-color: #cbd5f5;
206 | }
207 |
208 | .app-shell__actions {
209 | display: flex;
210 | align-items: center;
211 | gap: 12px;
212 | }
213 |
214 | .app-shell__action {
215 | border-radius: 12px;
216 | border: 1px solid #0f172a;
217 | background: #0f172a;
218 | color: #ffffff;
219 | padding: 10px 18px;
220 | font-size: 0.9rem;
221 | font-weight: 500;
222 | cursor: pointer;
223 | transition: all 0.2s ease;
224 | }
225 |
226 | .app-shell__action:hover {
227 | background: #111c34;
228 | }
229 |
230 | .app-shell__language {
231 | display: inline-flex;
232 | border: 1px solid #cbd5f5;
233 | background: #f8fafc;
234 | border-radius: 12px;
235 | overflow: hidden;
236 | }
237 |
238 | .app-shell__language-btn {
239 | padding: 8px 14px;
240 | font-size: 0.85rem;
241 | font-weight: 500;
242 | color: #475569;
243 | background: transparent;
244 | border: none;
245 | cursor: pointer;
246 | transition: all 0.2s ease;
247 | }
248 |
249 | .app-shell__language-btn:is(:focus-visible, :hover) {
250 | background: rgba(99, 102, 241, 0.15);
251 | outline: none;
252 | }
253 |
254 | .app-shell__language-btn.is-active {
255 | background: #0f172a;
256 | color: #ffffff;
257 | }
258 |
259 | .app-shell__icon-button {
260 | width: 40px;
261 | height: 40px;
262 | border-radius: 12px;
263 | border: 1px solid #e2e8f0;
264 | display: inline-flex;
265 | align-items: center;
266 | justify-content: center;
267 | color: #0f172a;
268 | background: #ffffff;
269 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
270 | transition: transform 0.2s ease, box-shadow 0.2s ease;
271 | }
272 |
273 | .app-shell__icon-button:hover {
274 | transform: translateY(-1px);
275 | box-shadow: 0 8px 16px rgba(15, 23, 42, 0.15);
276 | }
277 |
278 | .modal__backdrop {
279 | position: fixed;
280 | inset: 0;
281 | background: rgba(15, 23, 42, 0.45);
282 | display: flex;
283 | align-items: center;
284 | justify-content: center;
285 | padding: 20px;
286 | z-index: 40;
287 | }
288 |
289 | .modal {
290 | width: 440px;
291 | max-width: 100%;
292 | background: #ffffff;
293 | border-radius: 24px;
294 | border: 1px solid #e2e8f0;
295 | box-shadow: 0 30px 60px rgba(15, 23, 42, 0.25);
296 | }
297 |
298 | .modal__header {
299 | display: flex;
300 | align-items: center;
301 | justify-content: space-between;
302 | padding: 20px 24px;
303 | border-bottom: 1px solid #f1f5f9;
304 | }
305 |
306 | .modal__header-actions {
307 | display: flex;
308 | align-items: center;
309 | gap: 8px;
310 | }
311 |
312 | .modal__header h2 {
313 | margin: 0;
314 | font-size: 1.2rem;
315 | }
316 |
317 | .modal__help {
318 | border: none;
319 | background: #e2e8f0;
320 | width: 28px;
321 | height: 28px;
322 | border-radius: 50%;
323 | font-weight: 700;
324 | font-size: 0.95rem;
325 | cursor: pointer;
326 | color: #0f172a;
327 | transition: background 0.2s ease, color 0.2s ease;
328 | }
329 |
330 | .modal__help:hover {
331 | background: #cbd5f5;
332 | color: #0a0f1c;
333 | }
334 |
335 | .modal__close {
336 | border: none;
337 | background: transparent;
338 | font-size: 1.5rem;
339 | cursor: pointer;
340 | line-height: 1;
341 | color: #475569;
342 | }
343 |
344 | .modal__body {
345 | padding: 24px;
346 | display: flex;
347 | flex-direction: column;
348 | gap: 18px;
349 | }
350 |
351 | .modal__field {
352 | display: flex;
353 | flex-direction: column;
354 | gap: 8px;
355 | font-size: 0.9rem;
356 | text-align: left;
357 | }
358 |
359 | .modal__field input {
360 | border-radius: 14px;
361 | border: 1px solid #cbd5f5;
362 | padding: 12px 14px;
363 | font-size: 0.95rem;
364 | font-family: inherit;
365 | }
366 |
367 | .modal__field textarea {
368 | border-radius: 14px;
369 | border: 1px solid #cbd5f5;
370 | padding: 12px 14px;
371 | font-size: 0.95rem;
372 | font-family: inherit;
373 | resize: vertical;
374 | }
375 |
376 | .modal__field input:focus {
377 | outline: none;
378 | border-color: #6366f1;
379 | box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
380 | }
381 |
382 | .modal__field textarea:focus {
383 | outline: none;
384 | border-color: #6366f1;
385 | box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
386 | }
387 |
388 | .modal__options {
389 | display: flex;
390 | flex-direction: column;
391 | gap: 6px;
392 | text-align: left;
393 | }
394 |
395 | .modal__options--inline {
396 | flex-direction: row;
397 | align-items: center;
398 | gap: 16px;
399 | }
400 |
401 | .modal__remember {
402 | display: flex;
403 | gap: 8px;
404 | font-size: 0.85rem;
405 | color: #475569;
406 | }
407 |
408 | .modal__hint {
409 | margin: 0;
410 | font-size: 0.8rem;
411 | color: #94a3b8;
412 | }
413 |
414 | .modal__status {
415 | border-radius: 12px;
416 | padding: 10px 14px;
417 | font-size: 0.9rem;
418 | }
419 |
420 | .modal__status--error {
421 | background: #fee2e2;
422 | color: #b91c1c;
423 | }
424 |
425 | .modal__status--success {
426 | background: #dcfce7;
427 | color: #15803d;
428 | }
429 |
430 | .modal__profile {
431 | display: flex;
432 | flex-direction: column;
433 | gap: 12px;
434 | }
435 |
436 | .modal__profile-info {
437 | display: flex;
438 | gap: 14px;
439 | align-items: center;
440 | }
441 |
442 | .modal__profile-avatar {
443 | width: 60px;
444 | height: 60px;
445 | border-radius: 16px;
446 | border: 1px solid #e2e8f0;
447 | object-fit: cover;
448 | display: inline-flex;
449 | align-items: center;
450 | justify-content: center;
451 | font-weight: 600;
452 | background: #f8fafc;
453 | color: #1f2937;
454 | }
455 |
456 | .modal__profile-avatar--fallback {
457 | font-size: 1.25rem;
458 | }
459 |
460 | .modal__profile-name {
461 | margin: 0;
462 | font-size: 1rem;
463 | font-weight: 600;
464 | }
465 |
466 | .modal__profile-login,
467 | .modal__profile-email {
468 | margin: 0;
469 | font-size: 0.85rem;
470 | color: #475569;
471 | }
472 |
473 | .modal__actions {
474 | display: flex;
475 | justify-content: flex-end;
476 | gap: 12px;
477 | }
478 |
479 | .modal__button {
480 | border-radius: 12px;
481 | padding: 10px 18px;
482 | font-size: 0.9rem;
483 | font-weight: 600;
484 | cursor: pointer;
485 | border: 1px solid transparent;
486 | }
487 |
488 | .modal__button--ghost {
489 | border-color: #e2e8f0;
490 | background: #ffffff;
491 | color: #0f172a;
492 | }
493 |
494 | .modal__button--ghost:hover {
495 | border-color: #cbd5f5;
496 | }
497 |
498 | .modal__button--primary {
499 | background: #0f172a;
500 | color: #ffffff;
501 | }
502 |
503 | .modal__button--primary:disabled {
504 | opacity: 0.6;
505 | cursor: not-allowed;
506 | }
507 |
508 | .app-shell__main {
509 | background: #fdfdfd;
510 | border-radius: 28px;
511 | border: 1px solid #e4e7ec;
512 | padding: 36px;
513 | box-shadow: 0 40px 80px rgba(15, 23, 42, 0.08);
514 | }
515 |
516 | .workbench {
517 | display: flex;
518 | flex-direction: column;
519 | gap: 10px;
520 | }
521 |
522 | .workbench__canvas {
523 | background: #ffffff;
524 | border: 1px solid #e5e7eb;
525 | border-radius: 10px;
526 | padding: 10px;
527 | padding-top: 20px;
528 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0 20px 50px rgba(15, 23, 42, 0.07);
529 | overflow: hidden;
530 | }
531 |
532 | .workbench__panel {
533 | position: static;
534 | background: #ffffff;
535 | border-radius: 10px;
536 | padding: 24px;
537 | box-shadow: 0 25px 55px rgba(15, 23, 42, 0.12);
538 | border: 1px solid #e4e7ec;
539 | width: 50%;
540 | }
541 |
542 |
543 | @media (max-width: 1200px) {
544 | .app-shell {
545 | padding: 16px;
546 | }
547 |
548 | .app-shell__main {
549 | padding: 24px;
550 | }
551 |
552 | }
553 |
554 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags:
8 | - 'v*'
9 | pull_request:
10 | branches:
11 | - main
12 | workflow_dispatch:
13 | inputs:
14 | tag:
15 | description: 'Tag to release (existing tag name)'
16 | required: true
17 |
18 | jobs:
19 | build:
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | include:
24 | - os: ubuntu-latest
25 | name: Linux
26 | bin-name: green-wall
27 | - os: windows-latest
28 | name: Windows
29 | bin-name: green-wall.exe
30 | - os: macos-latest
31 | name: macOS
32 | bin-name: green-wall
33 |
34 | runs-on: ${{ matrix.os }}
35 |
36 | steps:
37 | - name: Checkout code
38 | uses: actions/checkout@v4
39 | with:
40 | fetch-depth: 0
41 | ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
42 |
43 | - name: Set up Go
44 | uses: actions/setup-go@v5
45 | with:
46 | go-version-file: go.mod
47 | cache: true
48 | cache-dependency-path: go.sum
49 |
50 | - name: Set up Node.js
51 | uses: actions/setup-node@v4
52 | with:
53 | node-version: '20'
54 | cache: 'npm'
55 | cache-dependency-path: frontend/package-lock.json
56 |
57 | - name: Install frontend dependencies
58 | working-directory: frontend
59 | run: npm ci
60 |
61 | - name: Build frontend
62 | working-directory: frontend
63 | run: npm run build
64 |
65 | - name: Generate Bindings
66 | if: matrix.os != 'windows-latest'
67 | run: |
68 | go run github.com/wailsapp/wails/v2/cmd/wails@latest generate module || true
69 |
70 | - name: Generate Bindings (Windows)
71 | if: matrix.os == 'windows-latest'
72 | shell: powershell
73 | run: |
74 | go run github.com/wailsapp/wails/v2/cmd/wails@latest generate module
75 |
76 | - name: Install Linux build dependencies
77 | if: matrix.os == 'ubuntu-latest'
78 | run: |
79 | sudo apt-get update
80 |
81 | # Install all required dependencies including WebKit
82 | sudo apt-get install -y \
83 | libgtk-3-dev \
84 | libwebkit2gtk-4.1-dev \
85 | libgdk-pixbuf2.0-dev \
86 | libglib2.0-dev \
87 | libcairo2-dev \
88 | libpango1.0-dev \
89 | libatk1.0-dev \
90 | libx11-dev \
91 | libxcomposite-dev \
92 | libxdamage-dev \
93 | libxext-dev \
94 | libxfixes-dev \
95 | libxkbcommon-dev \
96 | libxrandr-dev \
97 | libxrender-dev \
98 | libxshmfence-dev \
99 | libxtst-dev \
100 | pkg-config
101 |
102 | # Create symlink for webkit2gtk-4.0 to webkit2gtk-4.1 (for Wails compatibility)
103 | if pkg-config --exists webkit2gtk-4.1; then
104 | echo "WebKit 4.1 found, creating compatibility symlink for 4.0"
105 | sudo mkdir -p /usr/lib/x86_64-linux-gnu/pkgconfig
106 |
107 | # Create a wrapper .pc file that redirects webkit2gtk-4.0 to webkit2gtk-4.1
108 | echo "prefix=/usr" | sudo tee /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
109 | echo "exec_prefix=\${prefix}" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
110 | echo "libdir=\${exec_prefix}/lib/x86_64-linux-gnu" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
111 | echo "includedir=\${prefix}/include" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
112 | echo "" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
113 | echo "Name: webkit2gtk-4.0" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
114 | echo "Description: WebKit2GTK+ (compatibility wrapper for 4.1)" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
115 | echo "Version: 4.0" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
116 | echo "Requires: webkit2gtk-4.1" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
117 | echo "Libs: -L\${libdir}" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
118 | echo "Cflags: -I\${includedir}" | sudo tee -a /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
119 | fi
120 |
121 | # Verify installation
122 | echo "=== Verifying packages ==="
123 | pkg-config --modversion gtk+-3.0
124 | pkg-config --modversion webkit2gtk-4.1 || echo "webkit2gtk-4.1 not found"
125 | pkg-config --modversion webkit2gtk-4.0 || echo "webkit2gtk-4.0 not found (expected if using symlink)"
126 | pkg-config --cflags webkit2gtk-4.0 || echo "webkit2gtk-4.0 cflags failed"
127 |
128 | - name: Build application (Linux)
129 | if: matrix.os == 'ubuntu-latest'
130 | run: |
131 | go run github.com/wailsapp/wails/v2/cmd/wails@latest build -clean -ldflags "-w -s"
132 |
133 | - name: Build application (Windows)
134 | if: matrix.os == 'windows-latest'
135 | shell: powershell
136 | run: |
137 | go run github.com/wailsapp/wails/v2/cmd/wails@latest build -clean -ldflags "-w -s"
138 |
139 | - name: Build application (macOS)
140 | if: matrix.os == 'macos-latest'
141 | run: |
142 | go run github.com/wailsapp/wails/v2/cmd/wails@latest build -clean -ldflags "-w -s"
143 |
144 | - name: List build output (Linux/macOS)
145 | if: matrix.os != 'windows-latest'
146 | shell: bash
147 | run: |
148 | echo "=== Build directory contents ==="
149 | ls -la build/ || echo "build/ not found"
150 | echo "=== Build bin directory contents ==="
151 | ls -la build/bin/ || echo "build/bin/ not found"
152 | echo "=== Current directory ==="
153 | pwd
154 | find . -name "green-wall*" -o -name "*.exe" -o -name "*.app" 2>/dev/null || echo "No build artifacts found"
155 |
156 | - name: List build output (Windows)
157 | if: matrix.os == 'windows-latest'
158 | shell: powershell
159 | run: |
160 | echo "=== Build directory contents ==="
161 | Get-ChildItem -Path build -Recurse -ErrorAction SilentlyContinue
162 | echo "=== Build bin directory contents ==="
163 | Get-ChildItem -Path build/bin -ErrorAction SilentlyContinue
164 | echo "=== Current directory ==="
165 | Get-Location
166 | echo "=== Searching for build artifacts ==="
167 | Get-ChildItem -Recurse -Include green-wall*.exe -ErrorAction SilentlyContinue
168 |
169 | - name: Create macOS archive
170 | if: matrix.os == 'macos-latest'
171 | run: |
172 | cd build/bin
173 | zip -r ../../green-wall-${{ matrix.name }}-${{ runner.arch }}.zip green-wall.app
174 |
175 | - name: Create Linux archive
176 | if: matrix.os == 'ubuntu-latest'
177 | run: |
178 | cd build/bin
179 | tar -czf ../../green-wall-${{ matrix.name }}-${{ runner.arch }}.tar.gz green-wall*
180 |
181 | - name: Create Windows archive
182 | if: matrix.os == 'windows-latest'
183 | shell: powershell
184 | run: |
185 | cd build/bin
186 | Compress-Archive -Path green-wall*.exe -DestinationPath ../../green-wall-${{ matrix.name }}-${{ runner.arch }}.zip -Force
187 |
188 | - name: Upload macOS artifact
189 | if: matrix.os == 'macos-latest'
190 | uses: actions/upload-artifact@v4
191 | with:
192 | name: green-wall-${{ matrix.name }}-${{ runner.arch }}
193 | path: green-wall-${{ matrix.name }}-${{ runner.arch }}.zip
194 | if-no-files-found: error
195 | retention-days: 30
196 |
197 | - name: Upload Linux artifact
198 | if: matrix.os == 'ubuntu-latest'
199 | uses: actions/upload-artifact@v4
200 | with:
201 | name: green-wall-${{ matrix.name }}-${{ runner.arch }}
202 | path: green-wall-${{ matrix.name }}-${{ runner.arch }}.tar.gz
203 | if-no-files-found: error
204 | retention-days: 30
205 |
206 | - name: Upload Windows artifact
207 | if: matrix.os == 'windows-latest'
208 | uses: actions/upload-artifact@v4
209 | with:
210 | name: green-wall-${{ matrix.name }}-${{ runner.arch }}
211 | path: green-wall-${{ matrix.name }}-${{ runner.arch }}.zip
212 | if-no-files-found: error
213 | retention-days: 30
214 |
215 | release:
216 | needs: build
217 | if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))
218 | runs-on: ubuntu-latest
219 | permissions:
220 | contents: write
221 | env:
222 | RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
223 | RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', github.event.inputs.tag) || github.ref }}
224 |
225 | steps:
226 | - name: Checkout (for git history)
227 | uses: actions/checkout@v4
228 | with:
229 | fetch-depth: 0
230 | ref: ${{ env.RELEASE_REF }}
231 |
232 | - name: Download all artifacts
233 | uses: actions/download-artifact@v4
234 | with:
235 | pattern: green-wall-*
236 |
237 | - name: Generate contributors list
238 | id: contrib
239 | shell: bash
240 | env:
241 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
242 | run: |
243 | set -euo pipefail
244 | CURRENT_TAG="$RELEASE_TAG"
245 | PREV_TAG=""
246 | RANGE_EXPR="$CURRENT_TAG"
247 | SUMMARY_LABEL="Changes in this release"
248 | if PREV_FOUND=$(git describe --abbrev=0 --tags "${CURRENT_TAG}^" 2>/dev/null); then
249 | PREV_TAG=$PREV_FOUND
250 | RANGE_EXPR="${PREV_TAG}..${CURRENT_TAG}"
251 | SUMMARY_LABEL="Changes since $PREV_TAG"
252 | fi
253 | echo "Using range expression: $RANGE_EXPR"
254 | CONTRIBUTORS=$(git log --format='%aN' $RANGE_EXPR | \
255 | grep -viE '\\[bot\\]' | sort -fu | sed 's/^/- @/')
256 | if [ -z "$CONTRIBUTORS" ]; then
257 | CONTRIBUTORS="- (No user-contributed commits in this range)"
258 | fi
259 | echo "contributors<> $GITHUB_OUTPUT
260 | echo "$CONTRIBUTORS" >> $GITHUB_OUTPUT
261 | echo "EOF" >> $GITHUB_OUTPUT
262 | echo "range_expr=$RANGE_EXPR" >> $GITHUB_OUTPUT
263 | echo "range_label=$SUMMARY_LABEL" >> $GITHUB_OUTPUT
264 | echo "previous_tag=$PREV_TAG" >> $GITHUB_OUTPUT
265 |
266 | - name: Summarize changes
267 | id: changes
268 | shell: bash
269 | run: |
270 | set -euo pipefail
271 | RANGE_EXPR="${{ steps.contrib.outputs.range_expr }}"
272 | CHANGES=$(git log --no-merges --pretty='- %s' $RANGE_EXPR)
273 | if [ -z "$CHANGES" ]; then
274 | CHANGES="- No code changes recorded in this range."
275 | fi
276 | echo "summary<> $GITHUB_OUTPUT
277 | echo "$CHANGES" >> $GITHUB_OUTPUT
278 | echo "EOF" >> $GITHUB_OUTPUT
279 |
280 | - name: Build release notes body
281 | id: notes
282 | shell: bash
283 | run: |
284 | set -euo pipefail
285 | TAG="$RELEASE_TAG"
286 | DATE=$(date -u +%Y-%m-%d)
287 | {
288 | echo "Release $TAG ($DATE)"
289 | echo
290 | echo "${{ steps.contrib.outputs.range_label }}:"
291 | echo
292 | printf '%s\n' "${{ steps.changes.outputs.summary }}"
293 | echo
294 | echo "## Thanks to our contributors:"
295 | echo "## 特别感谢以下几位贡献者:"
296 | echo
297 | printf '%s\n' "${{ steps.contrib.outputs.contributors }}"
298 | } > RELEASE_BODY.md
299 | echo "body_path=RELEASE_BODY.md" >> $GITHUB_OUTPUT
300 |
301 | - name: Create Release
302 | uses: softprops/action-gh-release@v2
303 | with:
304 | draft: true
305 | prerelease: false
306 | files: green-wall-*/*
307 | body_path: ${{ steps.notes.outputs.body_path }}
308 | tag_name: ${{ env.RELEASE_TAG }}
309 |
310 |
--------------------------------------------------------------------------------
/frontend/src/components/CalendarControls.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import clsx from 'clsx';
3 | import { useTranslations } from '../i18n';
4 | import { CharacterSelector } from './CharacterSelector';
5 |
6 | type PenIntensity = 1 | 3 | 6 | 9;
7 | type PenOption = PenIntensity | 'auto';
8 |
9 | type Props = {
10 | year?: number;
11 | onYearChange: (year: number) => void;
12 | drawMode?: 'pen' | 'eraser';
13 | penIntensity?: PenIntensity;
14 | onDrawModeChange: (mode: 'pen' | 'eraser') => void;
15 | onPenIntensityChange?: (intensity: PenIntensity) => void;
16 | onReset?: () => void;
17 | onFillAllGreen?: () => void;
18 | onOpenRemoteRepoModal?: () => void;
19 | canCreateRemoteRepo?: boolean;
20 | isGeneratingRepo?: boolean;
21 | onExportContributions?: () => void;
22 | onImportContributions?: () => void;
23 | // 复制模式
24 | copyMode?: boolean;
25 | onCopyModeToggle?: () => void;
26 | // 字符预览相关
27 | onStartCharacterPreview?: (char: string) => void;
28 | previewMode?: boolean;
29 | onCancelCharacterPreview?: () => void;
30 | // 画笔模式
31 | penMode?: 'manual' | 'auto';
32 | onPenModeChange?: (mode: 'manual' | 'auto') => void;
33 | };
34 |
35 | export const CalendarControls: React.FC = ({
36 | year,
37 | onYearChange,
38 | drawMode,
39 | penIntensity = 1,
40 | onDrawModeChange,
41 | onPenIntensityChange,
42 | onReset,
43 | onFillAllGreen,
44 | onOpenRemoteRepoModal,
45 | canCreateRemoteRepo = false,
46 | isGeneratingRepo,
47 | onExportContributions,
48 | onImportContributions,
49 | // 复制模式
50 | copyMode = false,
51 | onCopyModeToggle,
52 | // 字符预览相关
53 | onStartCharacterPreview,
54 | previewMode,
55 | onCancelCharacterPreview,
56 | // 画笔模式
57 | penMode = 'manual',
58 | onPenModeChange,
59 | }) => {
60 | const { t } = useTranslations();
61 | const [yearInput, setYearInput] = React.useState(() =>
62 | typeof year === 'number' ? String(year) : ''
63 | );
64 |
65 | // Character selector visibility
66 | const [showCharacterSelector, setShowCharacterSelector] = React.useState(false);
67 | // Pen intensity picker visibility
68 | const [showPenIntensityPicker, setShowPenIntensityPicker] = React.useState(false);
69 |
70 | React.useEffect(() => {
71 | setYearInput(typeof year === 'number' ? String(year) : '');
72 | }, [year]);
73 |
74 | React.useEffect(() => {
75 | if (drawMode !== 'pen') {
76 | setShowPenIntensityPicker(false);
77 | }
78 | }, [drawMode]);
79 |
80 | const handleYearChange = (event: React.ChangeEvent) => {
81 | const { value } = event.target;
82 | setYearInput(value);
83 |
84 | if (value === '') {
85 | return;
86 | }
87 |
88 | const parsed = Number(value);
89 | const currentYear = new Date().getFullYear();
90 | if (!Number.isNaN(parsed) && parsed >= 2008 && parsed <= currentYear) {
91 | onYearChange(parsed);
92 | }
93 | };
94 |
95 | const handleYearBlur = () => {
96 | const parsed = Number(yearInput);
97 | const currentYear = new Date().getFullYear();
98 | const isValid =
99 | yearInput !== '' && !Number.isNaN(parsed) && parsed >= 2008 && parsed <= currentYear;
100 |
101 | if (!isValid) {
102 | setYearInput(typeof year === 'number' ? String(year) : '');
103 | }
104 | };
105 |
106 | const disableRemoteRepo = !onOpenRemoteRepoModal || !canCreateRemoteRepo || isGeneratingRepo;
107 | const handleOpenRemoteRepoModal = () => {
108 | if (!onOpenRemoteRepoModal) return;
109 | onOpenRemoteRepoModal();
110 | };
111 |
112 | const handleCharacterSelect = (char: string) => {
113 | if (onStartCharacterPreview) {
114 | onStartCharacterPreview(char);
115 | }
116 | };
117 |
118 | const handleCharacterButtonClick = () => {
119 | if (previewMode && onCancelCharacterPreview) {
120 | onCancelCharacterPreview();
121 | } else {
122 | setShowCharacterSelector(true);
123 | }
124 | };
125 |
126 | const penIntensityColors: Record = {
127 | 1: '#9be9a8',
128 | 3: '#40c463',
129 | 6: '#30a14e',
130 | 9: '#216e39',
131 | };
132 | const penOptions: PenOption[] = [1, 3, 6, 9, 'auto'];
133 |
134 | const handlePenSettingsButtonClick = (event: React.MouseEvent) => {
135 | event.preventDefault();
136 | event.stopPropagation();
137 | if (!onPenIntensityChange) {
138 | return;
139 | }
140 | if (drawMode !== 'pen') {
141 | onDrawModeChange('pen');
142 | }
143 | setShowPenIntensityPicker((prev) => !prev);
144 | };
145 |
146 | const handlePenOptionSelect = (option: PenOption) => {
147 | if (option === 'auto') {
148 | onPenModeChange?.('auto');
149 | setShowPenIntensityPicker(false);
150 | return;
151 | }
152 | onPenModeChange?.('manual');
153 | onPenIntensityChange?.(option);
154 | setShowPenIntensityPicker(false);
155 | };
156 |
157 | const getPenSettingsAriaLabel = () => {
158 | if (penMode === 'auto') {
159 | return t('penModes.auto');
160 | }
161 | return t('titles.penIntensity', { intensity: penIntensity });
162 | };
163 |
164 | return (
165 |
166 | {/* Row 1: year + draw controls */}
167 |
168 |
169 |
170 | {t('labels.year')}
171 |
172 |
182 |
183 |
184 |
185 |
186 |
{t('labels.drawMode')}
187 |
188 | onDrawModeChange('pen')}
191 | className={clsx(
192 | 'flex w-full items-center justify-center gap-2 rounded-none px-3 py-2 text-sm font-medium transition-all duration-200',
193 | drawMode === 'pen'
194 | ? 'scale-105 transform bg-black text-white shadow-lg'
195 | : 'border border-black bg-white text-black hover:bg-gray-100'
196 | )}
197 | title={t('titles.pen')}
198 | >
199 | {t('drawModes.pen')}
200 | {onPenIntensityChange && (
201 |
212 | {penMode === 'auto' ? (
213 | {t('penModes.auto')}
214 | ) : (
215 | <>
216 |
220 | {getPenSettingsAriaLabel()}
221 | >
222 | )}
223 |
224 | )}
225 |
226 | {
229 | onDrawModeChange('eraser');
230 | setShowPenIntensityPicker(false);
231 | }}
232 | className={clsx(
233 | 'flex w-full items-center justify-center gap-2 rounded-none px-3 py-2 text-sm font-medium transition-all duration-200',
234 | drawMode === 'eraser'
235 | ? 'scale-105 transform bg-black text-white shadow-lg'
236 | : 'border border-black bg-white text-black hover:bg-gray-100'
237 | )}
238 | title={t('titles.eraser')}
239 | >
240 | {t('drawModes.eraser')}
241 |
242 |
243 |
244 |
245 | {drawMode === 'pen' && onPenIntensityChange && showPenIntensityPicker && (
246 | <>
247 |
setShowPenIntensityPicker(false)}
250 | />
251 |
252 |
253 |
{t('labels.penIntensity')}
254 |
255 | {penOptions.map((option) => {
256 | const isAuto = option === 'auto';
257 | const isActive = isAuto
258 | ? penMode === 'auto'
259 | : penMode === 'manual' && penIntensity === option;
260 | return (
261 | handlePenOptionSelect(option)}
265 | className={clsx(
266 | 'flex items-center justify-between rounded-full px-2 py-1 text-xs font-medium transition-colors duration-200',
267 | isActive
268 | ? 'bg-black text-white'
269 | : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
270 | )}
271 | aria-label={
272 | isAuto
273 | ? t('penModes.auto')
274 | : t('titles.penIntensity', { intensity: option })
275 | }
276 | >
277 | {isAuto ? (
278 | {t('penModes.auto')}
279 | ) : (
280 | <>
281 |
287 |
288 | {t('titles.penIntensity', { intensity: option })}
289 |
290 | >
291 | )}
292 |
293 | );
294 | })}
295 |
296 |
297 |
298 | >
299 | )}
300 |
301 |
302 |
303 | {/* Row 2: character tool + import/export */}
304 |
305 |
306 |
319 | {previewMode ? t('characterSelector.cancelPreview') : t('characterSelector.character')}
320 |
321 |
322 |
323 |
324 |
325 |
331 | {t('buttons.export')}
332 |
333 |
339 | {t('buttons.import')}
340 |
341 |
342 |
343 |
344 |
345 | {/* Row 3: remaining actions */}
346 |
347 |
348 |
354 | {t('buttons.allGreen')}
355 |
356 |
362 | {t('buttons.reset')}
363 |
364 | onCopyModeToggle?.()}
367 | className={clsx(
368 | 'w-full rounded-none px-4 py-2 text-sm font-medium transition-colors duration-200',
369 | copyMode
370 | ? 'bg-black text-white'
371 | : 'border border-black bg-white text-black hover:bg-gray-100'
372 | )}
373 | title={t('titles.copyMode') || 'Ctrl+C复制选定区域'}
374 | >
375 | {t('buttons.copyMode')}
376 |
377 |
389 | {isGeneratingRepo ? t('buttons.generating') : t('buttons.createRemoteRepo')}
390 |
391 |
392 |
393 |
394 | {/* Character selector modal */}
395 | {showCharacterSelector && (
396 |
setShowCharacterSelector(false)}
399 | />
400 | )}
401 |
402 | );
403 | };
404 |
--------------------------------------------------------------------------------
/frontend/src/i18n.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export type Language = 'en' | 'zh';
4 |
5 | type TranslationDict = {
6 | languageName: string;
7 | labels: {
8 | githubUsername: string;
9 | githubEmail: string;
10 | repoName: string;
11 | year: string;
12 | drawMode: string;
13 | penIntensity: string;
14 | language: string;
15 | };
16 | placeholders: {
17 | githubUsername: string;
18 | githubEmail: string;
19 | repoName: string;
20 | };
21 | drawModes: {
22 | pen: string;
23 | eraser: string;
24 | };
25 | penModes: {
26 | manual: string;
27 | auto: string;
28 | };
29 | buttons: {
30 | allGreen: string;
31 | reset: string;
32 | copyMode: string;
33 | generateRepo: string;
34 | generating: string;
35 | export: string;
36 | import: string;
37 | createRemoteRepo: string;
38 | };
39 | titles: {
40 | pen: string;
41 | eraser: string;
42 | penIntensity: string;
43 | penManualMode: string;
44 | penAutoMode: string;
45 | allGreen: string;
46 | reset: string;
47 | copyMode: string;
48 | generate: string;
49 | export: string;
50 | import: string;
51 | };
52 | messages: {
53 | generateRepoMissing: string;
54 | generateRepoError: string;
55 | noContributions: string;
56 | exportSuccess: string;
57 | exportError: string;
58 | importSuccess: string;
59 | importError: string;
60 | remoteLoginRequired: string;
61 | };
62 | gitInstall: {
63 | title: string;
64 | notInstalled: string;
65 | notInstalledLabel: string;
66 | downloadLink: string;
67 | close: string;
68 | instructions: {
69 | windows: string;
70 | mac: string;
71 | linux: string;
72 | };
73 | checkAgain: string;
74 | version: string;
75 | };
76 | gitPathSettings: {
77 | title: string;
78 | description: string;
79 | label: string;
80 | placeholder: string;
81 | setPath: string;
82 | setting: string;
83 | reset: string;
84 | resetSuccess: string;
85 | setSuccess: string;
86 | setError: string;
87 | resetError: string;
88 | pathNotFound: string;
89 | noteTitle: string;
90 | noteEmpty: string;
91 | noteCustom: string;
92 | noteManualCheck: string;
93 | };
94 | calendar: {
95 | totalContributions: string;
96 | tooltipNone: string;
97 | tooltipSome: string;
98 | tooltipFuture: string;
99 | legendLess: string;
100 | legendMore: string;
101 | };
102 | workbench: {
103 | placeholder: string;
104 | };
105 | characterSelector: {
106 | title: string;
107 | selectCharacter: string;
108 | tabUppercase: string;
109 | tabLowercase: string;
110 | tabNumbers: string;
111 | tabSymbols: string;
112 | previewTooltip: string;
113 | cancelPreview: string;
114 | character: string;
115 | };
116 | months: string[];
117 | weekdays: {
118 | mon: string;
119 | wed: string;
120 | fri: string;
121 | };
122 | languageSwitcher: {
123 | english: string;
124 | chinese: string;
125 | };
126 | loginModal: {
127 | title: string;
128 | tokenLabel: string;
129 | tokenPlaceholder: string;
130 | remember: string;
131 | helpLink: string;
132 | submit: string;
133 | submitting: string;
134 | close: string;
135 | hint: string;
136 | success: string;
137 | emailFallback: string;
138 | missingUser: string;
139 | };
140 | remoteModal: {
141 | title: string;
142 | description: string;
143 | nameLabel: string;
144 | namePlaceholder: string;
145 | nameHelp: string;
146 | privacyLabel: string;
147 | publicOption: string;
148 | privateOption: string;
149 | repoDescriptionLabel: string;
150 | repoDescriptionPlaceholder: string;
151 | cancel: string;
152 | confirm: string;
153 | confirming: string;
154 | nameRequired: string;
155 | nameInvalid: string;
156 | };
157 | };
158 |
159 | const translations: Record
= {
160 | en: {
161 | languageName: 'English',
162 | labels: {
163 | githubUsername: 'GitHub Username',
164 | githubEmail: 'GitHub Email',
165 | repoName: 'Repository Name',
166 | year: 'Year',
167 | drawMode: 'Draw Mode',
168 | penIntensity: 'Pen Intensity',
169 | language: 'Language',
170 | },
171 | placeholders: {
172 | githubUsername: 'octocat',
173 | githubEmail: 'monalisa@github.com',
174 | repoName: 'my-contributions',
175 | },
176 | drawModes: {
177 | pen: 'Pen',
178 | eraser: 'Eraser',
179 | },
180 | penModes: {
181 | manual: 'Manual',
182 | auto: 'Auto',
183 | },
184 | buttons: {
185 | allGreen: 'All Green',
186 | reset: 'Reset',
187 | copyMode: 'Copy Mode',
188 | generateRepo: 'Generate Repo',
189 | generating: 'Generating...',
190 | export: 'Export',
191 | import: 'Import',
192 | createRemoteRepo: 'Create Remote Repo',
193 | },
194 | titles: {
195 | pen: 'Pen mode - click or drag to add contributions',
196 | eraser: 'Eraser mode - click or drag to clear contributions',
197 | penIntensity: 'Set pen intensity to {{intensity}} contributions',
198 | penManualMode: 'Manual Mode',
199 | penAutoMode: 'Auto Mode',
200 | allGreen: 'Set all contributions to green',
201 | reset: 'Clear all customised contribution data',
202 | generate: 'Create a local git repository matching this contribution calendar',
203 | export: 'Export current contributions to a JSON file',
204 | import: 'Import contributions from a JSON file',
205 | copyMode: 'Copy mode - select area then press Ctrl+C to copy',
206 | },
207 | messages: {
208 | generateRepoMissing:
209 | 'Please provide a GitHub username and email before generating a repository.',
210 | noContributions: 'No contributions to generate. Add contributions first.',
211 | generateRepoError: 'Failed to generate repository: {{message}}',
212 | exportSuccess: 'Contributions exported to {{filePath}}',
213 | exportError: 'Failed to export contributions: {{message}}',
214 | importSuccess: 'Contributions imported successfully',
215 | importError: 'Failed to import contributions: {{message}}',
216 | remoteLoginRequired:
217 | 'Please sign in with your GitHub token before creating a remote repository.',
218 | },
219 | gitInstall: {
220 | title: 'Git Installation Required',
221 | notInstalled:
222 | 'Git is not installed on your system. Please install Git to use this application.',
223 | notInstalledLabel: 'Git Not Installed',
224 | downloadLink: 'Download Git',
225 | close: 'Close',
226 | instructions: {
227 | windows: 'For Windows: Download Git from the official website and run the installer.',
228 | mac: "For macOS: Use Homebrew with 'brew install git' or download from the official website.",
229 | linux: "For Linux: Use your package manager (e.g., 'sudo apt install git' for Ubuntu).",
230 | },
231 | checkAgain: 'Check Again',
232 | version: 'Git Version: {{version}}',
233 | },
234 | gitPathSettings: {
235 | title: 'Git Path Settings',
236 | description:
237 | 'If Git is installed but not added to system PATH, enter the full path to the Git executable.',
238 | label: 'Git Executable Path',
239 | placeholder: 'e.g.: C:\\Program Files\\Git\\bin\\git.exe',
240 | setPath: 'Set Path',
241 | setting: 'Setting...',
242 | reset: 'Reset to Default',
243 | resetSuccess: 'Reset to default successfully',
244 | setSuccess: 'Git path set successfully',
245 | setError: 'Failed to set path: {{message}}',
246 | resetError: 'Failed to reset: {{message}}',
247 | pathNotFound: 'Specified path does not exist',
248 | noteTitle: 'Note:',
249 | noteEmpty: "Leave empty or click 'Reset to Default' to use the git command from system PATH",
250 | noteCustom:
251 | 'Enter full path (e.g., C:\\Program Files\\Git\\bin\\git.exe) to use that git executable',
252 | noteManualCheck: 'You need to manually check Git status after setting',
253 | },
254 | calendar: {
255 | totalContributions: '{{count}} contributions in {{year}}',
256 | tooltipNone: 'No contributions on {{date}} - Click to add!',
257 | tooltipSome: '{{count}} contributions on {{date}}',
258 | tooltipFuture: 'Upcoming date {{date}} - editing disabled',
259 | legendLess: 'Less',
260 | legendMore: 'More',
261 | },
262 | workbench: {
263 | placeholder:
264 | '✨ This area is under development! Got any wild feature ideas? Drop them in the issues and your creativity might ship~ Tips: Right-click to switch between the brush and eraser. In copy mode, select a pattern, press Ctrl+C to copy it, then Ctrl+V or left-click to paste.',
265 | },
266 | characterSelector: {
267 | title: 'Select Pattern',
268 | selectCharacter: 'Select Character (A-Z, a-z, 0-9)',
269 | tabUppercase: 'A-Z',
270 | tabLowercase: 'a-z',
271 | tabNumbers: '0-9',
272 | tabSymbols: '🎨 Symbols',
273 | previewTooltip: 'Preview character: {{char}}',
274 | cancelPreview: 'Cancel Preview',
275 | character: 'Character',
276 | },
277 | months: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
278 | weekdays: {
279 | mon: 'Mon',
280 | wed: 'Wed',
281 | fri: 'Fri',
282 | },
283 | languageSwitcher: {
284 | english: 'English',
285 | chinese: '中文',
286 | },
287 | loginModal: {
288 | title: 'GitHub Login',
289 | tokenLabel: 'Personal Access Token (classic)',
290 | tokenPlaceholder: 'Paste your PAT',
291 | remember: 'Remember this token (stored locally only)',
292 | helpLink: 'Read how to get a PAT',
293 | submit: 'Sign in with Token',
294 | submitting: 'Verifying...',
295 | close: 'Close',
296 | hint: 'Your token is only used for GitHub calls and stored locally if you choose to remember it.',
297 | success: 'Login successful',
298 | emailFallback: 'Email not public',
299 | missingUser: 'GitHub profile missing in response',
300 | },
301 | remoteModal: {
302 | title: 'Create Remote Repository',
303 | description:
304 | 'GreenWall will reuse your generated commits, create a GitHub repository, add it as origin, and push everything for you.',
305 | nameLabel: 'Repository Name',
306 | namePlaceholder: 'my-contributions',
307 | nameHelp: 'Use letters, numbers, ".", "_" or "-" (up to 100 characters).',
308 | privacyLabel: 'Visibility',
309 | publicOption: 'Public',
310 | privateOption: 'Private',
311 | repoDescriptionLabel: 'Description (optional)',
312 | repoDescriptionPlaceholder: 'Explain what this repository is about',
313 | cancel: 'Cancel',
314 | confirm: 'Generate & Push',
315 | confirming: 'Working...',
316 | nameRequired: 'Repository name is required.',
317 | nameInvalid: 'Repository name can only include letters, numbers, ".", "_" or "-".',
318 | },
319 | },
320 | zh: {
321 | languageName: '中文',
322 | labels: {
323 | githubUsername: 'GitHub 用户名',
324 | githubEmail: 'GitHub 邮箱',
325 | repoName: '仓库名称',
326 | year: '年份',
327 | drawMode: '绘制模式',
328 | penIntensity: '画笔强度',
329 | language: '语言',
330 | },
331 | placeholders: {
332 | githubUsername: 'octocat',
333 | githubEmail: 'monalisa@github.com',
334 | repoName: 'my-contributions',
335 | },
336 | drawModes: {
337 | pen: '画笔',
338 | eraser: '橡皮擦',
339 | },
340 | penModes: {
341 | manual: '手动',
342 | auto: '自动',
343 | },
344 | buttons: {
345 | allGreen: '全绿',
346 | reset: '重置',
347 | copyMode: '复制模式',
348 | generateRepo: '生成仓库',
349 | generating: '生成中...',
350 | export: '导出',
351 | import: '导入',
352 | createRemoteRepo: '创建远程仓库',
353 | },
354 | titles: {
355 | pen: '画笔模式 - 点击或拖拽添加贡献',
356 | eraser: '橡皮擦模式 - 点击或拖拽清除贡献',
357 | penIntensity: '设置画笔强度为 {{intensity}} 次贡献',
358 | penManualMode: '手动模式',
359 | penAutoMode: '自动模式',
360 | allGreen: '将所有贡献设置为绿色',
361 | reset: '清除所有自定义贡献数据',
362 | generate: '创建与当前贡献图匹配的本地 Git 仓库',
363 | export: '导出当前贡献数据到 JSON 文件',
364 | import: '从 JSON 文件导入贡献数据',
365 | copyMode: '复制模式 - 选中区域后按 Ctrl+C 复制',
366 | },
367 | messages: {
368 | generateRepoMissing: '请先填写 GitHub 用户名和邮箱,然后再生成仓库。',
369 | noContributions: '没有可生成的贡献,请先添加贡献。',
370 | generateRepoError: '生成仓库失败:{{message}}',
371 | exportSuccess: '贡献数据已导出到 {{filePath}}',
372 | exportError: '导出贡献数据失败:{{message}}',
373 | importSuccess: '贡献数据已成功导入',
374 | importError: '导入贡献数据失败:{{message}}',
375 | remoteLoginRequired: '请先登录 GitHub 再创建远程仓库。',
376 | },
377 | gitInstall: {
378 | title: '需要安装 Git',
379 | notInstalled: '系统未安装 Git。请安装 Git 以使用此应用程序。',
380 | notInstalledLabel: 'Git 未安装',
381 | downloadLink: '下载 Git',
382 | close: '关闭',
383 | instructions: {
384 | windows: 'Windows 系统:从官方网站下载 Git 并运行安装程序。',
385 | mac: "macOS 系统:使用 Homebrew 运行 'brew install git' 或从官方网站下载。",
386 | linux: "Linux 系统:使用包管理器安装(如 Ubuntu 使用 'sudo apt install git')。",
387 | },
388 | checkAgain: '再次检测',
389 | version: 'Git 版本:{{version}}',
390 | },
391 | gitPathSettings: {
392 | title: 'Git 路径设置',
393 | description: '如果 Git 已安装但未添加到系统 PATH,请输入 Git 可执行文件的完整路径。',
394 | label: 'Git 可执行文件路径',
395 | placeholder: '例如: C:\\Program Files\\Git\\bin\\git.exe',
396 | setPath: '设置路径',
397 | setting: '设置中...',
398 | reset: '重置为默认',
399 | resetSuccess: '已重置为默认路径',
400 | setSuccess: 'Git 路径设置成功',
401 | setError: '设置失败:{{message}}',
402 | resetError: '重置失败:{{message}}',
403 | pathNotFound: '指定的路径不存在',
404 | noteTitle: '说明:',
405 | noteEmpty: "留空或点击'重置为默认'将使用系统 PATH 中的 git 命令",
406 | noteCustom:
407 | '输入完整路径(如 C:\\Program Files\\Git\\bin\\git.exe)将使用该路径的 git 可执行文件',
408 | noteManualCheck: '设置后需要手动检查 Git 状态',
409 | },
410 | calendar: {
411 | totalContributions: '{{year}} 年共 {{count}} 次贡献',
412 | tooltipNone: '{{date}} 没有贡献 - 点击添加!',
413 | tooltipSome: '{{date}} 有 {{count}} 次贡献',
414 | tooltipFuture: '{{date}} 为未来日期,禁止编辑',
415 | legendLess: '较少',
416 | legendMore: '更多',
417 | },
418 | workbench: {
419 | placeholder:
420 | '✨ 该区域正在开发中!大家有哪些脑洞大开的功能想法?快来 issues 留言,你的创意可能会被实现哦~操作说明:右键可以切换画笔和橡皮擦,复制模式下框选好图案后按 ctrl+C 复制图案,ctrl+V 或者左键粘贴图案',
421 | },
422 | characterSelector: {
423 | title: '选择图案',
424 | selectCharacter: '选择字符 (A-Z, a-z, 0-9)',
425 | tabUppercase: 'A-Z',
426 | tabLowercase: 'a-z',
427 | tabNumbers: '0-9',
428 | tabSymbols: '🎨 符号',
429 | previewTooltip: '预览字符: {{char}}',
430 | cancelPreview: '取消预览',
431 | character: '字符',
432 | },
433 | months: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'],
434 | weekdays: {
435 | mon: '一',
436 | wed: '三',
437 | fri: '五',
438 | },
439 | languageSwitcher: {
440 | english: 'English',
441 | chinese: '中文',
442 | },
443 | loginModal: {
444 | title: 'GitHub 登录',
445 | tokenLabel: 'Personal Access Token (classic)',
446 | tokenPlaceholder: '粘贴你的 PAT',
447 | remember: '记住这个 token(仅保存在本机)',
448 | helpLink: '查看如何获取 PAT',
449 | submit: '使用 Token 登录',
450 | submitting: '验证中...',
451 | close: '关闭',
452 | hint: '我们只会将 token 用于调用 GitHub,并在本地安全保存。',
453 | success: '登录成功',
454 | emailFallback: '未公开邮箱',
455 | missingUser: '未能获取 GitHub 用户信息',
456 | },
457 | remoteModal: {
458 | title: '创建远程仓库',
459 | description: 'GreenWall 会复用刚生成的提交,创建 GitHub 仓库并自动推送。',
460 | nameLabel: '仓库名称',
461 | namePlaceholder: 'my-contributions',
462 | nameHelp: '仅可使用字母、数字、“.”、“_”或“-”,最多 100 个字符。',
463 | privacyLabel: '可见性',
464 | publicOption: '公开',
465 | privateOption: '私有',
466 | repoDescriptionLabel: '仓库描述(可选)',
467 | repoDescriptionPlaceholder: '简单介绍一下这个仓库',
468 | cancel: '取消',
469 | confirm: '生成并推送',
470 | confirming: '处理中...',
471 | nameRequired: '请填写仓库名称。',
472 | nameInvalid: '仓库名称只能包含字母、数字、.、_ 或 -。',
473 | },
474 | },
475 | };
476 |
477 | type TranslationContextValue = {
478 | language: Language;
479 | setLanguage: (language: Language) => void;
480 | t: (key: string, params?: Record) => string;
481 | dictionary: TranslationDict;
482 | };
483 |
484 | const LANGUAGE_STORAGE_KEY = 'github-contributor.language';
485 |
486 | const TranslationContext = React.createContext(undefined);
487 |
488 | function interpolate(template: string, params?: Record) {
489 | if (!params) {
490 | return template;
491 | }
492 | return template.replace(/\{\{(.*?)\}\}/g, (_, rawKey: string) => {
493 | const key = rawKey.trim();
494 | const value = params[key];
495 | return value === undefined ? `{{${key}}}` : String(value);
496 | });
497 | }
498 |
499 | function resolveKey(dictionary: TranslationDict, key: string): string | undefined {
500 | const parts = key.split('.');
501 | let current: unknown = dictionary;
502 |
503 | for (const part of parts) {
504 | if (current && typeof current === 'object' && part in current) {
505 | current = (current as Record)[part];
506 | } else {
507 | return undefined;
508 | }
509 | }
510 |
511 | return typeof current === 'string' ? current : undefined;
512 | }
513 |
514 | export const TranslationProvider: React.FC = ({ children }) => {
515 | const [language, setLanguageState] = React.useState(() => {
516 | if (typeof window === 'undefined') {
517 | return 'en';
518 | }
519 | const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY) as Language | null;
520 | return stored === 'en' || stored === 'zh' ? stored : 'en';
521 | });
522 |
523 | const dictionary = translations[language];
524 |
525 | const setLanguage = React.useCallback((next: Language) => {
526 | setLanguageState(next);
527 | if (typeof window !== 'undefined') {
528 | window.localStorage.setItem(LANGUAGE_STORAGE_KEY, next);
529 | }
530 | }, []);
531 |
532 | const translate = React.useCallback(
533 | (key: string, params?: Record) => {
534 | const template = resolveKey(dictionary, key) ?? key;
535 | return interpolate(template, params);
536 | },
537 | [dictionary]
538 | );
539 |
540 | const contextValue = React.useMemo(
541 | () => ({
542 | language,
543 | setLanguage,
544 | t: translate,
545 | dictionary,
546 | }),
547 | [language, setLanguage, translate, dictionary]
548 | );
549 |
550 | return {children} ;
551 | };
552 |
553 | export function useTranslations() {
554 | const context = React.useContext(TranslationContext);
555 | if (!context) {
556 | throw new Error('useTranslations must be used within a TranslationProvider');
557 | }
558 | return context;
559 | }
560 |
561 | export const AVAILABLE_LANGUAGES: { value: Language; label: string }[] = [
562 | { value: 'en', label: translations.en.languageName },
563 | { value: 'zh', label: translations.zh.languageName },
564 | ];
565 |
--------------------------------------------------------------------------------
/frontend/src/data/characterPatterns.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 字符图案数据
3 | * 每个字符使用 7x7 的网格表示,1 表示填充,0 表示空白
4 | */
5 |
6 | export type CharacterPattern = {
7 | id: string;
8 | name: string;
9 | category: 'uppercase' | 'lowercase' | 'numbers' | 'symbols';
10 | grid: number[][];
11 | };
12 |
13 | export const characterPatterns: CharacterPattern[] = [
14 | // 大写字母 A-Z
15 | {
16 | id: 'A',
17 | name: 'A',
18 | category: 'uppercase',
19 | grid: [
20 | [0, 1, 1, 1, 0],
21 | [1, 0, 0, 0, 1],
22 | [1, 0, 0, 0, 1],
23 | [1, 1, 1, 1, 1],
24 | [1, 0, 0, 0, 1],
25 | [1, 0, 0, 0, 1],
26 | [1, 0, 0, 0, 1],
27 | ],
28 | },
29 | {
30 | id: 'B',
31 | name: 'B',
32 | category: 'uppercase',
33 | grid: [
34 | [1, 1, 1, 1, 0],
35 | [1, 0, 0, 0, 1],
36 | [1, 0, 0, 0, 1],
37 | [1, 1, 1, 1, 0],
38 | [1, 0, 0, 0, 1],
39 | [1, 0, 0, 0, 1],
40 | [1, 1, 1, 1, 0],
41 | ],
42 | },
43 | {
44 | id: 'C',
45 | name: 'C',
46 | category: 'uppercase',
47 | grid: [
48 | [0, 1, 1, 1, 0],
49 | [1, 0, 0, 0, 1],
50 | [1, 0, 0, 0, 0],
51 | [1, 0, 0, 0, 0],
52 | [1, 0, 0, 0, 0],
53 | [1, 0, 0, 0, 1],
54 | [0, 1, 1, 1, 0],
55 | ],
56 | },
57 | {
58 | id: 'D',
59 | name: 'D',
60 | category: 'uppercase',
61 | grid: [
62 | [1, 1, 1, 1, 0],
63 | [1, 0, 0, 0, 1],
64 | [1, 0, 0, 0, 1],
65 | [1, 0, 0, 0, 1],
66 | [1, 0, 0, 0, 1],
67 | [1, 0, 0, 0, 1],
68 | [1, 1, 1, 1, 0],
69 | ],
70 | },
71 | {
72 | id: 'E',
73 | name: 'E',
74 | category: 'uppercase',
75 | grid: [
76 | [1, 1, 1, 1, 1],
77 | [1, 0, 0, 0, 0],
78 | [1, 0, 0, 0, 0],
79 | [1, 1, 1, 1, 0],
80 | [1, 0, 0, 0, 0],
81 | [1, 0, 0, 0, 0],
82 | [1, 1, 1, 1, 1],
83 | ],
84 | },
85 | {
86 | id: 'F',
87 | name: 'F',
88 | category: 'uppercase',
89 | grid: [
90 | [1, 1, 1, 1, 1],
91 | [1, 0, 0, 0, 0],
92 | [1, 0, 0, 0, 0],
93 | [1, 1, 1, 1, 0],
94 | [1, 0, 0, 0, 0],
95 | [1, 0, 0, 0, 0],
96 | [1, 0, 0, 0, 0],
97 | ],
98 | },
99 | {
100 | id: 'G',
101 | name: 'G',
102 | category: 'uppercase',
103 | grid: [
104 | [0, 1, 1, 1, 0],
105 | [1, 0, 0, 0, 1],
106 | [1, 0, 0, 0, 0],
107 | [1, 0, 1, 1, 1],
108 | [1, 0, 0, 0, 1],
109 | [1, 0, 0, 0, 1],
110 | [0, 1, 1, 1, 0],
111 | ],
112 | },
113 | {
114 | id: 'H',
115 | name: 'H',
116 | category: 'uppercase',
117 | grid: [
118 | [1, 0, 0, 0, 1],
119 | [1, 0, 0, 0, 1],
120 | [1, 0, 0, 0, 1],
121 | [1, 1, 1, 1, 1],
122 | [1, 0, 0, 0, 1],
123 | [1, 0, 0, 0, 1],
124 | [1, 0, 0, 0, 1],
125 | ],
126 | },
127 | {
128 | id: 'I',
129 | name: 'I',
130 | category: 'uppercase',
131 | grid: [
132 | [1, 1, 1, 1, 1],
133 | [0, 0, 1, 0, 0],
134 | [0, 0, 1, 0, 0],
135 | [0, 0, 1, 0, 0],
136 | [0, 0, 1, 0, 0],
137 | [0, 0, 1, 0, 0],
138 | [1, 1, 1, 1, 1],
139 | ],
140 | },
141 | {
142 | id: 'J',
143 | name: 'J',
144 | category: 'uppercase',
145 | grid: [
146 | [0, 0, 1, 1, 1],
147 | [0, 0, 0, 1, 0],
148 | [0, 0, 0, 1, 0],
149 | [0, 0, 0, 1, 0],
150 | [0, 0, 0, 1, 0],
151 | [1, 0, 0, 1, 0],
152 | [0, 1, 1, 0, 0],
153 | ],
154 | },
155 | {
156 | id: 'K',
157 | name: 'K',
158 | category: 'uppercase',
159 | grid: [
160 | [1, 0, 0, 0, 1],
161 | [1, 0, 0, 1, 0],
162 | [1, 0, 1, 0, 0],
163 | [1, 1, 0, 0, 0],
164 | [1, 0, 1, 0, 0],
165 | [1, 0, 0, 1, 0],
166 | [1, 0, 0, 0, 1],
167 | ],
168 | },
169 | {
170 | id: 'L',
171 | name: 'L',
172 | category: 'uppercase',
173 | grid: [
174 | [1, 0, 0, 0, 0],
175 | [1, 0, 0, 0, 0],
176 | [1, 0, 0, 0, 0],
177 | [1, 0, 0, 0, 0],
178 | [1, 0, 0, 0, 0],
179 | [1, 0, 0, 0, 0],
180 | [1, 1, 1, 1, 1],
181 | ],
182 | },
183 | {
184 | id: 'M',
185 | name: 'M',
186 | category: 'uppercase',
187 | grid: [
188 | [1, 0, 0, 0, 1],
189 | [1, 1, 0, 1, 1],
190 | [1, 0, 1, 0, 1],
191 | [1, 0, 0, 0, 1],
192 | [1, 0, 0, 0, 1],
193 | [1, 0, 0, 0, 1],
194 | [1, 0, 0, 0, 1],
195 | ],
196 | },
197 | {
198 | id: 'N',
199 | name: 'N',
200 | category: 'uppercase',
201 | grid: [
202 | [1, 0, 0, 0, 1],
203 | [1, 1, 0, 0, 1],
204 | [1, 0, 1, 0, 1],
205 | [1, 0, 0, 1, 1],
206 | [1, 0, 0, 0, 1],
207 | [1, 0, 0, 0, 1],
208 | [1, 0, 0, 0, 1],
209 | ],
210 | },
211 | {
212 | id: 'O',
213 | name: 'O',
214 | category: 'uppercase',
215 | grid: [
216 | [0, 1, 1, 1, 0],
217 | [1, 0, 0, 0, 1],
218 | [1, 0, 0, 0, 1],
219 | [1, 0, 0, 0, 1],
220 | [1, 0, 0, 0, 1],
221 | [1, 0, 0, 0, 1],
222 | [0, 1, 1, 1, 0],
223 | ],
224 | },
225 | {
226 | id: 'P',
227 | name: 'P',
228 | category: 'uppercase',
229 | grid: [
230 | [1, 1, 1, 1, 0],
231 | [1, 0, 0, 0, 1],
232 | [1, 0, 0, 0, 1],
233 | [1, 1, 1, 1, 0],
234 | [1, 0, 0, 0, 0],
235 | [1, 0, 0, 0, 0],
236 | [1, 0, 0, 0, 0],
237 | ],
238 | },
239 | {
240 | id: 'Q',
241 | name: 'Q',
242 | category: 'uppercase',
243 | grid: [
244 | [0, 1, 1, 1, 0],
245 | [1, 0, 0, 0, 1],
246 | [1, 0, 0, 0, 1],
247 | [1, 0, 0, 0, 1],
248 | [1, 0, 1, 0, 1],
249 | [1, 0, 0, 1, 0],
250 | [0, 1, 1, 0, 1],
251 | ],
252 | },
253 | {
254 | id: 'R',
255 | name: 'R',
256 | category: 'uppercase',
257 | grid: [
258 | [1, 1, 1, 1, 0],
259 | [1, 0, 0, 0, 1],
260 | [1, 0, 0, 0, 1],
261 | [1, 1, 1, 1, 0],
262 | [1, 0, 1, 0, 0],
263 | [1, 0, 0, 1, 0],
264 | [1, 0, 0, 0, 1],
265 | ],
266 | },
267 | {
268 | id: 'S',
269 | name: 'S',
270 | category: 'uppercase',
271 | grid: [
272 | [0, 1, 1, 1, 0],
273 | [1, 0, 0, 0, 1],
274 | [1, 0, 0, 0, 0],
275 | [0, 1, 1, 1, 0],
276 | [0, 0, 0, 0, 1],
277 | [1, 0, 0, 0, 1],
278 | [0, 1, 1, 1, 0],
279 | ],
280 | },
281 | {
282 | id: 'T',
283 | name: 'T',
284 | category: 'uppercase',
285 | grid: [
286 | [1, 1, 1, 1, 1],
287 | [0, 0, 1, 0, 0],
288 | [0, 0, 1, 0, 0],
289 | [0, 0, 1, 0, 0],
290 | [0, 0, 1, 0, 0],
291 | [0, 0, 1, 0, 0],
292 | [0, 0, 1, 0, 0],
293 | ],
294 | },
295 | {
296 | id: 'U',
297 | name: 'U',
298 | category: 'uppercase',
299 | grid: [
300 | [1, 0, 0, 0, 1],
301 | [1, 0, 0, 0, 1],
302 | [1, 0, 0, 0, 1],
303 | [1, 0, 0, 0, 1],
304 | [1, 0, 0, 0, 1],
305 | [1, 0, 0, 0, 1],
306 | [0, 1, 1, 1, 0],
307 | ],
308 | },
309 | {
310 | id: 'V',
311 | name: 'V',
312 | category: 'uppercase',
313 | grid: [
314 | [1, 0, 0, 0, 1],
315 | [1, 0, 0, 0, 1],
316 | [1, 0, 0, 0, 1],
317 | [1, 0, 0, 0, 1],
318 | [1, 0, 0, 0, 1],
319 | [0, 1, 0, 1, 0],
320 | [0, 0, 1, 0, 0],
321 | ],
322 | },
323 | {
324 | id: 'W',
325 | name: 'W',
326 | category: 'uppercase',
327 | grid: [
328 | [1, 0, 0, 0, 1],
329 | [1, 0, 0, 0, 1],
330 | [1, 0, 0, 0, 1],
331 | [1, 0, 0, 0, 1],
332 | [1, 0, 1, 0, 1],
333 | [1, 1, 0, 1, 1],
334 | [1, 0, 0, 0, 1],
335 | ],
336 | },
337 | {
338 | id: 'X',
339 | name: 'X',
340 | category: 'uppercase',
341 | grid: [
342 | [1, 0, 0, 0, 1],
343 | [1, 0, 0, 0, 1],
344 | [0, 1, 0, 1, 0],
345 | [0, 0, 1, 0, 0],
346 | [0, 1, 0, 1, 0],
347 | [1, 0, 0, 0, 1],
348 | [1, 0, 0, 0, 1],
349 | ],
350 | },
351 | {
352 | id: 'Y',
353 | name: 'Y',
354 | category: 'uppercase',
355 | grid: [
356 | [1, 0, 0, 0, 1],
357 | [1, 0, 0, 0, 1],
358 | [0, 1, 0, 1, 0],
359 | [0, 0, 1, 0, 0],
360 | [0, 0, 1, 0, 0],
361 | [0, 0, 1, 0, 0],
362 | [0, 0, 1, 0, 0],
363 | ],
364 | },
365 | {
366 | id: 'Z',
367 | name: 'Z',
368 | category: 'uppercase',
369 | grid: [
370 | [1, 1, 1, 1, 1],
371 | [0, 0, 0, 0, 1],
372 | [0, 0, 0, 1, 0],
373 | [0, 0, 1, 0, 0],
374 | [0, 1, 0, 0, 0],
375 | [1, 0, 0, 0, 0],
376 | [1, 1, 1, 1, 1],
377 | ],
378 | },
379 |
380 | // 小写字母 a-z
381 | {
382 | id: 'a',
383 | name: 'a',
384 | category: 'lowercase',
385 | grid: [
386 | [0, 0, 0, 0, 0],
387 | [0, 0, 0, 0, 0],
388 | [0, 1, 1, 1, 0],
389 | [0, 0, 0, 0, 1],
390 | [0, 1, 1, 1, 1],
391 | [1, 0, 0, 0, 1],
392 | [0, 1, 1, 1, 1],
393 | ],
394 | },
395 | {
396 | id: 'b',
397 | name: 'b',
398 | category: 'lowercase',
399 | grid: [
400 | [1, 0, 0, 0, 0],
401 | [1, 0, 0, 0, 0],
402 | [1, 1, 1, 1, 0],
403 | [1, 0, 0, 0, 1],
404 | [1, 0, 0, 0, 1],
405 | [1, 0, 0, 0, 1],
406 | [1, 1, 1, 1, 0],
407 | ],
408 | },
409 | {
410 | id: 'c',
411 | name: 'c',
412 | category: 'lowercase',
413 | grid: [
414 | [0, 0, 0, 0, 0],
415 | [0, 0, 0, 0, 0],
416 | [0, 1, 1, 1, 0],
417 | [1, 0, 0, 0, 1],
418 | [1, 0, 0, 0, 0],
419 | [1, 0, 0, 0, 1],
420 | [0, 1, 1, 1, 0],
421 | ],
422 | },
423 | {
424 | id: 'd',
425 | name: 'd',
426 | category: 'lowercase',
427 | grid: [
428 | [0, 0, 0, 0, 1],
429 | [0, 0, 0, 0, 1],
430 | [0, 1, 1, 1, 1],
431 | [1, 0, 0, 0, 1],
432 | [1, 0, 0, 0, 1],
433 | [1, 0, 0, 0, 1],
434 | [0, 1, 1, 1, 1],
435 | ],
436 | },
437 | {
438 | id: 'e',
439 | name: 'e',
440 | category: 'lowercase',
441 | grid: [
442 | [0, 0, 0, 0, 0],
443 | [0, 0, 0, 0, 0],
444 | [0, 1, 1, 1, 0],
445 | [1, 0, 0, 0, 1],
446 | [1, 1, 1, 1, 1],
447 | [1, 0, 0, 0, 0],
448 | [0, 1, 1, 1, 0],
449 | ],
450 | },
451 | {
452 | id: 'f',
453 | name: 'f',
454 | category: 'lowercase',
455 | grid: [
456 | [0, 0, 1, 1, 0],
457 | [0, 1, 0, 0, 0],
458 | [1, 1, 1, 1, 0],
459 | [0, 1, 0, 0, 0],
460 | [0, 1, 0, 0, 0],
461 | [0, 1, 0, 0, 0],
462 | [0, 1, 0, 0, 0],
463 | ],
464 | },
465 | {
466 | id: 'g',
467 | name: 'g',
468 | category: 'lowercase',
469 | grid: [
470 | [0, 0, 0, 0, 0],
471 | [0, 1, 1, 1, 1],
472 | [1, 0, 0, 0, 1],
473 | [1, 0, 0, 0, 1],
474 | [0, 1, 1, 1, 1],
475 | [0, 0, 0, 0, 1],
476 | [0, 1, 1, 1, 0],
477 | ],
478 | },
479 | {
480 | id: 'h',
481 | name: 'h',
482 | category: 'lowercase',
483 | grid: [
484 | [1, 0, 0, 0, 0],
485 | [1, 0, 0, 0, 0],
486 | [1, 1, 1, 1, 0],
487 | [1, 0, 0, 0, 1],
488 | [1, 0, 0, 0, 1],
489 | [1, 0, 0, 0, 1],
490 | [1, 0, 0, 0, 1],
491 | ],
492 | },
493 | {
494 | id: 'i',
495 | name: 'i',
496 | category: 'lowercase',
497 | grid: [
498 | [0, 0, 1, 0, 0],
499 | [0, 0, 0, 0, 0],
500 | [0, 1, 1, 0, 0],
501 | [0, 0, 1, 0, 0],
502 | [0, 0, 1, 0, 0],
503 | [0, 0, 1, 0, 0],
504 | [0, 1, 1, 1, 0],
505 | ],
506 | },
507 | {
508 | id: 'j',
509 | name: 'j',
510 | category: 'lowercase',
511 | grid: [
512 | [0, 0, 0, 1, 0],
513 | [0, 0, 0, 0, 0],
514 | [0, 0, 1, 1, 0],
515 | [0, 0, 0, 1, 0],
516 | [0, 0, 0, 1, 0],
517 | [1, 0, 0, 1, 0],
518 | [0, 1, 1, 0, 0],
519 | ],
520 | },
521 | {
522 | id: 'k',
523 | name: 'k',
524 | category: 'lowercase',
525 | grid: [
526 | [1, 0, 0, 0, 0],
527 | [1, 0, 0, 0, 0],
528 | [1, 0, 0, 1, 0],
529 | [1, 0, 1, 0, 0],
530 | [1, 1, 0, 0, 0],
531 | [1, 0, 1, 0, 0],
532 | [1, 0, 0, 1, 0],
533 | ],
534 | },
535 | {
536 | id: 'l',
537 | name: 'l',
538 | category: 'lowercase',
539 | grid: [
540 | [0, 1, 1, 0, 0],
541 | [0, 0, 1, 0, 0],
542 | [0, 0, 1, 0, 0],
543 | [0, 0, 1, 0, 0],
544 | [0, 0, 1, 0, 0],
545 | [0, 0, 1, 0, 0],
546 | [0, 1, 1, 1, 0],
547 | ],
548 | },
549 | {
550 | id: 'm',
551 | name: 'm',
552 | category: 'lowercase',
553 | grid: [
554 | [0, 0, 0, 0, 0],
555 | [0, 0, 0, 0, 0],
556 | [1, 1, 0, 1, 0],
557 | [1, 0, 1, 0, 1],
558 | [1, 0, 1, 0, 1],
559 | [1, 0, 0, 0, 1],
560 | [1, 0, 0, 0, 1],
561 | ],
562 | },
563 | {
564 | id: 'n',
565 | name: 'n',
566 | category: 'lowercase',
567 | grid: [
568 | [0, 0, 0, 0, 0],
569 | [0, 0, 0, 0, 0],
570 | [1, 1, 1, 1, 0],
571 | [1, 0, 0, 0, 1],
572 | [1, 0, 0, 0, 1],
573 | [1, 0, 0, 0, 1],
574 | [1, 0, 0, 0, 1],
575 | ],
576 | },
577 | {
578 | id: 'o',
579 | name: 'o',
580 | category: 'lowercase',
581 | grid: [
582 | [0, 0, 0, 0, 0],
583 | [0, 0, 0, 0, 0],
584 | [0, 1, 1, 1, 0],
585 | [1, 0, 0, 0, 1],
586 | [1, 0, 0, 0, 1],
587 | [1, 0, 0, 0, 1],
588 | [0, 1, 1, 1, 0],
589 | ],
590 | },
591 | {
592 | id: 'p',
593 | name: 'p',
594 | category: 'lowercase',
595 | grid: [
596 | [0, 0, 0, 0, 0],
597 | [0, 0, 0, 0, 0],
598 | [1, 1, 1, 1, 0],
599 | [1, 0, 0, 0, 1],
600 | [1, 1, 1, 1, 0],
601 | [1, 0, 0, 0, 0],
602 | [1, 0, 0, 0, 0],
603 | ],
604 | },
605 | {
606 | id: 'q',
607 | name: 'q',
608 | category: 'lowercase',
609 | grid: [
610 | [0, 0, 0, 0, 0],
611 | [0, 0, 0, 0, 0],
612 | [0, 1, 1, 1, 1],
613 | [1, 0, 0, 0, 1],
614 | [0, 1, 1, 1, 1],
615 | [0, 0, 0, 0, 1],
616 | [0, 0, 0, 0, 1],
617 | ],
618 | },
619 | {
620 | id: 'r',
621 | name: 'r',
622 | category: 'lowercase',
623 | grid: [
624 | [0, 0, 0, 0, 0],
625 | [0, 0, 0, 0, 0],
626 | [1, 0, 1, 1, 0],
627 | [1, 1, 0, 0, 1],
628 | [1, 0, 0, 0, 0],
629 | [1, 0, 0, 0, 0],
630 | [1, 0, 0, 0, 0],
631 | ],
632 | },
633 | {
634 | id: 's',
635 | name: 's',
636 | category: 'lowercase',
637 | grid: [
638 | [0, 0, 0, 0, 0],
639 | [0, 0, 0, 0, 0],
640 | [0, 1, 1, 1, 0],
641 | [1, 0, 0, 0, 0],
642 | [0, 1, 1, 1, 0],
643 | [0, 0, 0, 0, 1],
644 | [1, 1, 1, 1, 0],
645 | ],
646 | },
647 | {
648 | id: 't',
649 | name: 't',
650 | category: 'lowercase',
651 | grid: [
652 | [0, 1, 0, 0, 0],
653 | [0, 1, 0, 0, 0],
654 | [1, 1, 1, 1, 0],
655 | [0, 1, 0, 0, 0],
656 | [0, 1, 0, 0, 0],
657 | [0, 1, 0, 0, 1],
658 | [0, 0, 1, 1, 0],
659 | ],
660 | },
661 | {
662 | id: 'u',
663 | name: 'u',
664 | category: 'lowercase',
665 | grid: [
666 | [0, 0, 0, 0, 0],
667 | [0, 0, 0, 0, 0],
668 | [1, 0, 0, 0, 1],
669 | [1, 0, 0, 0, 1],
670 | [1, 0, 0, 0, 1],
671 | [1, 0, 0, 0, 1],
672 | [0, 1, 1, 1, 1],
673 | ],
674 | },
675 | {
676 | id: 'v',
677 | name: 'v',
678 | category: 'lowercase',
679 | grid: [
680 | [0, 0, 0, 0, 0],
681 | [0, 0, 0, 0, 0],
682 | [1, 0, 0, 0, 1],
683 | [1, 0, 0, 0, 1],
684 | [1, 0, 0, 0, 1],
685 | [0, 1, 0, 1, 0],
686 | [0, 0, 1, 0, 0],
687 | ],
688 | },
689 | {
690 | id: 'w',
691 | name: 'w',
692 | category: 'lowercase',
693 | grid: [
694 | [0, 0, 0, 0, 0],
695 | [0, 0, 0, 0, 0],
696 | [1, 0, 0, 0, 1],
697 | [1, 0, 0, 0, 1],
698 | [1, 0, 1, 0, 1],
699 | [1, 0, 1, 0, 1],
700 | [0, 1, 0, 1, 0],
701 | ],
702 | },
703 | {
704 | id: 'x',
705 | name: 'x',
706 | category: 'lowercase',
707 | grid: [
708 | [0, 0, 0, 0, 0],
709 | [0, 0, 0, 0, 0],
710 | [1, 0, 0, 0, 1],
711 | [0, 1, 0, 1, 0],
712 | [0, 0, 1, 0, 0],
713 | [0, 1, 0, 1, 0],
714 | [1, 0, 0, 0, 1],
715 | ],
716 | },
717 | {
718 | id: 'y',
719 | name: 'y',
720 | category: 'lowercase',
721 | grid: [
722 | [0, 0, 0, 0, 0],
723 | [0, 0, 0, 0, 0],
724 | [1, 0, 0, 0, 1],
725 | [1, 0, 0, 0, 1],
726 | [0, 1, 1, 1, 1],
727 | [0, 0, 0, 0, 1],
728 | [0, 1, 1, 1, 0],
729 | ],
730 | },
731 | {
732 | id: 'z',
733 | name: 'z',
734 | category: 'lowercase',
735 | grid: [
736 | [0, 0, 0, 0, 0],
737 | [0, 0, 0, 0, 0],
738 | [1, 1, 1, 1, 1],
739 | [0, 0, 0, 1, 0],
740 | [0, 0, 1, 0, 0],
741 | [0, 1, 0, 0, 0],
742 | [1, 1, 1, 1, 1],
743 | ],
744 | },
745 |
746 | // 数字 0-9
747 | {
748 | id: '0',
749 | name: '0',
750 | category: 'numbers',
751 | grid: [
752 | [0, 1, 1, 1, 0],
753 | [1, 0, 0, 0, 1],
754 | [1, 0, 0, 1, 1],
755 | [1, 0, 1, 0, 1],
756 | [1, 1, 0, 0, 1],
757 | [1, 0, 0, 0, 1],
758 | [0, 1, 1, 1, 0],
759 | ],
760 | },
761 | {
762 | id: '1',
763 | name: '1',
764 | category: 'numbers',
765 | grid: [
766 | [0, 0, 1, 0, 0],
767 | [0, 1, 1, 0, 0],
768 | [0, 0, 1, 0, 0],
769 | [0, 0, 1, 0, 0],
770 | [0, 0, 1, 0, 0],
771 | [0, 0, 1, 0, 0],
772 | [0, 1, 1, 1, 0],
773 | ],
774 | },
775 | {
776 | id: '2',
777 | name: '2',
778 | category: 'numbers',
779 | grid: [
780 | [0, 1, 1, 1, 0],
781 | [1, 0, 0, 0, 1],
782 | [0, 0, 0, 0, 1],
783 | [0, 0, 0, 1, 0],
784 | [0, 0, 1, 0, 0],
785 | [0, 1, 0, 0, 0],
786 | [1, 1, 1, 1, 1],
787 | ],
788 | },
789 | {
790 | id: '3',
791 | name: '3',
792 | category: 'numbers',
793 | grid: [
794 | [0, 1, 1, 1, 0],
795 | [1, 0, 0, 0, 1],
796 | [0, 0, 0, 0, 1],
797 | [0, 0, 1, 1, 0],
798 | [0, 0, 0, 0, 1],
799 | [1, 0, 0, 0, 1],
800 | [0, 1, 1, 1, 0],
801 | ],
802 | },
803 | {
804 | id: '4',
805 | name: '4',
806 | category: 'numbers',
807 | grid: [
808 | [0, 0, 0, 1, 0],
809 | [0, 0, 1, 1, 0],
810 | [0, 1, 0, 1, 0],
811 | [1, 0, 0, 1, 0],
812 | [1, 1, 1, 1, 1],
813 | [0, 0, 0, 1, 0],
814 | [0, 0, 0, 1, 0],
815 | ],
816 | },
817 | {
818 | id: '5',
819 | name: '5',
820 | category: 'numbers',
821 | grid: [
822 | [1, 1, 1, 1, 1],
823 | [1, 0, 0, 0, 0],
824 | [1, 1, 1, 1, 0],
825 | [0, 0, 0, 0, 1],
826 | [0, 0, 0, 0, 1],
827 | [1, 0, 0, 0, 1],
828 | [0, 1, 1, 1, 0],
829 | ],
830 | },
831 | {
832 | id: '6',
833 | name: '6',
834 | category: 'numbers',
835 | grid: [
836 | [0, 0, 1, 1, 0],
837 | [0, 1, 0, 0, 0],
838 | [1, 0, 0, 0, 0],
839 | [1, 1, 1, 1, 0],
840 | [1, 0, 0, 0, 1],
841 | [1, 0, 0, 0, 1],
842 | [0, 1, 1, 1, 0],
843 | ],
844 | },
845 | {
846 | id: '7',
847 | name: '7',
848 | category: 'numbers',
849 | grid: [
850 | [1, 1, 1, 1, 1],
851 | [0, 0, 0, 0, 1],
852 | [0, 0, 0, 1, 0],
853 | [0, 0, 1, 0, 0],
854 | [0, 1, 0, 0, 0],
855 | [0, 1, 0, 0, 0],
856 | [0, 1, 0, 0, 0],
857 | ],
858 | },
859 | {
860 | id: '8',
861 | name: '8',
862 | category: 'numbers',
863 | grid: [
864 | [0, 1, 1, 1, 0],
865 | [1, 0, 0, 0, 1],
866 | [1, 0, 0, 0, 1],
867 | [0, 1, 1, 1, 0],
868 | [1, 0, 0, 0, 1],
869 | [1, 0, 0, 0, 1],
870 | [0, 1, 1, 1, 0],
871 | ],
872 | },
873 | {
874 | id: '9',
875 | name: '9',
876 | category: 'numbers',
877 | grid: [
878 | [0, 1, 1, 1, 0],
879 | [1, 0, 0, 0, 1],
880 | [1, 0, 0, 0, 1],
881 | [0, 1, 1, 1, 1],
882 | [0, 0, 0, 0, 1],
883 | [0, 0, 0, 1, 0],
884 | [0, 1, 1, 0, 0],
885 | ],
886 | },
887 |
888 | // 符号
889 | {
890 | id: '!',
891 | name: '!',
892 | category: 'symbols',
893 | grid: [
894 | [0, 0, 1, 0, 0],
895 | [0, 0, 1, 0, 0],
896 | [0, 0, 1, 0, 0],
897 | [0, 0, 1, 0, 0],
898 | [0, 0, 1, 0, 0],
899 | [0, 0, 0, 0, 0],
900 | [0, 0, 1, 0, 0],
901 | ],
902 | },
903 | {
904 | id: '@',
905 | name: '@',
906 | category: 'symbols',
907 | grid: [
908 | [0, 1, 1, 1, 0],
909 | [1, 0, 0, 0, 1],
910 | [1, 0, 1, 1, 1],
911 | [1, 0, 1, 0, 1],
912 | [1, 0, 1, 1, 1],
913 | [1, 0, 0, 0, 0],
914 | [0, 1, 1, 1, 0],
915 | ],
916 | },
917 | {
918 | id: '#',
919 | name: '#',
920 | category: 'symbols',
921 | grid: [
922 | [0, 1, 0, 1, 0],
923 | [0, 1, 0, 1, 0],
924 | [1, 1, 1, 1, 1],
925 | [0, 1, 0, 1, 0],
926 | [1, 1, 1, 1, 1],
927 | [0, 1, 0, 1, 0],
928 | [0, 1, 0, 1, 0],
929 | ],
930 | },
931 | {
932 | id: '$',
933 | name: '$',
934 | category: 'symbols',
935 | grid: [
936 | [0, 0, 1, 0, 0],
937 | [0, 1, 1, 1, 1],
938 | [1, 0, 1, 0, 0],
939 | [0, 1, 1, 1, 0],
940 | [0, 0, 1, 0, 1],
941 | [1, 1, 1, 1, 0],
942 | [0, 0, 1, 0, 0],
943 | ],
944 | },
945 | {
946 | id: '%',
947 | name: '%',
948 | category: 'symbols',
949 | grid: [
950 | [1, 1, 0, 0, 1],
951 | [1, 1, 0, 1, 0],
952 | [0, 0, 1, 0, 0],
953 | [0, 1, 0, 0, 0],
954 | [1, 0, 0, 1, 1],
955 | [0, 0, 1, 1, 1],
956 | [0, 0, 0, 0, 0],
957 | ],
958 | },
959 | {
960 | id: '^',
961 | name: '^',
962 | category: 'symbols',
963 | grid: [
964 | [0, 0, 1, 0, 0],
965 | [0, 1, 0, 1, 0],
966 | [1, 0, 0, 0, 1],
967 | [0, 0, 0, 0, 0],
968 | [0, 0, 0, 0, 0],
969 | [0, 0, 0, 0, 0],
970 | [0, 0, 0, 0, 0],
971 | ],
972 | },
973 | {
974 | id: '&',
975 | name: '&',
976 | category: 'symbols',
977 | grid: [
978 | [0, 1, 1, 0, 0],
979 | [1, 0, 0, 1, 0],
980 | [1, 0, 0, 1, 0],
981 | [0, 1, 1, 0, 0],
982 | [1, 0, 0, 1, 0],
983 | [1, 0, 0, 0, 1],
984 | [0, 1, 1, 1, 1],
985 | ],
986 | },
987 | {
988 | id: '*',
989 | name: '*',
990 | category: 'symbols',
991 | grid: [
992 | [0, 0, 0, 0, 0],
993 | [0, 1, 0, 1, 0],
994 | [0, 0, 1, 0, 0],
995 | [1, 1, 1, 1, 1],
996 | [0, 0, 1, 0, 0],
997 | [0, 1, 0, 1, 0],
998 | [0, 0, 0, 0, 0],
999 | ],
1000 | },
1001 | {
1002 | id: '(',
1003 | name: '(',
1004 | category: 'symbols',
1005 | grid: [
1006 | [0, 0, 1, 0, 0],
1007 | [0, 1, 0, 0, 0],
1008 | [1, 0, 0, 0, 0],
1009 | [1, 0, 0, 0, 0],
1010 | [1, 0, 0, 0, 0],
1011 | [0, 1, 0, 0, 0],
1012 | [0, 0, 1, 0, 0],
1013 | ],
1014 | },
1015 | {
1016 | id: ')',
1017 | name: ')',
1018 | category: 'symbols',
1019 | grid: [
1020 | [0, 0, 1, 0, 0],
1021 | [0, 0, 0, 1, 0],
1022 | [0, 0, 0, 0, 1],
1023 | [0, 0, 0, 0, 1],
1024 | [0, 0, 0, 0, 1],
1025 | [0, 0, 0, 1, 0],
1026 | [0, 0, 1, 0, 0],
1027 | ],
1028 | },
1029 | {
1030 | id: '-',
1031 | name: '-',
1032 | category: 'symbols',
1033 | grid: [
1034 | [0, 0, 0, 0, 0],
1035 | [0, 0, 0, 0, 0],
1036 | [0, 0, 0, 0, 0],
1037 | [1, 1, 1, 1, 1],
1038 | [0, 0, 0, 0, 0],
1039 | [0, 0, 0, 0, 0],
1040 | [0, 0, 0, 0, 0],
1041 | ],
1042 | },
1043 | {
1044 | id: '+',
1045 | name: '+',
1046 | category: 'symbols',
1047 | grid: [
1048 | [0, 0, 0, 0, 0],
1049 | [0, 0, 1, 0, 0],
1050 | [0, 0, 1, 0, 0],
1051 | [1, 1, 1, 1, 1],
1052 | [0, 0, 1, 0, 0],
1053 | [0, 0, 1, 0, 0],
1054 | [0, 0, 0, 0, 0],
1055 | ],
1056 | },
1057 | {
1058 | id: '=',
1059 | name: '=',
1060 | category: 'symbols',
1061 | grid: [
1062 | [0, 0, 0, 0, 0],
1063 | [0, 0, 0, 0, 0],
1064 | [1, 1, 1, 1, 1],
1065 | [0, 0, 0, 0, 0],
1066 | [1, 1, 1, 1, 1],
1067 | [0, 0, 0, 0, 0],
1068 | [0, 0, 0, 0, 0],
1069 | ],
1070 | },
1071 | {
1072 | id: '_',
1073 | name: '_',
1074 | category: 'symbols',
1075 | grid: [
1076 | [0, 0, 0, 0, 0],
1077 | [0, 0, 0, 0, 0],
1078 | [0, 0, 0, 0, 0],
1079 | [0, 0, 0, 0, 0],
1080 | [0, 0, 0, 0, 0],
1081 | [0, 0, 0, 0, 0],
1082 | [1, 1, 1, 1, 1],
1083 | ],
1084 | },
1085 | ];
1086 |
1087 | /**
1088 | * 根据分类获取字符图案
1089 | */
1090 | export function getPatternsByCategory(category: CharacterPattern['category']): CharacterPattern[] {
1091 | return characterPatterns.filter((p) => p.category === category);
1092 | }
1093 |
1094 | /**
1095 | * 根据ID获取字符图案
1096 | */
1097 | export function getPatternById(id: string): CharacterPattern | undefined {
1098 | return characterPatterns.find((p) => p.id === id);
1099 | }
1100 |
1101 | /**
1102 | * 将字符图案转换为布尔数组(用于兼容现有代码)
1103 | */
1104 | export function gridToBoolean(grid: number[][]): boolean[][] {
1105 | return grid.map((row) => row.map((cell) => cell === 1));
1106 | }
1107 |
--------------------------------------------------------------------------------
/app.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | "os"
11 | "os/exec"
12 | "path/filepath"
13 | "regexp"
14 | "sort"
15 | "strings"
16 | "time"
17 |
18 | "github.com/wailsapp/wails/v2/pkg/runtime"
19 | )
20 |
21 | // App struct
22 | type App struct {
23 | ctx context.Context
24 | repoBasePath string
25 | gitPath string // custom git path; empty means use the system default
26 | githubToken string
27 | githubUser *GithubUserProfile
28 | }
29 |
30 | // NewApp creates a new App application struct
31 | func NewApp() *App {
32 | return &App{
33 | repoBasePath: filepath.Join(os.TempDir(), "green-wall"),
34 | }
35 | }
36 |
37 | // startup is called when the app starts. The context is saved
38 | // so we can call the runtime methods
39 | func (a *App) startup(ctx context.Context) {
40 | a.ctx = ctx
41 | if err := a.loadRememberedGithubToken(); err != nil {
42 | runtime.LogWarningf(ctx, "Failed to restore GitHub login: %v", err)
43 | }
44 | }
45 |
46 | type ContributionDay struct {
47 | Date string `json:"date"`
48 | Count int `json:"count"`
49 | }
50 |
51 | type GenerateRepoRequest struct {
52 | Year int `json:"year"`
53 | GithubUsername string `json:"githubUsername"`
54 | GithubEmail string `json:"githubEmail"`
55 | RepoName string `json:"repoName"`
56 | Contributions []ContributionDay `json:"contributions"`
57 | RemoteRepo *RemoteRepoOptions `json:"remoteRepo,omitempty"`
58 | }
59 |
60 | type GenerateRepoResponse struct {
61 | RepoPath string `json:"repoPath"`
62 | CommitCount int `json:"commitCount"`
63 | RemoteURL string `json:"remoteUrl,omitempty"`
64 | }
65 |
66 | var repoNameSanitiser = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
67 | var githubRepoNameValidator = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,100}$`)
68 |
69 | const githubAuthChangedEvent = "github:auth-changed"
70 |
71 | type CheckGitInstalledResponse struct {
72 | Installed bool `json:"installed"`
73 | Version string `json:"version"`
74 | }
75 |
76 | type SetGitPathRequest struct {
77 | GitPath string `json:"gitPath"`
78 | }
79 |
80 | type SetGitPathResponse struct {
81 | Success bool `json:"success"`
82 | Message string `json:"message"`
83 | Version string `json:"version"`
84 | }
85 |
86 | type RemoteRepoOptions struct {
87 | Enabled bool `json:"enabled"`
88 | Name string `json:"name"`
89 | Private bool `json:"private"`
90 | Description string `json:"description"`
91 | }
92 |
93 | type GithubAuthRequest struct {
94 | Token string `json:"token"`
95 | Remember bool `json:"remember"`
96 | }
97 |
98 | type GithubUserProfile struct {
99 | Login string `json:"login"`
100 | Name string `json:"name"`
101 | Email string `json:"email"`
102 | AvatarURL string `json:"avatarUrl"`
103 | }
104 |
105 | type GithubAuthResponse struct {
106 | User *GithubUserProfile `json:"user"`
107 | Remembered bool `json:"remembered"`
108 | }
109 |
110 | type GithubLoginStatus struct {
111 | Authenticated bool `json:"authenticated"`
112 | User *GithubUserProfile `json:"user,omitempty"`
113 | }
114 |
115 | type githubEmailEntry struct {
116 | Email string `json:"email"`
117 | Primary bool `json:"primary"`
118 | Verified bool `json:"verified"`
119 | Visibility string `json:"visibility"`
120 | }
121 |
122 | type githubRepository struct {
123 | Name string `json:"name"`
124 | FullName string `json:"full_name"`
125 | HTMLURL string `json:"html_url"`
126 | CloneURL string `json:"clone_url"`
127 | Owner struct {
128 | Login string `json:"login"`
129 | } `json:"owner"`
130 | }
131 |
132 | // CheckGitInstalled checks if Git is installed on the system
133 | func (a *App) CheckGitInstalled() (*CheckGitInstalledResponse, error) {
134 | gitCmd := a.getGitCommand()
135 | cmd := exec.Command(gitCmd, "--version")
136 | output, err := cmd.CombinedOutput()
137 | if err != nil {
138 | return &CheckGitInstalledResponse{
139 | Installed: false,
140 | Version: "",
141 | }, nil
142 | }
143 | return &CheckGitInstalledResponse{
144 | Installed: true,
145 | Version: strings.TrimSpace(string(output)),
146 | }, nil
147 | }
148 |
149 | // SetGitPath allows the user to set a custom git path
150 | func (a *App) SetGitPath(req SetGitPathRequest) (*SetGitPathResponse, error) {
151 | gitPath := strings.TrimSpace(req.GitPath)
152 |
153 | // 如果留空,使用系统默认路径
154 | if gitPath == "" {
155 | a.gitPath = ""
156 | return &SetGitPathResponse{
157 | Success: true,
158 | Message: "已重置为使用系统默认git路径",
159 | Version: "",
160 | }, nil
161 | }
162 |
163 | // 验证路径是否有效
164 | gitPath = filepath.Clean(gitPath)
165 |
166 | // 检查文件是否存在
167 | if _, err := os.Stat(gitPath); os.IsNotExist(err) {
168 | return &SetGitPathResponse{
169 | Success: false,
170 | Message: "指定的路径不存在",
171 | Version: "",
172 | }, nil
173 | }
174 |
175 | // 临时设置git路径来测试
176 | a.gitPath = gitPath
177 | cmd := exec.Command(gitPath, "--version")
178 | output, err := cmd.CombinedOutput()
179 | if err != nil {
180 | a.gitPath = "" // 恢复为空
181 | return &SetGitPathResponse{
182 | Success: false,
183 | Message: "无法执行git命令: " + err.Error(),
184 | Version: "",
185 | }, nil
186 | }
187 |
188 | version := strings.TrimSpace(string(output))
189 | return &SetGitPathResponse{
190 | Success: true,
191 | Message: "Git路径设置成功",
192 | Version: version,
193 | }, nil
194 | }
195 |
196 | // getGitCommand returns the git command to use
197 | func (a *App) getGitCommand() string {
198 | if a.gitPath != "" {
199 | return a.gitPath
200 | }
201 | return "git"
202 | }
203 |
204 | // GenerateRepo creates a git repository whose commit history mirrors the given contribution calendar.
205 | func (a *App) GenerateRepo(req GenerateRepoRequest) (*GenerateRepoResponse, error) {
206 | if len(req.Contributions) == 0 {
207 | return nil, fmt.Errorf("no contributions supplied")
208 | }
209 |
210 | totalRequestedCommits := 0
211 | for _, c := range req.Contributions {
212 | if c.Count < 0 {
213 | return nil, fmt.Errorf("invalid contribution count for %s: %d", c.Date, c.Count)
214 | }
215 | totalRequestedCommits += c.Count
216 | }
217 | if totalRequestedCommits == 0 {
218 | return nil, fmt.Errorf("no commits to generate")
219 | }
220 |
221 | var remoteOptions *RemoteRepoOptions
222 | if req.RemoteRepo != nil && req.RemoteRepo.Enabled {
223 | trimmedName := strings.TrimSpace(req.RemoteRepo.Name)
224 | if trimmedName == "" {
225 | return nil, fmt.Errorf("remote repository name cannot be empty")
226 | }
227 | if !githubRepoNameValidator.MatchString(trimmedName) {
228 | return nil, fmt.Errorf("remote repository name may only contain letters, numbers, '.', '_' or '-'")
229 | }
230 | if a.githubToken == "" || a.githubUser == nil {
231 | return nil, fmt.Errorf("GitHub login is required to create a remote repository")
232 | }
233 | remoteOptions = &RemoteRepoOptions{
234 | Enabled: true,
235 | Name: trimmedName,
236 | Private: req.RemoteRepo.Private,
237 | Description: strings.TrimSpace(req.RemoteRepo.Description),
238 | }
239 | }
240 |
241 | username := strings.TrimSpace(req.GithubUsername)
242 | if a.githubUser != nil && strings.TrimSpace(a.githubUser.Login) != "" {
243 | username = strings.TrimSpace(a.githubUser.Login)
244 | }
245 | if username == "" {
246 | username = "greenwall"
247 | }
248 | email := strings.TrimSpace(req.GithubEmail)
249 | if email == "" && a.githubUser != nil && strings.TrimSpace(a.githubUser.Email) != "" {
250 | email = strings.TrimSpace(a.githubUser.Email)
251 | }
252 | if email == "" {
253 | email = fmt.Sprintf("%s@users.noreply.github.com", username)
254 | }
255 |
256 | if err := os.MkdirAll(a.repoBasePath, 0o755); err != nil {
257 | return nil, fmt.Errorf("create repo base directory: %w", err)
258 | }
259 |
260 | repoName := strings.TrimSpace(req.RepoName)
261 | if remoteOptions != nil {
262 | repoName = remoteOptions.Name
263 | }
264 | if repoName == "" {
265 | repoName = username
266 | if req.Year > 0 {
267 | repoName = fmt.Sprintf("%s-%d", repoName, req.Year)
268 | }
269 | }
270 | if remoteOptions == nil {
271 | repoName = sanitiseRepoName(repoName)
272 | if repoName == "" {
273 | repoName = "contributions"
274 | }
275 | }
276 |
277 | repoPath, err := os.MkdirTemp(a.repoBasePath, repoName+"-")
278 | if err != nil {
279 | return nil, fmt.Errorf("create repo directory: %w", err)
280 | }
281 |
282 | readmePath := filepath.Join(repoPath, "README.md")
283 | readmeContent := fmt.Sprintf("# %s\n\nGenerated with https://github.com/zmrlft/GreenWall.\n", repoName)
284 | if err := os.WriteFile(readmePath, []byte(readmeContent), 0o644); err != nil {
285 | return nil, fmt.Errorf("write README: %w", err)
286 | }
287 |
288 | if err := a.runGitCommand(repoPath, "init"); err != nil {
289 | return nil, err
290 | }
291 | if err := a.runGitCommand(repoPath, "config", "user.name", username); err != nil {
292 | return nil, err
293 | }
294 | if err := a.runGitCommand(repoPath, "config", "user.email", email); err != nil {
295 | return nil, err
296 | }
297 |
298 | // Optimize: use git fast-import to avoid spawning a process per commit.
299 | // Also disable slow features for this repo.
300 | _ = a.runGitCommand(repoPath, "config", "commit.gpgsign", "false")
301 | _ = a.runGitCommand(repoPath, "config", "gc.auto", "0")
302 | _ = a.runGitCommand(repoPath, "config", "core.autocrlf", "false")
303 | _ = a.runGitCommand(repoPath, "config", "core.fsyncObjectFiles", "false")
304 | _ = a.runGitCommand(repoPath, "config", "credential.helper", "") // ensure global helpers can't override askpass
305 |
306 | // Sort contributions by date ascending to produce chronological history
307 | contribs := make([]ContributionDay, 0, len(req.Contributions))
308 | for _, c := range req.Contributions {
309 | if c.Count > 0 {
310 | contribs = append(contribs, c)
311 | }
312 | }
313 | sort.Slice(contribs, func(i, j int) bool { return contribs[i].Date < contribs[j].Date })
314 |
315 | // Build fast-import stream
316 | var stream bytes.Buffer
317 | // Create README blob once and mark it
318 | fmt.Fprintf(&stream, "blob\nmark :1\n")
319 | fmt.Fprintf(&stream, "data %d\n%s\n", len(readmeContent), readmeContent)
320 |
321 | // Prepare to accumulate activity log content across commits
322 | var activityBuf bytes.Buffer
323 | nextMark := 2
324 | totalCommits := 0
325 | branch := "refs/heads/main"
326 |
327 | for _, day := range contribs {
328 | parsedDate, err := time.Parse("2006-01-02", day.Date)
329 | if err != nil {
330 | return nil, fmt.Errorf("invalid date %q: %w", day.Date, err)
331 | }
332 | for i := 0; i < day.Count; i++ {
333 | // Update activity content in-memory
334 | entry := fmt.Sprintf("%s commit %d\n", day.Date, i+1)
335 | activityBuf.WriteString(entry)
336 |
337 | // Emit blob for activity.log
338 | fmt.Fprintf(&stream, "blob\nmark :%d\n", nextMark)
339 | act := activityBuf.Bytes()
340 | fmt.Fprintf(&stream, "data %d\n", len(act))
341 | stream.Write(act)
342 | stream.WriteString("\n")
343 |
344 | // Emit commit that points to README (:1) and activity (:nextMark)
345 | // Shift to midday UTC so GitHub won't classify the commit into the previous day across time zones.
346 | commitTime := parsedDate.Add(12*time.Hour + time.Duration(i)*time.Second)
347 | secs := commitTime.Unix()
348 | tz := commitTime.Format("-0700")
349 | msg := fmt.Sprintf("Contribution on %s (%d/%d)", day.Date, i+1, day.Count)
350 | fmt.Fprintf(&stream, "commit %s\n", branch)
351 | fmt.Fprintf(&stream, "author %s <%s> %d %s\n", username, email, secs, tz)
352 | fmt.Fprintf(&stream, "committer %s <%s> %d %s\n", username, email, secs, tz)
353 | fmt.Fprintf(&stream, "data %d\n%s\n", len(msg), msg)
354 | fmt.Fprintf(&stream, "M 100644 :1 %s\n", filepath.Base(readmePath))
355 | fmt.Fprintf(&stream, "M 100644 :%d activity.log\n", nextMark)
356 |
357 | nextMark++
358 | totalCommits++
359 | }
360 | }
361 | stream.WriteString("done\n")
362 |
363 | // Feed stream to fast-import
364 | if totalCommits > 0 {
365 | if err := a.runGitFastImport(repoPath, &stream); err != nil {
366 | return nil, fmt.Errorf("fast-import failed: %w", err)
367 | }
368 | // Update working tree to the generated branch for user convenience
369 | _ = a.runGitCommand(repoPath, "checkout", "-f", "main")
370 | }
371 |
372 | var remoteURL string
373 | if remoteOptions != nil {
374 | createdRepo, err := a.createGithubRepository(remoteOptions)
375 | if err != nil {
376 | return nil, err
377 | }
378 | targetURL := strings.TrimSpace(createdRepo.CloneURL)
379 | if targetURL == "" {
380 | return nil, fmt.Errorf("GitHub did not return a clone URL for the new repository")
381 | }
382 | ownerLogin := createdRepo.Owner.Login
383 | if ownerLogin == "" && a.githubUser != nil {
384 | ownerLogin = a.githubUser.Login
385 | }
386 | if err := a.configureRemoteAndPush(repoPath, targetURL, ownerLogin, a.githubToken); err != nil {
387 | return nil, err
388 | }
389 | if createdRepo.HTMLURL != "" {
390 | remoteURL = createdRepo.HTMLURL
391 | } else {
392 | remoteURL = targetURL
393 | }
394 | if remoteURL != "" && a.ctx != nil {
395 | runtime.BrowserOpenURL(a.ctx, remoteURL)
396 | }
397 | }
398 |
399 | if err := openDirectory(repoPath); err != nil {
400 | return nil, fmt.Errorf("open repo directory: %w", err)
401 | }
402 |
403 | return &GenerateRepoResponse{
404 | RepoPath: repoPath,
405 | CommitCount: totalCommits,
406 | RemoteURL: remoteURL,
407 | }, nil
408 | }
409 |
410 | type ExportContributionsRequest struct {
411 | Contributions []ContributionDay `json:"contributions"`
412 | }
413 |
414 | type ExportContributionsResponse struct {
415 | FilePath string `json:"filePath"`
416 | }
417 |
418 | // ExportContributions exports the current contributions to a JSON file.
419 | func (a *App) ExportContributions(req ExportContributionsRequest) (*ExportContributionsResponse, error) {
420 | data, err := json.MarshalIndent(req.Contributions, "", " ")
421 | if err != nil {
422 | return nil, fmt.Errorf("marshal contributions: %w", err)
423 | }
424 |
425 | // 使用对话框让用户选择保存位置
426 | filePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
427 | Title: "导出贡献数据",
428 | DefaultFilename: "contributions.json",
429 | Filters: []runtime.FileFilter{
430 | {DisplayName: "JSON 文件 (*.json)", Pattern: "*.json"},
431 | },
432 | })
433 | if err != nil {
434 | return nil, fmt.Errorf("open save file dialog: %w", err)
435 | }
436 | if filePath == "" {
437 | return nil, fmt.Errorf("export cancelled")
438 | }
439 |
440 | if err := os.WriteFile(filePath, data, 0o644); err != nil {
441 | return nil, fmt.Errorf("write contributions to file: %w", err)
442 | }
443 |
444 | return &ExportContributionsResponse{FilePath: filePath}, nil
445 | }
446 |
447 | type ImportContributionsResponse struct {
448 | Contributions []ContributionDay `json:"contributions"`
449 | }
450 |
451 | // ImportContributions imports contributions from a JSON file.
452 | func (a *App) ImportContributions() (*ImportContributionsResponse, error) {
453 | // 使用对话框让用户选择导入文件
454 | filePath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
455 | Title: "导入贡献数据",
456 | Filters: []runtime.FileFilter{
457 | {DisplayName: "JSON 文件 (*.json)", Pattern: "*.json"},
458 | },
459 | })
460 | if err != nil {
461 | return nil, fmt.Errorf("open file dialog: %w", err)
462 | }
463 | if filePath == "" {
464 | return nil, fmt.Errorf("import cancelled")
465 | }
466 |
467 | data, err := os.ReadFile(filePath)
468 | if err != nil {
469 | return nil, fmt.Errorf("read contributions file: %w", err)
470 | }
471 |
472 | var contributions []ContributionDay
473 | if err := json.Unmarshal(data, &contributions); err != nil {
474 | return nil, fmt.Errorf("unmarshal contributions: %w", err)
475 | }
476 |
477 | return &ImportContributionsResponse{Contributions: contributions}, nil
478 | }
479 |
480 | func sanitiseRepoName(input string) string {
481 | input = strings.TrimSpace(input)
482 | if input == "" {
483 | return ""
484 | }
485 | input = repoNameSanitiser.ReplaceAllString(input, "-")
486 | input = strings.Trim(input, "-")
487 | if input == "" {
488 | return ""
489 | }
490 | if len(input) > 64 {
491 | input = input[:64]
492 | }
493 | return input
494 | }
495 |
496 | func (a *App) runGitCommand(dir string, args ...string) error {
497 | gitCmd := a.getGitCommand()
498 | cmd := exec.Command(gitCmd, args...)
499 | cmd.Dir = dir
500 | configureCommand(cmd, true)
501 |
502 | var stderr bytes.Buffer
503 | cmd.Stderr = &stderr
504 |
505 | if err := cmd.Run(); err != nil {
506 | return fmt.Errorf("git %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
507 | }
508 |
509 | return nil
510 | }
511 |
512 | // runGitFastImport runs `git fast-import` with the given stream as stdin.
513 | func (a *App) runGitFastImport(dir string, r *bytes.Buffer) error {
514 | gitCmd := a.getGitCommand()
515 | cmd := exec.Command(gitCmd, "fast-import", "--quiet")
516 | cmd.Dir = dir
517 | configureCommand(cmd, true)
518 | cmd.Stdin = r
519 | var stderr bytes.Buffer
520 | cmd.Stderr = &stderr
521 | if err := cmd.Run(); err != nil {
522 | return fmt.Errorf("git fast-import: %w (%s)", err, strings.TrimSpace(stderr.String()))
523 | }
524 | return nil
525 | }
526 | func (a *App) AuthenticateWithToken(req GithubAuthRequest) (*GithubAuthResponse, error) {
527 | token := strings.TrimSpace(req.Token)
528 | if token == "" {
529 | return nil, fmt.Errorf("token cannot be empty")
530 | }
531 |
532 | user, err := a.fetchGithubUser(token)
533 | if err != nil {
534 | return nil, err
535 | }
536 |
537 | a.githubToken = token
538 | a.githubUser = user
539 | a.emitGithubAuthChanged()
540 |
541 | if req.Remember {
542 | if err := a.saveGithubToken(token); err != nil && a.ctx != nil {
543 | runtime.LogWarningf(a.ctx, "failed to store GitHub token: %v", err)
544 | }
545 | } else {
546 | if err := a.clearSavedToken(); err != nil && a.ctx != nil {
547 | runtime.LogWarningf(a.ctx, "failed to clear saved GitHub token: %v", err)
548 | }
549 | }
550 |
551 | return &GithubAuthResponse{
552 | User: cloneGithubUser(user),
553 | Remembered: req.Remember,
554 | }, nil
555 | }
556 |
557 | func (a *App) GetGithubLoginStatus() *GithubLoginStatus {
558 | if a.githubUser == nil {
559 | return &GithubLoginStatus{Authenticated: false}
560 | }
561 |
562 | return &GithubLoginStatus{
563 | Authenticated: true,
564 | User: cloneGithubUser(a.githubUser),
565 | }
566 | }
567 |
568 | func (a *App) LogoutGithub() error {
569 | a.githubToken = ""
570 | a.githubUser = nil
571 | a.emitGithubAuthChanged()
572 | return a.clearSavedToken()
573 | }
574 |
575 | func (a *App) fetchGithubUser(token string) (*GithubUserProfile, error) {
576 | req, err := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
577 | if err != nil {
578 | return nil, fmt.Errorf("build GitHub request failed: %w", err)
579 | }
580 |
581 | req.Header.Set("Accept", "application/vnd.github+json")
582 | req.Header.Set("Authorization", "Bearer "+token)
583 | req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
584 |
585 | client := &http.Client{Timeout: 10 * time.Second}
586 | resp, err := client.Do(req)
587 | if err != nil {
588 | return nil, fmt.Errorf("fetch GitHub user failed: %w", err)
589 | }
590 | defer resp.Body.Close()
591 |
592 | if resp.StatusCode == http.StatusUnauthorized {
593 | return nil, fmt.Errorf("token invalid or expired")
594 | }
595 | if resp.StatusCode >= 400 {
596 | body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
597 | return nil, fmt.Errorf("GitHub API returned error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
598 | }
599 |
600 | var payload struct {
601 | Login string `json:"login"`
602 | Name string `json:"name"`
603 | Email string `json:"email"`
604 | AvatarURL string `json:"avatar_url"`
605 | }
606 | if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
607 | return nil, fmt.Errorf("decode GitHub user payload failed: %w", err)
608 | }
609 |
610 | email := payload.Email
611 | if email == "" {
612 | if emails, err := a.fetchGithubEmails(token); err != nil {
613 | if a.ctx != nil {
614 | runtime.LogWarningf(a.ctx, "fetch GitHub emails failed: %v", err)
615 | }
616 | } else {
617 | email = pickBestEmail(emails)
618 | // if a.ctx != nil {
619 | // if raw, err := json.Marshal(emails); err == nil {
620 | // runtime.LogInfof(a.ctx, "GitHub /user/emails response: %s", raw)
621 | // }
622 | // }
623 | }
624 | }
625 |
626 | return &GithubUserProfile{
627 | Login: payload.Login,
628 | Name: payload.Name,
629 | Email: email,
630 | AvatarURL: payload.AvatarURL,
631 | }, nil
632 | }
633 |
634 | func (a *App) fetchGithubEmails(token string) ([]githubEmailEntry, error) {
635 | req, err := http.NewRequest(http.MethodGet, "https://api.github.com/user/emails", nil)
636 | if err != nil {
637 | return nil, fmt.Errorf("build GitHub email request failed: %w", err)
638 | }
639 |
640 | req.Header.Set("Accept", "application/vnd.github+json")
641 | req.Header.Set("Authorization", "Bearer "+token)
642 | req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
643 |
644 | client := &http.Client{Timeout: 10 * time.Second}
645 | resp, err := client.Do(req)
646 | if err != nil {
647 | return nil, fmt.Errorf("fetch GitHub emails failed: %w", err)
648 | }
649 | defer resp.Body.Close()
650 |
651 | if resp.StatusCode == http.StatusUnauthorized {
652 | return nil, fmt.Errorf("token invalid or expired when fetching emails")
653 | }
654 | if resp.StatusCode >= 400 {
655 | body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
656 | return nil, fmt.Errorf("GitHub API returned error for /user/emails (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
657 | }
658 |
659 | var entries []githubEmailEntry
660 | if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
661 | return nil, fmt.Errorf("decode GitHub emails payload failed: %w", err)
662 | }
663 |
664 | return entries, nil
665 | }
666 |
667 | func pickBestEmail(entries []githubEmailEntry) string {
668 | for _, entry := range entries {
669 | if entry.Primary && entry.Verified && entry.Email != "" {
670 | return entry.Email
671 | }
672 | }
673 | for _, entry := range entries {
674 | if entry.Verified && entry.Email != "" {
675 | return entry.Email
676 | }
677 | }
678 | for _, entry := range entries {
679 | if entry.Email != "" {
680 | return entry.Email
681 | }
682 | }
683 | return ""
684 | }
685 |
686 | func (a *App) createGithubRepository(opts *RemoteRepoOptions) (*githubRepository, error) {
687 | if a.githubToken == "" {
688 | return nil, fmt.Errorf("missing GitHub token for remote repository creation")
689 | }
690 |
691 | payload := map[string]interface{}{
692 | "name": opts.Name,
693 | "private": opts.Private,
694 | }
695 | if desc := strings.TrimSpace(opts.Description); desc != "" {
696 | payload["description"] = desc
697 | }
698 |
699 | body, err := json.Marshal(payload)
700 | if err != nil {
701 | return nil, fmt.Errorf("encode GitHub repository payload: %w", err)
702 | }
703 |
704 | req, err := http.NewRequest(http.MethodPost, "https://api.github.com/user/repos", bytes.NewReader(body))
705 | if err != nil {
706 | return nil, fmt.Errorf("build GitHub repository request failed: %w", err)
707 | }
708 | req.Header.Set("Accept", "application/vnd.github+json")
709 | req.Header.Set("Authorization", "Bearer "+a.githubToken)
710 | req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
711 | req.Header.Set("Content-Type", "application/json")
712 |
713 | client := &http.Client{Timeout: 10 * time.Second}
714 | resp, err := client.Do(req)
715 | if err != nil {
716 | return nil, fmt.Errorf("create GitHub repository failed: %w", err)
717 | }
718 | defer resp.Body.Close()
719 |
720 | if resp.StatusCode >= 400 {
721 | body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
722 | return nil, fmt.Errorf("GitHub API returned error for repository creation (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
723 | }
724 |
725 | var repo githubRepository
726 | if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
727 | return nil, fmt.Errorf("decode GitHub repository response failed: %w", err)
728 | }
729 | return &repo, nil
730 | }
731 |
732 | func (a *App) configureRemoteAndPush(repoPath string, remoteURL string, username string, token string) error {
733 | if username == "" && a.githubUser != nil {
734 | username = a.githubUser.Login
735 | }
736 | if username == "" {
737 | username = "git"
738 | }
739 |
740 | // Remove any existing origin to avoid conflicts, ignore errors if it doesn't exist.
741 | _ = a.runGitCommand(repoPath, "remote", "remove", "origin")
742 |
743 | if err := a.runGitCommand(repoPath, "remote", "add", "origin", remoteURL); err != nil {
744 | return fmt.Errorf("add remote origin: %w", err)
745 | }
746 |
747 | if err := a.gitPushWithToken(repoPath, username, token); err != nil {
748 | return err
749 | }
750 | return nil
751 | }
752 |
753 | func (a *App) gitPushWithToken(repoPath, username, token string) error {
754 | helperPath, cleanup, err := createGitAskPassHelper()
755 | if err != nil {
756 | return err
757 | }
758 | defer cleanup()
759 |
760 | gitCmd := a.getGitCommand()
761 | cmd := exec.Command(gitCmd, "push", "-u", "origin", "main")
762 | cmd.Dir = repoPath
763 | configureCommand(cmd, true)
764 |
765 | env := os.Environ()
766 | env = append(env,
767 | fmt.Sprintf("GIT_ASKPASS=%s", helperPath),
768 | "GIT_TERMINAL_PROMPT=0",
769 | fmt.Sprintf("GITHUB_ASKPASS_USERNAME=%s", username),
770 | fmt.Sprintf("GITHUB_ASKPASS_TOKEN=%s", token),
771 | )
772 | cmd.Env = env
773 |
774 | var stderr bytes.Buffer
775 | cmd.Stderr = &stderr
776 |
777 | if err := cmd.Run(); err != nil {
778 | return fmt.Errorf("git push: %w (%s)", err, strings.TrimSpace(stderr.String()))
779 | }
780 |
781 | return nil
782 | }
783 |
784 | func createGitAskPassHelper() (string, func(), error) {
785 | isWindows := os.PathSeparator == '\\'
786 | pattern := "gw-askpass-*"
787 | if isWindows {
788 | pattern = "gw-askpass-*.cmd"
789 | }
790 |
791 | file, err := os.CreateTemp("", pattern)
792 | if err != nil {
793 | return "", func() {}, fmt.Errorf("create askpass helper failed: %w", err)
794 | }
795 | path := file.Name()
796 |
797 | var script string
798 | if isWindows {
799 | script = "@echo off\r\nsetlocal EnableDelayedExpansion\r\nset prompt=%*\r\necho !prompt! | findstr /I \"Username\" >nul\r\nif %errorlevel%==0 (\r\n echo %GITHUB_ASKPASS_USERNAME%\r\n) else (\r\n echo %GITHUB_ASKPASS_TOKEN%\r\n)\r\nendlocal\r\n"
800 | } else {
801 | script = "#!/bin/sh\ncase \"$1\" in\n*Username*) printf '%s\\n' \"$GITHUB_ASKPASS_USERNAME\" ;;\n*) printf '%s\\n' \"$GITHUB_ASKPASS_TOKEN\" ;;\nesac\n"
802 | }
803 |
804 | if _, err := file.WriteString(script); err != nil {
805 | file.Close()
806 | return "", func() {}, fmt.Errorf("write askpass helper failed: %w", err)
807 | }
808 | file.Close()
809 |
810 | if !isWindows {
811 | if err := os.Chmod(path, 0o700); err != nil {
812 | return "", func() {}, fmt.Errorf("chmod askpass helper failed: %w", err)
813 | }
814 | }
815 |
816 | cleanup := func() {
817 | _ = os.Remove(path)
818 | }
819 | return path, cleanup, nil
820 | }
821 |
822 | func (a *App) saveGithubToken(token string) error {
823 | path, err := a.tokenStoragePath()
824 | if err != nil {
825 | return err
826 | }
827 | return os.WriteFile(path, []byte(token), 0o600)
828 | }
829 |
830 | func (a *App) clearSavedToken() error {
831 | path, err := a.tokenStoragePath()
832 | if err != nil {
833 | return err
834 | }
835 | if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
836 | return err
837 | }
838 | return nil
839 | }
840 |
841 | func (a *App) loadRememberedGithubToken() error {
842 | path, err := a.tokenStoragePath()
843 | if err != nil {
844 | return err
845 | }
846 |
847 | data, err := os.ReadFile(path)
848 | if err != nil {
849 | if os.IsNotExist(err) {
850 | return nil
851 | }
852 | return err
853 | }
854 |
855 | token := strings.TrimSpace(string(data))
856 | if token == "" {
857 | return nil
858 | }
859 |
860 | user, err := a.fetchGithubUser(token)
861 | if err != nil {
862 | _ = os.Remove(path)
863 | return err
864 | }
865 |
866 | a.githubToken = token
867 | a.githubUser = user
868 | a.emitGithubAuthChanged()
869 | return nil
870 | }
871 |
872 | func (a *App) tokenStoragePath() (string, error) {
873 | dir, err := os.UserConfigDir()
874 | if err != nil {
875 | return "", err
876 | }
877 |
878 | appDir := filepath.Join(dir, "green-wall")
879 | if err := os.MkdirAll(appDir, 0o700); err != nil {
880 | return "", err
881 | }
882 |
883 | return filepath.Join(appDir, "github_token"), nil
884 | }
885 |
886 | func cloneGithubUser(user *GithubUserProfile) *GithubUserProfile {
887 | if user == nil {
888 | return nil
889 | }
890 | clone := *user
891 | return &clone
892 | }
893 |
894 | func (a *App) emitGithubAuthChanged() {
895 | if a.ctx == nil {
896 | return
897 | }
898 |
899 | status := &GithubLoginStatus{
900 | Authenticated: a.githubUser != nil,
901 | User: cloneGithubUser(a.githubUser),
902 | }
903 |
904 | runtime.EventsEmit(a.ctx, githubAuthChangedEvent, status)
905 | }
906 |
--------------------------------------------------------------------------------