From 4cce2990e584dee4831107207d2e35dbb80d575e Mon Sep 17 00:00:00 2001 From: Cedric <131578574+ced2711@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:04:49 -0500 Subject: [PATCH 1/7] Package Commit Canvas as a desktop app with Tauri Move the web interface into web/ and wrap it with Tauri so Windows, macOS and Linux installers can be built. GitHub Actions builds the installers on pull requests and publishes them as a release for v* tags; GitHub Pages is now deployed from web/ by a workflow. The confirmation prompts use the in-page dialog instead of window.confirm, external links open in the system browser inside the app, and the desktop app remembers the token by default. --- .github/workflows/build.yml | 70 ++++++++++++++++++++++++++++ .github/workflows/pages.yml | 30 ++++++++++++ .gitignore | 4 ++ .nojekyll | 1 - README.md | 36 +++++++++++--- app-icon.png | Bin 0 -> 19004 bytes package.json | 15 ++++++ src-tauri/.gitignore | 4 ++ src-tauri/Cargo.toml | 22 +++++++++ src-tauri/build.rs | 3 ++ src-tauri/capabilities/default.json | 7 +++ src-tauri/src/main.rs | 9 ++++ src-tauri/tauri.conf.json | 42 +++++++++++++++++ index.html => web/index.html | 8 ++-- {src => web/src}/app.js | 51 +++++++++++++++----- {src => web/src}/font.js | 0 {src => web/src}/github.js | 0 {src => web/src}/i18n.js | 0 styles.css => web/styles.css | 0 19 files changed, 279 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/pages.yml delete mode 100644 .nojekyll create mode 100644 app-icon.png create mode 100644 package.json create mode 100644 src-tauri/.gitignore create mode 100644 src-tauri/Cargo.toml create mode 100644 src-tauri/build.rs create mode 100644 src-tauri/capabilities/default.json create mode 100644 src-tauri/src/main.rs create mode 100644 src-tauri/tauri.conf.json rename index.html => web/index.html (96%) rename {src => web/src}/app.js (92%) rename {src => web/src}/font.js (100%) rename {src => web/src}/github.js (100%) rename {src => web/src}/i18n.js (100%) rename styles.css => web/styles.css (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..607b0b0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,70 @@ +name: Build desktop app + +on: + push: + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - platform: windows-latest + args: '' + - platform: macos-latest + args: '--target universal-apple-darwin' + - platform: ubuntu-22.04 + args: '' + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - name: Install Linux dependencies + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - run: npm install + - run: npm run icons + + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }} + releaseName: Commit Canvas ${{ github.ref_name }} + releaseBody: | + Download the installer for your system below. + + - **Windows**: `Commit.Canvas_x.y.z_x64-setup.exe` (or the `.msi`). If SmartScreen warns, choose *More info → Run anyway*. + - **macOS**: `Commit.Canvas_x.y.z_universal.dmg`. The app is not notarized; if macOS says it is damaged, run `xattr -cr "/Applications/Commit Canvas.app"`. + - **Linux**: `.AppImage` or `.deb`. + releaseDraft: false + args: ${{ matrix.args }} + + - name: Upload build artifacts + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: actions/upload-artifact@v4 + with: + name: commit-canvas-${{ matrix.platform }} + path: src-tauri/target/**/release/bundle/ + if-no-files-found: error diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..3833ecf --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,30 @@ +name: Deploy web app + +on: + push: + branches: [main] + paths: ['web/**', '.github/workflows/pages.yml'] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/upload-pages-artifact@v3 + with: + path: web + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 4569d0c..8ca34b8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ Desktop.ini # Local secrets .env .env.* + +# Build output +node_modules/ +package-lock.json diff --git a/.nojekyll b/.nojekyll deleted file mode 100644 index 8b13789..0000000 --- a/.nojekyll +++ /dev/null @@ -1 +0,0 @@ - diff --git a/README.md b/README.md index 8b35524..75c735a 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,25 @@ Draw on your GitHub contribution graph. Connect your account, pick a repository, - **Past year or any year** you've been active in. - **Pick or create** the target repository right in the app. - **Undo last paint** resets the branch to where it was before, as long as nothing else was pushed since. -- Your drawing is saved in the browser automatically. English and 简体中文 interface. +- Your drawing is saved automatically. English and 简体中文 interface. -## Use it +## Download + +Get the installer for your system from the [latest release](https://github.com/ced2711/commit-canvas/releases/latest): + +| System | File | +| --- | --- | +| Windows | `…_x64-setup.exe` (or `.msi`) | +| macOS (Intel & Apple silicon) | `…_universal.dmg` | +| Linux | `.AppImage` or `.deb` | + +The builds are not code‑signed. On Windows, if SmartScreen appears choose **More info → Run anyway**. On macOS, if the app is reported as damaged, run `xattr -cr "/Applications/Commit Canvas.app"` once. + +Prefer not to install anything? The same app runs in the browser at . -It's a static web page — nothing to install. +## Use it -1. Open the app (GitHub Pages, or any static server, e.g. `python -m http.server` in this folder and visit ). +1. Open Commit Canvas. 2. Create a [personal access token with the `repo` scope](https://github.com/settings/tokens/new?scopes=repo&description=Commit%20Canvas) and paste it. If you use the GitHub CLI, `gh auth token` prints one you can paste. 3. Choose a repository (or click **New**), draw, and press **Paint to GitHub**. @@ -35,7 +47,15 @@ For commits to count, GitHub requires the repository to be a non‑fork you own, ### Privacy -The token is sent only to `api.github.com`. By default it lives in the tab's session storage and disappears when you close the tab; tick **Remember on this device** to keep it in local storage. **Sign out** removes it. You can revoke the token on GitHub at any time. +The token is sent only to `api.github.com`. In the desktop app it is remembered on your computer by default; on the web page it only lives in the current tab unless you tick **Remember on this device**. **Sign out** removes it. You can revoke the token on GitHub at any time. + +## Development + +The interface is plain HTML/CSS/JavaScript in [`web/`](web); the desktop app wraps it with [Tauri](https://tauri.app) in [`src-tauri/`](src-tauri). + +- Web: serve `web/` with any static server, e.g. `python -m http.server -d web`. +- Desktop: install Node.js and Rust, then `npm install`, `npm run icons`, and `npm run dev` (or `npm run build` for installers). +- Releases: pushing a `v*` tag builds Windows, macOS and Linux installers with GitHub Actions and publishes them as a release. Pushes to `main` redeploy the web version to GitHub Pages. ## Please be fair @@ -49,9 +69,11 @@ Decorative commits are not work. Use this for fun, in a repository you own, and **功能**:四种深浅的画笔和橡皮擦(右键也能擦);输入文字自动居中印到墙上;整体左右移动、撤销(Ctrl+Z)、清空;底下半透明显示你已有的真实贡献;可选最近一年或任意年份;在应用里直接选择或新建仓库;可以撤销上一次绘制;草稿自动保存。 -**使用**:这是一个静态网页,无需安装。打开页面后粘贴一个 [带 `repo` 权限的 Token](https://github.com/settings/tokens/new?scopes=repo&description=Commit%20Canvas)(装了 GitHub CLI 的话运行 `gh auth token` 即可得到),选择仓库,画好后点 **画到 GitHub**。通常几分钟内就能在主页看到,最长可能需要 24 小时。 +**下载**:到 [Releases](https://github.com/ced2711/commit-canvas/releases/latest) 下载对应系统的安装包——Windows 选 `…_x64-setup.exe`,macOS 选 `…_universal.dmg`,Linux 选 `.AppImage` 或 `.deb`。安装包没有代码签名:Windows 弹出 SmartScreen 时点「更多信息 → 仍要运行」;macOS 提示“已损坏”时运行一次 `xattr -cr "/Applications/Commit Canvas.app"`。不想安装也可以直接用网页版:。 + +**使用**:打开软件后粘贴一个 [带 `repo` 权限的 Token](https://github.com/settings/tokens/new?scopes=repo&description=Commit%20Canvas)(装了 GitHub CLI 的话运行 `gh auth token` 即可得到),选择仓库,画好后点 **画到 GitHub**。通常几分钟内就能在主页看到,最长可能需要 24 小时。 -**说明**:每个格子会生成若干个中午时间的空 commit,作者是你的 GitHub `noreply` 邮箱,提交到默认分支并一次性快进推送;只有在你主动“撤销上次绘制”时才会强制重置分支。Token 只会发送给 `api.github.com`,默认只保存在当前标签页,关闭即消失。私有仓库需要在主页开启 “Private contributions” 才会显示。 +**说明**:每个格子会生成若干个中午时间的空 commit,作者是你的 GitHub `noreply` 邮箱,提交到默认分支并一次性快进推送;只有在你主动“撤销上次绘制”时才会强制重置分支。Token 只会发送给 `api.github.com`;桌面版默认记住在本机,网页版默认只保存在当前标签页。私有仓库需要在主页开启 “Private contributions” 才会显示。 装饰性的 commit 不代表真实工作量,请只在自己的仓库里娱乐使用。 diff --git a/app-icon.png b/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bad4bb16224d00d8f8c2b8ed33a548560a33a4ec GIT binary patch literal 19004 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 E01coZPyhe` literal 0 HcmV?d00001 diff --git a/package.json b/package.json new file mode 100644 index 0000000..d430d4c --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "commit-canvas", + "version": "1.0.0", + "private": true, + "description": "Draw on your GitHub contribution graph by adding commits to a repository you choose.", + "license": "AGPL-3.0-only", + "scripts": { + "icons": "tauri icon app-icon.png", + "dev": "tauri dev", + "build": "tauri build" + }, + "devDependencies": { + "@tauri-apps/cli": "^2" + } +} diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..de281a9 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,4 @@ +/target/ +/gen/schemas +/icons/ +Cargo.lock diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..b2c23d5 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "commit-canvas" +version = "1.0.0" +description = "Draw on your GitHub contribution graph" +authors = ["Cedric"] +license = "AGPL-3.0-only" +repository = "https://github.com/ced2711/commit-canvas" +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "s" +panic = "abort" +strip = true diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..575313d --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Window access plus opening links in the system browser", + "windows": ["main"], + "permissions": ["core:default", "opener:default"] +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..8ebceb3 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,9 @@ +// Hide the console window on Windows release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .run(tauri::generate_context!()) + .expect("error while running Commit Canvas"); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9cf21d7 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Commit Canvas", + "version": "1.0.0", + "identifier": "io.github.ced2711.commitcanvas", + "build": { + "frontendDist": "../web" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "label": "main", + "title": "Commit Canvas", + "width": 1000, + "height": 780, + "minWidth": 420, + "minHeight": 560, + "center": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "category": "DeveloperTool", + "shortDescription": "Draw on your GitHub contribution graph", + "longDescription": "Draw on your GitHub contribution graph by adding commits to a repository you choose.", + "copyright": "Copyright © 2026 Cedric. Licensed under the GNU AGPL v3.0.", + "licenseFile": "../LICENSE", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/index.html b/web/index.html similarity index 96% rename from index.html rename to web/index.html index dd584af..bda02bb 100644 --- a/index.html +++ b/web/index.html @@ -5,7 +5,7 @@ - + Commit Canvas @@ -133,12 +133,12 @@

Connect GitHub

-

Paint to GitHub?

+

-

+

