diff --git a/.specstory/.gitignore b/.specstory/.gitignore new file mode 100644 index 0000000..53b537f --- /dev/null +++ b/.specstory/.gitignore @@ -0,0 +1,2 @@ +# SpecStory explanation file +/.what-is-this.md diff --git a/README.md b/README.md index 2d847f9..33b62bc 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,23 @@ > 中文: [README (中文)](README_zh.md) +## Contents + +- [Project Milestones](#project-milestones) +- [How to Use](#how-to-use) + - [Image → Contribution Heatmap](#image--contribution-heatmap) + - [Quick Tips](#quick-tips) + - [Platform Notes](#platform-notes) +- [Rendering Examples](#rendering-examples) +- [Development Guide](#development-guide) +- [Star History](#star-history) +- [Disclaimer](#disclaimer) + ## Project Milestones 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 Featured|HelloGitHub -## How to use +## How to Use Make sure Git is installed on your computer. @@ -14,7 +26,7 @@ Make sure Git is installed on your computer. 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). -### New: Image → Contribution Heatmap +### Image → Contribution Heatmap - Upload any image (PNG/JPG/SVG) and turn it into a contribution heatmap. - Choose rows (1–7) and columns (1–52) to fit your image’s shape. @@ -59,11 +71,13 @@ Once you’re logged in you’ll see your avatar and name in the upper-left corn - Use the brush intensity control to switch between different shades of green. - **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. -### Windows/Linux +### Platform Notes + +#### Windows/Linux Download and run the application directly. -### macOS +#### macOS Since this application is not yet signed, you may encounter security restrictions on first launch. Follow these steps to resolve: @@ -77,62 +91,55 @@ sudo xattr -r -d com.apple.quarantine ./green-wall.app **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). -## Rendering +## Rendering Examples -![text](docs/images/cailg.png) -![catfish](docs/images/cat.png) -![lovecat](docs/images/darkcat.jpg) -![helloWorld](docs/images/darkhw.png) -![androidlife](docs/images/darkandroid.png) +| ![text](docs/images/cailg.png) | ![catfish](docs/images/cat.png) | +| --- | --- | +| ![lovecat](docs/images/darkcat.jpg) | ![helloWorld](docs/images/darkhw.png) | +| ![androidlife](docs/images/darkandroid.png) | | ## Development Guide -- Environmental Preparation - - Install Go 1.23+ - - Install Node.js (v22+) +### Environment setup - Install Git +- Install Go 1.24+ +- Install Node.js (v22+) +- Install Git -- Install dependent tools +### Install dependent tools - ``` - go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2 - ``` - -- Project operation - - Clone the repository and enter the directory: +```bash +go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2 +``` - ``` - git clone https://github.com/zmrlft/GreenWall.git - cd GreenWall - ``` +### Project workflow - Install front-end dependencies: +Clone the repository and enter the directory: - ``` - cd frontend && npm install - ``` +```bash +git clone https://github.com/zmrlft/GreenWall.git +cd GreenWall +``` - Start the development environment +Install front-end dependencies: - ``` - wails dev - ``` +```bash +cd frontend && npm install +``` - Construction +Start the development environment: - ``` - wails build - ``` +```bash +wails dev +``` - Output path: build/bin/ +Build: -## Future features +```bash +wails build +``` -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. +Output path: `build/bin/` ## Star History diff --git a/README_zh.md b/README_zh.md index de94d25..b89f8ee 100644 --- a/README_zh.md +++ b/README_zh.md @@ -2,62 +2,78 @@ > English: [README (English)](README.md) -## 项目里程碑: +## 目录 + +- [项目里程碑](#项目里程碑) +- [如何使用](#如何使用) + - [图片转贡献图](#图片转贡献图) + - [快速提示](#快速提示) + - [平台说明](#平台说明) +- [效果图](#效果图) +- [开发指南](#开发指南) +- [Star History](#star-history) +- [免责](#免责) + +## 项目里程碑 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”Featured|HelloGitHub ## 如何使用 -请确保你的电脑已经安装了 git。 +请确保你的电脑已经安装了 Git。 ![app screenshot](/docs/images/app_zh.png) -下载软件,打开后,首先要获取你的PAT来登录github,你可以参考这个:[如何获取你的github访问令牌](docs/githubtoken.md) +下载软件后,先获取 PAT 来登录 GitHub。可参考:[如何获取你的 GitHub 访问令牌](docs/githubtoken.md)。 -### 新增:图片转贡献图 +### 图片转贡献图 - 上传图片(PNG/JPG/SVG),一键生成贡献热力图。 - - 尽量选择线条清晰、对比度的照片,但是细节不宜过多,否则将被压缩的难以辨认。 + - 建议使用线条清晰、对比度适中的图片,细节过多会在压缩后丢失。 - 自由设置行数(1~7)和列数(1~52),匹配图片形状。 - 两种模式: - - **自动** – 出来会有色阶变化 - - **二值化** – 出来是最深和无色 + - **自动**:会保留色阶变化。 + - **二值化**:仅保留最深色和无色。 - 可调亮度反转、阈值、缩放平滑、笔画补强。 - 在日历上悬停定位,左键应用,右键取消。 **小贴士** -- 二值化结果太稀疏时,加大“二值补笔画强度” +- 二值化结果太稀疏时,加大“二值补笔画强度”。 - 文字清晰优先选“邻近点(保细节)”。 -- 又想要色阶变化,又想要文字清晰,可以适当调高亮度阈值 +- 如果既想保留色阶又想提高文字清晰度,可适当提高亮度阈值。 **成果示例** -![success_example1](docs/images/success_example1.png) -![success_example2](docs/images/success_example2.png) -![success_example3](docs/images/success_example3.png) -![success_example4](docs/images/success_example4.png) +- ![success_example1](docs/images/success_example1.png) +- ![success_example2](docs/images/success_example2.png) +- ![success_example3](docs/images/success_example3.png) +- ![success_example4](docs/images/success_example4.png) + **失败示例** -![failure_example1](docs/images/failure_example1.png) -![failure_example2](docs/images/failure_example2.png) -![failure_example3](docs/images/failure_example3.png) +- ![failure_example1](docs/images/failure_example1.png) +- ![failure_example2](docs/images/failure_example2.png) +- ![failure_example3](docs/images/failure_example3.png) -登录成功左上角会显示你的头像和名字。拖动鼠标在日历上尽情画画,发挥你的艺术才能!画完后点击创建远程仓库,你可以自定义仓库名称和描述,选择仓库是否公开,确认无误后点击生成并且推送,软件会自动在你的GitHub上创建对应的仓库。 +登录成功后,左上角会显示你的头像和名字。拖动鼠标在日历上绘制图案,完成后点击创建远程仓库。你可以自定义仓库名称和描述,选择仓库是否公开,确认后点击生成并推送,软件会自动在你的 GitHub 上创建并推送仓库。 + +> 注意:GitHub 可能需要 5 分钟到两天才会显示贡献图案。你可以把仓库设为私有,并在贡献统计里开启“显示私有仓库贡献”,这样他人看不到仓库内容但能看到你的贡献记录。 -注意: GitHub 可能需要 5 分钟至两天才会显示你的贡献度图案。你可以把仓库设置为私人仓库,并在贡献统计中允许显示私人仓库的贡献,这样他人看不到仓库内容但可以看到贡献记录。 ![private setting screenshot](docs/images/privatesetting.png) ### 快速提示 -- 绘画过程中右键可以切换画笔和橡皮擦 -- 可以调节画笔的强度 -- **复制粘贴功能**:点击"复制模式"按钮进入复制模式,在日历上拖选一块区域后按 `Ctrl+C` 复制,软件会弹出"复制成功"提示。复制后,被选中区域的图案会跟随鼠标移动作为预览,你可以左键点击或按 `Ctrl+V` 粘贴到目标位置,右键取消粘贴预览。按`Ctrl+V`可以快速恢复上次复制的图案 +- 绘画过程中右键可以切换画笔和橡皮擦。 +- 可以调节画笔强度。 +- **复制粘贴功能**:点击“复制模式”进入复制模式,在日历上拖选区域后按 `Ctrl+C` 复制。复制成功后,被选区域会跟随鼠标作为预览。你可以左键点击或按 `Ctrl+V` 粘贴到目标位置,右键取消粘贴预览。按 `Ctrl+V` 也可以快速恢复上次复制的图案。 + +### 平台说明 -### Windows/Linux +#### Windows/Linux -下载后直接点击运行即可。软件开源,报毒正常 +下载后直接运行即可(开源软件被误报为病毒属于常见现象)。 -### macOS +#### macOS -由于本应用暂时未进行签名服务,首次运行时可能会遇到安全限制。按以下步骤解决: +由于本应用暂未签名,首次运行时可能遇到系统安全限制。可按以下步骤处理: ```bash cd 你的green-wall.app存在的目录 @@ -65,66 +81,59 @@ sudo xattr -cr ./green-wall.app sudo xattr -r -d com.apple.quarantine ./green-wall.app ``` -**提示:** 这些指令并不需要全部执行,从上往下依次尝试,如果某条指令解决了问题就无需继续执行。 +**提示:** 不需要全部执行,从上到下依次尝试,问题解决后即可停止。 -**警告:** 命令执行后不会自动弹出应用界面,需要手动双击应用来启动(命令只是改变了文件属性)。 +**警告:** 命令执行后不会自动启动应用,需要手动双击打开(命令仅修改文件属性)。 ## 效果图 -![text](docs/images/cailg.png) -![catfish](docs/images/cat.png) -![lovecat](docs/images/darkcat.jpg) -![helloWorld](docs/images/darkhw.png) -![androidlife](docs/images/darkandroid.png) +| ![text](docs/images/cailg.png) | ![catfish](docs/images/cat.png) | +| --- | --- | +| ![lovecat](docs/images/darkcat.jpg) | ![helloWorld](docs/images/darkhw.png) | +| ![androidlife](docs/images/darkandroid.png) | | ## 开发指南 -- 环境准备 +### 环境准备 - 安装 Go 1.23+ +- 安装 Go 1.24+ +- 安装 Node.js (v22+) +- 安装 Git - 安装 Node.js (v22+) +### 安装依赖工具 - 安装 git - -- 安装依赖工具 - - ``` - go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2 - ``` - -- 项目操作 - - 克隆仓库并进入目录: +```bash +go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2 +``` - ``` - git clone https://github.com/zmrlft/GreenWall.git - cd GreenWall - ``` +### 项目操作 - 安装前端依赖: +克隆仓库并进入目录: - ``` - cd frontend && npm install - ``` +```bash +git clone https://github.com/zmrlft/GreenWall.git +cd GreenWall +``` - 启动开发环境 +安装前端依赖: - ``` - wails dev - ``` +```bash +cd frontend && npm install +``` - 构建 +启动开发环境: - ``` - wails build - ``` +```bash +wails dev +``` - 输出路径:build/bin/ +构建: -## 未来的功能 +```bash +wails build +``` -我们可能会增加创建自定义语言仓库的功能,例如生成一个 Java 仓库并在你的主页语言占比中统计它。 +输出路径:`build/bin/` ## Star History @@ -132,4 +141,4 @@ sudo xattr -r -d com.apple.quarantine ./green-wall.app ## 免责 -免责声明:本项目仅用于教育、演示及研究 GitHub 贡献机制,如用于求职造假,所造成后果自负。 +本项目仅用于教育、演示及研究 GitHub 贡献机制;如用于求职造假等不当用途,后果由使用者自行承担。 diff --git a/frontend/src/App.css b/frontend/src/App.css index 8e402fb..4e807d9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,306 +1,635 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); - #app { min-height: 100vh; - text-align: center; } -/* Additional responsive styles */ +:root { + color-scheme: light; +} body { + margin: 0; + color: #112018; font-family: - 'Inter', - -apple-system, - BlinkMacSystemFont, + 'Nunito', + 'SF Pro Display', 'Segoe UI', - 'Roboto', sans-serif; - color: black !important; + background: #ffffff; } -/* Custom scrollbar for small screens */ ::-webkit-scrollbar { - width: 6px; - height: 6px; + width: 8px; + height: 8px; } ::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 3px; + background: rgba(15, 23, 42, 0.06); + border-radius: 999px; } ::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 3px; + background: rgba(22, 101, 52, 0.28); + border-radius: 999px; } ::-webkit-scrollbar-thumb:hover { - background: #a1a1a1; + background: rgba(22, 101, 52, 0.4); } -#logo { - display: block; - width: 50%; - height: 50%; - margin: auto; - padding: 10% 0 0; - background-position: center; - background-repeat: no-repeat; - background-size: 100% 100%; - background-origin: content-box; +.app-shell { + min-height: 100vh; + box-sizing: border-box; } -.result { - height: 20px; - line-height: 20px; - margin: 1.5rem auto; +.workspace { + min-height: 100vh; + display: flex; + overflow: hidden; + background: #ffffff; } -.input-box .btn { - width: 60px; - height: 30px; - line-height: 30px; - border-radius: 3px; - border: none; - margin: 0 0 0 20px; - padding: 0 8px; +.workspace__sidebar { + width: 84px; + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + padding: 22px 14px; + box-sizing: border-box; + border-right: 1px solid rgba(208, 218, 208, 0.9); + background: linear-gradient(180deg, #ffffff 0%, #f8fbf8 100%); +} + +.workspace__nav { + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; +} + +.workspace__icon-button { + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 16px; + background: transparent; + color: #5d6d61; cursor: pointer; + transition: + transform 0.18s ease, + background-color 0.18s ease, + border-color 0.18s ease, + color 0.18s ease; } -.input-box .btn:hover { - background-image: linear-gradient(to top, #cfd9df 0%, #e2ebf0 100%); - color: #333333; +.workspace__icon-button:hover { + transform: translateY(-1px); + background: rgba(25, 135, 84, 0.08); + border-color: rgba(116, 169, 132, 0.25); + color: #20482c; } -.input-box .input { - border: none; - border-radius: 3px; - outline: none; - height: 30px; - line-height: 30px; - padding: 0 10px; - background-color: rgba(240, 240, 240, 1); - -webkit-font-smoothing: antialiased; +.workspace__icon-button--ghost { + background: #fff; + border-color: rgba(208, 218, 208, 0.9); } -.input-box .input:hover { - border: none; - background-color: rgba(255, 255, 255, 1); +.workspace__icon { + width: 22px; + height: 22px; } -.input-box .input:focus { - border: none; - background-color: rgba(255, 255, 255, 1); +.workspace__icon--small { + width: 18px; + height: 18px; } -.app-shell { - min-height: 100vh; - background: radial-gradient(circle at top, #f4f8fb, #e5ecf5); - padding: 32px; - display: flex; - justify-content: center; +.workspace__sidebar-spacer { + flex: 1; } -.app-shell__surface { - width: 100%; - max-width: 1400px; +.workspace__profile { display: flex; flex-direction: column; + align-items: center; gap: 10px; } -.app-shell__topbar { +.workspace__avatar { + width: 42px; + height: 42px; + border-radius: 16px; + object-fit: cover; + border: 1px solid rgba(208, 218, 208, 0.95); + background: linear-gradient(180deg, #f6faf7 0%, #edf5ee 100%); + box-shadow: 0 10px 24px rgba(16, 24, 40, 0.1); +} + +.workspace__avatar--fallback, +.workspace__avatar--login { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; + letter-spacing: 0.04em; + color: #1c4b28; +} + +.workspace__avatar--login { + border: none; + cursor: pointer; + background: linear-gradient(180deg, #dcffd1 0%, #b9f8a9 100%); +} + +.workspace__main { + min-width: 0; + flex: 1; display: flex; + flex-direction: column; + background: + radial-gradient(circle at 1px 1px, rgba(47, 128, 72, 0.1) 1.1px, transparent 0) 0 0 / 20px + 20px, + linear-gradient(180deg, #fbfdfb 0%, #f2f7f2 100%); +} + +.workspace__topbar { + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - justify-content: space-between; - background: #ffffff; - border: 1px solid #e4e7ec; - border-radius: 10px; - padding: 20px 28px; - box-shadow: 0 20px 50px rgba(15, 23, 42, 0.08); + gap: 18px; + padding: 24px 32px 20px; + border-bottom: 1px solid rgba(208, 218, 208, 0.9); + background: rgba(255, 255, 255, 0.92); } -.app-shell__identity { +.workspace__toolbar-group { display: flex; align-items: center; - gap: 12px; + gap: 14px; + min-width: 0; } -.app-shell__login { - border-radius: 999px; - border: 1px solid #cbd5f5; +.workspace__toolbar-group--end { + justify-content: flex-end; + flex-wrap: nowrap; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 4px; + scrollbar-gutter: stable; +} + +.workspace__brand { + justify-self: center; + font-size: 1.45rem; + font-weight: 800; + letter-spacing: 0.04em; + color: #1d3927; +} + +.workspace__language { + display: inline-flex; + align-items: center; + padding: 4px; + border-radius: 14px; + background: #f2f6f3; + border: 1px solid rgba(208, 218, 208, 0.9); +} + +.workspace__language-button { + min-width: 68px; + border: none; + background: transparent; + border-radius: 10px; + padding: 8px 14px; + font-size: 0.92rem; + font-weight: 700; + color: #667568; + cursor: pointer; + transition: + background-color 0.18s ease, + color 0.18s ease; +} + +.workspace__language-button:hover, +.workspace__language-button.is-active { + background: #ffffff; + color: #183021; + box-shadow: 0 8px 20px rgba(16, 24, 40, 0.08); +} + +.workspace__git-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid rgba(208, 218, 208, 0.9); + border-radius: 14px; background: #ffffff; - color: #0f172a; - padding: 10px 22px; + color: #2c4e34; font-size: 0.95rem; - font-weight: 600; + font-weight: 700; cursor: pointer; - transition: box-shadow 0.2s ease, transform 0.2s ease; + transition: + transform 0.18s ease, + box-shadow 0.18s ease; + flex: 0 0 auto; + white-space: nowrap; } -.app-shell__login:hover { - box-shadow: 0 8px 16px rgba(15, 23, 42, 0.1); +.workspace__git-badge:hover { transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(16, 24, 40, 0.08); +} + +.workspace__git-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: #c2d1c3; } -.app-shell__user { +.workspace__git-badge.is-ready .workspace__git-dot { + background: #2f9f60; +} + +.workspace__git-badge.is-warning .workspace__git-dot { + background: #ff8c42; +} + +.workspace__command { display: inline-flex; align-items: center; gap: 10px; - border-radius: 999px; - border: 1px solid #cbd5f5; + border: 1px solid rgba(208, 218, 208, 0.95); + border-radius: 16px; background: #ffffff; - color: #0f172a; - padding: 8px 16px; + color: #1f3125; + padding: 10px 16px; font-size: 0.95rem; - font-weight: 600; + font-weight: 700; cursor: pointer; - transition: box-shadow 0.2s ease, transform 0.2s ease; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + background-color 0.18s ease; + flex: 0 0 auto; + white-space: nowrap; } -.app-shell__user:hover { - box-shadow: 0 8px 16px rgba(15, 23, 42, 0.1); +.workspace__command:hover:not(:disabled) { transform: translateY(-1px); + background: #f7faf7; + box-shadow: 0 12px 24px rgba(16, 24, 40, 0.08); } -.app-shell__avatar { - width: 36px; - height: 36px; - border-radius: 999px; - object-fit: cover; - border: 1px solid #e2e8f0; - background: #f8fafc; - display: inline-flex; - align-items: center; +.workspace__command:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.workspace__command--primary { + border-color: #111827; + background: #111827; + color: #ffffff; +} + +.workspace__command--primary:hover:not(:disabled) { + background: #1f2937; +} + +.workspace__command-icon { + width: 18px; + height: 18px; +} + +.workspace__content { + flex: 1; + display: flex; + flex-direction: column; + gap: 28px; + padding: 34px 32px 32px; + overflow: auto; +} + +.workspace__stage { + flex: 1; + display: flex; justify-content: center; - font-weight: 600; - color: #475569; + align-items: flex-start; } -.app-shell__avatar--fallback { - font-size: 0.95rem; +.workspace__stage-card { + width: min(100%, 1280px); + display: flex; + flex-direction: column; + gap: 26px; + padding: 34px clamp(18px, 4vw, 52px); + border: 1px solid rgba(220, 228, 220, 0.95); + border-radius: 30px; + background: rgba(255, 255, 255, 0.94); + box-shadow: + 0 26px 60px rgba(18, 35, 24, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.92); +} + +.workspace__stage-meta { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; } -.app-shell__user-name { - max-width: 160px; +.workspace__stage-status, +.workspace__stage-total { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 8px 14px; + border-radius: 14px; + border: 1px solid rgba(213, 224, 213, 0.95); + background: #f7faf7; + color: #38503c; + font-size: 0.94rem; + font-weight: 700; +} + +.workspace__stage-status { + min-width: 0; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.app-shell__logout { - border-radius: 12px; - border: 1px solid #e2e8f0; - background: #f8fafc; - padding: 8px 14px; - font-size: 0.85rem; - font-weight: 500; - cursor: pointer; - color: #0f172a; - transition: background 0.2s ease, border 0.2s ease; +.workspace__stage-total { + white-space: nowrap; } -.app-shell__logout:hover { - background: #e2e8f0; - border-color: #cbd5f5; +.workspace__calendar-frame { + border: 1px solid rgba(226, 233, 226, 0.96); + border-radius: 26px; + background: linear-gradient(180deg, #ffffff 0%, #f9fbf8 100%); + padding: 26px 22px 18px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.96); } -.app-shell__actions { - display: flex; - align-items: center; - gap: 12px; +.workspace__calendar-scroll { + overflow-x: auto; + padding-bottom: 6px; } -.app-shell__action { - border-radius: 12px; - border: 1px solid #0f172a; - background: #0f172a; - color: #ffffff; - padding: 10px 18px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; +.workspace__year-switcher { + align-self: center; + display: inline-flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + border-radius: 18px; + background: #f1f5f1; + border: 1px solid rgba(211, 220, 211, 0.95); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); } -.app-shell__action:hover { - background: #111c34; +.workspace__year-label { + font-size: 0.98rem; + font-weight: 800; + color: #39503d; } -.app-shell__language { +.workspace__year-control { display: inline-flex; - border: 1px solid #cbd5f5; - background: #f8fafc; - border-radius: 12px; + align-items: center; + border: 1px solid rgba(207, 216, 207, 0.95); + border-radius: 14px; overflow: hidden; + background: #ffffff; } -.app-shell__language-btn { - padding: 8px 14px; - font-size: 0.85rem; - font-weight: 500; - color: #475569; - background: transparent; +.workspace__year-button { + width: 44px; + height: 42px; border: none; + background: transparent; + color: #45624a; + font-size: 1.2rem; + font-weight: 700; cursor: pointer; - transition: all 0.2s ease; + transition: background-color 0.18s ease; } -.app-shell__language-btn:is(:focus-visible, :hover) { - background: rgba(99, 102, 241, 0.15); - outline: none; +.workspace__year-button:hover:not(:disabled) { + background: rgba(25, 135, 84, 0.08); } -.app-shell__language-btn.is-active { - background: #0f172a; - color: #ffffff; +.workspace__year-button:disabled { + color: #b1b8b2; + cursor: not-allowed; } -.app-shell__icon-button { - width: 40px; - height: 40px; - border-radius: 12px; - border: 1px solid #e2e8f0; +.workspace__year-value { + min-width: 84px; + text-align: center; + padding: 0 12px; + font-size: 1.1rem; + font-weight: 800; + color: #203727; +} + +.workspace__dock { + align-self: center; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 12px; + flex-wrap: nowrap; + width: max-content; + max-width: 100%; + padding: 16px 18px; + border: 1px solid rgba(220, 228, 220, 0.96); + border-radius: 26px; + background: rgba(255, 255, 255, 0.96); + overflow-x: auto; + overflow-y: hidden; + scrollbar-gutter: stable; + box-shadow: + 0 22px 40px rgba(18, 35, 24, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.96); +} + +.workspace__dock-button, +.workspace__swatch { + min-height: 44px; + border: 1px solid rgba(208, 218, 208, 0.95); + border-radius: 16px; + background: #ffffff; + color: #203727; + cursor: pointer; + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease; +} + +.workspace__dock-button { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + font-size: 0.95rem; + font-weight: 700; + flex: 0 0 auto; + white-space: nowrap; +} + +.workspace__dock-button:hover, +.workspace__swatch:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(16, 24, 40, 0.08); +} + +.workspace__dock-button.is-active, +.workspace__swatch.is-active { + border-color: rgba(47, 128, 72, 0.55); + box-shadow: + 0 14px 24px rgba(41, 122, 66, 0.14), + inset 0 0 0 1px rgba(47, 128, 72, 0.45); +} + +.workspace__dock-icon { + width: 18px; + height: 18px; +} + +.workspace__dock-divider { + width: 1px; + height: 34px; + background: rgba(220, 228, 220, 0.96); + flex: 0 0 auto; +} + +.workspace__swatches { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; +} + +.workspace__swatch { + width: 44px; + height: 44px; + padding: 0; +} + +.workspace__swatch--auto { display: inline-flex; align-items: center; justify-content: center; - color: #0f172a; + background: #f3f7f2; +} + +.workspace__tool-modal { + width: min(860px, calc(100vw - 32px)); + border-radius: 30px; + border: 1px solid rgba(220, 228, 220, 0.96); background: #ffffff; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); - transition: transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: 0 32px 70px rgba(16, 24, 40, 0.24); } -.app-shell__icon-button:hover { - transform: translateY(-1px); - box-shadow: 0 8px 16px rgba(15, 23, 42, 0.15); +.workspace__tool-modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + padding: 24px 26px 18px; + border-bottom: 1px solid rgba(233, 238, 233, 0.95); +} + +.workspace__tool-modal-header h2 { + margin: 0; + font-size: 1.3rem; + color: #1b3122; +} + +.workspace__tool-modal-header p { + margin: 6px 0 0; + color: #718076; + font-size: 0.92rem; +} + +.workspace__tool-modal-close { + width: 40px; + height: 40px; + border: none; + border-radius: 14px; + background: #f5f7f5; + color: #425444; + font-size: 1.4rem; + cursor: pointer; +} + +.workspace__tool-modal-body { + padding: 18px 26px 26px; +} + +.workspace__image-card { + border: none !important; + background: transparent !important; + box-shadow: none !important; + padding: 0 !important; +} + +.workspace__toast { + position: fixed; + top: 24px; + left: 50%; + transform: translateX(-50%); + padding: 12px 18px; + border-radius: 16px; + background: rgba(15, 23, 42, 0.9); + color: #ffffff; + font-size: 0.94rem; + font-weight: 700; + z-index: 70; } .modal__backdrop { position: fixed; inset: 0; - background: rgba(15, 23, 42, 0.45); display: flex; align-items: center; justify-content: center; padding: 20px; - z-index: 40; + background: rgba(15, 23, 42, 0.42); + z-index: 60; } .modal { - width: 440px; + width: 460px; max-width: 100%; + border-radius: 28px; + border: 1px solid rgba(220, 228, 220, 0.96); background: #ffffff; - border-radius: 24px; - border: 1px solid #e2e8f0; - box-shadow: 0 30px 60px rgba(15, 23, 42, 0.25); + box-shadow: 0 36px 70px rgba(16, 24, 40, 0.25); } .modal__header { display: flex; align-items: center; justify-content: space-between; - padding: 20px 24px; - border-bottom: 1px solid #f1f5f9; + gap: 12px; + padding: 22px 24px; + border-bottom: 1px solid rgba(236, 240, 236, 0.98); +} + +.modal__header h2 { + margin: 0; + font-size: 1.2rem; + color: #17261c; } .modal__header-actions { @@ -309,87 +638,69 @@ body { gap: 8px; } -.modal__header h2 { - margin: 0; - font-size: 1.2rem; -} - .modal__help { + width: 30px; + height: 30px; border: none; - background: #e2e8f0; - width: 28px; - height: 28px; - border-radius: 50%; - font-weight: 700; + border-radius: 999px; + background: #eff4ef; + color: #314834; font-size: 0.95rem; + font-weight: 800; cursor: pointer; - color: #0f172a; - transition: background 0.2s ease, color 0.2s ease; -} - -.modal__help:hover { - background: #cbd5f5; - color: #0a0f1c; } .modal__close { border: none; background: transparent; - font-size: 1.5rem; - cursor: pointer; + color: #5f6d61; + font-size: 1.6rem; line-height: 1; - color: #475569; + cursor: pointer; } .modal__body { - padding: 24px; display: flex; flex-direction: column; gap: 18px; + padding: 24px; } .modal__field { display: flex; flex-direction: column; gap: 8px; - font-size: 0.9rem; - text-align: left; -} - -.modal__field input { - border-radius: 14px; - border: 1px solid #cbd5f5; - padding: 12px 14px; font-size: 0.95rem; - font-family: inherit; + color: #25352a; } +.modal__field input, .modal__field textarea { - border-radius: 14px; - border: 1px solid #cbd5f5; + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(208, 218, 208, 0.96); + border-radius: 16px; padding: 12px 14px; font-size: 0.95rem; font-family: inherit; - resize: vertical; + color: #15231a; } -.modal__field input:focus { +.modal__field input:focus, +.modal__field textarea:focus { outline: none; - border-color: #6366f1; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); + border-color: rgba(47, 128, 72, 0.6); + box-shadow: 0 0 0 4px rgba(47, 128, 72, 0.14); } -.modal__field textarea:focus { - outline: none; - border-color: #6366f1; - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +.modal__field textarea { + resize: vertical; } .modal__options { display: flex; flex-direction: column; - gap: 6px; - text-align: left; + gap: 8px; } .modal__options--inline { @@ -399,32 +710,34 @@ body { } .modal__remember { - display: flex; + display: inline-flex; + align-items: center; gap: 8px; - font-size: 0.85rem; - color: #475569; + color: #465646; + font-size: 0.9rem; } .modal__hint { margin: 0; - font-size: 0.8rem; - color: #94a3b8; + color: #7a857c; + font-size: 0.84rem; } .modal__status { - border-radius: 12px; padding: 10px 14px; + border-radius: 14px; font-size: 0.9rem; + font-weight: 700; } .modal__status--error { - background: #fee2e2; - color: #b91c1c; + background: #fee9e5; + color: #b94a28; } .modal__status--success { - background: #dcfce7; - color: #15803d; + background: #e8f7eb; + color: #237140; } .modal__profile { @@ -435,39 +748,40 @@ body { .modal__profile-info { display: flex; - gap: 14px; align-items: center; + gap: 14px; } .modal__profile-avatar { width: 60px; height: 60px; - border-radius: 16px; - border: 1px solid #e2e8f0; + border-radius: 18px; + border: 1px solid rgba(220, 228, 220, 0.96); object-fit: cover; - display: inline-flex; - align-items: center; - justify-content: center; - font-weight: 600; - background: #f8fafc; - color: #1f2937; + background: #f2f6f2; } .modal__profile-avatar--fallback { - font-size: 1.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: 800; + color: #36523f; } .modal__profile-name { margin: 0; font-size: 1rem; - font-weight: 600; + font-weight: 800; + color: #203126; } .modal__profile-login, .modal__profile-email { margin: 0; - font-size: 0.85rem; - color: #475569; + color: #5f6d61; + font-size: 0.88rem; } .modal__actions { @@ -477,77 +791,127 @@ body { } .modal__button { - border-radius: 12px; + border: 1px solid transparent; + border-radius: 14px; padding: 10px 18px; - font-size: 0.9rem; - font-weight: 600; + font-size: 0.92rem; + font-weight: 800; cursor: pointer; - border: 1px solid transparent; } .modal__button--ghost { - border-color: #e2e8f0; + border-color: rgba(208, 218, 208, 0.95); background: #ffffff; - color: #0f172a; -} - -.modal__button--ghost:hover { - border-color: #cbd5f5; + color: #1c2c21; } .modal__button--primary { - background: #0f172a; + background: #111827; color: #ffffff; } .modal__button--primary:disabled { - opacity: 0.6; + opacity: 0.65; cursor: not-allowed; } -.app-shell__main { - background: #fdfdfd; - border-radius: 28px; - border: 1px solid #e4e7ec; - padding: 36px; - box-shadow: 0 40px 80px rgba(15, 23, 42, 0.08); -} +@media (max-width: 1024px) { + .workspace { + min-height: 100vh; + } -.workbench { - display: flex; - flex-direction: column; - gap: 10px; -} + .workspace__topbar { + grid-template-columns: 1fr; + padding: 20px 22px 18px; + } -.workbench__canvas { - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 10px; - padding: 10px; - padding-top: 20px; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3), 0 20px 50px rgba(15, 23, 42, 0.07); - overflow: hidden; -} + .workspace__brand { + justify-self: start; + } -.workbench__panel { - position: static; - background: #ffffff; - border-radius: 10px; - padding: 24px; - box-shadow: 0 25px 55px rgba(15, 23, 42, 0.12); - border: 1px solid #e4e7ec; - width: 50%; + .workspace__toolbar-group--end { + justify-content: flex-start; + } + + .workspace__content { + padding: 22px; + } } +@media (max-width: 820px) { + .workspace { + flex-direction: column; + } -@media (max-width: 1200px) { - .app-shell { - padding: 16px; + .workspace__sidebar { + width: auto; + flex-direction: row; + justify-content: space-between; + padding: 16px 18px; + border-right: none; + border-bottom: 1px solid rgba(208, 218, 208, 0.9); } - .app-shell__main { - padding: 24px; + .workspace__nav { + flex-direction: row; } + .workspace__sidebar-spacer { + display: none; + } + + .workspace__profile { + flex-direction: row; + } } +@media (max-width: 640px) { + .workspace__content { + padding: 18px 14px; + } + + .workspace__stage-card { + padding: 20px 14px; + } + + .workspace__stage-meta { + display: flex; + flex-direction: column; + align-items: stretch; + } + + .workspace__year-switcher { + width: 100%; + justify-content: space-between; + } + + .workspace__dock { + width: 100%; + box-sizing: border-box; + flex-wrap: wrap; + overflow-x: visible; + overflow-y: visible; + } + + .workspace__dock-button { + width: calc(50% - 8px); + justify-content: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .workspace__dock-divider { + display: none; + } + + .workspace__swatches { + width: 100%; + justify-content: center; + flex-wrap: wrap; + } + + .workspace__toolbar-group { + flex-wrap: wrap; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34c2312..36466d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,22 +4,23 @@ import ContributionCalendar, { OneDay } from './components/ContributionCalendar' import GitInstallSidebar from './components/GitInstallSidebar'; import GitPathSettings from './components/GitPathSettings'; import LoginModal from './components/LoginModal'; -import { TranslationProvider, useTranslations, Language } from './i18n'; +import { TranslationProvider } from './i18n'; import type { main } from '../wailsjs/go/models'; function App() { const generateEmptyYearData = (year: number): OneDay[] => { const data: OneDay[] = []; - const d = new Date(Date.UTC(year, 0, 1)); + const date = new Date(Date.UTC(year, 0, 1)); - while (d.getUTCFullYear() === year) { + while (date.getUTCFullYear() === year) { data.push({ - date: d.toISOString().slice(0, 10), + date: date.toISOString().slice(0, 10), count: 0, level: 0, }); - d.setUTCDate(d.getUTCDate() + 1); + date.setUTCDate(date.getUTCDate() + 1); } + return data; }; @@ -30,14 +31,13 @@ function App() { for (let year = 2008; year <= currentYear; year++) { data.push(...generateEmptyYearData(year)); } + return data; }; - const multiYearData: OneDay[] = generateMultiYearData(); - return ( - + ); } @@ -48,14 +48,15 @@ type AppLayoutProps = { const AppLayout: React.FC = ({ contributions }) => { const hasWailsApp = React.useCallback(() => { - if (typeof window === 'undefined') return false; + if (typeof window === 'undefined') { + return false; + } const w = window as typeof window & { go?: { main?: { App?: unknown } } }; return Boolean(w?.go?.main?.App); }, []); - const { language, setLanguage, t } = useTranslations(); const [isGitInstalled, setIsGitInstalled] = React.useState(null); - const [isGitPathSettingsOpen, setIsGitPathSettingsOpen] = React.useState(false); + const [isGitPathSettingsOpen, setIsGitPathSettingsOpen] = React.useState(false); const [isLoginModalOpen, setIsLoginModalOpen] = React.useState(false); const [githubUser, setGithubUser] = React.useState(null); @@ -66,17 +67,19 @@ const AppLayout: React.FC = ({ contributions }) => { setIsGitInstalled(false); return; } + const mod = await import('../wailsjs/go/main/App'); if (!mod || typeof mod.CheckGitInstalled !== 'function') { throw new Error('CheckGitInstalled not available'); } + const response = await mod.CheckGitInstalled(); setIsGitInstalled(response.installed); } catch (error) { console.error('Failed to check Git installation:', error); setIsGitInstalled(false); } - }, []); + }, [hasWailsApp]); React.useEffect(() => { checkGit(); @@ -89,10 +92,12 @@ const AppLayout: React.FC = ({ contributions }) => { console.warn('GetGithubLoginStatus skipped: wails runtime not available (dev mode)'); return; } + const mod = await import('../wailsjs/go/main/App'); if (!mod || typeof mod.GetGithubLoginStatus !== 'function') { throw new Error('GetGithubLoginStatus not available'); } + const status = await mod.GetGithubLoginStatus(); if (status.authenticated && status.user) { setGithubUser(status.user); @@ -103,16 +108,17 @@ const AppLayout: React.FC = ({ contributions }) => { console.error('Failed to fetch GitHub login status:', error); } })(); - }, []); + }, [hasWailsApp]); React.useEffect(() => { let unsubscribe: (() => void) | undefined; + (async () => { try { const { EventsOn } = await import('../wailsjs/runtime/runtime'); if (typeof EventsOn === 'function') { unsubscribe = EventsOn('github:auth-changed', (status: main.GithubLoginStatus) => { - if (status && status.authenticated && status.user) { + if (status?.authenticated && status.user) { setGithubUser(status.user); return; } @@ -135,16 +141,6 @@ const AppLayout: React.FC = ({ contributions }) => { checkGit(); }, [checkGit]); - const languageOptions = React.useMemo( - () => [ - { value: 'en' as Language, label: t('languageSwitcher.english') }, - { value: 'zh' as Language, label: t('languageSwitcher.chinese') }, - ], - [t] - ); - - const loginLabel = language === 'zh' ? '登录' : 'Log in'; - const logoutLabel = language === 'zh' ? '退出' : 'Log out'; const handleLogout = React.useCallback(async () => { try { const mod = await import('../wailsjs/go/main/App'); @@ -156,108 +152,21 @@ const AppLayout: React.FC = ({ contributions }) => { console.error('Failed to log out from GitHub:', error); } }, []); + const handleAuthSuccess = React.useCallback((user: main.GithubUserProfile) => { setGithubUser(user); }, []); - const displayName = githubUser?.name?.trim() || githubUser?.login || ''; - - const openRepository = React.useCallback(async () => { - try { - const { BrowserOpenURL } = await import('../wailsjs/runtime/runtime'); - if (typeof BrowserOpenURL === 'function') { - BrowserOpenURL('https://github.com/zmrlft/GreenWall'); - return; - } - } catch (error) { - console.warn('BrowserOpenURL not available (dev mode)', error); - } - if (typeof window !== 'undefined') { - window.open('https://github.com/zmrlft/GreenWall', '_blank', 'noopener,noreferrer'); - } - }, []); return (
-
-
-
- {githubUser ? ( - <> - - - - ) : ( - - )} -
-
- -
- {languageOptions.map((option) => { - const isActive = language === option.value; - return ( - - ); - })} -
- -
-
- - -
+ setIsGitPathSettingsOpen(true)} + onOpenLogin={() => setIsLoginModalOpen(true)} + onLogout={handleLogout} + /> {isGitInstalled === false && } @@ -267,6 +176,7 @@ const AppLayout: React.FC = ({ contributions }) => { onCheckAgain={handleCheckAgain} /> )} + {isLoginModalOpen && ( = 1 && count <= 2) return 1; if (count >= 3 && count <= 5) return 2; @@ -20,988 +21,419 @@ function calculateLevel(count: number): 0 | 1 | 2 | 3 | 4 { return 0; } -// 逐步递进贡献次数 (0 → 1 → 3 → 6 → 9) -function getNextContribution(current: number): number { - if (current < 1) return 1; - if (current < 3) return 3; - if (current < 6) return 6; - if (current < 9) return 9; - return current; // 已是最大值 -} - -// 将字符转换为像素图案 - 使用预定义的图案数据 -function characterToPattern(char: string): boolean[][] { - const pattern = getPatternById(char); - if (pattern) { - return gridToBoolean(pattern.grid); - } - - // 如果找不到预定义图案,返回空图案 - return Array(7) - .fill(null) - .map(() => Array(5).fill(false)); -} +type IconProps = { + className?: string; +}; -export type OneDay = { level: number; count: number; date: string }; +const InfoIcon = ({ className }: IconProps) => ( + + + + + +); + +const BookIcon = ({ className }: IconProps) => ( + + + + +); + +const ImportIcon = ({ className }: IconProps) => ( + + + + + +); + +const ExportIcon = ({ className }: IconProps) => ( + + + + + +); + +const GenerateIcon = ({ className }: IconProps) => ( + + + + + +); + +const PenIcon = ({ className }: IconProps) => ( + + + +); + +const EraserIcon = ({ className }: IconProps) => ( + + + + +); + +const CopyIcon = ({ className }: IconProps) => ( + + + + +); + +const ImageIcon = ({ className }: IconProps) => ( + + + + + +); + +const TypeIcon = ({ className }: IconProps) => ( + + + +); + +const AutoIcon = ({ className }: IconProps) => ( + + + +); + +const LogoutIcon = ({ className }: IconProps) => ( + + + + + +); + +const UserIcon = ({ className }: IconProps) => ( + + + + +); -/** - * 仿 GitHub 的贡献图,支持交互式点击和拖拽绘制贡献次数。 - * - * 功能说明: - * - 画笔模式:点击画笔按钮显示悬浮滑动条,选择画笔强度(1、3、6、9),点击或拖拽绘制格子 - * - 橡皮擦模式:点击或拖拽清除格子贡献 - * - 画笔强度对应不同的绿色深度:1(浅绿)、3(中绿)、6(深绿)、9(最深绿) - * - 可以输入不同年份查看(2008年-当前年份) - * - 清除按钮会重置所有用户设置 - * - 支持鼠标左键长按拖拽连续绘制 - * - * 数据可以用 /script/fetch-contributions.js 抓取。 - * - * @example - * const data = [{ level: 1, count: 5, date: 1728272654618 }, ...]; - * - */ type Props = { contributions: OneDay[]; className?: string; githubUser?: main.GithubUserProfile | null; + isGitInstalled: boolean | null; + onOpenGitSettings: () => void; + onOpenLogin: () => void; + onLogout: () => void | Promise; } & React.HTMLAttributes; -type DrawMode = 'pen' | 'eraser'; - -// 画笔强度类型:对应不同的贡献次数 -type PenIntensity = 1 | 3 | 6 | 9; - -type ContainerVars = React.CSSProperties & { - '--cell'?: string; - '--gap'?: string; +const penIntensityColors: Record = { + 1: '#b9edc1', + 3: '#79d792', + 6: '#4db66c', + 9: '#2f8048', }; function ContributionCalendar({ contributions: originalContributions, className, githubUser, + isGitInstalled, + onOpenGitSettings, + onOpenLogin, + onLogout, ...rest }: Props) { const { style: externalStyle, ...divProps } = rest; - // 选中日期状态 - 改为存储每个日期的贡献次数 - const { t, dictionary } = useTranslations(); - const monthNames = dictionary.months; - - const { userContributions, setUserContributions, pushSnapshot, undo, redo } = - useContributionHistory(new Map()); - const [year, setYear] = React.useState(new Date().getFullYear()); - - // 绘画模式状态 - const [drawMode, setDrawMode] = React.useState('pen'); - const [penIntensity, setPenIntensity] = React.useState(1); // 画笔强度,默认为1 - const [penMode, setPenMode] = React.useState<'manual' | 'auto'>('auto'); // 画笔模式:manual 手动强度,auto 自动逐步递进 - const [isDrawing, setIsDrawing] = React.useState(false); - const [lastHoveredDate, setLastHoveredDate] = React.useState(null); - const [isGeneratingRepo, setIsGeneratingRepo] = React.useState(false); - const [isRemoteModalOpen, setIsRemoteModalOpen] = React.useState(false); - const [isMaximized, setIsMaximized] = React.useState(false); - const containerRef = React.useRef(null); - const [containerVars, setContainerVars] = React.useState({}); - - // 字符预览状态 - const [previewMode, setPreviewMode] = React.useState(false); - const [previewCharacter, setPreviewCharacter] = React.useState(''); - const [previewDates, setPreviewDates] = React.useState>(new Set()); - // 复制/粘贴相关状态 - const [copyMode, setCopyMode] = React.useState(false); - const [selectionStart, setSelectionStart] = React.useState(null); - const [selectionEnd, setSelectionEnd] = React.useState(null); - const [selectionDates, setSelectionDates] = React.useState>(new Set()); - const [selectionBuffer, setSelectionBuffer] = React.useState<{ - width: number; - height: number; - data: number[][]; - } | null>(null); - const [pastePreviewActive, setPastePreviewActive] = React.useState(false); - const [pastePreviewDates, setPastePreviewDates] = React.useState>(new Set()); - // 简单 toast - const [toast, setToast] = React.useState(null); - - // 允许选择年份,过滤贡献数据 - const filteredContributions = originalContributions.filter( - (c) => new Date(c.date).getFullYear() === year - ); - - // 计算当前日期与明天零点,用于判断未来日期 - const now = new Date(); - const currentYear = now.getFullYear(); - const todayStart = new Date(currentYear, now.getMonth(), now.getDate()); - const tomorrowStart = new Date(todayStart); - tomorrowStart.setDate(tomorrowStart.getDate() + 1); - const tomorrowTime = tomorrowStart.getTime(); - const remoteRepoDefaultName = githubUser?.login?.trim() - ? `${githubUser.login.trim()}-${year}` - : `green-wall-${year}`; - const isCurrentYear = year === currentYear; - - const isFutureDate = React.useCallback( - (dateStr: string) => { - if (!isCurrentYear) { - return false; - } - const parsed = new Date(dateStr); - const localDate = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()); - return localDate.getTime() >= tomorrowTime; - }, - [isCurrentYear, tomorrowTime] - ); - - // 计算字符预览的日期列表 - 以指定日期为中心 - const calculatePreviewDates = React.useCallback( - (char: string, centerDateStr: string | null) => { - if (!char || !centerDateStr || filteredContributions.length === 0) { - return new Set(); - } - - const pattern = characterToPattern(char); - const previewDatesSet = new Set(); - - // 找到中心日期在日历中的位置 - const centerContribution = filteredContributions.find((c) => c.date === centerDateStr); - if (!centerContribution) return new Set(); - - const centerDate = new Date(centerDateStr); - const yearStart = new Date(year, 0, 1); - const daysSinceYearStart = Math.floor( - (centerDate.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24) - ); - - // 计算中心日期的行列位置 - const firstDayOfWeek = yearStart.getDay(); // 0=周日, 1=周一, ... - const centerDayOfWeek = centerDate.getDay(); - const centerWeek = Math.floor((daysSinceYearStart + firstDayOfWeek) / 7); - const centerRow = centerDayOfWeek; - - // 图案尺寸 - const patternHeight = pattern.length; - const patternWidth = pattern[0]?.length || 0; - - // 以图案中心为基准计算偏移 - const patternCenterY = Math.floor(patternHeight / 2); - const patternCenterX = Math.floor(patternWidth / 2); - - // 遍历图案的每个像素 - for (let patternY = 0; patternY < patternHeight; patternY++) { - for (let patternX = 0; patternX < patternWidth; patternX++) { - if (pattern[patternY][patternX]) { - // 计算相对于中心的偏移 - const offsetY = patternY - patternCenterY; - const offsetX = patternX - patternCenterX; - - // 计算目标位置 - const targetRow = centerRow + offsetY; - const targetCol = centerWeek + offsetX; - - // 检查是否在日历范围内 - if (targetRow >= 0 && targetRow < 7 && targetCol >= 0) { - // 计算目标日期 - const daysOffset = targetCol * 7 + targetRow - (centerWeek * 7 + centerRow); - const targetDate = new Date(centerDate); - targetDate.setDate(targetDate.getDate() + daysOffset); - - const dateStr = targetDate.toISOString().slice(0, 10); - - // 检查该日期是否存在于贡献数据中且不是未来日期 - const contribution = filteredContributions.find((c) => c.date === dateStr); - if (contribution && !isFutureDate(dateStr)) { - previewDatesSet.add(dateStr); - } - } - } - } - } - - return previewDatesSet; - }, - [filteredContributions, year, isFutureDate] - ); - - const getTooltip = React.useCallback( - (oneDay: OneDay, date: Date) => { - const s = date.toISOString().split('T')[0]; - if (isFutureDate(oneDay.date)) { - return t('calendar.tooltipFuture', { date: s }); - } - if (oneDay.count === 0) { - return t('calendar.tooltipNone', { date: s }); - } - return t('calendar.tooltipSome', { count: oneDay.count, date: s }); - }, - [isFutureDate, t] - ); - - // 清除所有选中 - const handleReset = () => { - pushSnapshot(); - setUserContributions(new Map()); - }; - - // 将可编辑的格子全部填充为最深绿色(计数为 9) - const handleFillAllGreen = () => { - pushSnapshot(); - setUserContributions((prev) => { - const newMap = new Map(prev); - for (const c of filteredContributions) { - if (!isFutureDate(c.date)) { - newMap.set(c.date, 9); - } - } - return newMap; - }); - }; - - // 开始字符预览 - const handleStartCharacterPreview = React.useCallback((char: string) => { - setPreviewCharacter(char); - setPreviewDates(new Set()); // 初始为空,等待鼠标悬停 - setPreviewMode(true); - // 清除粘贴预览状态,避免冲突 - setPastePreviewActive(false); - setPastePreviewDates(new Set()); - }, []); - - // helper: convert date -> {row, col} - const getDateCoord = React.useCallback( - (dateStr: string) => { - const date = new Date(dateStr); - const yearStart = new Date(year, 0, 1); - const firstDayOfWeek = yearStart.getDay(); - const daysSinceYearStart = Math.floor( - (date.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24) - ); - const col = Math.floor((daysSinceYearStart + firstDayOfWeek) / 7); - const row = date.getDay(); - return { row, col }; - }, - [year] - ); - - const computeSelectionDates = React.useCallback( - (startDate: string, endDate: string) => { - const a = getDateCoord(startDate); - const b = getDateCoord(endDate); - const minRow = Math.min(a.row, b.row); - const maxRow = Math.max(a.row, b.row); - const minCol = Math.min(a.col, b.col); - const maxCol = Math.max(a.col, b.col); - - const set = new Set(); - for (const c of filteredContributions) { - const coord = getDateCoord(c.date); - if ( - coord.row >= minRow && - coord.row <= maxRow && - coord.col >= minCol && - coord.col <= maxCol - ) { - set.add(c.date); - } - } - return { set, minRow, minCol, maxRow, maxCol }; - }, - [filteredContributions, getDateCoord] - ); - - const buildBufferFromSelection = React.useCallback( - (startDate: string, endDate: string) => { - const { set } = computeSelectionDates(startDate, endDate); - - // 首先收集所有有颜色的格子 - const coloredCells: { row: number; col: number; value: number }[] = []; - - for (const dateStr of set) { - const coord = getDateCoord(dateStr); - const current = - userContributions.get(dateStr) ?? - filteredContributions.find((x) => x.date === dateStr)?.count ?? - 0; - // 只收集有贡献的格子 - if (current > 0) { - coloredCells.push({ - row: coord.row, - col: coord.col, - value: current, - }); - } - } - - // 如果没有涂色的格子,返回空buffer - if (coloredCells.length === 0) { - return { width: 0, height: 0, data: [] }; - } - - // 计算涂色格子的边界 - const coloredMinRow = Math.min(...coloredCells.map((c) => c.row)); - const coloredMaxRow = Math.max(...coloredCells.map((c) => c.row)); - const coloredMinCol = Math.min(...coloredCells.map((c) => c.col)); - const coloredMaxCol = Math.max(...coloredCells.map((c) => c.col)); - - const width = coloredMaxCol - coloredMinCol + 1; - const height = coloredMaxRow - coloredMinRow + 1; - const data: number[][] = Array.from({ length: height }, () => Array(width).fill(0)); - - // 将涂色格子填入data数组 - for (const cell of coloredCells) { - const r = cell.row - coloredMinRow; - const c = cell.col - coloredMinCol; - data[r][c] = cell.value; - } - - return { width, height, data }; - }, - [computeSelectionDates, getDateCoord, userContributions, filteredContributions] - ); - - const calculateBufferPreviewDates = React.useCallback( - (buffer: { width: number; height: number; data: number[][] }, centerDateStr: string) => { - if (!buffer || !centerDateStr) return new Set(); - const previewSet = new Set(); - const pattern = buffer.data; - const patternHeight = buffer.height; - const patternWidth = buffer.width; - const patternCenterY = Math.floor(patternHeight / 2); - const patternCenterX = Math.floor(patternWidth / 2); - - const centerDate = new Date(centerDateStr); - const yearStart = new Date(year, 0, 1); - const daysSinceYearStart = Math.floor( - (centerDate.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24) - ); - const firstDayOfWeek = yearStart.getDay(); - const centerWeek = Math.floor((daysSinceYearStart + firstDayOfWeek) / 7); - const centerRow = centerDate.getDay(); - - for (let py = 0; py < patternHeight; py++) { - for (let px = 0; px < patternWidth; px++) { - const val = pattern[py][px]; - // 只预览有贡献的格子 - if (!val || val === 0) continue; - const offsetY = py - patternCenterY; - const offsetX = px - patternCenterX; - const targetRow = centerRow + offsetY; - const targetCol = centerWeek + offsetX; - if (targetRow >= 0 && targetRow < 7 && targetCol >= 0) { - const daysOffset = targetCol * 7 + targetRow - (centerWeek * 7 + centerRow); - const targetDate = new Date(centerDate); - targetDate.setDate(targetDate.getDate() + daysOffset); - const dateStr = targetDate.toISOString().slice(0, 10); - const contribution = filteredContributions.find((c) => c.date === dateStr); - if (contribution && !isFutureDate(dateStr)) { - previewSet.add(dateStr); - } - } - } - } - return previewSet; - }, - [filteredContributions, year, isFutureDate] - ); - - // 将 buffer 写回网格(以中心日期为锚点) - const applyPaste = React.useCallback( - (centerDateStr: string) => { - if (!selectionBuffer || !centerDateStr) return; - const buffer = selectionBuffer; - const pattern = buffer.data; - const patternHeight = buffer.height; - const patternWidth = buffer.width; - const patternCenterY = Math.floor(patternHeight / 2); - const patternCenterX = Math.floor(patternWidth / 2); - - const centerDate = new Date(centerDateStr); - const yearStart = new Date(year, 0, 1); - const daysSinceYearStart = Math.floor( - (centerDate.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24) - ); - const firstDayOfWeek = yearStart.getDay(); - const centerWeek = Math.floor((daysSinceYearStart + firstDayOfWeek) / 7); - const centerRow = centerDate.getDay(); - - pushSnapshot(); - setUserContributions((prev: Map) => { - const newMap = new Map(prev); - for (let py = 0; py < patternHeight; py++) { - for (let px = 0; px < patternWidth; px++) { - const val = pattern[py][px]; - // 只处理有颜色的格子,跳过空格子 - if (!val || val === 0) continue; - const offsetY = py - patternCenterY; - const offsetX = px - patternCenterX; - const targetRow = centerRow + offsetY; - const targetCol = centerWeek + offsetX; - if (targetRow >= 0 && targetRow < 7 && targetCol >= 0) { - const daysOffset = targetCol * 7 + targetRow - (centerWeek * 7 + centerRow); - const targetDate = new Date(centerDate); - targetDate.setDate(targetDate.getDate() + daysOffset); - const dateStr = targetDate.toISOString().slice(0, 10); - if (isFutureDate(dateStr)) continue; - newMap.set(dateStr, val); - } - } - } - return newMap; - }); - - // 应用粘贴后清除预览状态 - setPastePreviewActive(false); - setPastePreviewDates(new Set()); - }, - [selectionBuffer, year, isFutureDate, pushSnapshot, setUserContributions] - ); - - const handlePreviewImageGrid = React.useCallback( - (grid: { width: number; height: number; data: number[][] }) => { - setSelectionBuffer(grid); - setPastePreviewActive(true); - setPastePreviewDates(new Set()); - setSelectionStart(null); - setSelectionEnd(null); - setSelectionDates(new Set()); - setPreviewMode(false); - }, - [] - ); - - // 取消字符预览 - const handleCancelCharacterPreview = React.useCallback(() => { - setPreviewMode(false); - setPreviewCharacter(''); - setPreviewDates(new Set()); - }, []); - - // 应用字符预览到贡献图 - const handleApplyCharacterPreview = React.useCallback(() => { - if (!previewMode || previewDates.size === 0) return; - - pushSnapshot(); - setUserContributions((prev: Map) => { - const newMap = new Map(prev); - for (const dateStr of previewDates) { - const current = prev.get(dateStr) ?? 0; - - if (penMode === 'auto') { - // auto 模式:逐步递进 - newMap.set(dateStr, getNextContribution(current)); - } else { - // manual 模式:直接设置为选定的画笔强度值 - newMap.set(dateStr, penIntensity); - } - } - return newMap; - }); - - // 取消预览 - handleCancelCharacterPreview(); - }, [ - previewMode, - previewDates, - handleCancelCharacterPreview, + const { language, setLanguage, t, dictionary } = useTranslations(); + const { + MIN_YEAR, + currentYear, + year, + setYear, + filteredContributions, + userContributions, + total, + drawMode, + setDrawMode, penIntensity, + setPenIntensity, penMode, - pushSnapshot, - setUserContributions, - ]); - - // 检测窗口是否最大化/全屏,用于切换布局与放大样式 - React.useEffect(() => { - let disposed = false; - const check = async () => { - try { - const m = await WindowIsMaximised(); - const f = await WindowIsFullscreen(); - if (!disposed) setIsMaximized(m || f); - } catch (error) { - console.warn('Failed to determine window state', error); - } - }; - check(); - const onResize = () => { - check(); - }; - window.addEventListener('resize', onResize); - return () => { - disposed = true; - window.removeEventListener('resize', onResize); - }; - }, []); - - // 在最大化/全屏时,计算不超出窗口宽度的单元格尺寸,避免横向滚动 - React.useEffect(() => { - const recalc = () => { - if (!isMaximized) { - setContainerVars({}); - return; - } - const el = containerRef.current; - const wrapper = el?.parentElement; // 外层 overflow 容器 - const wrapperWidth = wrapper?.clientWidth ?? window.innerWidth; - - // 变量与常量(应尽量与样式中的值一致) - const paddingX = 40; // .container 左右 padding: 20 + 20 - const borderX = 2; // 左右边框近似 1px + 1px - const cols = 53; // 一年最多 53 列 - const gaps = 53; // 列与列之间的 gap 数(54 列 -> 53 间隔),包含周标签列与第一列之间 - const preferredGap = 6; // maximized 下默认 gap - const minGap = 2; // 允许缩小的最小 gap - const preferredCell = 20; // maximized 下默认 cell - const minCell = 8; // 兜底格子尺寸 - - // 估计左侧周标签列宽度:取三个星期标签的最大宽度 - let labelW = 48; // 更保守的兜底 - try { - const weeks = el?.querySelectorAll(`.${styles.week}`); - if (weeks && weeks.length) { - weeks.forEach((node) => { - const elem = node as HTMLElement; - const w = elem.offsetWidth || 0; - // 叠加 margin-right 作为 track 的近似宽度 - const cs = window.getComputedStyle(elem); - const mr = parseFloat(cs.marginRight || '0') || 0; - const track = w + mr; - if (w > labelW) labelW = w; - if (track > labelW) labelW = track; - }); - } - } catch (error) { - console.warn('Failed to measure calendar layout', error); - } - - // 预留少量余量,避免四舍五入导致轻微溢出 - const safety = 6; - const availForTracks = wrapperWidth - paddingX - borderX - labelW - safety; - - // 先尽量用较大的 cell,再退而缩小 gap,最后兜底为最小 cell + 最小 gap - let finalGap = preferredGap; - let finalCell = preferredCell; - - // 如果首选组合超出,按顺序降低 - const fits = (cell: number, gap: number) => cols * cell + gaps * gap <= availForTracks; - if (!fits(finalCell, finalGap)) { - // 优先保证 cell 大小,计算允许的最大 gap - const maxGap = Math.floor((availForTracks - cols * minCell) / gaps); - finalGap = Math.max(minGap, Math.min(preferredGap, maxGap)); - // 再计算在该 gap 下允许的最大 cell - const maxCell = Math.floor((availForTracks - gaps * finalGap) / cols); - finalCell = Math.max(minCell, Math.min(preferredCell, maxCell)); - // 如仍不 fit,则进一步把 gap 降到最小并重算 cell - if (!fits(finalCell, finalGap)) { - finalGap = minGap; - const maxCell2 = Math.floor((availForTracks - gaps * finalGap) / cols); - finalCell = Math.max(minCell, Math.min(preferredCell, maxCell2)); - } - } - - const nextVars: ContainerVars = { - '--cell': `${finalCell}px`, - '--gap': `${finalGap}px`, - maxWidth: '100%', - }; - setContainerVars(nextVars); - }; + setPenMode, + copyMode, + toggleCopyMode, + previewMode, + previewCharacter, + previewDates, + startCharacterPreview, + cancelCharacterPreview, + applyCharacterPreview, + selectionDates, + pastePreviewActive, + pastePreviewDates, + previewImageGrid, + cancelPastePreview, + applyPaste, + getTooltip, + isFutureDate, + handleTileMouseDown, + handleTileMouseEnter, + handleTileMouseUp, + reset, + fillAllGreen, + exportContributions, + importContributions, + openRemoteModal, + closeRemoteModal, + submitRemoteModal, + isRemoteModalOpen, + remoteRepoDefaultName, + isGeneratingRepo, + toast, + } = useContributionEditor({ + contributions: originalContributions, + githubUser, + }); - recalc(); - const onResize = () => recalc(); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, [isMaximized]); + const [isImageImportOpen, setIsImageImportOpen] = React.useState(false); + const [isCharacterSelectorOpen, setIsCharacterSelectorOpen] = React.useState(false); - const runGenerateRepo = React.useCallback( - async (remoteRepoOptions: RemoteRepoPayload) => { - const githubLogin = githubUser?.login?.trim() ?? ''; - const githubEmail = - githubUser?.email?.trim() || (githubLogin ? `${githubLogin}@users.noreply.github.com` : ''); + const displayName = githubUser?.name?.trim() || githubUser?.login || 'GreenWall'; + const languageOptions = [ + { value: 'en' as const, label: t('languageSwitcher.english') }, + { value: 'zh' as const, label: t('languageSwitcher.chinese') }, + ]; - if (githubLogin === '' || githubEmail === '') { - window.alert(t('messages.remoteLoginRequired')); - return; - } - - const contributionsForBackend = filteredContributions - .map((c) => { - const override = userContributions.get(c.date); - const finalCount = override !== undefined ? override : c.count; - return { date: c.date, count: finalCount }; - }) - .filter((entry) => entry.count > 0); - - if (contributionsForBackend.length === 0) { - window.alert(t('messages.noContributions')); + const openExternalUrl = React.useCallback(async (url: string) => { + try { + const { BrowserOpenURL } = await import('../../wailsjs/runtime/runtime'); + if (typeof BrowserOpenURL === 'function') { + BrowserOpenURL(url); return; } - - setIsGeneratingRepo(true); - try { - const payload = main.GenerateRepoRequest.createFrom({ - year, - githubUsername: githubLogin, - githubEmail, - repoName: remoteRepoOptions.name.trim(), - contributions: contributionsForBackend, - remoteRepo: { - enabled: true, - name: remoteRepoOptions.name.trim(), - private: remoteRepoOptions.isPrivate, - description: remoteRepoOptions.description.trim(), - }, - }); - const result = await GenerateRepo(payload); - const baseMessage = `Repository created at ${result.repoPath} with ${result.commitCount} commits.`; - const fullMessage = - result.remoteUrl && result.remoteUrl !== '' - ? `${baseMessage}\nRemote repository: ${result.remoteUrl}` - : baseMessage; - window.alert(fullMessage); - } catch (error) { - console.error('Failed to generate repository', error); - const message = error instanceof Error ? error.message : String(error); - window.alert(t('messages.generateRepoError', { message })); - } finally { - setIsGeneratingRepo(false); - } - }, - [filteredContributions, githubUser, t, userContributions, year] - ); - - const handleRemoteModalSubmit = React.useCallback( - (payload: RemoteRepoPayload) => { - setIsRemoteModalOpen(false); - runGenerateRepo(payload); - }, - [runGenerateRepo] - ); - - const handleOpenRemoteModal = React.useCallback(() => { - if (!githubUser?.login) { - window.alert(t('messages.remoteLoginRequired')); - return; - } - setIsRemoteModalOpen(true); - }, [githubUser, t]); - - const handleExportContributions = React.useCallback(async () => { - const contributionsToExport = filteredContributions - .map((c) => { - const override = userContributions.get(c.date); - const finalCount = override !== undefined ? override : c.count; - return { - date: c.date, - count: finalCount, - }; - }) - .filter((entry) => entry.count > 0); - - try { - const payload = main.ExportContributionsRequest.createFrom({ - contributions: contributionsToExport, - }); - const result = await ExportContributions(payload); - window.alert(t('messages.exportSuccess', { filePath: result.filePath })); } catch (error) { - console.error('Failed to export contributions', error); - const message = error instanceof Error ? error.message : String(error); - window.alert(t('messages.exportError', { message })); + console.warn('BrowserOpenURL not available (dev mode)', error); } - }, [filteredContributions, userContributions, t]); - const handleImportContributions = React.useCallback(async () => { - try { - const result = await ImportContributions(); - const importedMap = new Map(); - result.contributions.forEach((c) => { - importedMap.set(c.date, c.count); - }); - pushSnapshot(); - setUserContributions(importedMap); - window.alert(t('messages.importSuccess')); - } catch (error) { - console.error('Failed to import contributions', error); - const message = error instanceof Error ? error.message : String(error); - window.alert(t('messages.importError', { message })); + if (typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener,noreferrer'); } - }, [t, pushSnapshot, setUserContributions]); - - // 计算总贡献次数(考虑用户设置的数据) - const total = filteredContributions.reduce((sum, c) => { - const userContribution = userContributions.get(c.date) || 0; - const displayCount = userContribution > 0 ? userContribution : c.count; - return sum + displayCount; - }, 0); - - const hasContributions = filteredContributions.length > 0; - const firstContribution = hasContributions ? filteredContributions[0] : undefined; - const firstDate = firstContribution ? new Date(firstContribution.date) : new Date(year, 0, 1); - const startRow = firstDate.getDay(); - const months: (React.ReactElement | undefined)[] = []; - let latestMonth = -1; + }, []); - // 处理格子点击或绘制 - const handleTileAction = (dateStr: string, mode: DrawMode) => { - if (isFutureDate(dateStr)) { + const openRepository = React.useCallback(() => { + openExternalUrl('https://github.com/zmrlft/GreenWall'); + }, [openExternalUrl]); + + const openDocumentation = React.useCallback(() => { + const url = + language === 'zh' + ? 'https://github.com/zmrlft/GreenWall/blob/main/README_zh.md' + : 'https://github.com/zmrlft/GreenWall/blob/main/README.md'; + openExternalUrl(url); + }, [language, openExternalUrl]); + + const handleCharacterButtonClick = React.useCallback(() => { + if (previewMode) { + cancelCharacterPreview(); return; } - if (mode === 'pen') { - setUserContributions((prev: Map) => { - const newMap = new Map(prev); - - if (penMode === 'auto') { - // auto 模式:逐步递进 0 → 1 → 3 → 6 → 9 - const current = prev.get(dateStr) ?? 0; - // 改为调用方法 - const nextCount = getNextContribution(current); + setIsCharacterSelectorOpen(true); + }, [cancelCharacterPreview, previewMode]); - newMap.set(dateStr, nextCount); - } else { - // manual 模式:直接设置为选定的画笔强度值 - newMap.set(dateStr, penIntensity); - } + const handleImagePreview = React.useCallback( + (grid: { width: number; height: number; data: number[][] }) => { + previewImageGrid(grid); + setIsImageImportOpen(false); + }, + [previewImageGrid] + ); - return newMap; - }); - } else if (mode === 'eraser') { - setUserContributions((prev: Map) => { - const newMap = new Map(prev); - newMap.delete(dateStr); - return newMap; - }); + const calendarMeta = React.useMemo(() => { + if (filteredContributions.length === 0) { + return { + renderedMonths: [] as React.ReactElement[], + startRow: 0, + }; } - }; - // 鼠标事件处理 - const handleMouseDown = (dateStr: string, event: React.MouseEvent) => { - if (isFutureDate(dateStr)) { - return; - } - // 如果处于复制模式,开始/结束选择或触发粘贴 - if (copyMode) { - // 右键在复制模式下取消选择/预览 - if (event.button === 2) { - event.preventDefault(); - // 取消选择或取消粘贴预览 - setSelectionStart(null); - setSelectionEnd(null); - setSelectionDates(new Set()); - setPastePreviewActive(false); - setPastePreviewDates(new Set()); - return; + const firstDate = parseIsoDate(filteredContributions[0].date); + const startRow = firstDate.getDay(); + const months: (React.ReactElement | undefined)[] = []; + let latestMonth = -1; + + filteredContributions.forEach((entry, index) => { + const date = parseIsoDate(entry.date); + const month = date.getMonth(); + if (date.getDay() === 0 && month !== latestMonth) { + const gridColumn = 2 + Math.floor((index + startRow) / 7); + latestMonth = month; + months.push( + + {dictionary.months[month]} + + ); } + }); - // 左键开始选择 - setSelectionStart(dateStr); - setSelectionEnd(dateStr); - const { set } = computeSelectionDates(dateStr, dateStr); - setSelectionDates(set); - setLastHoveredDate(dateStr); - return; - } - - // 非复制模式,保留原有行为 - // 阻止默认右键菜单 - if (event.button === 2) { - event.preventDefault(); - setDrawMode((prevMode) => (prevMode === 'pen' ? 'eraser' : 'pen')); - return; - } - - setIsDrawing(true); - setLastHoveredDate(dateStr); - handleTileAction(dateStr, drawMode); - }; - - const handleMouseEnter = (dateStr: string) => { - if (isFutureDate(dateStr)) { - return; - } - - // 预览模式:字符预览(优先级最高) - if (previewMode && previewCharacter) { - const newPreviewDates = calculatePreviewDates(previewCharacter, dateStr); - setPreviewDates(newPreviewDates); - return; - } - - // 粘贴预览跟随鼠标 - if (pastePreviewActive && selectionBuffer) { - setLastHoveredDate(dateStr); - const newPreview = calculateBufferPreviewDates(selectionBuffer, dateStr); - setPastePreviewDates(newPreview); - return; + const firstMonth = months[0]; + if (firstMonth && dictionary.months[firstDate.getMonth()] === firstMonth.props.children) { + months[0] = React.cloneElement(firstMonth, { + style: { ...(firstMonth.props.style || {}), gridColumn: 2 }, + }); } - // 处于复制模式并且正在拖动选择 - if (copyMode && selectionStart) { - if (dateStr !== selectionEnd) { - setSelectionEnd(dateStr); - const { set } = computeSelectionDates(selectionStart, dateStr); - setSelectionDates(set); + if (months.length > 1 && months[0] && months[1]) { + const firstColumn = months[0]?.props?.style?.gridColumn as number | undefined; + const secondColumn = months[1]?.props?.style?.gridColumn as number | undefined; + if ( + typeof firstColumn === 'number' && + typeof secondColumn === 'number' && + secondColumn - firstColumn < 3 + ) { + months[0] = undefined; } - return; } - // 绘制模式 - if (isDrawing && dateStr !== lastHoveredDate) { - setLastHoveredDate(dateStr); - handleTileAction(dateStr, drawMode); + const lastMonth = months.at(-1); + if ( + lastMonth && + typeof lastMonth.props?.style?.gridColumn === 'number' && + lastMonth.props.style.gridColumn > 53 + ) { + months[months.length - 1] = undefined; } - }; - - const handleMouseUp = () => { - setIsDrawing(false); - setLastHoveredDate(null); - }; - - React.useEffect(() => { - const handleGlobalMouseUp = () => { - setIsDrawing(false); - setLastHoveredDate(null); - }; - - window.addEventListener('mouseup', handleGlobalMouseUp); - return () => { - window.removeEventListener('mouseup', handleGlobalMouseUp); - }; - }, []); - - // Ctrl+C: 复制选区 / Ctrl+V: 粘贴 / Ctrl+X: 剪切 / 右键: 取消 - React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'x' && copyMode && selectionStart && selectionEnd) { - e.preventDefault(); - const buffer = buildBufferFromSelection(selectionStart, selectionEnd); - const coloredCount = buffer.data.flat().filter((v) => v > 0).length; - - if (coloredCount === 0) { - setToast(t('messages.noColoredCells')); - setTimeout(() => setToast(null), 2000); - return; - } - - setSelectionBuffer(buffer); - - pushSnapshot(); - setUserContributions((prev) => { - const newMap = new Map(prev); - const { set } = computeSelectionDates(selectionStart, selectionEnd); - for (const dateStr of set) { - newMap.delete(dateStr); - } - return newMap; - }); - - setToast(t('messages.cutSuccess', { count: coloredCount })); - setTimeout(() => setToast(null), 2000); - - setPastePreviewActive(true); - setSelectionStart(null); - setSelectionEnd(null); - setSelectionDates(new Set()); - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'c' && copyMode && selectionStart && selectionEnd) { - e.preventDefault(); - const buffer = buildBufferFromSelection(selectionStart, selectionEnd); - - // 统计复制的有色格子数量 - const coloredCount = buffer.data.flat().filter((v) => v > 0).length; - - if (coloredCount === 0) { - setToast(t('messages.noColoredCells')); - setTimeout(() => setToast(null), 2000); - return; - } - - setSelectionBuffer(buffer); - setToast(t('messages.copySuccess', { count: coloredCount })); - setTimeout(() => setToast(null), 2000); - // 复制后自动启用粘贴预览模式 - setPastePreviewActive(true); - // 清除选择区域 - setSelectionStart(null); - setSelectionEnd(null); - setSelectionDates(new Set()); - } - if ((e.metaKey || e.ctrlKey) && e.key === 'v' && selectionBuffer) { - e.preventDefault(); - if (!pastePreviewActive) { - setPastePreviewActive(true); - } else if (lastHoveredDate) { - applyPaste(lastHoveredDate); - } - } - - if ((e.metaKey || e.ctrlKey) && !isDrawing) { - if (e.code === 'KeyZ' && !e.shiftKey) { - e.preventDefault(); - undo(); - } - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => { - window.removeEventListener('keydown', handleKeyDown); + return { + renderedMonths: months.filter(Boolean) as React.ReactElement[], + startRow, }; - }, [ - copyMode, - selectionStart, - selectionEnd, - buildBufferFromSelection, - selectionBuffer, - pastePreviewActive, - lastHoveredDate, - applyPaste, - isDrawing, - undo, - redo, - computeSelectionDates, - pushSnapshot, - setUserContributions, - t, - ]); - - const tiles = filteredContributions.map((c, i) => { - const date = new Date(c.date); - const month = date.getMonth(); - const future = isFutureDate(c.date); - - // 计算实际显示的贡献次数(用户设置的优先) - const userContribution = userContributions.get(c.date) || 0; - const displayCount = userContribution > 0 ? userContribution : c.count; + }, [dictionary.months, filteredContributions]); - // 在星期天的月份出现变化的列上面显示月份。 - if (date.getDay() === 0 && month !== latestMonth) { - // 计算月份对应的列,从 1 开始、左上角格子留空所以 +2 - const gridColumn = 2 + Math.floor((i + startRow) / 7); - latestMonth = month; - months.push( - - {monthNames[date.getMonth()]} - - ); - } - - // 计算显示的level:用户设置的优先,否则用原始数据 - let displayLevel = userContribution > 0 ? calculateLevel(userContribution) : c.level; - - // 如果在预览模式且该日期在预览列表中,显示预览样式 - const isCharacterPreviewDate = previewMode && previewDates.has(c.date); - const isPastePreviewDate = pastePreviewActive && pastePreviewDates.has(c.date); + const tiles = filteredContributions.map((entry, index) => { + const isCharacterPreviewDate = previewMode && previewDates.has(entry.date); + const isPastePreviewDate = pastePreviewActive && pastePreviewDates.has(entry.date); const isPreviewDate = isCharacterPreviewDate || isPastePreviewDate; - if (isPreviewDate) { - displayLevel = 4; // 预览时显示最深绿色 - } - - // 选择高亮 - const isSelectionDate = selectionDates.has(c.date); - - // 创建新的tip信息,反映用户设置的贡献次数 - const displayOneDay = { level: displayLevel, count: displayCount, date: c.date }; + const isSelectionDate = selectionDates.has(entry.date); + const future = isFutureDate(entry.date); + const userContribution = userContributions.get(entry.date) || 0; + const displayCount = userContribution > 0 ? userContribution : entry.count; + const displayLevel = isPreviewDate + ? 4 + : userContribution > 0 + ? calculateLevel(userContribution) + : entry.level; return ( { - // 处理字符预览应用/取消(预览模式下所有格子都响应,不仅仅是预览格子) + onMouseDown={(event) => { if (previewMode) { - if (e.button === 0) { - // 左键应用预览 - handleApplyCharacterPreview(); - } else if (e.button === 2) { - // 右键取消预览 - e.preventDefault(); - handleCancelCharacterPreview(); + if (event.button === 0) { + applyCharacterPreview(); + } else if (event.button === 2) { + event.preventDefault(); + cancelCharacterPreview(); } return; } - // 处理粘贴预览:左键任意位置应用粘贴(以点击位置为中心),右键任意位置取消预览 if (pastePreviewActive) { - if (e.button === 0) { - // 左键应用粘贴,以当前点击的格子为中心 - applyPaste(c.date); - } else if (e.button === 2) { - // 右键任意位置都可以取消预览 - e.preventDefault(); - setPastePreviewActive(false); - setPastePreviewDates(new Set()); + if (event.button === 0) { + applyPaste(entry.date); + } else if (event.button === 2) { + event.preventDefault(); + cancelPastePreview(); } return; } - // 非预览时,默认行为(包括复制选择逻辑) - handleMouseDown(c.date, e); - }} - onMouseEnter={() => handleMouseEnter(c.date)} - onMouseUp={handleMouseUp} - onContextMenu={(e) => { - e.preventDefault(); // 始终阻止默认右键菜单 + handleTileMouseDown(entry.date, event); }} + onMouseEnter={() => handleTileMouseEnter(entry.date)} + onMouseUp={handleTileMouseUp} + onContextMenu={(event) => event.preventDefault()} style={{ cursor: future ? 'not-allowed' @@ -1062,150 +484,363 @@ function ContributionCalendar({ : drawMode === 'pen' ? 'crosshair' : 'grab', - // userSelect: 'none' }} /> ); }); - // 第一格不一定是周日,此时前面会有空白,需要设置下起始行。 if (tiles.length > 0) { - tiles[0] = React.cloneElement(tiles[0], { - style: { gridRow: startRow + 1 }, + const firstTile = tiles[0]; + tiles[0] = React.cloneElement(firstTile, { + style: { ...(firstTile.props.style || {}), gridRow: calendarMeta.startRow + 1 }, }); } - // 如果第一格不是周日,则首月可能跑到第二列,需要再检查下。 - // Safely adjust months. Use optional chaining and avoid mutating props directly. - if (months.length > 0) { - const first = months[0]; - if (first && monthNames[firstDate.getMonth()] === (first.props && first.props.children)) { - // create a new element with adjusted style instead of mutating props - months[0] = React.cloneElement(first, { - style: { ...(first.props.style || {}), gridColumn: 2 }, - }); - } - } - if (months.length > 1 && months[0] && months[1]) { - const m0 = months[0]; - const m1 = months[1]; - const g0 = m0?.props?.style?.gridColumn as number | undefined; - const g1 = m1?.props?.style?.gridColumn as number | undefined; - if (typeof g0 === 'number' && typeof g1 === 'number' && g1 - g0 < 3) { - months[0] = undefined; - } - } + const stageStatus = previewMode + ? t('characterSelector.previewTooltip', { char: previewCharacter }) + : pastePreviewActive + ? t('imageImport.previewOnCalendarHint') + : copyMode + ? t('titles.copyMode') + : drawMode === 'eraser' + ? t('titles.eraser') + : penMode === 'auto' + ? t('penModes.auto') + : t('titles.penIntensity', { intensity: penIntensity }); + + const gitBadgeState = + isGitInstalled === false ? 'is-warning' : isGitInstalled === true ? 'is-ready' : ''; - const last = months.at(-1); - if (last && last.props && last.props.style && typeof last.props.style.gridColumn === 'number') { - if (last.props.style.gridColumn > 53) { - months[months.length - 1] = undefined; - } - } + return ( +
+ + +
+
+
+
+ {languageOptions.map((option) => { + const isActive = option.value === language; + return ( + + ); + })} +
+ +
- const renderedMonths = months.filter(Boolean) as React.ReactElement[]; +
GreenWall
+ +
+ + + +
+
+ +
+
+
+
+ + {stageStatus} + + + {t('calendar.totalContributions', { count: total, year })} + +
+ +
+
+
+ {calendarMeta.renderedMonths} + + {dictionary.weekdays.mon} + + + {dictionary.weekdays.wed} + + + {dictionary.weekdays.fri} + + +
{tiles}
+
+
+
+ +
+ {t('labels.year')}: +
+ + {year} + +
+
+
+
+ +
+ + + + + + +
+ +
+ + {(Object.keys(penIntensityColors) as Array<`${PenIntensity}`>).map((key) => { + const value = Number(key) as PenIntensity; + const isActive = + drawMode === 'pen' && penMode === 'manual' && penIntensity === value; + + return ( + + ); + })} +
+ +
+ + + +
+
+
- if (!hasContributions) { - return null; - } + {toast &&
{toast}
} - return ( -
-
-
{ + startCharacterPreview(char); + setIsCharacterSelectorOpen(false); }} - onMouseUp={handleMouseUp} - > - {renderedMonths} - Mon - Wed - Fri + onClose={() => setIsCharacterSelectorOpen(false)} + /> + )} -
{tiles}
-
- {t('calendar.totalContributions', { count: total, year })} -
-
- {t('calendar.legendLess')} - - - - - - {t('calendar.legendMore')} + {isImageImportOpen && ( +
+
+
+
+

{t('imageImport.title')}

+

{t('imageImport.previewOnCalendarHint')}

+
+ +
+
+ +
- {/* Simple toast */} - {toast && ( -
- {toast} -
- )} -
+ )} -
- - -
{isRemoteModalOpen && ( setIsRemoteModalOpen(false)} - onSubmit={handleRemoteModalSubmit} + onClose={closeRemoteModal} + onSubmit={submitRemoteModal} /> )}
); } -// 里头需要循环 365 次,耗时 3ms,还是用 memo 包装下吧。 +export type { OneDay }; export default React.memo(ContributionCalendar); diff --git a/frontend/src/components/GitPathSettings.tsx b/frontend/src/components/GitPathSettings.tsx index 7a5b128..e97241a 100644 --- a/frontend/src/components/GitPathSettings.tsx +++ b/frontend/src/components/GitPathSettings.tsx @@ -34,7 +34,6 @@ const GitPathSettings: React.FC = ({ onClose, onCheckAgain }); if (result.success) { - // 成功设置后,清空输入框并重新检查git状态 setCustomGitPath(''); setTimeout(() => { onCheckAgain(); @@ -129,7 +128,7 @@ const GitPathSettings: React.FC = ({ onClose, onCheckAgain diff --git a/frontend/src/components/ImageImportCard.tsx b/frontend/src/components/ImageImportCard.tsx index 6201e36..03ba6b1 100644 --- a/frontend/src/components/ImageImportCard.tsx +++ b/frontend/src/components/ImageImportCard.tsx @@ -314,7 +314,6 @@ export const ImageImportCard: React.FC = ({ onPreview, className }) => { t, threshold, mode, - invert, imageSmoothing, binaryRelax, binaryRelax2, @@ -388,6 +387,7 @@ export const ImageImportCard: React.FC = ({ onPreview, className }) => { binaryRelax2, fileUrl, isProcessing, + processImage, ]); return ( diff --git a/frontend/src/hooks/useContributionEditor.ts b/frontend/src/hooks/useContributionEditor.ts new file mode 100644 index 0000000..e465d7f --- /dev/null +++ b/frontend/src/hooks/useContributionEditor.ts @@ -0,0 +1,904 @@ +import React from 'react'; +import { ExportContributions, GenerateRepo, ImportContributions } from '../../wailsjs/go/main/App'; +import { main } from '../../wailsjs/go/models'; +import type { RemoteRepoPayload } from '../components/RemoteRepoModal'; +import { getPatternById, gridToBoolean } from '../data/characterPatterns'; +import { useTranslations } from '../i18n'; +import { formatIsoDate, getYearFromIsoDate, parseIsoDate } from '../utils/date'; +import { useContributionHistory } from './useContributionHistory'; + +export type OneDay = { level: number; count: number; date: string }; +export type DrawMode = 'pen' | 'eraser'; +export type PenIntensity = 1 | 3 | 6 | 9; +export type ContributionBuffer = { + width: number; + height: number; + data: number[][]; +}; + +const MIN_YEAR = 2008; + +function getNextContribution(current: number): number { + if (current < 1) return 1; + if (current < 3) return 3; + if (current < 6) return 6; + if (current < 9) return 9; + return current; +} + +function characterToPattern(char: string): boolean[][] { + const pattern = getPatternById(char); + if (pattern) { + return gridToBoolean(pattern.grid); + } + + return Array(7) + .fill(null) + .map(() => Array(5).fill(false)); +} + +type UseContributionEditorOptions = { + contributions: OneDay[]; + githubUser?: main.GithubUserProfile | null; +}; + +export function useContributionEditor({ + contributions: originalContributions, + githubUser, +}: UseContributionEditorOptions) { + const { t } = useTranslations(); + const { userContributions, setUserContributions, pushSnapshot, undo, redo } = + useContributionHistory(new Map()); + + const currentYear = React.useMemo(() => new Date().getFullYear(), []); + const [year, setYearState] = React.useState(currentYear); + const [drawMode, setDrawMode] = React.useState('pen'); + const [penIntensity, setPenIntensity] = React.useState(1); + const [penMode, setPenMode] = React.useState<'manual' | 'auto'>('auto'); + const [isDrawing, setIsDrawing] = React.useState(false); + const [lastHoveredDate, setLastHoveredDate] = React.useState(null); + const [isGeneratingRepo, setIsGeneratingRepo] = React.useState(false); + const [isRemoteModalOpen, setIsRemoteModalOpen] = React.useState(false); + const [previewMode, setPreviewMode] = React.useState(false); + const [previewCharacter, setPreviewCharacter] = React.useState(''); + const [previewDates, setPreviewDates] = React.useState>(new Set()); + const [copyMode, setCopyMode] = React.useState(false); + const [selectionStart, setSelectionStart] = React.useState(null); + const [selectionEnd, setSelectionEnd] = React.useState(null); + const [selectionDates, setSelectionDates] = React.useState>(new Set()); + const [selectionBuffer, setSelectionBuffer] = React.useState(null); + const [pastePreviewActive, setPastePreviewActive] = React.useState(false); + const [pastePreviewDates, setPastePreviewDates] = React.useState>(new Set()); + const [toast, setToast] = React.useState(null); + const toastTimeoutRef = React.useRef(null); + + const setYear = React.useCallback( + (nextYear: number) => { + const clampedYear = Math.min(Math.max(nextYear, MIN_YEAR), currentYear); + setYearState(clampedYear); + }, + [currentYear] + ); + + const filteredContributions = React.useMemo( + () => originalContributions.filter((entry) => getYearFromIsoDate(entry.date) === year), + [originalContributions, year] + ); + + const tomorrowTime = React.useMemo(() => { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrowStart = new Date(todayStart); + tomorrowStart.setDate(tomorrowStart.getDate() + 1); + return tomorrowStart.getTime(); + }, []); + + const isCurrentYear = year === currentYear; + + const isFutureDate = React.useCallback( + (dateStr: string) => { + if (!isCurrentYear) { + return false; + } + + return parseIsoDate(dateStr).getTime() >= tomorrowTime; + }, + [isCurrentYear, tomorrowTime] + ); + + const showToast = React.useCallback((message: string) => { + if (toastTimeoutRef.current !== null) { + window.clearTimeout(toastTimeoutRef.current); + } + setToast(message); + toastTimeoutRef.current = window.setTimeout(() => { + setToast(null); + toastTimeoutRef.current = null; + }, 2200); + }, []); + + React.useEffect(() => { + return () => { + if (toastTimeoutRef.current !== null) { + window.clearTimeout(toastTimeoutRef.current); + } + }; + }, []); + + const clearSelection = React.useCallback(() => { + setSelectionStart(null); + setSelectionEnd(null); + setSelectionDates(new Set()); + }, []); + + const cancelPastePreview = React.useCallback(() => { + setPastePreviewActive(false); + setPastePreviewDates(new Set()); + }, []); + + const cancelCharacterPreview = React.useCallback(() => { + setPreviewMode(false); + setPreviewCharacter(''); + setPreviewDates(new Set()); + }, []); + + const getDateCoord = React.useCallback( + (dateStr: string) => { + const date = parseIsoDate(dateStr); + const yearStart = new Date(year, 0, 1); + const firstDayOfWeek = yearStart.getDay(); + const daysSinceYearStart = Math.floor( + (date.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24) + ); + + return { + row: date.getDay(), + col: Math.floor((daysSinceYearStart + firstDayOfWeek) / 7), + }; + }, + [year] + ); + + const computeSelectionDates = React.useCallback( + (startDate: string, endDate: string) => { + const a = getDateCoord(startDate); + const b = getDateCoord(endDate); + const minRow = Math.min(a.row, b.row); + const maxRow = Math.max(a.row, b.row); + const minCol = Math.min(a.col, b.col); + const maxCol = Math.max(a.col, b.col); + const set = new Set(); + + for (const entry of filteredContributions) { + const coord = getDateCoord(entry.date); + if ( + coord.row >= minRow && + coord.row <= maxRow && + coord.col >= minCol && + coord.col <= maxCol + ) { + set.add(entry.date); + } + } + + return { set, minRow, minCol, maxRow, maxCol }; + }, + [filteredContributions, getDateCoord] + ); + + const calculatePreviewDates = React.useCallback( + (char: string, centerDateStr: string | null) => { + if (!char || !centerDateStr || filteredContributions.length === 0) { + return new Set(); + } + + const pattern = characterToPattern(char); + const previewSet = new Set(); + const centerContribution = filteredContributions.find( + (entry) => entry.date === centerDateStr + ); + if (!centerContribution) { + return new Set(); + } + + const centerDate = parseIsoDate(centerDateStr); + const yearStart = new Date(year, 0, 1); + const daysSinceYearStart = Math.floor( + (centerDate.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24) + ); + const firstDayOfWeek = yearStart.getDay(); + const centerWeek = Math.floor((daysSinceYearStart + firstDayOfWeek) / 7); + const centerRow = centerDate.getDay(); + const patternHeight = pattern.length; + const patternWidth = pattern[0]?.length ?? 0; + const patternCenterY = Math.floor(patternHeight / 2); + const patternCenterX = Math.floor(patternWidth / 2); + + for (let patternY = 0; patternY < patternHeight; patternY++) { + for (let patternX = 0; patternX < patternWidth; patternX++) { + if (!pattern[patternY][patternX]) { + continue; + } + + const offsetY = patternY - patternCenterY; + const offsetX = patternX - patternCenterX; + const targetRow = centerRow + offsetY; + const targetCol = centerWeek + offsetX; + + if (targetRow < 0 || targetRow >= 7 || targetCol < 0) { + continue; + } + + const daysOffset = targetCol * 7 + targetRow - (centerWeek * 7 + centerRow); + const targetDate = new Date(centerDate); + targetDate.setDate(targetDate.getDate() + daysOffset); + const dateStr = formatIsoDate(targetDate); + const contribution = filteredContributions.find((entry) => entry.date === dateStr); + + if (contribution && !isFutureDate(dateStr)) { + previewSet.add(dateStr); + } + } + } + + return previewSet; + }, + [filteredContributions, isFutureDate, year] + ); + + const buildBufferFromSelection = React.useCallback( + (startDate: string, endDate: string) => { + const { set } = computeSelectionDates(startDate, endDate); + const coloredCells: { row: number; col: number; value: number }[] = []; + + for (const dateStr of set) { + const coord = getDateCoord(dateStr); + const current = + userContributions.get(dateStr) ?? + filteredContributions.find((entry) => entry.date === dateStr)?.count ?? + 0; + + if (current > 0) { + coloredCells.push({ row: coord.row, col: coord.col, value: current }); + } + } + + if (coloredCells.length === 0) { + return { width: 0, height: 0, data: [] }; + } + + const minRow = Math.min(...coloredCells.map((cell) => cell.row)); + const maxRow = Math.max(...coloredCells.map((cell) => cell.row)); + const minCol = Math.min(...coloredCells.map((cell) => cell.col)); + const maxCol = Math.max(...coloredCells.map((cell) => cell.col)); + const width = maxCol - minCol + 1; + const height = maxRow - minRow + 1; + const data: number[][] = Array.from({ length: height }, () => Array(width).fill(0)); + + for (const cell of coloredCells) { + data[cell.row - minRow][cell.col - minCol] = cell.value; + } + + return { width, height, data }; + }, + [computeSelectionDates, filteredContributions, getDateCoord, userContributions] + ); + + const calculateBufferPreviewDates = React.useCallback( + (buffer: ContributionBuffer, centerDateStr: string) => { + const previewSet = new Set(); + const patternHeight = buffer.height; + const patternWidth = buffer.width; + const patternCenterY = Math.floor(patternHeight / 2); + const patternCenterX = Math.floor(patternWidth / 2); + const centerDate = parseIsoDate(centerDateStr); + const yearStart = new Date(year, 0, 1); + const daysSinceYearStart = Math.floor( + (centerDate.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24) + ); + const firstDayOfWeek = yearStart.getDay(); + const centerWeek = Math.floor((daysSinceYearStart + firstDayOfWeek) / 7); + const centerRow = centerDate.getDay(); + + for (let py = 0; py < patternHeight; py++) { + for (let px = 0; px < patternWidth; px++) { + const value = buffer.data[py][px]; + if (!value) { + continue; + } + + const offsetY = py - patternCenterY; + const offsetX = px - patternCenterX; + const targetRow = centerRow + offsetY; + const targetCol = centerWeek + offsetX; + + if (targetRow < 0 || targetRow >= 7 || targetCol < 0) { + continue; + } + + const daysOffset = targetCol * 7 + targetRow - (centerWeek * 7 + centerRow); + const targetDate = new Date(centerDate); + targetDate.setDate(targetDate.getDate() + daysOffset); + const dateStr = formatIsoDate(targetDate); + const contribution = filteredContributions.find((entry) => entry.date === dateStr); + + if (contribution && !isFutureDate(dateStr)) { + previewSet.add(dateStr); + } + } + } + + return previewSet; + }, + [filteredContributions, isFutureDate, year] + ); + + const applyPaste = React.useCallback( + (centerDateStr: string) => { + if (!selectionBuffer || !centerDateStr) { + return; + } + + const patternHeight = selectionBuffer.height; + const patternWidth = selectionBuffer.width; + const patternCenterY = Math.floor(patternHeight / 2); + const patternCenterX = Math.floor(patternWidth / 2); + const centerDate = parseIsoDate(centerDateStr); + const yearStart = new Date(year, 0, 1); + const daysSinceYearStart = Math.floor( + (centerDate.getTime() - yearStart.getTime()) / (1000 * 60 * 60 * 24) + ); + const firstDayOfWeek = yearStart.getDay(); + const centerWeek = Math.floor((daysSinceYearStart + firstDayOfWeek) / 7); + const centerRow = centerDate.getDay(); + + pushSnapshot(); + setUserContributions((previous) => { + const nextMap = new Map(previous); + for (let py = 0; py < patternHeight; py++) { + for (let px = 0; px < patternWidth; px++) { + const value = selectionBuffer.data[py][px]; + if (!value) { + continue; + } + + const offsetY = py - patternCenterY; + const offsetX = px - patternCenterX; + const targetRow = centerRow + offsetY; + const targetCol = centerWeek + offsetX; + + if (targetRow < 0 || targetRow >= 7 || targetCol < 0) { + continue; + } + + const daysOffset = targetCol * 7 + targetRow - (centerWeek * 7 + centerRow); + const targetDate = new Date(centerDate); + targetDate.setDate(targetDate.getDate() + daysOffset); + const dateStr = formatIsoDate(targetDate); + + if (!isFutureDate(dateStr)) { + nextMap.set(dateStr, value); + } + } + } + return nextMap; + }); + + cancelPastePreview(); + }, + [cancelPastePreview, isFutureDate, pushSnapshot, selectionBuffer, setUserContributions, year] + ); + + const startCharacterPreview = React.useCallback( + (char: string) => { + setPreviewCharacter(char); + setPreviewDates(new Set()); + setPreviewMode(true); + cancelPastePreview(); + clearSelection(); + setCopyMode(false); + }, + [cancelPastePreview, clearSelection] + ); + + const applyCharacterPreview = React.useCallback(() => { + if (!previewMode || previewDates.size === 0) { + return; + } + + pushSnapshot(); + setUserContributions((previous) => { + const nextMap = new Map(previous); + for (const dateStr of previewDates) { + const current = previous.get(dateStr) ?? 0; + nextMap.set(dateStr, penMode === 'auto' ? getNextContribution(current) : penIntensity); + } + return nextMap; + }); + + cancelCharacterPreview(); + }, [ + cancelCharacterPreview, + penIntensity, + penMode, + previewDates, + previewMode, + pushSnapshot, + setUserContributions, + ]); + + const previewImageGrid = React.useCallback( + (grid: ContributionBuffer) => { + setSelectionBuffer(grid); + setPastePreviewActive(true); + setPastePreviewDates(new Set()); + clearSelection(); + cancelCharacterPreview(); + setCopyMode(false); + }, + [cancelCharacterPreview, clearSelection] + ); + + const reset = React.useCallback(() => { + pushSnapshot(); + setUserContributions(new Map()); + }, [pushSnapshot, setUserContributions]); + + const fillAllGreen = React.useCallback(() => { + pushSnapshot(); + setUserContributions((previous) => { + const nextMap = new Map(previous); + for (const entry of filteredContributions) { + if (!isFutureDate(entry.date)) { + nextMap.set(entry.date, 9); + } + } + return nextMap; + }); + }, [filteredContributions, isFutureDate, pushSnapshot, setUserContributions]); + + const exportContributions = React.useCallback(async () => { + const contributionsToExport = filteredContributions + .map((entry) => { + const override = userContributions.get(entry.date); + return { + date: entry.date, + count: override !== undefined ? override : entry.count, + }; + }) + .filter((entry) => entry.count > 0); + + try { + const payload = main.ExportContributionsRequest.createFrom({ + contributions: contributionsToExport, + }); + const result = await ExportContributions(payload); + window.alert(t('messages.exportSuccess', { filePath: result.filePath })); + } catch (error) { + console.error('Failed to export contributions', error); + const message = error instanceof Error ? error.message : String(error); + window.alert(t('messages.exportError', { message })); + } + }, [filteredContributions, t, userContributions]); + + const importContributions = React.useCallback(async () => { + try { + const result = await ImportContributions(); + const importedMap = new Map(); + result.contributions.forEach((entry) => { + importedMap.set(entry.date, entry.count); + }); + pushSnapshot(); + setUserContributions(importedMap); + window.alert(t('messages.importSuccess')); + } catch (error) { + console.error('Failed to import contributions', error); + const message = error instanceof Error ? error.message : String(error); + window.alert(t('messages.importError', { message })); + } + }, [pushSnapshot, setUserContributions, t]); + + const runGenerateRepo = React.useCallback( + async (remoteRepoOptions: RemoteRepoPayload) => { + const githubLogin = githubUser?.login?.trim() ?? ''; + const githubEmail = + githubUser?.email?.trim() || (githubLogin ? `${githubLogin}@users.noreply.github.com` : ''); + + if (!githubLogin || !githubEmail) { + window.alert(t('messages.remoteLoginRequired')); + return; + } + + const contributionsForBackend = filteredContributions + .map((entry) => { + const override = userContributions.get(entry.date); + return { + date: entry.date, + count: override !== undefined ? override : entry.count, + }; + }) + .filter((entry) => entry.count > 0); + + if (contributionsForBackend.length === 0) { + window.alert(t('messages.noContributions')); + return; + } + + setIsGeneratingRepo(true); + try { + const payload = main.GenerateRepoRequest.createFrom({ + year, + githubUsername: githubLogin, + githubEmail, + repoName: remoteRepoOptions.name.trim(), + contributions: contributionsForBackend, + remoteRepo: { + enabled: true, + name: remoteRepoOptions.name.trim(), + private: remoteRepoOptions.isPrivate, + description: remoteRepoOptions.description.trim(), + }, + }); + const result = await GenerateRepo(payload); + const baseMessage = `Repository created at ${result.repoPath} with ${result.commitCount} commits.`; + const fullMessage = + result.remoteUrl && result.remoteUrl !== '' + ? `${baseMessage}\nRemote repository: ${result.remoteUrl}` + : baseMessage; + window.alert(fullMessage); + } catch (error) { + console.error('Failed to generate repository', error); + const message = error instanceof Error ? error.message : String(error); + window.alert(t('messages.generateRepoError', { message })); + } finally { + setIsGeneratingRepo(false); + } + }, + [filteredContributions, githubUser, t, userContributions, year] + ); + + const openRemoteModal = React.useCallback(() => { + if (!githubUser?.login) { + window.alert(t('messages.remoteLoginRequired')); + return; + } + setIsRemoteModalOpen(true); + }, [githubUser, t]); + + const closeRemoteModal = React.useCallback(() => { + setIsRemoteModalOpen(false); + }, []); + + const submitRemoteModal = React.useCallback( + (payload: RemoteRepoPayload) => { + setIsRemoteModalOpen(false); + runGenerateRepo(payload); + }, + [runGenerateRepo] + ); + + const total = React.useMemo( + () => + filteredContributions.reduce((sum, entry) => { + const userContribution = userContributions.get(entry.date) || 0; + const displayCount = userContribution > 0 ? userContribution : entry.count; + return sum + displayCount; + }, 0), + [filteredContributions, userContributions] + ); + + const remoteRepoDefaultName = React.useMemo( + () => (githubUser?.login?.trim() ? `${githubUser.login.trim()}-${year}` : `green-wall-${year}`), + [githubUser, year] + ); + + const getTooltip = React.useCallback( + (oneDay: OneDay) => { + const isoDate = oneDay.date; + if (isFutureDate(oneDay.date)) { + return t('calendar.tooltipFuture', { date: isoDate }); + } + if (oneDay.count === 0) { + return t('calendar.tooltipNone', { date: isoDate }); + } + return t('calendar.tooltipSome', { count: oneDay.count, date: isoDate }); + }, + [isFutureDate, t] + ); + + const applyDrawAction = React.useCallback( + (dateStr: string, mode: DrawMode) => { + if (isFutureDate(dateStr)) { + return; + } + + if (mode === 'pen') { + setUserContributions((previous) => { + const nextMap = new Map(previous); + if (penMode === 'auto') { + nextMap.set(dateStr, getNextContribution(previous.get(dateStr) ?? 0)); + } else { + nextMap.set(dateStr, penIntensity); + } + return nextMap; + }); + return; + } + + setUserContributions((previous) => { + const nextMap = new Map(previous); + nextMap.delete(dateStr); + return nextMap; + }); + }, + [isFutureDate, penIntensity, penMode, setUserContributions] + ); + + const handleTileMouseDown = React.useCallback( + (dateStr: string, event: React.MouseEvent) => { + if (isFutureDate(dateStr)) { + return; + } + + if (copyMode) { + if (event.button === 2) { + event.preventDefault(); + clearSelection(); + cancelPastePreview(); + return; + } + + setSelectionStart(dateStr); + setSelectionEnd(dateStr); + setSelectionDates(computeSelectionDates(dateStr, dateStr).set); + setLastHoveredDate(dateStr); + return; + } + + if (event.button === 2) { + event.preventDefault(); + setDrawMode((previous) => (previous === 'pen' ? 'eraser' : 'pen')); + return; + } + + pushSnapshot(); + setIsDrawing(true); + setLastHoveredDate(dateStr); + applyDrawAction(dateStr, drawMode); + }, + [ + applyDrawAction, + cancelPastePreview, + clearSelection, + computeSelectionDates, + copyMode, + drawMode, + isFutureDate, + pushSnapshot, + ] + ); + + const handleTileMouseEnter = React.useCallback( + (dateStr: string) => { + if (isFutureDate(dateStr)) { + return; + } + + if (previewMode && previewCharacter) { + setPreviewDates(calculatePreviewDates(previewCharacter, dateStr)); + return; + } + + if (pastePreviewActive && selectionBuffer) { + setLastHoveredDate(dateStr); + setPastePreviewDates(calculateBufferPreviewDates(selectionBuffer, dateStr)); + return; + } + + if (copyMode && selectionStart) { + if (dateStr !== selectionEnd) { + setSelectionEnd(dateStr); + setSelectionDates(computeSelectionDates(selectionStart, dateStr).set); + } + return; + } + + if (isDrawing && dateStr !== lastHoveredDate) { + setLastHoveredDate(dateStr); + applyDrawAction(dateStr, drawMode); + } + }, + [ + applyDrawAction, + calculateBufferPreviewDates, + calculatePreviewDates, + computeSelectionDates, + copyMode, + drawMode, + isDrawing, + isFutureDate, + lastHoveredDate, + pastePreviewActive, + previewCharacter, + previewMode, + selectionBuffer, + selectionEnd, + selectionStart, + ] + ); + + const handleTileMouseUp = React.useCallback(() => { + setIsDrawing(false); + setLastHoveredDate(null); + }, []); + + const toggleCopyMode = React.useCallback(() => { + setCopyMode((previous) => { + const next = !previous; + if (!next) { + clearSelection(); + } + return next; + }); + }, [clearSelection]); + + React.useEffect(() => { + const handleGlobalMouseUp = () => { + handleTileMouseUp(); + }; + + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => { + window.removeEventListener('mouseup', handleGlobalMouseUp); + }; + }, [handleTileMouseUp]); + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const hasModifier = event.metaKey || event.ctrlKey; + + if (hasModifier && event.key === 'x' && copyMode && selectionStart && selectionEnd) { + event.preventDefault(); + const buffer = buildBufferFromSelection(selectionStart, selectionEnd); + const coloredCount = buffer.data.flat().filter((value) => value > 0).length; + + if (coloredCount === 0) { + showToast(t('messages.noColoredCells')); + return; + } + + setSelectionBuffer(buffer); + pushSnapshot(); + setUserContributions((previous) => { + const nextMap = new Map(previous); + const { set } = computeSelectionDates(selectionStart, selectionEnd); + for (const dateStr of set) { + nextMap.delete(dateStr); + } + return nextMap; + }); + + showToast(t('messages.cutSuccess', { count: coloredCount })); + setPastePreviewActive(true); + clearSelection(); + } + + if (hasModifier && event.key === 'c' && copyMode && selectionStart && selectionEnd) { + event.preventDefault(); + const buffer = buildBufferFromSelection(selectionStart, selectionEnd); + const coloredCount = buffer.data.flat().filter((value) => value > 0).length; + + if (coloredCount === 0) { + showToast(t('messages.noColoredCells')); + return; + } + + setSelectionBuffer(buffer); + showToast(t('messages.copySuccess', { count: coloredCount })); + setPastePreviewActive(true); + clearSelection(); + } + + if (hasModifier && event.key === 'v' && selectionBuffer) { + event.preventDefault(); + if (!pastePreviewActive) { + setPastePreviewActive(true); + } else if (lastHoveredDate) { + applyPaste(lastHoveredDate); + } + } + + if (hasModifier && !isDrawing) { + if (event.code === 'KeyZ') { + event.preventDefault(); + if (event.shiftKey) { + redo(); + } else { + undo(); + } + } else if (event.code === 'KeyY') { + event.preventDefault(); + redo(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [ + applyPaste, + buildBufferFromSelection, + clearSelection, + computeSelectionDates, + copyMode, + isDrawing, + lastHoveredDate, + pastePreviewActive, + pushSnapshot, + redo, + selectionBuffer, + selectionEnd, + selectionStart, + setUserContributions, + showToast, + t, + undo, + ]); + + React.useEffect(() => { + handleTileMouseUp(); + clearSelection(); + cancelCharacterPreview(); + cancelPastePreview(); + setLastHoveredDate(null); + }, [cancelCharacterPreview, cancelPastePreview, clearSelection, handleTileMouseUp, year]); + + return { + MIN_YEAR, + currentYear, + year, + setYear, + filteredContributions, + userContributions, + total, + drawMode, + setDrawMode, + penIntensity, + setPenIntensity, + penMode, + setPenMode, + copyMode, + toggleCopyMode, + previewMode, + previewCharacter, + previewDates, + startCharacterPreview, + cancelCharacterPreview, + applyCharacterPreview, + selectionDates, + pastePreviewActive, + pastePreviewDates, + previewImageGrid, + cancelPastePreview, + applyPaste, + getTooltip, + isFutureDate, + handleTileMouseDown, + handleTileMouseEnter, + handleTileMouseUp, + reset, + fillAllGreen, + exportContributions, + importContributions, + openRemoteModal, + closeRemoteModal, + submitRemoteModal, + isRemoteModalOpen, + remoteRepoDefaultName, + isGeneratingRepo, + toast, + hasSelectionBuffer: Boolean(selectionBuffer), + }; +} diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 0000000..8a88e98 --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,15 @@ +export function parseIsoDate(dateStr: string): Date { + const [year, month, day] = dateStr.split('-').map(Number); + return new Date(year, month - 1, day); +} + +export function formatIsoDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function getYearFromIsoDate(dateStr: string): number { + return Number(dateStr.slice(0, 4)); +} diff --git a/go.mod b/go.mod index 3759974..2c4283b 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module green-wall go 1.24.0 -require github.com/wailsapp/wails/v2 v2.10.2 +require github.com/wailsapp/wails/v2 v2.11.0 require ( github.com/bep/debounce v1.2.1 // indirect @@ -26,7 +26,7 @@ require ( github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - github.com/wailsapp/go-webview2 v1.0.19 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect diff --git a/go.sum b/go.sum index 0812849..14cfbee 100644 --- a/go.sum +++ b/go.sum @@ -53,12 +53,12 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/wailsapp/go-webview2 v1.0.19 h1:7U3QcDj1PrBPaxJNCui2k1SkWml+Q5kvFUFyTImA6NU= -github.com/wailsapp/go-webview2 v1.0.19/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= -github.com/wailsapp/wails/v2 v2.10.2 h1:29U+c5PI4K4hbx8yFbFvwpCuvqK9VgNv8WGobIlKlXk= -github.com/wailsapp/wails/v2 v2.10.2/go.mod h1:XuN4IUOPpzBrHUkEd7sCU5ln4T/p1wQedfxP7fKik+4= +github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= +github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=