- +
diff --git a/src/app.js b/web/src/app.js similarity index 92% rename from src/app.js rename to web/src/app.js index 9f67234..7dfc89c 100644 --- a/src/app.js +++ b/web/src/app.js @@ -446,6 +446,21 @@ $('progress').firstElementChild.style.width = total ? `${(100 * done) / total}%` : '0'; } + // In-page confirmation dialog; resolves to true when confirmed. + function ask(title, body, okLabel, note = '') { + const dialog = $('confirm'); + $('confirm-title').textContent = title; + $('confirm-body').textContent = body; + $('confirm-note').textContent = note; + $('confirm-note').hidden = !note; + $('confirm-ok').textContent = okLabel; + dialog.returnValue = ''; + dialog.showModal(); + return new Promise((resolve) => { + dialog.addEventListener('close', () => resolve(dialog.returnValue === 'ok'), { once: true }); + }); + } + $('paint').addEventListener('click', async () => { const repo = $('repo').value; if (!state.api || !repo) { @@ -459,13 +474,14 @@ return; } - const dialog = $('confirm'); const branch = state.repos.find((r) => r.fullName === repo)?.branch ?? 'main'; - $('confirm-body').textContent = t('confirm.body', { commits: dates.length, days: drawn.length, repo, branch }); - dialog.returnValue = ''; - dialog.showModal(); - await new Promise((resolve) => dialog.addEventListener('close', resolve, { once: true })); - if (dialog.returnValue !== 'ok') return; + const ok = await ask( + t('confirm.title'), + t('confirm.body', { commits: dates.length, days: drawn.length, repo, branch }), + t('confirm.ok'), + t('confirm.note'), + ); + if (!ok) return; setBusy(true); setStatus(t('progress.prepare')); @@ -500,7 +516,9 @@ $('last-undo').addEventListener('click', async () => { const last = store.get('cc.last'); - if (!last || !window.confirm(t('undoPaint.confirm', { repo: last.fullName, branch: last.branch }))) return; + if (!last) return; + const ok = await ask(t('last.undo'), t('undoPaint.confirm', { repo: last.fullName, branch: last.branch }), t('last.undo')); + if (!ok) return; setBusy(true); try { await state.api.undoPaint(last); @@ -525,6 +543,18 @@ applyLanguage(); }); + // ---------- desktop app ---------- + // Inside the Tauri desktop app, open external links in the system browser. + const desktop = window.__TAURI__; + if (desktop) { + document.addEventListener('click', (event) => { + const link = event.target.closest('a[href^="http"]'); + if (!link) return; + event.preventDefault(); + desktop.opener.openUrl(link.href).catch(() => window.open(link.href, '_blank')); + }); + } + // ---------- start ---------- state.weeks = offlineWeeks(); state.strength ??= 4; @@ -532,8 +562,7 @@ selectLevel(4); applyLanguage(); const saved = savedToken(); - if (saved.token) { - $('remember').checked = saved.remember; - connect(saved.token, saved.remember); - } + // The desktop app keeps its own storage, so remember the token by default there. + $('remember').checked = saved.token ? saved.remember : Boolean(desktop); + if (saved.token) connect(saved.token, saved.remember); })(); diff --git a/src/font.js b/web/src/font.js similarity index 100% rename from src/font.js rename to web/src/font.js diff --git a/src/github.js b/web/src/github.js similarity index 100% rename from src/github.js rename to web/src/github.js diff --git a/src/i18n.js b/web/src/i18n.js similarity index 100% rename from src/i18n.js rename to web/src/i18n.js diff --git a/styles.css b/web/styles.css similarity index 100% rename from styles.css rename to web/styles.css From 5c30a25ee561e75c01558806d0677daeebd07948 Mon Sep 17 00:00:00 2001 From: Cedric <131578574+ced2711@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:14:33 -0500 Subject: [PATCH 2/7] Restore the full feature set with a beginner-friendly flow Existing contributions are shown in full color again, with planned squares marked by a dot, a preview of the combined result, and an option (on by default) to skip days that already have contributions. Bring back what the original version could do: redo, templates, bigger squares, keyboard drawing, touch scroll mode, a detailed review before sending with an extra check for large sends, saving and opening designs (including the old design format), and Bash/PowerShell script export. Guide beginners through four numbered steps with token instructions and a one-click dedicated repository. The desktop app can reuse a GitHub CLI login and is ready for OAuth device-flow sign-in once a client ID is set. --- README.md | 30 +- src-tauri/Cargo.toml | 2 + src-tauri/capabilities/default.json | 4 +- src-tauri/src/main.rs | 64 +++ web/index.html | 188 +++++-- web/src/app.js | 835 +++++++++++++++++++++------- web/src/github.js | 31 +- web/src/i18n.js | 272 ++++++--- web/styles.css | 277 ++++++--- 9 files changed, 1283 insertions(+), 420 deletions(-) diff --git a/README.md b/README.md index 75c735a..3b43fe7 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,17 @@ Draw on your GitHub contribution graph. Connect your account, pick a repository, ## Features -- **Paint** with four shades of green, or erase. Click a square with the same shade again to clear it; right‑click also erases. -- **Text**: type a word (A–Z, 0–9, `! ? . , : - + < > / * ♥`) and it's stamped centered on the graph. -- **Shift** the whole drawing left or right, **undo** (Ctrl+Z), **clear**. Keys `1`–`4` pick a shade, `0` the eraser. -- **Your real graph** is shown faded underneath, so you can see what's already there. +- **See your real graph**: your existing contributions are shown in full color; squares you add get a dot, and **Preview result** shows roughly how the graph will look afterwards. +- **Skip busy days** (on by default): no commits are added on days that already have contributions, so your drawing stays clean. +- **Paint** with four shades or the eraser; click a square again (or right‑click) to erase. Each shade shows how many commits it adds. +- **Text and templates**: stamp a word (A–Z, 0–9, `! ? . , : - + < > / * ♥`) or start from Heart, HELLO, Wave or Stars. +- **Edit comfortably**: undo / redo (Ctrl+Z / Ctrl+Y), move the drawing left or right, clear, bigger squares, keyboard drawing (arrows + Space, `0`–`4` for shades) and a scroll mode on touch screens. - **Past year or any year** you've been active in. -- **Pick or create** the target repository right in the app. -- **Undo last paint** resets the branch to where it was before, as long as nothing else was pushed since. -- Your drawing is saved automatically. English and 简体中文 interface. +- **Pick or create** the repository in the app — one click creates a dedicated `commit-canvas-art` repository. +- **Review before sending**: account, repository, branch, number of commits and dates; large sends need an extra tick. +- **Undo last send** puts the branch back as long as nothing else was pushed since. +- **Save / open designs** as files (designs from the previous version open too) and **export a Bash or PowerShell script** if you'd rather create the commits yourself. +- Drawing saved automatically; English and 简体中文 interface. ## Download @@ -31,9 +34,12 @@ Prefer not to install anything? The same app runs in the browser at 。 -**使用**:打开软件后粘贴一个 [带 `repo` 权限的 Token](https://github.com/settings/tokens/new?scopes=repo&description=Commit%20Canvas)(装了 GitHub CLI 的话运行 `gh auth token` 即可得到),选择仓库,画好后点 **画到 GitHub**。通常几分钟内就能在主页看到,最长可能需要 24 小时。 +**使用**:软件按 1–4 步引导:① 登录——桌面版装了 GitHub CLI 可一键沿用登录,否则点「创建 Token」(权限已自动勾好),点 Generate token 后粘贴回来;② 选择仓库或一键新建;③ 画画;④ 提交到 GitHub,核对后确认。通常几分钟内就能在主页看到,最长可能需要 24 小时。 **说明**:每个格子会生成若干个中午时间的空 commit,作者是你的 GitHub `noreply` 邮箱,提交到默认分支并一次性快进推送;只有在你主动“撤销上次绘制”时才会强制重置分支。Token 只会发送给 `api.github.com`;桌面版默认记住在本机,网页版默认只保存在当前标签页。私有仓库需要在主页开启 “Private contributions” 才会显示。 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b2c23d5..65d62f5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -13,6 +13,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-dialog = "2" +ureq = "2" [profile.release] codegen-units = 1 diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 575313d..7f98968 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Window access plus opening links in the system browser", + "description": "Window access, links in the system browser, and save dialogs", "windows": ["main"], - "permissions": ["core:default", "opener:default"] + "permissions": ["core:default", "opener:default", "dialog:default"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8ebceb3..320efcd 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,9 +1,73 @@ // Hide the console window on Windows release builds. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use std::process::Command; + +/// Writes a file the user picked in the save dialog (designs and scripts). +#[tauri::command] +fn write_text(path: String, contents: String) -> Result<(), String> { + std::fs::write(path, contents).map_err(|e| e.to_string()) +} + +/// Returns the token of an existing GitHub CLI login, if there is one. +#[tauri::command(async)] +fn gh_token() -> Result { + // Apps started from Finder don't inherit the shell PATH, so try common locations too. + for gh in ["gh", "/opt/homebrew/bin/gh", "/usr/local/bin/gh", "/usr/bin/gh"] { + let mut command = Command::new(gh); + command.args(["auth", "token"]); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + if let Ok(output) = command.output() { + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() && !token.is_empty() { + return Ok(token); + } + } + } + Err("GitHub CLI is not installed or not signed in".into()) +} + +// GitHub's device-flow endpoints don't allow browser (CORS) requests, so the +// app makes them from Rust and hands the JSON response back to the page. +fn post_form(url: &str, form: &[(&str, &str)]) -> Result { + ureq::post(url) + .set("Accept", "application/json") + .send_form(form) + .map_err(|e| e.to_string())? + .into_string() + .map_err(|e| e.to_string()) +} + +#[tauri::command(async)] +fn device_start(client_id: String) -> Result { + post_form( + "https://github.com/login/device/code", + &[("client_id", client_id.as_str()), ("scope", "repo")], + ) +} + +#[tauri::command(async)] +fn device_poll(client_id: String, device_code: String) -> Result { + post_form( + "https://github.com/login/oauth/access_token", + &[ + ("client_id", client_id.as_str()), + ("device_code", device_code.as_str()), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ], + ) +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .invoke_handler(tauri::generate_handler![write_text, gh_token, device_start, device_poll]) .run(tauri::generate_context!()) .expect("error while running Commit Canvas"); } diff --git a/web/index.html b/web/index.html index bda02bb..79e9de5 100644 --- a/web/index.html +++ b/web/index.html @@ -28,113 +28,189 @@

Commit Canvas

-
-

Connect GitHub

-

Paste a personal access token with the repo scope.

-
- - -
-
- - Create a token + +
+

1Sign in to GitHub

+
+ + + +
+ + +
+
+ + Create a token +
+
+ How do I get a token? +
    +
  1. +
  2. +
  3. +
+

+
-

Using GitHub CLI? Run gh auth token and paste the output.

+
-

+
+

diff --git a/web/src/app.js b/web/src/app.js index 7dfc89c..8b578ca 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -1,6 +1,11 @@ (() => { const MAX_COMMITS = 2000; + const MANY_COMMITS = 200; + // OAuth App client ID for "Sign in with GitHub" (device flow, desktop app only). + const OAUTH_CLIENT_ID = ''; + const $ = (id) => document.getElementById(id); + const desktop = window.__TAURI__ ?? null; const store = { get(key, fallback = null) { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } @@ -19,11 +24,18 @@ user: null, repos: [], weeks: [], + total: null, drawing: store.get('cc.drawing', {}), // date -> level (1-4) - history: [], + undo: [], + redo: [], level: 4, - strength: store.get('cc.strength', null), + strength: store.get('cc.strength', null) ?? 4, + avoid: store.get('cc.avoid', true), + preview: false, + pan: false, + cursor: { col: 0, row: 0 }, busy: false, + device: null, }; // ---------- i18n ---------- @@ -39,43 +51,42 @@ for (const el of document.querySelectorAll('[data-i18n-placeholder]')) el.placeholder = t(el.dataset.i18nPlaceholder); for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle); for (const el of document.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel)); - const pastYear = $('year').querySelector('option[value=""]'); - if (pastYear) pastYear.textContent = t('year.past'); - const noRepo = $('repo').querySelector('option[value=""]'); - if (noRepo) noRepo.textContent = t('repo.none'); + const past = $('range').querySelector('option[value=""]'); + if (past) past.textContent = t('range.past'); + const none = $('repo').querySelector('option[value=""]'); + if (none) none.textContent = t('repo.none'); buildGraph(); + renderAccount(); + renderRepoHint(); renderLast(); } // ---------- status ---------- function setStatus(text = '', kind = '') { - const el = $('status'); - el.className = `status ${kind}`; - el.textContent = text; + $('status').className = `status ${kind}`; + $('status').textContent = text; } function setStatusLink(text, href, label) { setStatus(`${text} `, 'ok'); - const link = document.createElement('a'); - link.href = href; - link.target = '_blank'; - link.rel = 'noopener'; - link.textContent = label; + const link = Object.assign(document.createElement('a'), { href, target: '_blank', rel: 'noopener', textContent: label }); $('status').append(link); } - function showError(error) { - if (error?.status === 401) setStatus(t('error.token'), 'error'); - else if (error?.message === 'NO_PUSH') setStatus(t('error.noPush'), 'error'); - else setStatus(t('error.generic', { msg: error?.message ?? String(error) }), 'error'); + function errorText(error) { + if (error?.status === 401) return t('error.token'); + if (error?.status === 0) return t('error.network'); + if (error?.message === 'NO_PUSH') return t('error.noPush'); + return t('error.generic', { msg: error?.message ?? String(error) }); } // ---------- dates & grid ---------- const pad = (n) => String(n).padStart(2, '0'); const isoLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const today = () => isoLocal(new Date()); + const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; - // Empty past-year grid used before connecting. + // Empty past-year grid used before signing in. function offlineWeeks() { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay() - 52 * 7); @@ -86,19 +97,25 @@ })); } - const paintable = (cell) => cell && cell.date <= today(); + const paintable = (cell) => Boolean(cell) && cell.date <= today(); + const cellAtPos = (col, row) => state.weeks[col]?.[row] ?? null; + const countFor = (level) => Math.max(1, Math.round((state.strength * level) / 4)); - function visibleDrawn() { + // Planned cells in the current view: {date, level, blocked}. + function planned() { const out = []; for (const week of state.weeks) { for (const cell of week) { - if (paintable(cell) && state.drawing[cell.date]) out.push([cell.date, state.drawing[cell.date]]); + const level = paintable(cell) ? state.drawing[cell.date] : 0; + if (level) out.push({ date: cell.date, level, blocked: state.avoid && cell.count > 0 }); } } - return out; + return out.sort((a, b) => a.date.localeCompare(b.date)); } - const countFor = (level) => Math.max(1, Math.round((state.strength * level) / 4)); + function planDates() { + return planned().filter((p) => !p.blocked).flatMap((p) => Array(countFor(p.level)).fill(p.date)); + } function autoStrength() { const counts = state.weeks.flat().filter((c) => c?.count > 0).map((c) => c.count).sort((a, b) => a - b); @@ -106,13 +123,26 @@ return Math.min(30, Math.max(4, p90)); } + // Estimated levels after sending, using quartiles like GitHub does. + function previewLevels() { + const added = new Map(planned().filter((p) => !p.blocked).map((p) => [p.date, countFor(p.level)])); + const totals = new Map(); + for (const cell of state.weeks.flat()) if (cell) totals.set(cell.date, cell.count + (added.get(cell.date) ?? 0)); + const nonZero = [...totals.values()].filter((n) => n > 0).sort((a, b) => a - b); + const q = (p) => nonZero[Math.floor((nonZero.length - 1) * p)] ?? 0; + const [q1, q2, q3] = [q(0.25), q(0.5), q(0.75)]; + const levels = new Map(); + for (const [date, n] of totals) levels.set(date, n === 0 ? 0 : n <= q1 ? 1 : n <= q2 ? 2 : n <= q3 ? 3 : 4); + return levels; + } + function buildGraph() { const graph = $('graph'); graph.replaceChildren(); graph.style.setProperty('--cols', state.weeks.length); - - const monthFmt = new Intl.DateTimeFormat(state.lang === 'zh' ? 'zh-CN' : 'en', { month: 'short' }); - const dayFmt = new Intl.DateTimeFormat(state.lang === 'zh' ? 'zh-CN' : 'en', { weekday: 'short' }); + const locale = state.lang === 'zh' ? 'zh-CN' : 'en'; + const monthFmt = new Intl.DateTimeFormat(locale, { month: 'short' }); + const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short' }); graph.append(Object.assign(document.createElement('span'), { className: 'corner' })); const months = state.weeks.map((week) => week.find(Boolean)?.date.slice(0, 7)); @@ -138,122 +168,250 @@ if (row % 2 === 1) day.textContent = dayFmt.format(new Date(2024, 0, 7 + row)); graph.append(day); state.weeks.forEach((week, col) => { - const cell = week[row]; const el = document.createElement('i'); - el.className = 'cell'; el.style.gridColumn = col + 2; el.style.gridRow = row + 2; el.dataset.col = col; el.dataset.row = row; - if (cell) { - el.dataset.date = cell.date; - el.title = cell.count ? `${cell.date} · ${cell.count}` : cell.date; - } graph.append(el); }); } renderCells(); - // On narrow screens start at the most recent weeks, like GitHub does. + // Start at the most recent weeks on narrow screens, like GitHub does. graph.parentElement.scrollLeft = graph.parentElement.scrollWidth; } function renderCells() { - for (const el of $('graph').querySelectorAll('.cell')) { - const cell = state.weeks[el.dataset.col][el.dataset.row]; + const preview = state.preview ? previewLevels() : null; + for (const el of $('graph').querySelectorAll('i[data-col]')) { + const cell = cellAtPos(el.dataset.col, el.dataset.row); let cls = 'cell'; - if (!paintable(cell)) cls += ' off'; - else if (state.drawing[cell.date]) cls += ` l${state.drawing[cell.date]} drawn`; - else if (cell.level) cls += ` l${cell.level} existing`; - else cls += ' l0'; + let title = ''; + if (!paintable(cell)) { + cls += ' off'; + } else { + const drawn = state.drawing[cell.date]; + title = cell.count ? `${cell.date} · ${cell.count}` : cell.date; + if (preview) cls += ` l${preview.get(cell.date) ?? 0}`; + else if (drawn) cls += ` l${drawn} drawn${state.avoid && cell.count ? ' blocked' : ''}`; + else cls += ` l${cell.level} existing`; + if (drawn) title += ` → +${countFor(drawn)}`; + } + if (Number(el.dataset.col) === state.cursor.col && Number(el.dataset.row) === state.cursor.row) cls += ' cursor'; el.className = cls; + el.title = title; } renderSummary(); } function renderSummary() { - const drawn = visibleDrawn(); - const commits = drawn.reduce((sum, [, level]) => sum + countFor(level), 0); - $('summary-commits').textContent = t('summary.commits', { n: commits }); - $('summary-pixels').textContent = t('summary.pixels', { n: drawn.length }); + const items = planned(); + const active = items.filter((p) => !p.blocked); + const commits = active.reduce((sum, p) => sum + countFor(p.level), 0); + $('sum-commits').textContent = commits; + $('sum-days').textContent = active.length; + $('sum-range').textContent = active.length ? `${active[0].date} – ${active.at(-1).date}` : '—'; + const skipped = items.length - active.length; + $('sum-skipped').hidden = skipped === 0; + $('sum-skipped').textContent = t('summary.skipped', { n: skipped }); + $('legend-blocked').hidden = !state.avoid; $('paint').disabled = state.busy || commits === 0; + $('undo').disabled = state.busy || !state.undo.length; + $('redo').disabled = state.busy || !state.redo.length; + for (const brush of document.querySelectorAll('.brush[data-level]')) { + const level = Number(brush.dataset.level); + if (level > 0) brush.querySelector('small').textContent = t('tool.commits', { n: countFor(level) }); + } + renderSteps(); } - // ---------- drawing ---------- - function snapshot() { - state.history.push(JSON.stringify(state.drawing)); - if (state.history.length > 100) state.history.shift(); + function renderSteps() { + $('step-connect').classList.toggle('done', Boolean(state.user)); + $('step-repo').classList.toggle('done', Boolean($('repo').value)); + $('step-draw').classList.toggle('done', planned().some((p) => !p.blocked)); + } + + // ---------- history ---------- + function remember() { + state.undo.push(JSON.stringify(state.drawing)); + if (state.undo.length > 100) state.undo.shift(); + state.redo = []; } - function commit() { + function save() { store.set('cc.drawing', state.drawing); renderCells(); } + function undo() { + if (!state.undo.length) return; + state.redo.push(JSON.stringify(state.drawing)); + state.drawing = JSON.parse(state.undo.pop()); + save(); + } + + function redo() { + if (!state.redo.length) return; + state.undo.push(JSON.stringify(state.drawing)); + state.drawing = JSON.parse(state.redo.pop()); + save(); + } + + // ---------- drawing ---------- function setCell(cell, level) { if (!paintable(cell)) return; if (level > 0) state.drawing[cell.date] = level; else delete state.drawing[cell.date]; } - function cellAt(x, y) { + // Clicking a square that already has the selected shade erases it. + const strokeLevel = (cell, erase) => (erase || (state.level > 0 && state.drawing[cell.date] === state.level) ? 0 : state.level); + + function leavePreview() { + if (!state.preview) return; + state.preview = false; + renderMode(); + } + + function cellFromPoint(x, y) { const el = document.elementFromPoint(x, y); - if (!el?.classList.contains('cell') || !el.closest('#graph')) return null; - return state.weeks[el.dataset.col]?.[el.dataset.row] ?? null; + if (!el?.dataset?.col || !el.closest('#graph')) return null; + return { col: Number(el.dataset.col), row: Number(el.dataset.row), cell: cellAtPos(el.dataset.col, el.dataset.row) }; } let stroke = null; $('graph').addEventListener('pointerdown', (event) => { - const cell = cellAt(event.clientX, event.clientY); - if (!paintable(cell) || state.busy) return; + if (state.busy || state.pan) return; + const hit = cellFromPoint(event.clientX, event.clientY); + if (!hit || !paintable(hit.cell)) return; event.preventDefault(); + leavePreview(); $('graph').setPointerCapture(event.pointerId); - snapshot(); - const current = state.drawing[cell.date] ?? 0; - // Right-click, or clicking a square that already has this shade, erases. - const level = event.button === 2 || (current === state.level && state.level > 0) ? 0 : state.level; - stroke = { level }; - setCell(cell, level); + remember(); + stroke = { level: strokeLevel(hit.cell, event.button === 2) }; + state.cursor = { col: hit.col, row: hit.row }; + setCell(hit.cell, stroke.level); renderCells(); }); $('graph').addEventListener('pointermove', (event) => { if (!stroke) return; - const cell = cellAt(event.clientX, event.clientY); - if (cell) { - setCell(cell, stroke.level); + const hit = cellFromPoint(event.clientX, event.clientY); + if (hit?.cell) { + setCell(hit.cell, stroke.level); renderCells(); } }); const endStroke = () => { if (!stroke) return; stroke = null; - commit(); + save(); }; $('graph').addEventListener('pointerup', endStroke); $('graph').addEventListener('pointercancel', endStroke); $('graph').addEventListener('contextmenu', (event) => event.preventDefault()); + // Keyboard drawing: arrows move, Space/Enter paints, Home/End jump. + $('graph').addEventListener('keydown', (event) => { + const { col, row } = state.cursor; + const last = state.weeks.length - 1; + const moves = { + ArrowLeft: [Math.max(0, col - 1), row], + ArrowRight: [Math.min(last, col + 1), row], + ArrowUp: [col, Math.max(0, row - 1)], + ArrowDown: [col, Math.min(6, row + 1)], + Home: [0, row], + End: [last, row], + PageUp: [Math.max(0, col - 4), row], + PageDown: [Math.min(last, col + 4), row], + }; + if (moves[event.key]) { + event.preventDefault(); + state.cursor = { col: moves[event.key][0], row: moves[event.key][1] }; + renderCells(); + } else if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + const cell = cellAtPos(col, row); + if (!paintable(cell) || state.busy) return; + leavePreview(); + remember(); + setCell(cell, strokeLevel(cell, false)); + save(); + } + }); + function selectLevel(level) { state.level = level; - for (const button of document.querySelectorAll('.swatch')) { - button.setAttribute('aria-checked', String(Number(button.dataset.level) === level)); + for (const brush of document.querySelectorAll('.brush')) { + brush.setAttribute('aria-checked', String(Number(brush.dataset.level) === level)); } } - for (const button of document.querySelectorAll('.swatch')) { - button.addEventListener('click', () => selectLevel(Number(button.dataset.level))); + for (const brush of document.querySelectorAll('.brush')) { + brush.addEventListener('click', () => selectLevel(Number(brush.dataset.level))); + } + + // Paints [col, row] pixels starting at the column that centers them. + function stamp(pixels, width, level) { + const start = Math.max(0, Math.floor((state.weeks.length - width) / 2)); + for (const [col, row, own] of pixels) setCell(cellAtPos(start + col, row), own ?? level); + } + + function clearView() { + for (const week of state.weeks) for (const cell of week) if (cell) delete state.drawing[cell.date]; } $('text-form').addEventListener('submit', (event) => { event.preventDefault(); const { pixels, width } = window.CC_renderText($('text').value); if (!pixels.length) return; - snapshot(); - const start = Math.max(0, Math.floor((state.weeks.length - width) / 2)); - for (const [col, row] of pixels) setCell(state.weeks[start + col]?.[row], state.level || 4); - commit(); + leavePreview(); + remember(); + stamp(pixels, width, state.level || 4); + save(); }); + const TEMPLATES = { + heart: () => window.CC_renderText('♥'), + hello: () => window.CC_renderText('HELLO'), + wave() { + const width = state.weeks.length; + const pixels = []; + for (let col = 0; col < width; col += 1) { + const y = 3 - 2.6 * Math.sin((col / 12) * 2 * Math.PI); + for (let row = 0; row < 7; row += 1) { + const d = Math.abs(row - y); + if (d < 0.6) pixels.push([col, row, 4]); + else if (d < 1.4) pixels.push([col, row, 2]); + } + } + return { pixels, width }; + }, + stars() { + const width = state.weeks.length; + const pixels = []; + for (let col = 3, i = 0; col < width - 1; col += 7, i += 1) { + const row = i % 2 ? 4 : 2; + pixels.push([col, row, 4], [col - 1, row, 2], [col + 1, row, 2], [col, row - 1, 2], [col, row + 1, 2]); + pixels.push([col + 3, i % 2 ? 1 : 5, 1]); + } + return { pixels, width }; + }, + }; + + for (const button of document.querySelectorAll('[data-template]')) { + button.addEventListener('click', async () => { + if (planned().length && !(await ask(t('template.label'), t('template.replace'), t('confirm.ok')))) return; + const { pixels, width } = TEMPLATES[button.dataset.template](); + leavePreview(); + remember(); + clearView(); + stamp(pixels, width, state.level || 4); + save(); + }); + } + function shift(delta) { - snapshot(); + leavePreview(); + remember(); const moved = []; state.weeks.forEach((week, col) => week.forEach((cell, row) => { if (paintable(cell) && state.drawing[cell.date]) { @@ -261,30 +419,58 @@ delete state.drawing[cell.date]; } })); - for (const [col, row, level] of moved) setCell(state.weeks[col]?.[row], level); - commit(); + for (const [col, row, level] of moved) setCell(cellAtPos(col, row), level); + save(); } $('shift-left').addEventListener('click', () => shift(-1)); $('shift-right').addEventListener('click', () => shift(1)); + $('undo').addEventListener('click', undo); + $('redo').addEventListener('click', redo); + $('clear').addEventListener('click', () => { + leavePreview(); + remember(); + clearView(); + save(); + }); - function undo() { - if (!state.history.length) return; - state.drawing = JSON.parse(state.history.pop()); - commit(); + $('zoom').addEventListener('click', () => { + const on = !$('graph').classList.contains('zoomed'); + $('graph').classList.toggle('zoomed', on); + $('zoom').setAttribute('aria-pressed', String(on)); + store.set('cc.zoom', on); + }); + $('pan').addEventListener('click', () => { + state.pan = !state.pan; + $('graph').classList.toggle('panning', state.pan); + $('pan').setAttribute('aria-pressed', String(state.pan)); + }); + + function renderMode() { + $('mode-edit').setAttribute('aria-pressed', String(!state.preview)); + $('mode-preview').setAttribute('aria-pressed', String(state.preview)); + $('legend-edit').hidden = state.preview; + $('legend-preview').hidden = !state.preview; + renderCells(); } - $('undo').addEventListener('click', undo); + $('mode-edit').addEventListener('click', () => { state.preview = false; renderMode(); }); + $('mode-preview').addEventListener('click', () => { state.preview = true; renderMode(); }); - $('clear').addEventListener('click', () => { - snapshot(); - for (const week of state.weeks) for (const cell of week) if (cell) delete state.drawing[cell.date]; - commit(); + $('avoid').addEventListener('change', () => { + state.avoid = $('avoid').checked; + store.set('cc.avoid', state.avoid); + renderCells(); }); document.addEventListener('keydown', (event) => { - if (event.target.closest('input, select, textarea')) return; - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { + if (event.target.closest('input, select, textarea, dialog')) return; + const key = event.key.toLowerCase(); + if ((event.ctrlKey || event.metaKey) && key === 'z') { + event.preventDefault(); + if (event.shiftKey) redo(); + else undo(); + } else if ((event.ctrlKey || event.metaKey) && key === 'y') { event.preventDefault(); - undo(); + redo(); } else if (/^[0-4]$/.test(event.key)) { selectLevel(Number(event.key)); } @@ -295,11 +481,175 @@ if (value >= 1 && value <= 50) { state.strength = value; store.set('cc.strength', value); - renderSummary(); + renderCells(); + } + }); + + // ---------- dialog ---------- + // In-page confirmation; resolves to true when confirmed. + function ask(title, body, okLabel, { details = [], check = '', note = '' } = {}) { + const dialog = $('confirm'); + $('confirm-title').textContent = title; + $('confirm-body').textContent = body; + $('confirm-details').replaceChildren(...details.flatMap(([k, v]) => [ + Object.assign(document.createElement('dt'), { textContent: k }), + Object.assign(document.createElement('dd'), { textContent: v }), + ])); + $('confirm-details').hidden = !details.length; + $('confirm-check-wrap').hidden = !check; + $('confirm-check').checked = false; + $('confirm-check-text').textContent = check; + $('confirm-note').textContent = note; + $('confirm-note').hidden = !note; + $('confirm-ok').textContent = okLabel; + $('confirm-ok').disabled = Boolean(check); + dialog.returnValue = ''; + dialog.showModal(); + return new Promise((resolve) => { + dialog.addEventListener('close', () => resolve(dialog.returnValue === 'ok'), { once: true }); + }); + } + $('confirm-check').addEventListener('change', () => { $('confirm-ok').disabled = !$('confirm-check').checked; }); + + // ---------- files ---------- + async function saveFile(name, text) { + if (desktop) { + const path = await desktop.dialog.save({ defaultPath: name }); + if (!path) return false; + await desktop.core.invoke('write_text', { path, contents: text }); + return true; + } + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' })); + const link = Object.assign(document.createElement('a'), { href: url, download: name }); + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + return true; + } + + $('design-save').addEventListener('click', async () => { + const design = { kind: 'commit-canvas-design', version: 2, strength: state.strength, drawing: state.drawing }; + try { + if (await saveFile('commit-canvas-design.json', `${JSON.stringify(design, null, 2)}\n`)) setStatus(t('design.saved'), 'ok'); + } catch (error) { + setStatus(errorText(error), 'error'); } }); - // ---------- GitHub ---------- + $('design-open').addEventListener('click', () => $('design-file').click()); + $('design-file').addEventListener('change', async () => { + const file = $('design-file').files[0]; + $('design-file').value = ''; + if (!file) return; + const drawing = parseDesign(await file.text().catch(() => '')); + if (!drawing) { + setStatus(t('design.invalid'), 'error'); + return; + } + leavePreview(); + remember(); + state.drawing = drawing.drawing; + if (drawing.strength) { + state.strength = drawing.strength; + $('strength').value = drawing.strength; + store.set('cc.strength', drawing.strength); + } + save(); + setStatus(t('design.loaded'), 'ok'); + }); + + // Accepts this version's designs and the original 53×7 design files. + function parseDesign(text) { + let data; + try { data = JSON.parse(text); } catch { return null; } + if (!data || typeof data !== 'object') return null; + const drawing = {}; + const okLevel = (l) => Number.isInteger(l) && l >= 1 && l <= 4; + if (data.kind === 'commit-canvas-design' && data.drawing && typeof data.drawing === 'object') { + for (const [date, level] of Object.entries(data.drawing)) { + if (DATE_RE.test(date) && okLevel(level)) drawing[date] = level; + } + const strength = Number.isInteger(data.strength) && data.strength >= 1 && data.strength <= 50 ? data.strength : null; + return { drawing, strength }; + } + if (data.version === 1 && DATE_RE.test(data.endDate ?? '') && Array.isArray(data.levels) && data.levels.length === 371) { + const [y, m, d] = data.endDate.split('-').map(Number); + const end = new Date(Date.UTC(y, m - 1, d)); + const first = new Date(end); + first.setUTCDate(end.getUTCDate() - end.getUTCDay() - 52 * 7); + data.levels.forEach((level, index) => { + if (!okLevel(level)) return; + const day = new Date(first); + day.setUTCDate(first.getUTCDate() + index); + drawing[day.toISOString().slice(0, 10)] = level; + }); + return { drawing, strength: null }; + } + return null; + } + + function scriptFor(kind, email) { + const dates = planDates(); + const perDay = new Map(); + const commits = dates.map((date) => { + const n = (perDay.get(date) ?? 0) + 1; + perDay.set(date, n); + return [window.CC_GitHub.noonOn(date, n), `Commit Canvas ${date} #${n}`]; + }); + if (kind === 'bash') { + return [ + '#!/usr/bin/env bash', + `# Commit Canvas: creates ${commits.length} empty commits in the current Git repository.`, + 'set -euo pipefail', + 'git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "Run this inside a Git repository folder."; exit 1; }', + `read -r -p "Create ${commits.length} empty commits in $(pwd)? [y/N] " answer`, + '[[ "$answer" == [yY]* ]] || exit 1', + `export GIT_AUTHOR_EMAIL='${email}' GIT_COMMITTER_EMAIL='${email}'`, + 'c() { GIT_AUTHOR_DATE="$1" GIT_COMMITTER_DATE="$1" git commit --allow-empty -q -m "$2"; }', + ...commits.map(([date, message]) => `c '${date}' '${message}'`), + 'echo "Done. Now run: git push"', + '', + ].join('\n'); + } + return [ + `# Commit Canvas: creates ${commits.length} empty commits in the current Git repository.`, + "$ErrorActionPreference = 'Stop'", + 'git rev-parse --is-inside-work-tree *> $null', + "if ($LASTEXITCODE -ne 0) { throw 'Run this inside a Git repository folder.' }", + `$answer = Read-Host "Create ${commits.length} empty commits in $(Get-Location)? [y/N]"`, + "if ($answer -notmatch '^[yY]') { exit 1 }", + `$env:GIT_AUTHOR_EMAIL = '${email}'; $env:GIT_COMMITTER_EMAIL = '${email}'`, + 'function c($d, $m) { $env:GIT_AUTHOR_DATE = $d; $env:GIT_COMMITTER_DATE = $d; git commit --allow-empty -q -m $m; if ($LASTEXITCODE -ne 0) { throw "git commit failed" } }', + ...commits.map(([date, message]) => `c '${date}' '${message}'`), + "Remove-Item Env:GIT_AUTHOR_DATE, Env:GIT_COMMITTER_DATE, Env:GIT_AUTHOR_EMAIL, Env:GIT_COMMITTER_EMAIL", + "Write-Host 'Done. Now run: git push'", + '', + ].join('\r\n'); + } + + async function exportScript(kind) { + const email = $('script-email').value.trim(); + if (!/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email)) { + setStatus(t('script.badEmail'), 'error'); + $('script-email').focus(); + return; + } + if (!planDates().length) { + setStatus(t('paint.empty'), 'error'); + return; + } + try { + const name = kind === 'bash' ? 'commit-canvas.sh' : 'commit-canvas.ps1'; + if (await saveFile(name, scriptFor(kind, email))) setStatus(t('script.saved'), 'ok'); + } catch (error) { + setStatus(errorText(error), 'error'); + } + } + $('script-bash').addEventListener('click', () => exportScript('bash')); + $('script-ps').addEventListener('click', () => exportScript('powershell')); + + // ---------- sign in ---------- function savedToken() { try { const remembered = localStorage.getItem('cc.token'); @@ -309,59 +659,152 @@ } } - async function connect(token, remember) { + function forgetToken() { + try { + localStorage.removeItem('cc.token'); + sessionStorage.removeItem('cc.token'); + } catch { /* storage unavailable */ } + } + + async function connect(token, rememberToken) { state.api = window.CC_GitHub.create(token, { onWait: (seconds) => setStatus(t('progress.wait', { s: seconds })), }); - setStatus('…'); try { state.user = await state.api.user(); } catch (error) { state.api = null; if (error.status === 401) forgetToken(); - showError(error); + setConnectError(errorText(error)); return; } try { - if (remember) localStorage.setItem('cc.token', token); + if (rememberToken) localStorage.setItem('cc.token', token); else sessionStorage.setItem('cc.token', token); } catch { /* storage unavailable */ } $('token').value = ''; - $('connect').hidden = true; - $('setup').hidden = false; - $('account').hidden = false; - $('avatar').src = state.user.avatar; - $('login').textContent = state.user.login; - $('login').href = state.user.url; - setStatus(); + setConnectError(''); + if (!$('script-email').value) $('script-email').value = state.user.email; + renderAccount(); await Promise.all([loadRepos(), loadCalendar()]); } - function forgetToken() { - try { - localStorage.removeItem('cc.token'); - sessionStorage.removeItem('cc.token'); - } catch { /* storage unavailable */ } + function setConnectError(text) { + let el = $('connect-error'); + if (!el) { + el = Object.assign(document.createElement('p'), { id: 'connect-error', className: 'status error' }); + $('connect-body').append(el); + } + el.textContent = text; + } + + function renderAccount() { + const user = state.user; + $('account').hidden = !user; + $('connect-body').hidden = Boolean(user); + $('connect-done').hidden = !user; + $('repo').disabled = !user; + $('repo-new').disabled = !user; + $('refresh').hidden = !user; + if (!user) { + $('total').textContent = ''; + return; + } + $('avatar').src = user.avatar; + $('login').textContent = user.login; + $('login').href = user.url; + $('connect-done').textContent = `✓ ${user.name} (@${user.login})`; + $('total').textContent = state.total === null ? '' : t('account.total', { n: state.total }); + renderSteps(); } function signOut() { forgetToken(); - state.api = null; - state.user = null; - $('connect').hidden = false; - $('setup').hidden = true; - $('account').hidden = true; - $('last').hidden = true; + stopDevice(); + Object.assign(state, { api: null, user: null, repos: [], total: null }); + $('repo').replaceChildren(new Option(t('repo.none'), '')); + $('range').replaceChildren(new Option(t('range.past'), '')); + $('new-repo').hidden = true; state.weeks = offlineWeeks(); + renderAccount(); + renderRepoHint(); + renderLast(); buildGraph(); setStatus(); } + $('connect-form').addEventListener('submit', (event) => { + event.preventDefault(); + const token = $('token').value.trim(); + if (token) connect(token, $('remember').checked); + }); + $('signout').addEventListener('click', signOut); + + // Desktop app: reuse an existing GitHub CLI login. + $('gh-login').addEventListener('click', async () => { + try { + const token = await desktop.core.invoke('gh_token'); + await connect(token, true); + } catch { + setConnectError(t('gh.missing')); + } + }); + + // Desktop app: OAuth device flow ("Sign in with GitHub"). + function stopDevice() { + if (state.device) clearTimeout(state.device.timer); + state.device = null; + $('device').hidden = true; + $('quick-login').hidden = !desktop; + } + + $('device-login').addEventListener('click', async () => { + setConnectError(''); + let start; + try { + start = JSON.parse(await desktop.core.invoke('device_start', { clientId: OAUTH_CLIENT_ID })); + if (!start.device_code) throw new Error(start.error_description || start.error || 'device flow failed'); + } catch (error) { + setConnectError(errorText(error)); + return; + } + state.device = { ...start, interval: start.interval || 5 }; + $('device-code').textContent = start.user_code; + $('device').hidden = false; + $('quick-login').hidden = true; + navigator.clipboard?.writeText(start.user_code).catch(() => {}); + openExternal(start.verification_uri); + const poll = async () => { + if (!state.device) return; + let result; + try { + result = JSON.parse(await desktop.core.invoke('device_poll', { clientId: OAUTH_CLIENT_ID, deviceCode: start.device_code })); + } catch { + result = { error: 'authorization_pending' }; + } + if (!state.device) return; + if (result.access_token) { + stopDevice(); + await connect(result.access_token, true); + } else if (result.error === 'authorization_pending' || result.error === 'slow_down') { + if (result.error === 'slow_down') state.device.interval += 5; + state.device.timer = setTimeout(poll, state.device.interval * 1000); + } else { + stopDevice(); + setConnectError(t(result.error === 'access_denied' ? 'device.denied' : 'device.expired')); + } + }; + state.device.timer = setTimeout(poll, state.device.interval * 1000); + }); + $('device-open').addEventListener('click', () => state.device && openExternal(state.device.verification_uri)); + $('device-cancel').addEventListener('click', stopDevice); + + // ---------- repositories ---------- async function loadRepos(created) { try { state.repos = await state.api.repos(); } catch (error) { - showError(error); + setStatus(errorText(error), 'error'); return; } // A just-created repository may not be listed yet. @@ -377,47 +820,35 @@ } function onRepoChange() { - const repo = state.repos.find((r) => r.fullName === $('repo').value); - store.set('cc.repo', repo?.fullName ?? null); - $('private-hint').hidden = !repo?.private; + store.set('cc.repo', $('repo').value || null); + renderRepoHint(); renderLast(); + renderSteps(); } - async function loadCalendar() { - const year = $('year').value; - try { - const { weeks, years } = await state.api.calendar(year ? Number(year) : null); - state.weeks = weeks; - const el = $('year'); - const options = ['', ...years.map(String)]; - if (el.options.length !== options.length) { - el.replaceChildren(...options.map((value) => new Option(value || t('year.past'), value))); - el.value = year; - } - } catch (error) { - showError(error); - return; - } - if (store.get('cc.strength') === null) { - state.strength = autoStrength(); - $('strength').value = state.strength; - } - buildGraph(); + function renderRepoHint() { + const repo = state.repos.find((r) => r.fullName === $('repo').value); + let hint = ''; + if (state.user && !repo) hint = t('repo.suggest'); + else if (repo?.private) hint = t('repo.privateHint'); + else if (repo && !/canvas|art|draw|paint|graffiti/i.test(repo.fullName)) hint = t('repo.realHint'); + $('repo-hint').textContent = hint; + $('repo-hint').hidden = !hint; + $('repo-new').classList.toggle('primary', Boolean(state.user && !repo)); } - $('connect-form').addEventListener('submit', (event) => { - event.preventDefault(); - const token = $('token').value.trim(); - if (token) connect(token, $('remember').checked); - }); - $('signout').addEventListener('click', signOut); - $('repo').addEventListener('change', onRepoChange); - $('year').addEventListener('change', loadCalendar); - $('refresh').addEventListener('click', loadCalendar); + function suggestName() { + const taken = new Set(state.repos.map((r) => r.fullName.split('/')[1].toLowerCase())); + let name = 'commit-canvas-art'; + for (let i = 2; taken.has(name); i += 1) name = `commit-canvas-art-${i}`; + return name; + } + $('repo').addEventListener('change', onRepoChange); $('repo-new').addEventListener('click', () => { $('new-repo').hidden = false; - $('new-repo-name').focus(); + $('new-repo-name').value = suggestName(); + $('new-repo-name').select(); }); $('new-repo-cancel').addEventListener('click', () => { $('new-repo').hidden = true; }); $('new-repo').addEventListener('submit', async (event) => { @@ -425,19 +856,46 @@ try { const repo = await state.api.createRepo($('new-repo-name').value.trim(), $('new-repo-private').checked); $('new-repo').hidden = true; - $('new-repo-name').value = ''; await loadRepos(repo); } catch (error) { - showError(error); + setStatus(errorText(error), 'error'); } }); - // ---------- paint ---------- + // ---------- contributions ---------- + async function loadCalendar() { + const year = $('range').value; + try { + const { weeks, years, total } = await state.api.calendar(year ? Number(year) : null); + state.weeks = weeks; + state.total = total; + const options = ['', ...years.map(String)]; + if ($('range').options.length !== options.length) { + $('range').replaceChildren(...options.map((value) => new Option(value || t('range.past'), value))); + $('range').value = year; + } + } catch (error) { + setStatus(errorText(error), 'error'); + return; + } + if (store.get('cc.strength') === null) { + state.strength = autoStrength(); + $('strength').value = state.strength; + } + state.cursor = { col: state.weeks.length - 1, row: 0 }; + renderAccount(); + buildGraph(); + } + $('range').addEventListener('change', loadCalendar); + $('refresh').addEventListener('click', loadCalendar); + + // ---------- send ---------- function setBusy(busy) { state.busy = busy; - for (const el of document.querySelectorAll('button, input, select')) { - if (!el.closest('.top')) el.disabled = busy; + for (const el of document.querySelectorAll('main button, main input, main select')) { + if (!el.closest('#step-connect')) el.disabled = busy; } + if (!busy) renderAccount(); renderSummary(); } @@ -446,41 +904,34 @@ $('progress').firstElementChild.style.width = total ? `${(100 * done) / total}%` : '0'; } - // In-page confirmation dialog; resolves to true when confirmed. - function ask(title, body, okLabel, note = '') { - const dialog = $('confirm'); - $('confirm-title').textContent = title; - $('confirm-body').textContent = body; - $('confirm-note').textContent = note; - $('confirm-note').hidden = !note; - $('confirm-ok').textContent = okLabel; - dialog.returnValue = ''; - dialog.showModal(); - return new Promise((resolve) => { - dialog.addEventListener('close', () => resolve(dialog.returnValue === 'ok'), { once: true }); - }); - } - $('paint').addEventListener('click', async () => { const repo = $('repo').value; if (!state.api || !repo) { setStatus(t('paint.needConnect'), 'error'); return; } - const drawn = visibleDrawn().sort(([a], [b]) => a.localeCompare(b)); - const dates = drawn.flatMap(([date, level]) => Array(countFor(level)).fill(date)); + const items = planned().filter((p) => !p.blocked); + const dates = planDates(); + if (!dates.length) { + setStatus(t('paint.empty'), 'error'); + return; + } if (dates.length > MAX_COMMITS) { setStatus(t('error.tooMany', { n: dates.length, max: MAX_COMMITS }), 'error'); return; } - const branch = state.repos.find((r) => r.fullName === repo)?.branch ?? 'main'; - const ok = await ask( - t('confirm.title'), - t('confirm.body', { commits: dates.length, days: drawn.length, repo, branch }), - t('confirm.ok'), - t('confirm.note'), - ); + const ok = await ask(t('confirm.title'), t('confirm.body'), t('confirm.ok'), { + details: [ + [t('confirm.account'), state.user.login], + [t('confirm.repo'), repo], + [t('confirm.branch'), branch], + [t('confirm.commits'), String(dates.length)], + [t('confirm.dates'), `${items[0].date} – ${items.at(-1).date}`], + ], + check: dates.length >= MANY_COMMITS ? t('confirm.many') : '', + note: t('confirm.note'), + }); if (!ok) return; setBusy(true); @@ -491,14 +942,14 @@ progress(done, total); setStatus(phase === 'push' ? t('progress.push') : t('progress.commits', { done, total })); }); - snapshot(); - const painted = Object.fromEntries(drawn); - for (const [date] of drawn) delete state.drawing[date]; + remember(); + const sent = Object.fromEntries(items.map((p) => [p.date, p.level])); + for (const p of items) delete state.drawing[p.date]; store.set('cc.drawing', state.drawing); - store.set('cc.last', { ...result, drawing: painted }); + store.set('cc.last', { ...result, drawing: sent }); setStatusLink(t('done.body', { n: result.count, repo }), state.user.url, t('done.view')); } catch (error) { - showError(error); + setStatus(errorText(error), 'error'); } finally { progress(0, 0); setBusy(false); @@ -523,13 +974,12 @@ try { await state.api.undoPaint(last); store.set('cc.last', null); - snapshot(); + remember(); Object.assign(state.drawing, last.drawing ?? {}); store.set('cc.drawing', state.drawing); setStatus(t('undoPaint.done'), 'ok'); } catch (error) { - if (error.message === 'MOVED') setStatus(t('undoPaint.moved'), 'error'); - else showError(error); + setStatus(error.message === 'MOVED' ? t('undoPaint.moved') : errorText(error), 'error'); } finally { setBusy(false); renderLast(); @@ -544,23 +994,36 @@ }); // ---------- desktop app ---------- - // Inside the Tauri desktop app, open external links in the system browser. - const desktop = window.__TAURI__; + function openExternal(url) { + if (desktop) desktop.opener.openUrl(url).catch(() => window.open(url, '_blank')); + else window.open(url, '_blank', 'noopener'); + } + if (desktop) { + // Open external links in the system browser instead of the app window. document.addEventListener('click', (event) => { const link = event.target.closest('a[href^="http"]'); if (!link) return; event.preventDefault(); - desktop.opener.openUrl(link.href).catch(() => window.open(link.href, '_blank')); + openExternal(link.href); }); + $('quick-login').hidden = false; + $('device-login').hidden = !OAUTH_CLIENT_ID; + $('gh-login').hidden = false; + $('or-token').hidden = false; } // ---------- start ---------- state.weeks = offlineWeeks(); - state.strength ??= 4; + state.cursor = { col: state.weeks.length - 1, row: 0 }; + $('repo').replaceChildren(new Option(t('repo.none'), '')); + $('range').replaceChildren(new Option(t('range.past'), '')); $('strength').value = state.strength; + $('avoid').checked = state.avoid; + if (store.get('cc.zoom')) $('zoom').click(); selectLevel(4); applyLanguage(); + renderMode(); const saved = savedToken(); // The desktop app keeps its own storage, so remember the token by default there. $('remember').checked = saved.token ? saved.remember : Boolean(desktop); diff --git a/web/src/github.js b/web/src/github.js index 24a9b8b..8bd938c 100644 --- a/web/src/github.js +++ b/web/src/github.js @@ -12,17 +12,22 @@ window.CC_GitHub = (() => { function create(token, { onWait } = {}) { async function request(path, { method = 'GET', body } = {}, attempt = 0) { - const response = await fetch(API + path, { - method, - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28', - ...(body ? { 'Content-Type': 'application/json' } : {}), - }, - body: body ? JSON.stringify(body) : undefined, - cache: 'no-store', - }); + let response; + try { + response = await fetch(API + path, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + cache: 'no-store', + }); + } catch { + throw new GitHubError(0, 'NETWORK'); + } if (response.status === 204) return null; const data = await response.json().catch(() => null); if (response.ok) return data; @@ -94,7 +99,7 @@ window.CC_GitHub = (() => { const data = await graphql(`query($from: DateTime, $to: DateTime) { viewer { contributionsCollection(from: $from, to: $to) { contributionYears - contributionCalendar { weeks { contributionDays { date weekday contributionCount contributionLevel } } } + contributionCalendar { totalContributions weeks { contributionDays { date weekday contributionCount contributionLevel } } } } } }`, variables); const collection = data.viewer.contributionsCollection; @@ -106,7 +111,7 @@ window.CC_GitHub = (() => { } return slots; }); - return { weeks, years: collection.contributionYears }; + return { weeks, years: collection.contributionYears, total: collection.contributionCalendar.totalContributions }; }, // Creates one empty commit per entry of `dates` (repeated dates allowed) diff --git a/web/src/i18n.js b/web/src/i18n.js index 070414b..951ac5d 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -1,118 +1,252 @@ window.CC_I18N = { en: { tagline: 'Draw on your GitHub contribution graph', - 'connect.title': 'Connect GitHub', - 'connect.body': 'Paste a personal access token with the repo scope. It is sent only to api.github.com.', + 'step.connect': 'Sign in to GitHub', + 'step.repo': 'Choose a repository', + 'step.draw': 'Draw', + 'step.paint': 'Send to GitHub', + + 'connect.device': 'Sign in with GitHub', + 'connect.gh': 'Use my GitHub CLI login', + 'connect.or': 'or paste a token', 'connect.placeholder': 'ghp_… or github_pat_…', 'connect.button': 'Connect', 'connect.remember': 'Remember on this device', 'connect.create': 'Create a token', - 'connect.gh': 'Using GitHub CLI? Run gh auth token and paste the output.', + 'connect.help': 'How do I get a token?', + 'connect.help1': 'Click “Create a token”. GitHub opens with the right permission (repo) already ticked.', + 'connect.help2': 'Scroll down and click the green “Generate token” button.', + 'connect.help3': 'Copy the code that starts with ghp_ and paste it above.', + 'connect.privacy': 'Your token is only sent to GitHub (api.github.com).', + 'device.title': 'Enter this code on GitHub', + 'device.body': 'A browser window has opened. Paste the code (it’s already copied), then click Authorize.', + 'device.open': 'Open the page again', + 'device.waiting': 'Waiting for you to authorize…', + 'device.expired': 'The code expired. Please try again.', + 'device.denied': 'Sign-in was cancelled.', + 'gh.missing': 'GitHub CLI isn’t installed or isn’t signed in. Use one of the other options.', 'account.signout': 'Sign out', - 'repo.label': 'Repository', + 'account.total': '{n} contributions in this range', + 'repo.none': 'Select a repository…', - 'repo.new': 'New', - 'repo.namePlaceholder': 'contribution-art', + 'repo.new': 'New repository', + 'repo.namePlaceholder': 'commit-canvas-art', 'repo.private': 'Private', 'repo.create': 'Create', 'repo.cancel': 'Cancel', - 'repo.privateHint': 'Private repo: enable “Private contributions” on your profile so they show on the graph.', - 'year.label': 'Year', - 'year.past': 'Past year', - refresh: 'Reload graph', + 'repo.suggest': 'No repository for your art yet? Click “New repository” — one click and it’s ready.', + 'repo.privateHint': 'This repository is private: turn on “Private contributions” on your GitHub profile, or the drawing won’t be visible to others.', + 'repo.realHint': 'Tip: use a separate repository just for drawing, so these commits don’t mix with a real project.', + 'repo.visibilityHint': 'Public repositories show up on your graph right away.', + + 'range.label': 'Range', + 'range.past': 'Past year', + refresh: 'Reload my contributions', + 'mode.edit': 'Edit', + 'mode.preview': 'Preview result', 'tool.eraser': 'Eraser', - 'tool.level': 'Shade', - 'text.placeholder': 'Text', - 'text.add': 'Add', - 'shift.left': 'Shift left', - 'shift.right': 'Shift right', + 'tool.commits': '{n}×', + 'text.placeholder': 'Type text…', + 'text.add': 'Stamp text', + 'template.label': 'Templates', + 'template.heart': '♥ Heart', + 'template.hello': 'HELLO', + 'template.wave': '≋ Wave', + 'template.stars': '✦ Stars', + 'template.replace': 'Replace the current drawing with this template?', + 'shift.left': 'Move drawing left', + 'shift.right': 'Move drawing right', undo: 'Undo', + redo: 'Redo', clear: 'Clear', + zoom: 'Bigger squares', + pan: 'Scroll mode', + 'grid.label': 'Drawing grid, one square per day', + 'grid.help': 'Click or drag to draw. Click a square again to erase it. Keyboard: arrow keys to move, Space to draw, 0–4 to pick a shade.', + 'legend.existing': 'Already on GitHub', + 'legend.planned': 'Will be added', + 'legend.blocked': 'Skipped (day already has contributions)', + 'legend.preview': 'Estimated look after sending (GitHub decides the final shades).', + 'avoid.label': 'Don’t add commits on days that already have contributions', less: 'Less', more: 'More', - 'legend.existing': 'Faded = contributions you already have', - 'summary.pixels': '{n} days', - 'summary.commits': '{n} commits', - 'strength.label': 'Commits for darkest shade', - 'paint.button': 'Paint to GitHub', - 'paint.needConnect': 'Connect GitHub and choose a repository to paint.', - 'confirm.title': 'Paint to GitHub?', - 'confirm.body': 'This creates {commits} empty commits across {days} days in {repo} (branch {branch}) and pushes them. New squares usually show up within minutes, sometimes up to 24 hours.', - 'confirm.note': 'Decorative commits are not real work — please use this for fun, in a repository you own.', - 'confirm.ok': 'Paint', + + 'summary.commits': 'Commits', + 'summary.days': 'Days', + 'summary.range': 'Dates', + 'summary.skipped': '{n} days skipped', + 'strength.label': 'Commits for the darkest shade', + 'strength.help': 'Suggested automatically from your activity so the drawing stands out.', + 'paint.button': 'Send to GitHub', + 'paint.needConnect': 'Sign in and choose a repository first (steps 1 and 2).', + 'paint.empty': 'Draw something first.', + + 'confirm.title': 'Send this drawing to GitHub?', + 'confirm.body': 'Commit Canvas will add empty commits to the repository below. New squares usually appear within minutes, occasionally up to 24 hours.', + 'confirm.account': 'Account', + 'confirm.repo': 'Repository', + 'confirm.branch': 'Branch', + 'confirm.commits': 'Commits', + 'confirm.dates': 'Dates', + 'confirm.many': 'I understand this creates a lot of commits.', + 'confirm.note': 'Decorative commits aren’t real work — keep it fun and honest.', + 'confirm.ok': 'Send', 'confirm.cancel': 'Cancel', 'progress.prepare': 'Preparing repository…', 'progress.commits': 'Creating commits {done}/{total}…', 'progress.push': 'Pushing…', - 'progress.wait': 'GitHub rate limit — waiting {s}s…', - 'done.body': 'Painted {n} commits to {repo}.', - 'done.view': 'View profile', - 'last.title': 'Last paint: {n} commits to {repo}', - 'last.undo': 'Undo last paint', - 'undoPaint.confirm': 'Reset {repo} ({branch}) to the commit before the last paint? The painted commits will be removed from the branch.', - 'undoPaint.done': 'Last paint removed.', - 'undoPaint.moved': 'The branch has changed since the last paint, so it can’t be undone automatically.', - 'error.token': 'This token was rejected by GitHub.', + 'progress.wait': 'GitHub asked us to slow down — waiting {s}s…', + 'done.body': 'Done! {n} commits added to {repo}.', + 'done.view': 'Open my profile', + 'last.title': 'Last send: {n} commits to {repo}', + 'last.undo': 'Undo last send', + 'undoPaint.confirm': 'Move {repo} ({branch}) back to how it was before the last send? Those commits will be removed.', + 'undoPaint.done': 'Last send undone.', + 'undoPaint.moved': 'The repository changed after the last send, so it can’t be undone automatically.', + + 'more.title': 'More tools', + 'design.save': 'Save design', + 'design.open': 'Open design', + 'design.saved': 'Design saved.', + 'design.loaded': 'Design opened.', + 'design.invalid': 'This file isn’t a Commit Canvas design.', + 'script.title': 'Offline script', + 'script.help': 'Prefer doing it yourself? Export a script that creates the same commits in a local Git repository; then run git push.', + 'script.email': 'Commit email (must belong to your GitHub account)', + 'script.bash': 'Export Bash script', + 'script.powershell': 'Export PowerShell script', + 'script.saved': 'Script saved. Read it before running it inside your repository folder.', + 'script.badEmail': 'Enter a valid email address.', + + 'error.token': 'GitHub didn’t accept this token. Check that you copied all of it.', 'error.generic': 'Something went wrong: {msg}', - 'error.tooMany': 'Too many commits ({n}). Keep a single paint under {max}.', - 'error.noPush': 'You don’t have push access to this repository.', + 'error.tooMany': 'That’s {n} commits — please keep one send under {max}.', + 'error.noPush': 'You can’t push to this repository.', + 'error.network': 'Couldn’t reach GitHub. Check your internet connection.', }, zh: { - tagline: '在 GitHub 贡献图上画画', - 'connect.title': '连接 GitHub', - 'connect.body': '粘贴一个带 repo 权限的 Personal Access Token,它只会发送给 api.github.com。', + tagline: '在 GitHub 贡献图(绿墙)上画画', + 'step.connect': '登录 GitHub', + 'step.repo': '选择仓库', + 'step.draw': '画画', + 'step.paint': '提交到 GitHub', + + 'connect.device': '用 GitHub 账号登录', + 'connect.gh': '使用本机 GitHub CLI 的登录', + 'connect.or': '或者粘贴 Token', 'connect.placeholder': 'ghp_… 或 github_pat_…', 'connect.button': '连接', 'connect.remember': '在此设备上记住', 'connect.create': '创建 Token', - 'connect.gh': '装了 GitHub CLI?运行 gh auth token 然后粘贴输出即可。', + 'connect.help': '怎么获取 Token?', + 'connect.help1': '点击「创建 Token」,GitHub 会打开并已经帮你勾好所需权限(repo)。', + 'connect.help2': '拉到页面最下面,点绿色的「Generate token」按钮。', + 'connect.help3': '复制以 ghp_ 开头的那串字符,粘贴到上面的输入框。', + 'connect.privacy': 'Token 只会发送给 GitHub(api.github.com)。', + 'device.title': '在 GitHub 页面输入这个验证码', + 'device.body': '浏览器已经打开,验证码也已复制好。粘贴后点击 Authorize 即可。', + 'device.open': '重新打开页面', + 'device.waiting': '等待你在浏览器里授权…', + 'device.expired': '验证码已过期,请重试。', + 'device.denied': '登录已取消。', + 'gh.missing': '没有检测到已登录的 GitHub CLI,请用其他方式登录。', 'account.signout': '退出', - 'repo.label': '仓库', + 'account.total': '这段时间共有 {n} 次贡献', + 'repo.none': '选择一个仓库…', - 'repo.new': '新建', - 'repo.namePlaceholder': 'contribution-art', + 'repo.new': '新建仓库', + 'repo.namePlaceholder': 'commit-canvas-art', 'repo.private': '私有', 'repo.create': '创建', 'repo.cancel': '取消', - 'repo.privateHint': '私有仓库:需要在个人主页开启“Private contributions”才会显示在贡献图上。', - 'year.label': '年份', - 'year.past': '最近一年', - refresh: '刷新贡献图', + 'repo.suggest': '还没有用来画画的仓库?点「新建仓库」,一键就好。', + 'repo.privateHint': '这是私有仓库:需要在 GitHub 个人主页开启「Private contributions」,别人才能看到你的画。', + 'repo.realHint': '建议:用一个专门画画的仓库,避免这些 commit 混进正式项目。', + 'repo.visibilityHint': '公开仓库的贡献会直接显示在绿墙上。', + + 'range.label': '范围', + 'range.past': '最近一年', + refresh: '重新读取我的贡献', + 'mode.edit': '编辑', + 'mode.preview': '预览效果', 'tool.eraser': '橡皮擦', - 'tool.level': '深浅', - 'text.placeholder': '文字', - 'text.add': '添加', + 'tool.commits': '{n}×', + 'text.placeholder': '输入文字…', + 'text.add': '印上文字', + 'template.label': '模板', + 'template.heart': '♥ 爱心', + 'template.hello': 'HELLO', + 'template.wave': '≋ 波浪', + 'template.stars': '✦ 星星', + 'template.replace': '用这个模板替换当前的画吗?', 'shift.left': '整体左移', 'shift.right': '整体右移', undo: '撤销', + redo: '重做', clear: '清空', + zoom: '放大格子', + pan: '滑动模式', + 'grid.label': '画布,每格代表一天', + 'grid.help': '点击或拖动来画,再点一次同一格可以擦掉。键盘:方向键移动,空格画,0–4 选深浅。', + 'legend.existing': '已有的贡献', + 'legend.planned': '将要添加', + 'legend.blocked': '已跳过(这天已有贡献)', + 'legend.preview': '提交后的大致效果(最终深浅由 GitHub 计算)。', + 'avoid.label': '已经有贡献的日子不再添加 commit', less: '少', more: '多', - 'legend.existing': '半透明 = 你已有的贡献', - 'summary.pixels': '{n} 天', - 'summary.commits': '{n} 个 commit', + + 'summary.commits': 'commit 数', + 'summary.days': '天数', + 'summary.range': '日期', + 'summary.skipped': '跳过 {n} 天', 'strength.label': '最深一格的 commit 数', - 'paint.button': '画到 GitHub', - 'paint.needConnect': '先连接 GitHub 并选择仓库才能绘制。', - 'confirm.title': '确认绘制到 GitHub?', - 'confirm.body': '将在 {repo}(分支 {branch})中创建 {commits} 个空 commit,覆盖 {days} 天,并推送上去。新的格子通常几分钟内出现,最长可能需要 24 小时。', - 'confirm.note': '装饰性的 commit 不代表真实工作量——请只在自己的仓库里娱乐使用。', - 'confirm.ok': '开始绘制', + 'strength.help': '已根据你平时的贡献自动推荐,让画更显眼。', + 'paint.button': '提交到 GitHub', + 'paint.needConnect': '请先完成第 1、2 步:登录并选择仓库。', + 'paint.empty': '先画点东西吧。', + + 'confirm.title': '把这幅画提交到 GitHub?', + 'confirm.body': '将在下面的仓库里添加空 commit。新的格子通常几分钟内出现,偶尔需要最多 24 小时。', + 'confirm.account': '账号', + 'confirm.repo': '仓库', + 'confirm.branch': '分支', + 'confirm.commits': 'commit 数', + 'confirm.dates': '日期', + 'confirm.many': '我知道这次会创建很多 commit。', + 'confirm.note': '装饰性的 commit 不代表真实工作量,娱乐就好。', + 'confirm.ok': '提交', 'confirm.cancel': '取消', 'progress.prepare': '正在准备仓库…', 'progress.commits': '正在创建 commit {done}/{total}…', 'progress.push': '正在推送…', - 'progress.wait': '触发 GitHub 频率限制,等待 {s} 秒…', - 'done.body': '已向 {repo} 绘制 {n} 个 commit。', - 'done.view': '查看主页', - 'last.title': '上次绘制:{n} 个 commit → {repo}', - 'last.undo': '撤销上次绘制', - 'undoPaint.confirm': '把 {repo}({branch})重置到上次绘制之前?绘制的 commit 会从分支中移除。', - 'undoPaint.done': '已撤销上次绘制。', - 'undoPaint.moved': '上次绘制之后分支已有新的提交,无法自动撤销。', - 'error.token': 'GitHub 拒绝了这个 Token。', + 'progress.wait': 'GitHub 要求放慢速度,等待 {s} 秒…', + 'done.body': '完成!已向 {repo} 添加 {n} 个 commit。', + 'done.view': '打开我的主页', + 'last.title': '上次提交:{n} 个 commit → {repo}', + 'last.undo': '撤销上次提交', + 'undoPaint.confirm': '把 {repo}({branch})恢复到上次提交之前?那些 commit 会被移除。', + 'undoPaint.done': '已撤销上次提交。', + 'undoPaint.moved': '上次提交之后仓库有了新的变化,无法自动撤销。', + + 'more.title': '更多工具', + 'design.save': '保存设计', + 'design.open': '打开设计', + 'design.saved': '设计已保存。', + 'design.loaded': '设计已打开。', + 'design.invalid': '这不是 Commit Canvas 的设计文件。', + 'script.title': '离线脚本', + 'script.help': '想自己动手?导出一个脚本,在本地 Git 仓库里创建同样的 commit,然后 git push。', + 'script.email': 'commit 邮箱(必须属于你的 GitHub 账号)', + 'script.bash': '导出 Bash 脚本', + 'script.powershell': '导出 PowerShell 脚本', + 'script.saved': '脚本已保存。运行前请先看一遍,并在仓库文件夹里运行。', + 'script.badEmail': '请输入有效的邮箱地址。', + + 'error.token': 'GitHub 不接受这个 Token,请检查是否完整复制。', 'error.generic': '出错了:{msg}', - 'error.tooMany': 'commit 太多({n} 个),单次请控制在 {max} 个以内。', + 'error.tooMany': '一共 {n} 个 commit,单次请控制在 {max} 个以内。', 'error.noPush': '你没有这个仓库的推送权限。', + 'error.network': '连不上 GitHub,请检查网络。', }, }; diff --git a/web/styles.css b/web/styles.css index 664ba5d..1369d9d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -6,7 +6,9 @@ --border: #d0d7de; --accent: #1f883d; --accent-hover: #1a7f37; + --accent-soft: #dafbe1; --danger: #cf222e; + --mark: #ffffff; --l0: #ebedf0; --l1: #9be9a8; --l2: #40c463; @@ -26,7 +28,9 @@ --border: #30363d; --accent: #238636; --accent-hover: #2ea043; + --accent-soft: #12261e; --danger: #f85149; + --mark: #0d1117; --l0: #21262d; --l1: #0e4429; --l2: #006d32; @@ -45,15 +49,16 @@ body { } a { color: var(--accent); } -h1, h2 { margin: 0; } +h1, h2, h3 { margin: 0; } [hidden] { display: none !important; } .top, main, .foot { - max-width: 920px; + max-width: 940px; margin: 0 auto; padding: 0 16px; } +/* ---------- header ---------- */ .top { display: flex; align-items: center; @@ -62,53 +67,71 @@ h1, h2 { margin: 0; } padding-top: 24px; padding-bottom: 16px; } - .brand { display: flex; align-items: center; gap: 12px; } .brand h1 { font-size: 18px; } .brand p { margin: 0; color: var(--muted); font-size: 13px; } - -.logo { - display: grid; - grid-template-columns: repeat(2, 12px); - gap: 3px; -} +.logo { display: grid; grid-template-columns: repeat(2, 12px); gap: 3px; } .logo i { width: 12px; height: 12px; border-radius: 3px; background: var(--l1); } .logo i:nth-child(2) { background: var(--l2); } .logo i:nth-child(3) { background: var(--l3); } .logo i:nth-child(4) { background: var(--l4); } - .top-actions, .account { display: flex; align-items: center; gap: 8px; } .account img { border-radius: 50%; } .account a { color: var(--text); font-weight: 600; text-decoration: none; } +/* ---------- layout ---------- */ main { display: grid; gap: 16px; } main > * { min-width: 0; } .card { background: var(--card); border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; + border-radius: 10px; + padding: 18px; } -.card h2 { font-size: 16px; margin-bottom: 4px; } + +.step h2 { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + margin-bottom: 12px; +} +.num { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--l0); + color: var(--muted); + font-size: 13px; + font-weight: 700; +} +.step.done .num { background: var(--accent); color: #fff; font-size: 0; } +.step.done .num::after { content: "✓"; font-size: 13px; } +.step-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; } +.step-head h2 { margin-bottom: 0; } .muted { color: var(--muted); } .small-text { font-size: 12px; } -.hint { margin: 12px 0 0; color: var(--muted); font-size: 12px; } +.hint { margin: 10px 0 0; color: var(--muted); font-size: 12px; } +.done-line { margin: 0; font-weight: 600; } +.total { margin: 6px 0 0; } .row { display: flex; align-items: center; gap: 8px; margin-top: 12px; } +.row:first-child { margin-top: 0; } .row.wrap { flex-wrap: wrap; } .row.end { justify-content: flex-end; } -#setup .row:first-child { margin-top: 0; align-items: flex-end; } -.row.small-text { justify-content: space-between; margin-top: 8px; } +.row.spread { justify-content: space-between; margin-top: 8px; } +.row.tight { margin: 0; gap: 6px; } .grow { flex: 1 1 220px; } -.field { display: grid; gap: 4px; font-size: 12px; color: var(--muted); } - +/* ---------- controls ---------- */ input, select, button { font: inherit; color: inherit; } input, select { - height: 32px; + height: 34px; padding: 0 10px; background: var(--bg); border: 1px solid var(--border); @@ -116,15 +139,15 @@ input, select { min-width: 0; } #connect-form input { flex: 1; } -input:focus-visible, select:focus-visible, button:focus-visible { +input:focus-visible, select:focus-visible, button:focus-visible, .graph:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); - outline-offset: 1px; + outline-offset: 2px; } -.check { display: inline-flex; align-items: center; gap: 6px; } +.check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; } .check input { height: auto; } button { - height: 32px; + height: 34px; padding: 0 12px; border-radius: 6px; border: 1px solid var(--border); @@ -135,7 +158,7 @@ button { button:hover:not(:disabled) { background: var(--bg); } button:disabled { opacity: .5; cursor: not-allowed; } button.small { height: 28px; padding: 0 10px; font-size: 12px; } -button.icon { width: 32px; padding: 0; } +button.icon { width: 34px; padding: 0; } button.primary { background: var(--accent); border-color: transparent; @@ -143,59 +166,105 @@ button.primary { font-weight: 600; } button.primary:hover:not(:disabled) { background: var(--accent-hover); } -button.big { height: 40px; padding: 0 20px; } +button.big { height: 42px; padding: 0 24px; font-size: 15px; } +button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); } +/* ---------- sign in ---------- */ +.divider { + display: flex; + align-items: center; + gap: 10px; + margin: 14px 0 0; +} +.divider::before, .divider::after { content: ""; flex: 1; border-top: 1px solid var(--border); } +.device { + margin-top: 4px; + padding: 14px; + border: 1px dashed var(--accent); + border-radius: 8px; + text-align: center; +} +.device p { margin: 0; } +.device .row { justify-content: center; } +.code { + margin: 6px 0 !important; + font: 700 28px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; + letter-spacing: 4px; + user-select: all; +} +.help { margin-top: 10px; font-size: 13px; } +.help summary { cursor: pointer; color: var(--accent); width: max-content; } +.help ol { margin: 8px 0; padding-left: 20px; } +.help li { margin: 2px 0; } + +/* ---------- drawing ---------- */ .toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; - gap: 12px; - margin-bottom: 16px; + gap: 10px; + margin-top: 14px; } -.tools { display: flex; gap: 6px; } - +.tools { display: flex; flex-wrap: wrap; gap: 6px; } +.brushes { display: flex; gap: 6px; } +.brush { + display: grid; + justify-items: center; + gap: 2px; + height: auto; + padding: 4px 6px; + min-width: 46px; + border-color: transparent; + background: transparent; +} +.brush small { font-size: 11px; color: var(--muted); } +.brush[aria-checked="true"] { border-color: var(--text); background: var(--bg); } .swatch { - width: 28px; - height: 28px; - padding: 0; - border-radius: 6px; - border: 2px solid transparent; - background-clip: padding-box; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 5px; + font-style: normal; + font-size: 12px; } -.swatch.l1 { background-color: var(--l1); } -.swatch.l2 { background-color: var(--l2); } -.swatch.l3 { background-color: var(--l3); } -.swatch.l4 { background-color: var(--l4); } -.swatch.eraser { background: var(--l0); color: var(--muted); font-size: 13px; } -.swatch[aria-checked="true"] { border-color: var(--text); } -.swatch:hover:not(:disabled) { filter: brightness(1.08); } -.swatch.l1:hover:not(:disabled) { background-color: var(--l1); } -.swatch.l2:hover:not(:disabled) { background-color: var(--l2); } -.swatch.l3:hover:not(:disabled) { background-color: var(--l3); } -.swatch.l4:hover:not(:disabled) { background-color: var(--l4); } -.swatch.eraser:hover:not(:disabled) { background: var(--l0); } +.swatch.l1 { background: var(--l1); } +.swatch.l2 { background: var(--l2); } +.swatch.l3 { background: var(--l3); } +.swatch.l4 { background: var(--l4); } +.swatch.eraser { background: var(--l0); color: var(--muted); } .text-tool { display: flex; gap: 6px; } -.text-tool input { width: 120px; } +.text-tool input { width: 150px; } +.templates { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } -.graph-scroll { overflow-x: auto; padding-bottom: 4px; } +.segmented { display: inline-flex; } +.segmented button { border-radius: 0; height: 30px; font-size: 12px; } +.segmented button:first-child { border-radius: 6px 0 0 6px; } +.segmented button:last-child { border-radius: 0 6px 6px 0; margin-left: -1px; } +.graph-scroll { overflow-x: auto; margin-top: 16px; padding: 4px 2px; } .graph { display: grid; - grid-template-columns: 28px repeat(var(--cols, 53), var(--cell)); + grid-template-columns: 30px repeat(var(--cols, 53), var(--cell)); grid-template-rows: 16px repeat(7, var(--cell)); gap: var(--gap); width: max-content; margin: 0 auto; user-select: none; touch-action: none; + border-radius: 4px; } +.graph.zoomed { --cell: 17px; --gap: 4px; } +.graph.panning { touch-action: pan-x; } +.graph.panning .cell { cursor: grab; } .month, .weekday { font-size: 10px; color: var(--muted); line-height: var(--cell); white-space: nowrap; } -.month { grid-row: 1; line-height: 16px; overflow: visible; } +.month { grid-row: 1; line-height: 16px; } .weekday { grid-column: 1; } .cell { + position: relative; display: block; width: var(--cell); height: var(--cell); @@ -210,73 +279,117 @@ button.big { height: 40px; padding: 0 20px; } .cell.l2 { background: var(--l2); } .cell.l3 { background: var(--l3); } .cell.l4 { background: var(--l4); } -.cell.existing { opacity: .35; } -.graph .cell:hover { outline: 1px solid var(--text); } +/* Planned squares get a dot so they stand apart from existing contributions. */ +.cell.drawn::after { + content: ""; + position: absolute; + inset: 30%; + border-radius: 50%; + background: var(--mark); + opacity: .9; +} +.cell.blocked { opacity: .45; outline: 1px dashed var(--danger); outline-offset: 0; } +.graph .cell:hover { outline: 1px solid var(--text); outline-offset: 0; } +.graph:focus-visible .cell.cursor { outline: 2px solid var(--accent); outline-offset: 0; } .legend { display: flex; flex-wrap: wrap; + align-items: center; justify-content: space-between; - gap: 8px; - margin-top: 12px; + gap: 8px 16px; + margin-top: 8px; } -.scale { display: inline-flex; align-items: center; gap: 3px; color: var(--muted); } -.scale .cell { width: 10px; height: 10px; } -.scale span:first-child { margin-right: 2px; } -.scale span:last-child { margin-left: 2px; } +.keys { display: flex; flex-wrap: wrap; gap: 6px 14px; } +.keys > span { display: inline-flex; align-items: center; gap: 6px; } +.legend .cell { width: 11px; height: 11px; } +.scale { display: inline-flex; align-items: center; gap: 3px; } +.scale span:first-child { margin-right: 3px; } +.scale span:last-child { margin-left: 3px; } +.avoid { margin-top: 12px; } +#grid-help { margin: 8px 0 0; } -.paint { +/* ---------- send ---------- */ +.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } +.stats div { display: grid; gap: 2px; padding: 10px 12px; background: var(--bg); border-radius: 8px; } +.stats strong { font-size: 17px; overflow-wrap: anywhere; } +#sum-skipped { margin: 8px 0 0; } +.paint-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; + margin-top: 14px; } -.summary { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; } -.summary strong { font-size: 16px; } -.strength { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); } -.strength input { width: 64px; height: 28px; } - -.status { margin: 0; min-height: 1.5em; color: var(--muted); } +.strength { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; max-width: 520px; } +.strength input { width: 64px; height: 30px; } +.strength .muted { flex-basis: 100%; } +.status { margin: 12px 0 0; min-height: 1.5em; color: var(--muted); } +.status:empty { margin: 0; min-height: 0; } .status.ok { color: var(--accent); } .status.error { color: var(--danger); } - -.progress { - height: 6px; - border-radius: 3px; - background: var(--l0); - overflow: hidden; -} +.progress { height: 6px; margin-top: 10px; border-radius: 3px; background: var(--l0); overflow: hidden; } .progress div { height: 100%; width: 0; background: var(--l3); transition: width .2s; } - .last { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; } -.foot { padding-top: 32px; padding-bottom: 32px; text-align: center; } +/* ---------- more ---------- */ +.more summary { cursor: pointer; font-weight: 600; } +.more[open] summary { margin-bottom: 14px; } +.more h3 { margin-top: 18px; font-size: 14px; } +.more p { margin: 4px 0 0; } + +.foot { padding-top: 28px; padding-bottom: 32px; text-align: center; } .foot a { color: inherit; } +/* ---------- dialog ---------- */ dialog { - max-width: min(440px, calc(100vw - 32px)); - padding: 20px; + width: min(460px, calc(100vw - 32px)); + padding: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--card); color: var(--text); } -dialog::backdrop { background: rgb(0 0 0 / 40%); } -dialog h2 { font-size: 16px; } -dialog p { margin: 12px 0 0; } +dialog::backdrop { background: rgb(0 0 0 / 45%); } +dialog h2 { font-size: 17px; } +dialog p { margin: 10px 0 0; } +dialog dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 14px; + margin: 14px 0 0; + padding: 12px; + background: var(--bg); + border-radius: 8px; + font-size: 13px; +} +dialog dt { color: var(--muted); } +dialog dd { margin: 0; font-weight: 600; overflow-wrap: anywhere; } +dialog .check { margin-top: 12px; } +dialog .row.end { margin-top: 18px; } + +.touch-only { display: none; } +@media (pointer: coarse) { + .touch-only { display: inline-block; } +} @media (max-width: 600px) { .top { flex-wrap: wrap; } - .text-tool { flex: 1; } + .text-tool { flex: 1 1 100%; } .text-tool input { flex: 1; width: auto; } - .paint button.big { width: 100%; } + .paint-row button.big { width: 100%; } + .stats { grid-template-columns: 1fr 1fr; } + .stats div:last-child { grid-column: 1 / -1; } } From 5b31ff217888b33233fdca1f54b7685e830037df Mon Sep 17 00:00:00 2001 From: Cedric <131578574+ced2711@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:14:52 -0500 Subject: [PATCH 3/7] Add the tauri npm script expected by tauri-action --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index d430d4c..3521c87 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Draw on your GitHub contribution graph by adding commits to a repository you choose.", "license": "AGPL-3.0-only", "scripts": { + "tauri": "tauri", "icons": "tauri icon app-icon.png", "dev": "tauri dev", "build": "tauri build" From 675f2a51bec49dbba25432909485ed0cf1606443 Mon Sep 17 00:00:00 2001 From: Cedric <131578574+ced2711@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:18:27 -0500 Subject: [PATCH 4/7] Recognize drawing repositories and offer one-click creation After sign-in, repositories created by Commit Canvas, the original version's managed commit-canvas-* repositories and art repositories are listed under "Ready for drawing" and picked automatically. Without one, a single button creates a public commit-canvas-art repository. CI also uploads the portable Windows executable for pull request builds. --- .github/workflows/build.yml | 8 ++++ web/index.html | 4 ++ web/src/app.js | 92 +++++++++++++++++++++++++++---------- web/src/github.js | 20 ++++++-- web/src/i18n.js | 14 +++++- web/styles.css | 15 ++++++ 6 files changed, 124 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 607b0b0..d994b50 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -68,3 +68,11 @@ jobs: name: commit-canvas-${{ matrix.platform }} path: src-tauri/target/**/release/bundle/ if-no-files-found: error + + - name: Upload portable Windows exe + if: ${{ !startsWith(github.ref, 'refs/tags/') && matrix.platform == 'windows-latest' }} + uses: actions/upload-artifact@v4 + with: + name: commit-canvas-windows-portable + path: src-tauri/target/release/commit-canvas.exe + if-no-files-found: error diff --git a/web/index.html b/web/index.html index 79e9de5..e1e77f9 100644 --- a/web/index.html +++ b/web/index.html @@ -77,6 +77,10 @@

1Sign in to GitHub

2Choose a repository

+
diff --git a/web/src/app.js b/web/src/app.js index 8b578ca..93034f9 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -53,8 +53,7 @@ for (const el of document.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel)); const past = $('range').querySelector('option[value=""]'); if (past) past.textContent = t('range.past'); - const none = $('repo').querySelector('option[value=""]'); - if (none) none.textContent = t('repo.none'); + renderRepoOptions(); buildGraph(); renderAccount(); renderRepoHint(); @@ -725,6 +724,7 @@ $('repo').replaceChildren(new Option(t('repo.none'), '')); $('range').replaceChildren(new Option(t('range.past'), '')); $('new-repo').hidden = true; + $('repo-quick').hidden = true; state.weeks = offlineWeeks(); renderAccount(); renderRepoHint(); @@ -809,14 +809,50 @@ } // A just-created repository may not be listed yet. if (created && !state.repos.some((r) => r.fullName === created.fullName)) state.repos.unshift(created); - const current = created?.fullName ?? store.get('cc.repo'); - const el = $('repo'); - el.replaceChildren(new Option(t('repo.none'), '')); + await markDrawingRepos(); + renderRepoOptions(); + const stored = store.get('cc.repo'); + const pick = created?.fullName + ?? (state.repos.some((r) => r.fullName === stored) ? stored : state.repos.find((r) => r.drawing)?.fullName) + ?? ''; + state.autoPicked = Boolean(pick && !created && pick !== stored); + $('repo').value = pick; + onRepoChange(); + } + + // Recognizes repositories meant for drawing: ones this app created, the + // original version's managed "commit-canvas-*" repositories, and art repos. + async function markDrawingRepos() { for (const repo of state.repos) { - el.append(new Option(repo.private ? `${repo.fullName} 🔒` : repo.fullName, repo.fullName)); + const name = repo.fullName.split('/')[1]; + repo.drawing = /Commit Canvas/i.test(repo.description) + || /^commit-canvas-./i.test(name) + || /contribution[-_]?art|commit[-_]?art|gitfiti|graffiti/i.test(name); } - el.value = state.repos.some((r) => r.fullName === current) ? current : ''; - onRepoChange(); + // A repository named exactly "commit-canvas" may be the app itself; the + // original version marked its practice repositories with a file. + await Promise.all(state.repos + .filter((r) => !r.drawing && /\/commit-canvas$/i.test(r.fullName)) + .map(async (r) => { r.drawing = await state.api.hasFile(r.fullName, '.commit-canvas-managed'); })); + } + + function renderRepoOptions() { + const el = $('repo'); + const label = (repo) => (repo.private ? `${repo.fullName} 🔒` : repo.fullName); + const group = (key, repos) => { + const g = Object.assign(document.createElement('optgroup'), { label: t(key) }); + g.append(...repos.map((repo) => new Option(label(repo), repo.fullName))); + return g; + }; + const drawing = state.repos.filter((r) => r.drawing); + const other = state.repos.filter((r) => !r.drawing); + const value = el.value; + el.replaceChildren(new Option(t('repo.none'), '')); + if (drawing.length) el.append(group('repo.groupDrawing', drawing)); + if (other.length) el.append(group('repo.groupOther', other)); + el.value = value; + $('repo-quick').hidden = !state.user || drawing.length > 0; + $('repo-quick-create').textContent = t('repo.quickCreate', { name: suggestName() }); } function onRepoChange() { @@ -828,13 +864,13 @@ function renderRepoHint() { const repo = state.repos.find((r) => r.fullName === $('repo').value); - let hint = ''; - if (state.user && !repo) hint = t('repo.suggest'); - else if (repo?.private) hint = t('repo.privateHint'); - else if (repo && !/canvas|art|draw|paint|graffiti/i.test(repo.fullName)) hint = t('repo.realHint'); - $('repo-hint').textContent = hint; - $('repo-hint').hidden = !hint; - $('repo-new').classList.toggle('primary', Boolean(state.user && !repo)); + const hints = []; + if (state.user && !repo && state.repos.some((r) => r.drawing)) hints.push(t('repo.pick')); + if (repo && state.autoPicked) hints.push(t('repo.autoPicked')); + if (repo?.private) hints.push(t('repo.privateHint')); + else if (repo && !repo.drawing) hints.push(t('repo.realHint')); + $('repo-hint').textContent = hints.join(' '); + $('repo-hint').hidden = !hints.length; } function suggestName() { @@ -844,22 +880,30 @@ return name; } - $('repo').addEventListener('change', onRepoChange); + $('repo').addEventListener('change', () => { + state.autoPicked = false; + onRepoChange(); + }); + + async function createRepo(name, isPrivate) { + try { + const repo = await state.api.createRepo(name, isPrivate); + $('new-repo').hidden = true; + await loadRepos({ ...repo, drawing: true }); + } catch (error) { + setStatus(errorText(error), 'error'); + } + } + $('repo-quick-create').addEventListener('click', () => createRepo(suggestName(), false)); $('repo-new').addEventListener('click', () => { $('new-repo').hidden = false; $('new-repo-name').value = suggestName(); $('new-repo-name').select(); }); $('new-repo-cancel').addEventListener('click', () => { $('new-repo').hidden = true; }); - $('new-repo').addEventListener('submit', async (event) => { + $('new-repo').addEventListener('submit', (event) => { event.preventDefault(); - try { - const repo = await state.api.createRepo($('new-repo-name').value.trim(), $('new-repo-private').checked); - $('new-repo').hidden = true; - await loadRepos(repo); - } catch (error) { - setStatus(errorText(error), 'error'); - } + createRepo($('new-repo-name').value.trim(), $('new-repo-private').checked); }); // ---------- contributions ---------- diff --git a/web/src/github.js b/web/src/github.js index 8bd938c..4927970 100644 --- a/web/src/github.js +++ b/web/src/github.js @@ -81,8 +81,22 @@ window.CC_GitHub = (() => { if (batch.length < 100) break; } return list - .filter((repo) => !repo.fork && !repo.archived) - .map((repo) => ({ fullName: repo.full_name, private: repo.private, branch: repo.default_branch })); + .filter((repo) => !repo.fork && !repo.archived && repo.permissions?.push !== false) + .map((repo) => ({ + fullName: repo.full_name, + private: repo.private, + branch: repo.default_branch, + description: repo.description ?? '', + })); + }, + + async hasFile(fullName, path) { + try { + await request(`${repoPath(fullName)}/contents/${path}`); + return true; + } catch { + return false; + } }, async createRepo(name, isPrivate) { @@ -90,7 +104,7 @@ window.CC_GitHub = (() => { method: 'POST', body: { name, private: isPrivate, description: 'Contribution art made with Commit Canvas', auto_init: false }, }); - return { fullName: repo.full_name, private: repo.private, branch: repo.default_branch }; + return { fullName: repo.full_name, private: repo.private, branch: repo.default_branch, description: repo.description ?? '' }; }, // Returns weeks as arrays of 7 slots ({date, count, level} or null). diff --git a/web/src/i18n.js b/web/src/i18n.js index 951ac5d..0ac6c18 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -34,7 +34,12 @@ window.CC_I18N = { 'repo.private': 'Private', 'repo.create': 'Create', 'repo.cancel': 'Cancel', - 'repo.suggest': 'No repository for your art yet? Click “New repository” — one click and it’s ready.', + 'repo.noneFound': 'No repository for drawing found in your account yet.', + 'repo.quickCreate': 'Create “{name}” with one click', + 'repo.groupDrawing': 'Ready for drawing', + 'repo.groupOther': 'Other repositories', + 'repo.pick': 'Pick one of the repositories under “Ready for drawing”.', + 'repo.autoPicked': 'Picked automatically — you can change it.', 'repo.privateHint': 'This repository is private: turn on “Private contributions” on your GitHub profile, or the drawing won’t be visible to others.', 'repo.realHint': 'Tip: use a separate repository just for drawing, so these commits don’t mix with a real project.', 'repo.visibilityHint': 'Public repositories show up on your graph right away.', @@ -159,7 +164,12 @@ window.CC_I18N = { 'repo.private': '私有', 'repo.create': '创建', 'repo.cancel': '取消', - 'repo.suggest': '还没有用来画画的仓库?点「新建仓库」,一键就好。', + 'repo.noneFound': '你的账号里还没有用来画画的仓库。', + 'repo.quickCreate': '一键创建「{name}」', + 'repo.groupDrawing': '适合画画的仓库', + 'repo.groupOther': '其他仓库', + 'repo.pick': '从「适合画画的仓库」里选一个即可。', + 'repo.autoPicked': '已自动选好,可以随时更换。', 'repo.privateHint': '这是私有仓库:需要在 GitHub 个人主页开启「Private contributions」,别人才能看到你的画。', 'repo.realHint': '建议:用一个专门画画的仓库,避免这些 commit 混进正式项目。', 'repo.visibilityHint': '公开仓库的贡献会直接显示在绿墙上。', diff --git a/web/styles.css b/web/styles.css index 1369d9d..6774cdd 100644 --- a/web/styles.css +++ b/web/styles.css @@ -197,6 +197,21 @@ button[aria-pressed="true"] { background: var(--accent-soft); border-color: var( .help ol { margin: 8px 0; padding-left: 20px; } .help li { margin: 2px 0; } +/* ---------- repository ---------- */ +.quick-repo { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; + padding: 12px 14px; + border: 1px dashed var(--accent); + border-radius: 8px; +} +.quick-repo p { margin: 0; } +.quick-repo + .row { margin-top: 0; } + /* ---------- drawing ---------- */ .toolbar { display: flex; From 2e6099cd278c10c06f22226383aa0a6c37acaa31 Mon Sep 17 00:00:00 2001 From: Cedric <131578574+ced2711@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:22:01 -0500 Subject: [PATCH 5/7] Enable Sign in with GitHub with the Commit Canvas OAuth app --- web/src/app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app.js b/web/src/app.js index 93034f9..cec33d2 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -2,7 +2,7 @@ const MAX_COMMITS = 2000; const MANY_COMMITS = 200; // OAuth App client ID for "Sign in with GitHub" (device flow, desktop app only). - const OAUTH_CLIENT_ID = ''; + const OAUTH_CLIENT_ID = 'Ov23liiuYaEx3OwBhJGK'; const $ = (id) => document.getElementById(id); const desktop = window.__TAURI__ ?? null; From ee48dd2d99fede2ac0a5ce002d1f34228a47b6bc Mon Sep 17 00:00:00 2001 From: Cedric <131578574+ced2711@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:24:59 -0500 Subject: [PATCH 6/7] Keep the page from scrolling when the graph is rebuilt --- web/src/app.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/app.js b/web/src/app.js index cec33d2..dd014d4 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -176,8 +176,11 @@ }); } renderCells(); - // Start at the most recent weeks on narrow screens, like GitHub does. + // Start at the most recent weeks on narrow screens, like GitHub does, + // without letting the rebuild move the page itself. + const pageY = window.scrollY; graph.parentElement.scrollLeft = graph.parentElement.scrollWidth; + window.scrollTo(window.scrollX, pageY); } function renderCells() { @@ -1072,4 +1075,5 @@ // The desktop app keeps its own storage, so remember the token by default there. $('remember').checked = saved.token ? saved.remember : Boolean(desktop); if (saved.token) connect(saved.token, saved.remember); + requestAnimationFrame(() => window.scrollTo(0, 0)); })(); From f820c3416835c450a044a7754d3fcc76f4f6c0c2 Mon Sep 17 00:00:00 2001 From: Cedric <131578574+ced2711@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:30:57 -0500 Subject: [PATCH 7/7] Drop the web version; Commit Canvas is desktop-only GitHub Pages is switched off, the Pages workflow is removed, the interface folder is renamed from web/ to ui/, and the README now only describes the desktop app and its Sign in with GitHub flow. --- .github/workflows/pages.yml | 30 ------------------------------ README.md | 21 ++++++++++----------- src-tauri/tauri.conf.json | 2 +- {web => ui}/index.html | 0 {web => ui}/src/app.js | 0 {web => ui}/src/font.js | 0 {web => ui}/src/github.js | 0 {web => ui}/src/i18n.js | 0 {web => ui}/styles.css | 0 9 files changed, 11 insertions(+), 42 deletions(-) delete mode 100644 .github/workflows/pages.yml rename {web => ui}/index.html (100%) rename {web => ui}/src/app.js (100%) rename {web => ui}/src/font.js (100%) rename {web => ui}/src/github.js (100%) rename {web => ui}/src/i18n.js (100%) rename {web => ui}/styles.css (100%) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index 3833ecf..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Deploy web app - -on: - push: - branches: [main] - paths: ['web/**', '.github/workflows/pages.yml'] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - uses: actions/upload-pages-artifact@v3 - with: - path: web - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index 3b43fe7..743a48c 100644 --- a/README.md +++ b/README.md @@ -30,14 +30,14 @@ Get the installer for your system from the [latest release](https://github.com/c The builds are not code‑signed. On Windows, if SmartScreen appears choose **More info → Run anyway**. On macOS, if the app is reported as damaged, run `xattr -cr "/Applications/Commit Canvas.app"` once. -Prefer not to install anything? The same app runs in the browser at . +On Windows you can also run `commit-canvas.exe` directly without installing. ## Use it The app walks you through four numbered steps: -1. **Sign in.** In the desktop app, if you already use the GitHub CLI, click **Use my GitHub CLI login**. Otherwise click **Create a token** — GitHub opens with the right permission (`repo`) ticked — press **Generate token**, and paste the token. -2. **Choose a repository**, or click **New repository** to create one just for drawing. +1. **Sign in.** Click **Sign in with GitHub**: the app shows a short code (already copied) and opens GitHub in your browser — paste it and click **Authorize**. If you use the GitHub CLI, **Use my GitHub CLI login** is even quicker; pasting a personal access token also works. +2. **Choose a repository.** Repositories meant for drawing are recognized and picked automatically; if you have none, one click creates `commit-canvas-art`. 3. **Draw.** 4. **Send to GitHub**, check the summary, and confirm. @@ -53,15 +53,14 @@ For commits to count, GitHub requires the repository to be a non‑fork you own, ### Privacy -The token is sent only to `api.github.com`. In the desktop app it is remembered on your computer by default; on the web page it only lives in the current tab unless you tick **Remember on this device**. **Sign out** removes it. You can revoke the token on GitHub at any time. +Your GitHub login stays on your computer and is only sent to GitHub. **Sign out** removes it, and you can revoke the app's access on GitHub at any time (Settings → Applications). ## Development -The interface is plain HTML/CSS/JavaScript in [`web/`](web); the desktop app wraps it with [Tauri](https://tauri.app) in [`src-tauri/`](src-tauri). +Commit Canvas is a [Tauri](https://tauri.app) app: the interface is plain HTML/CSS/JavaScript in [`ui/`](ui) and the native shell is in [`src-tauri/`](src-tauri). -- Web: serve `web/` with any static server, e.g. `python -m http.server -d web`. -- Desktop: install Node.js and Rust, then `npm install`, `npm run icons`, and `npm run dev` (or `npm run build` for installers). -- Releases: pushing a `v*` tag builds Windows, macOS and Linux installers with GitHub Actions and publishes them as a release. Pushes to `main` redeploy the web version to GitHub Pages. +- Install Node.js and Rust, then `npm install`, `npm run icons`, and `npm run dev` (or `npm run build` for installers). +- Releases: pushing a `v*` tag builds Windows, macOS and Linux installers with GitHub Actions and publishes them as a release. Pull requests build the same installers as downloadable artifacts. ## Please be fair @@ -75,11 +74,11 @@ Decorative commits are not work. Use this for fun, in a repository you own, and **功能**:完整显示你已有的绿点,新加的格子带圆点标记,并可「预览效果」;默认避开已有贡献的日子;四种深浅画笔和橡皮擦(每种显示对应 commit 数);文字和模板(爱心 / HELLO / 波浪 / 星星);撤销 / 重做、整体移动、清空、放大格子、键盘绘图、触屏滑动模式;最近一年或任意年份;在软件里选择或一键新建专用仓库;提交前核对账号、仓库、分支、数量和日期;可撤销上次提交;保存 / 打开设计文件(兼容旧版),导出 Bash / PowerShell 脚本;自动保存草稿;中英文界面。 -**下载**:到 [Releases](https://github.com/ced2711/commit-canvas/releases/latest) 下载对应系统的安装包——Windows 选 `…_x64-setup.exe`,macOS 选 `…_universal.dmg`,Linux 选 `.AppImage` 或 `.deb`。安装包没有代码签名:Windows 弹出 SmartScreen 时点「更多信息 → 仍要运行」;macOS 提示“已损坏”时运行一次 `xattr -cr "/Applications/Commit Canvas.app"`。不想安装也可以直接用网页版:。 +**下载**:到 [Releases](https://github.com/ced2711/commit-canvas/releases/latest) 下载对应系统的安装包——Windows 选 `…_x64-setup.exe`,macOS 选 `…_universal.dmg`,Linux 选 `.AppImage` 或 `.deb`。安装包没有代码签名:Windows 弹出 SmartScreen 时点「更多信息 → 仍要运行」;macOS 提示“已损坏”时运行一次 `xattr -cr "/Applications/Commit Canvas.app"`。Windows 也可以直接双击免安装的 `commit-canvas.exe`。 -**使用**:软件按 1–4 步引导:① 登录——桌面版装了 GitHub CLI 可一键沿用登录,否则点「创建 Token」(权限已自动勾好),点 Generate token 后粘贴回来;② 选择仓库或一键新建;③ 画画;④ 提交到 GitHub,核对后确认。通常几分钟内就能在主页看到,最长可能需要 24 小时。 +**使用**:软件按 1–4 步引导:① 登录——点「用 GitHub 账号登录」,软件显示验证码(已自动复制)并打开 GitHub,粘贴后点 Authorize 即可;装了 GitHub CLI 也可以一键沿用登录,或粘贴 Token;② 自动识别并选好画画用的仓库,没有的话一键创建 `commit-canvas-art`;③ 画画;④ 提交到 GitHub,核对后确认。通常几分钟内就能在主页看到,最长可能需要 24 小时。 -**说明**:每个格子会生成若干个中午时间的空 commit,作者是你的 GitHub `noreply` 邮箱,提交到默认分支并一次性快进推送;只有在你主动“撤销上次绘制”时才会强制重置分支。Token 只会发送给 `api.github.com`;桌面版默认记住在本机,网页版默认只保存在当前标签页。私有仓库需要在主页开启 “Private contributions” 才会显示。 +**说明**:每个格子会生成若干个中午时间的空 commit,作者是你的 GitHub `noreply` 邮箱,提交到默认分支并一次性快进推送;只有在你主动“撤销上次绘制”时才会强制重置分支。登录信息只保存在你的电脑上、只发送给 GitHub,点「退出」即可清除。私有仓库需要在主页开启 “Private contributions” 才会显示。 装饰性的 commit 不代表真实工作量,请只在自己的仓库里娱乐使用。 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9cf21d7..9ed4bb9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,7 +4,7 @@ "version": "1.0.0", "identifier": "io.github.ced2711.commitcanvas", "build": { - "frontendDist": "../web" + "frontendDist": "../ui" }, "app": { "withGlobalTauri": true, diff --git a/web/index.html b/ui/index.html similarity index 100% rename from web/index.html rename to ui/index.html diff --git a/web/src/app.js b/ui/src/app.js similarity index 100% rename from web/src/app.js rename to ui/src/app.js diff --git a/web/src/font.js b/ui/src/font.js similarity index 100% rename from web/src/font.js rename to ui/src/font.js diff --git a/web/src/github.js b/ui/src/github.js similarity index 100% rename from web/src/github.js rename to ui/src/github.js diff --git a/web/src/i18n.js b/ui/src/i18n.js similarity index 100% rename from web/src/i18n.js rename to ui/src/i18n.js diff --git a/web/styles.css b/ui/styles.css similarity index 100% rename from web/styles.css rename to ui/styles.css