diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d994b50 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,78 @@ +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 + + - 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/.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..743a48c 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,40 @@ 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 in the browser 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 + +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. + +On Windows you can also run `commit-canvas.exe` directly without installing. ## Use it -It's a static web page — nothing to install. +The app walks you through four numbered steps: -1. Open the app (GitHub Pages, or any static server, e.g. `python -m http.server` in this folder and visit ). -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**. +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. New squares usually show up within minutes; GitHub says it can take up to 24 hours. @@ -35,7 +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`. 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. +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 + +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). + +- 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 @@ -47,11 +72,13 @@ Decorative commits are not work. Use this for fun, in a repository you own, and 在 GitHub 贡献图(绿墙)上画画:连接账号、选一个仓库、涂格子,Commit Canvas 会往这个仓库添加对应日期的 commit,画就会出现在你的主页上。 -**功能**:四种深浅的画笔和橡皮擦(右键也能擦);输入文字自动居中印到墙上;整体左右移动、撤销(Ctrl+Z)、清空;底下半透明显示你已有的真实贡献;可选最近一年或任意年份;在应用里直接选择或新建仓库;可以撤销上一次绘制;草稿自动保存。 +**功能**:完整显示你已有的绿点,新加的格子带圆点标记,并可「预览效果」;默认避开已有贡献的日子;四种深浅画笔和橡皮擦(每种显示对应 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"`。Windows 也可以直接双击免安装的 `commit-canvas.exe`。 -**使用**:这是一个静态网页,无需安装。打开页面后粘贴一个 [带 `repo` 权限的 Token](https://github.com/settings/tokens/new?scopes=repo&description=Commit%20Canvas)(装了 GitHub CLI 的话运行 `gh auth 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/app-icon.png b/app-icon.png new file mode 100644 index 0000000..bad4bb1 Binary files /dev/null and b/app-icon.png differ diff --git a/index.html b/index.html deleted file mode 100644 index dd584af..0000000 --- a/index.html +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - Commit Canvas - - - - - - - - -
-
- -
-

Commit Canvas

-

Draw on your GitHub contribution graph

-
-
-
- - -
-
- -
-
-

Connect GitHub

-

Paste a personal access token with the repo scope.

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

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

-
- - - -
-
-
- - - - - -
-
- - -
-
- - - - -
-
- -
-
-
- -
- Faded = contributions you already have - - Less - - More - -
-
- -
-
- 0 commits - 0 days - -
- -
- -

- - - -
- - - - -
-

Paint to GitHub?

-

-

-
- - -
-
-
- - diff --git a/package.json b/package.json new file mode 100644 index 0000000..3521c87 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "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": { + "tauri": "tauri", + "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..65d62f5 --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,24 @@ +[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" +tauri-plugin-dialog = "2" +ureq = "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..7f98968 --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Window access, links in the system browser, and save dialogs", + "windows": ["main"], + "permissions": ["core:default", "opener:default", "dialog:default"] +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..320efcd --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +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/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9ed4bb9 --- /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": "../ui" + }, + "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/src/app.js b/src/app.js deleted file mode 100644 index 9f67234..0000000 --- a/src/app.js +++ /dev/null @@ -1,539 +0,0 @@ -(() => { - const MAX_COMMITS = 2000; - const $ = (id) => document.getElementById(id); - const store = { - get(key, fallback = null) { - try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } - }, - set(key, value) { - try { - if (value === null) localStorage.removeItem(key); - else localStorage.setItem(key, JSON.stringify(value)); - } catch { /* storage unavailable */ } - }, - }; - - const state = { - lang: store.get('cc.lang') ?? (navigator.language?.startsWith('zh') ? 'zh' : 'en'), - api: null, - user: null, - repos: [], - weeks: [], - drawing: store.get('cc.drawing', {}), // date -> level (1-4) - history: [], - level: 4, - strength: store.get('cc.strength', null), - busy: false, - }; - - // ---------- i18n ---------- - function t(key, vars = {}) { - const text = window.CC_I18N[state.lang][key] ?? window.CC_I18N.en[key] ?? key; - return text.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? ''); - } - - function applyLanguage() { - document.documentElement.lang = state.lang === 'zh' ? 'zh-CN' : 'en'; - $('lang').textContent = state.lang === 'zh' ? 'English' : '中文'; - for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); - 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'); - buildGraph(); - renderLast(); - } - - // ---------- status ---------- - function setStatus(text = '', kind = '') { - const el = $('status'); - el.className = `status ${kind}`; - el.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; - $('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'); - } - - // ---------- 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()); - - // Empty past-year grid used before connecting. - function offlineWeeks() { - const now = new Date(); - const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay() - 52 * 7); - const end = today(); - return Array.from({ length: 53 }, (_, w) => Array.from({ length: 7 }, (_, d) => { - const date = isoLocal(new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + d)); - return date > end ? null : { date, count: 0, level: 0 }; - })); - } - - const paintable = (cell) => cell && cell.date <= today(); - - function visibleDrawn() { - 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]]); - } - } - return out; - } - - const countFor = (level) => Math.max(1, Math.round((state.strength * level) / 4)); - - function autoStrength() { - const counts = state.weeks.flat().filter((c) => c?.count > 0).map((c) => c.count).sort((a, b) => a - b); - const p90 = counts.length ? counts[Math.floor((counts.length - 1) * 0.9)] : 0; - return Math.min(30, Math.max(4, p90)); - } - - 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' }); - - graph.append(Object.assign(document.createElement('span'), { className: 'corner' })); - const months = state.weeks.map((week) => week.find(Boolean)?.date.slice(0, 7)); - months.forEach((month, col) => { - const label = document.createElement('span'); - label.className = 'month'; - label.style.gridColumn = col + 2; - if (month && month !== months[col - 1]) { - // Skip a leading partial month whose label would overlap the next one. - const next = months.findIndex((m, i) => i > col && m !== month); - if (!(col === 0 && next !== -1 && next < 3)) { - const [y, m] = month.split('-').map(Number); - label.textContent = monthFmt.format(new Date(y, m - 1, 1)); - } - } - graph.append(label); - }); - - for (let row = 0; row < 7; row += 1) { - const day = document.createElement('span'); - day.className = 'weekday'; - day.style.gridRow = row + 2; - 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. - 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]; - 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'; - el.className = cls; - } - 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 }); - $('paint').disabled = state.busy || commits === 0; - } - - // ---------- drawing ---------- - function snapshot() { - state.history.push(JSON.stringify(state.drawing)); - if (state.history.length > 100) state.history.shift(); - } - - function commit() { - store.set('cc.drawing', state.drawing); - renderCells(); - } - - 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) { - 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; - } - - let stroke = null; - $('graph').addEventListener('pointerdown', (event) => { - const cell = cellAt(event.clientX, event.clientY); - if (!paintable(cell) || state.busy) return; - event.preventDefault(); - $('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); - renderCells(); - }); - $('graph').addEventListener('pointermove', (event) => { - if (!stroke) return; - const cell = cellAt(event.clientX, event.clientY); - if (cell) { - setCell(cell, stroke.level); - renderCells(); - } - }); - const endStroke = () => { - if (!stroke) return; - stroke = null; - commit(); - }; - $('graph').addEventListener('pointerup', endStroke); - $('graph').addEventListener('pointercancel', endStroke); - $('graph').addEventListener('contextmenu', (event) => event.preventDefault()); - - 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 button of document.querySelectorAll('.swatch')) { - button.addEventListener('click', () => selectLevel(Number(button.dataset.level))); - } - - $('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(); - }); - - function shift(delta) { - snapshot(); - const moved = []; - state.weeks.forEach((week, col) => week.forEach((cell, row) => { - if (paintable(cell) && state.drawing[cell.date]) { - moved.push([col + delta, row, state.drawing[cell.date]]); - delete state.drawing[cell.date]; - } - })); - for (const [col, row, level] of moved) setCell(state.weeks[col]?.[row], level); - commit(); - } - $('shift-left').addEventListener('click', () => shift(-1)); - $('shift-right').addEventListener('click', () => shift(1)); - - function undo() { - if (!state.history.length) return; - state.drawing = JSON.parse(state.history.pop()); - commit(); - } - $('undo').addEventListener('click', undo); - - $('clear').addEventListener('click', () => { - snapshot(); - for (const week of state.weeks) for (const cell of week) if (cell) delete state.drawing[cell.date]; - commit(); - }); - - document.addEventListener('keydown', (event) => { - if (event.target.closest('input, select, textarea')) return; - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { - event.preventDefault(); - undo(); - } else if (/^[0-4]$/.test(event.key)) { - selectLevel(Number(event.key)); - } - }); - - $('strength').addEventListener('input', () => { - const value = Math.round(Number($('strength').value)); - if (value >= 1 && value <= 50) { - state.strength = value; - store.set('cc.strength', value); - renderSummary(); - } - }); - - // ---------- GitHub ---------- - function savedToken() { - try { - const remembered = localStorage.getItem('cc.token'); - return remembered ? { token: remembered, remember: true } : { token: sessionStorage.getItem('cc.token'), remember: false }; - } catch { - return { token: null, remember: false }; - } - } - - async function connect(token, remember) { - 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); - return; - } - try { - if (remember) 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(); - await Promise.all([loadRepos(), loadCalendar()]); - } - - function forgetToken() { - try { - localStorage.removeItem('cc.token'); - sessionStorage.removeItem('cc.token'); - } catch { /* storage unavailable */ } - } - - function signOut() { - forgetToken(); - state.api = null; - state.user = null; - $('connect').hidden = false; - $('setup').hidden = true; - $('account').hidden = true; - $('last').hidden = true; - state.weeks = offlineWeeks(); - buildGraph(); - setStatus(); - } - - async function loadRepos(created) { - try { - state.repos = await state.api.repos(); - } catch (error) { - showError(error); - return; - } - // 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'), '')); - for (const repo of state.repos) { - el.append(new Option(repo.private ? `${repo.fullName} 🔒` : repo.fullName, repo.fullName)); - } - el.value = state.repos.some((r) => r.fullName === current) ? current : ''; - onRepoChange(); - } - - function onRepoChange() { - const repo = state.repos.find((r) => r.fullName === $('repo').value); - store.set('cc.repo', repo?.fullName ?? null); - $('private-hint').hidden = !repo?.private; - renderLast(); - } - - 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(); - } - - $('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); - - $('repo-new').addEventListener('click', () => { - $('new-repo').hidden = false; - $('new-repo-name').focus(); - }); - $('new-repo-cancel').addEventListener('click', () => { $('new-repo').hidden = true; }); - $('new-repo').addEventListener('submit', async (event) => { - event.preventDefault(); - 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); - } - }); - - // ---------- paint ---------- - function setBusy(busy) { - state.busy = busy; - for (const el of document.querySelectorAll('button, input, select')) { - if (!el.closest('.top')) el.disabled = busy; - } - renderSummary(); - } - - function progress(done, total) { - $('progress').hidden = total === 0; - $('progress').firstElementChild.style.width = total ? `${(100 * done) / total}%` : '0'; - } - - $('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)); - if (dates.length > MAX_COMMITS) { - setStatus(t('error.tooMany', { n: dates.length, max: MAX_COMMITS }), 'error'); - 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; - - setBusy(true); - setStatus(t('progress.prepare')); - progress(0, dates.length); - try { - const result = await state.api.paint(repo, dates, state.user, (done, total, phase) => { - 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]; - store.set('cc.drawing', state.drawing); - store.set('cc.last', { ...result, drawing: painted }); - setStatusLink(t('done.body', { n: result.count, repo }), state.user.url, t('done.view')); - } catch (error) { - showError(error); - } finally { - progress(0, 0); - setBusy(false); - renderLast(); - } - await loadCalendar(); - }); - - function renderLast() { - const last = store.get('cc.last'); - const show = Boolean(state.api && last); - $('last').hidden = !show; - if (show) $('last-text').textContent = t('last.title', { n: last.count, repo: last.fullName }); - } - - $('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; - setBusy(true); - try { - await state.api.undoPaint(last); - store.set('cc.last', null); - snapshot(); - 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); - } finally { - setBusy(false); - renderLast(); - } - await loadCalendar(); - }); - - $('lang').addEventListener('click', () => { - state.lang = state.lang === 'zh' ? 'en' : 'zh'; - store.set('cc.lang', state.lang); - applyLanguage(); - }); - - // ---------- start ---------- - state.weeks = offlineWeeks(); - state.strength ??= 4; - $('strength').value = state.strength; - selectLevel(4); - applyLanguage(); - const saved = savedToken(); - if (saved.token) { - $('remember').checked = saved.remember; - connect(saved.token, saved.remember); - } -})(); diff --git a/src/i18n.js b/src/i18n.js deleted file mode 100644 index 070414b..0000000 --- a/src/i18n.js +++ /dev/null @@ -1,118 +0,0 @@ -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.', - '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.', - 'account.signout': 'Sign out', - 'repo.label': 'Repository', - 'repo.none': 'Select a repository…', - 'repo.new': 'New', - 'repo.namePlaceholder': 'contribution-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', - 'tool.eraser': 'Eraser', - 'tool.level': 'Shade', - 'text.placeholder': 'Text', - 'text.add': 'Add', - 'shift.left': 'Shift left', - 'shift.right': 'Shift right', - undo: 'Undo', - clear: 'Clear', - 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', - '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.', - '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.', - }, - zh: { - tagline: '在 GitHub 贡献图上画画', - 'connect.title': '连接 GitHub', - 'connect.body': '粘贴一个带 repo 权限的 Personal Access Token,它只会发送给 api.github.com。', - 'connect.placeholder': 'ghp_… 或 github_pat_…', - 'connect.button': '连接', - 'connect.remember': '在此设备上记住', - 'connect.create': '创建 Token', - 'connect.gh': '装了 GitHub CLI?运行 gh auth token 然后粘贴输出即可。', - 'account.signout': '退出', - 'repo.label': '仓库', - 'repo.none': '选择一个仓库…', - 'repo.new': '新建', - 'repo.namePlaceholder': 'contribution-art', - 'repo.private': '私有', - 'repo.create': '创建', - 'repo.cancel': '取消', - 'repo.privateHint': '私有仓库:需要在个人主页开启“Private contributions”才会显示在贡献图上。', - 'year.label': '年份', - 'year.past': '最近一年', - refresh: '刷新贡献图', - 'tool.eraser': '橡皮擦', - 'tool.level': '深浅', - 'text.placeholder': '文字', - 'text.add': '添加', - 'shift.left': '整体左移', - 'shift.right': '整体右移', - undo: '撤销', - clear: '清空', - less: '少', - more: '多', - 'legend.existing': '半透明 = 你已有的贡献', - 'summary.pixels': '{n} 天', - 'summary.commits': '{n} 个 commit', - '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': '开始绘制', - '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。', - 'error.generic': '出错了:{msg}', - 'error.tooMany': 'commit 太多({n} 个),单次请控制在 {max} 个以内。', - 'error.noPush': '你没有这个仓库的推送权限。', - }, -}; diff --git a/styles.css b/styles.css deleted file mode 100644 index 664ba5d..0000000 --- a/styles.css +++ /dev/null @@ -1,282 +0,0 @@ -:root { - --bg: #f6f8fa; - --card: #ffffff; - --text: #1f2328; - --muted: #656d76; - --border: #d0d7de; - --accent: #1f883d; - --accent-hover: #1a7f37; - --danger: #cf222e; - --l0: #ebedf0; - --l1: #9be9a8; - --l2: #40c463; - --l3: #30a14e; - --l4: #216e39; - --cell: 12px; - --gap: 3px; - color-scheme: light dark; -} - -@media (prefers-color-scheme: dark) { - :root { - --bg: #0d1117; - --card: #161b22; - --text: #e6edf3; - --muted: #8d96a0; - --border: #30363d; - --accent: #238636; - --accent-hover: #2ea043; - --danger: #f85149; - --l0: #21262d; - --l1: #0e4429; - --l2: #006d32; - --l3: #26a641; - --l4: #39d353; - } -} - -* { box-sizing: border-box; } - -body { - margin: 0; - background: var(--bg); - color: var(--text); - font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif; -} - -a { color: var(--accent); } -h1, h2 { margin: 0; } -[hidden] { display: none !important; } - -.top, main, .foot { - max-width: 920px; - margin: 0 auto; - padding: 0 16px; -} - -.top { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - 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 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; } - -main { display: grid; gap: 16px; } -main > * { min-width: 0; } - -.card { - background: var(--card); - border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; -} -.card h2 { font-size: 16px; margin-bottom: 4px; } - -.muted { color: var(--muted); } -.small-text { font-size: 12px; } -.hint { margin: 12px 0 0; color: var(--muted); font-size: 12px; } - -.row { display: flex; align-items: center; gap: 8px; margin-top: 12px; } -.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; } -.grow { flex: 1 1 220px; } - -.field { display: grid; gap: 4px; font-size: 12px; color: var(--muted); } - -input, select, button { font: inherit; color: inherit; } - -input, select { - height: 32px; - padding: 0 10px; - background: var(--bg); - border: 1px solid var(--border); - border-radius: 6px; - min-width: 0; -} -#connect-form input { flex: 1; } -input:focus-visible, select:focus-visible, button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} -.check { display: inline-flex; align-items: center; gap: 6px; } -.check input { height: auto; } - -button { - height: 32px; - padding: 0 12px; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--card); - cursor: pointer; - white-space: nowrap; -} -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.primary { - background: var(--accent); - border-color: transparent; - color: #fff; - font-weight: 600; -} -button.primary:hover:not(:disabled) { background: var(--accent-hover); } -button.big { height: 40px; padding: 0 20px; } - -.toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; -} -.tools { display: flex; gap: 6px; } - -.swatch { - width: 28px; - height: 28px; - padding: 0; - border-radius: 6px; - border: 2px solid transparent; - background-clip: padding-box; -} -.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); } - -.text-tool { display: flex; gap: 6px; } -.text-tool input { width: 120px; } - -.graph-scroll { overflow-x: auto; padding-bottom: 4px; } - -.graph { - display: grid; - grid-template-columns: 28px 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; -} -.month, .weekday { font-size: 10px; color: var(--muted); line-height: var(--cell); white-space: nowrap; } -.month { grid-row: 1; line-height: 16px; overflow: visible; } -.weekday { grid-column: 1; } - -.cell { - display: block; - width: var(--cell); - height: var(--cell); - border-radius: 2px; - background: var(--l0); - outline: 1px solid rgb(27 31 35 / 6%); - outline-offset: -1px; -} -.graph .cell { cursor: crosshair; } -.cell.off { visibility: hidden; } -.cell.l1 { background: var(--l1); } -.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); } - -.legend { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 8px; - margin-top: 12px; -} -.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; } - -.paint { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px; -} -.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); } -.status.ok { color: var(--accent); } -.status.error { color: var(--danger); } - -.progress { - height: 6px; - 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; - color: var(--muted); - font-size: 13px; -} - -.foot { padding-top: 32px; padding-bottom: 32px; text-align: center; } -.foot a { color: inherit; } - -dialog { - max-width: min(440px, calc(100vw - 32px)); - padding: 20px; - 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; } - -@media (max-width: 600px) { - .top { flex-wrap: wrap; } - .text-tool { flex: 1; } - .text-tool input { flex: 1; width: auto; } - .paint button.big { width: 100%; } -} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..e1e77f9 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,226 @@ + + + + + + + + + + Commit Canvas + + + + + + + + +
+
+ +
+

Commit Canvas

+

Draw on your GitHub contribution graph

+
+
+
+ + +
+
+ +
+ +
+

1Sign in to GitHub

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

+
+
+ +
+ + +
+

2Choose a repository

+ +
+ + +
+ +

+
+ + +
+
+

3Draw

+
+ + +
+ + +
+
+
+

+ +
+
+ + + + + +
+
+ + + + + + + +
+
+ +
+
+ + +
+
+ Templates + + + + +
+
+ +
+
+
+

+ +
+ + Already on GitHub + Will be added + Skipped + + + + Less + + More + +
+ +
+ + +
+

4Send to GitHub

+
+
Commits0
+
Days0
+
Dates—
+
+ +
+ + +
+

+ + +
+ +
+ More tools +
+ + + +
+

Offline script

+

+
+ + + +
+
+
+ + + + +
+

+

+
+ +

+
+ + +
+
+
+ + diff --git a/ui/src/app.js b/ui/src/app.js new file mode 100644 index 0000000..dd014d4 --- /dev/null +++ b/ui/src/app.js @@ -0,0 +1,1079 @@ +(() => { + 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 = 'Ov23liiuYaEx3OwBhJGK'; + + 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; } + }, + set(key, value) { + try { + if (value === null) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(value)); + } catch { /* storage unavailable */ } + }, + }; + + const state = { + lang: store.get('cc.lang') ?? (navigator.language?.startsWith('zh') ? 'zh' : 'en'), + api: null, + user: null, + repos: [], + weeks: [], + total: null, + drawing: store.get('cc.drawing', {}), // date -> level (1-4) + undo: [], + redo: [], + level: 4, + 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 ---------- + function t(key, vars = {}) { + const text = window.CC_I18N[state.lang][key] ?? window.CC_I18N.en[key] ?? key; + return text.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? ''); + } + + function applyLanguage() { + document.documentElement.lang = state.lang === 'zh' ? 'zh-CN' : 'en'; + $('lang').textContent = state.lang === 'zh' ? 'English' : '中文'; + for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); + 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 past = $('range').querySelector('option[value=""]'); + if (past) past.textContent = t('range.past'); + renderRepoOptions(); + buildGraph(); + renderAccount(); + renderRepoHint(); + renderLast(); + } + + // ---------- status ---------- + function setStatus(text = '', kind = '') { + $('status').className = `status ${kind}`; + $('status').textContent = text; + } + + function setStatusLink(text, href, label) { + setStatus(`${text} `, 'ok'); + const link = Object.assign(document.createElement('a'), { href, target: '_blank', rel: 'noopener', textContent: label }); + $('status').append(link); + } + + 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 signing in. + function offlineWeeks() { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - now.getDay() - 52 * 7); + const end = today(); + return Array.from({ length: 53 }, (_, w) => Array.from({ length: 7 }, (_, d) => { + const date = isoLocal(new Date(start.getFullYear(), start.getMonth(), start.getDate() + w * 7 + d)); + return date > end ? null : { date, count: 0, level: 0 }; + })); + } + + 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)); + + // Planned cells in the current view: {date, level, blocked}. + function planned() { + const out = []; + for (const week of state.weeks) { + for (const cell of week) { + 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.sort((a, b) => a.date.localeCompare(b.date)); + } + + 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); + const p90 = counts.length ? counts[Math.floor((counts.length - 1) * 0.9)] : 0; + 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 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)); + months.forEach((month, col) => { + const label = document.createElement('span'); + label.className = 'month'; + label.style.gridColumn = col + 2; + if (month && month !== months[col - 1]) { + // Skip a leading partial month whose label would overlap the next one. + const next = months.findIndex((m, i) => i > col && m !== month); + if (!(col === 0 && next !== -1 && next < 3)) { + const [y, m] = month.split('-').map(Number); + label.textContent = monthFmt.format(new Date(y, m - 1, 1)); + } + } + graph.append(label); + }); + + for (let row = 0; row < 7; row += 1) { + const day = document.createElement('span'); + day.className = 'weekday'; + day.style.gridRow = row + 2; + if (row % 2 === 1) day.textContent = dayFmt.format(new Date(2024, 0, 7 + row)); + graph.append(day); + state.weeks.forEach((week, col) => { + const el = document.createElement('i'); + el.style.gridColumn = col + 2; + el.style.gridRow = row + 2; + el.dataset.col = col; + el.dataset.row = row; + graph.append(el); + }); + } + renderCells(); + // 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() { + 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'; + 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 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(); + } + + 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 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]; + } + + // 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?.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) => { + 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); + 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 hit = cellFromPoint(event.clientX, event.clientY); + if (hit?.cell) { + setCell(hit.cell, stroke.level); + renderCells(); + } + }); + const endStroke = () => { + if (!stroke) return; + stroke = null; + 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 brush of document.querySelectorAll('.brush')) { + brush.setAttribute('aria-checked', String(Number(brush.dataset.level) === 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; + 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) { + leavePreview(); + remember(); + const moved = []; + state.weeks.forEach((week, col) => week.forEach((cell, row) => { + if (paintable(cell) && state.drawing[cell.date]) { + moved.push([col + delta, row, state.drawing[cell.date]]); + delete state.drawing[cell.date]; + } + })); + 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(); + }); + + $('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(); + } + $('mode-edit').addEventListener('click', () => { state.preview = false; renderMode(); }); + $('mode-preview').addEventListener('click', () => { state.preview = true; renderMode(); }); + + $('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, 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(); + redo(); + } else if (/^[0-4]$/.test(event.key)) { + selectLevel(Number(event.key)); + } + }); + + $('strength').addEventListener('input', () => { + const value = Math.round(Number($('strength').value)); + if (value >= 1 && value <= 50) { + state.strength = value; + store.set('cc.strength', value); + 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'); + } + }); + + $('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'); + return remembered ? { token: remembered, remember: true } : { token: sessionStorage.getItem('cc.token'), remember: false }; + } catch { + return { token: null, remember: false }; + } + } + + 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 })), + }); + try { + state.user = await state.api.user(); + } catch (error) { + state.api = null; + if (error.status === 401) forgetToken(); + setConnectError(errorText(error)); + return; + } + try { + if (rememberToken) localStorage.setItem('cc.token', token); + else sessionStorage.setItem('cc.token', token); + } catch { /* storage unavailable */ } + $('token').value = ''; + setConnectError(''); + if (!$('script-email').value) $('script-email').value = state.user.email; + renderAccount(); + await Promise.all([loadRepos(), loadCalendar()]); + } + + 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(); + 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; + $('repo-quick').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) { + setStatus(errorText(error), 'error'); + return; + } + // A just-created repository may not be listed yet. + if (created && !state.repos.some((r) => r.fullName === created.fullName)) state.repos.unshift(created); + 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) { + 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); + } + // 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() { + store.set('cc.repo', $('repo').value || null); + renderRepoHint(); + renderLast(); + renderSteps(); + } + + function renderRepoHint() { + const repo = state.repos.find((r) => r.fullName === $('repo').value); + 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() { + 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', () => { + 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', (event) => { + event.preventDefault(); + createRepo($('new-repo-name').value.trim(), $('new-repo-private').checked); + }); + + // ---------- 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('main button, main input, main select')) { + if (!el.closest('#step-connect')) el.disabled = busy; + } + if (!busy) renderAccount(); + renderSummary(); + } + + function progress(done, total) { + $('progress').hidden = total === 0; + $('progress').firstElementChild.style.width = total ? `${(100 * done) / total}%` : '0'; + } + + $('paint').addEventListener('click', async () => { + const repo = $('repo').value; + if (!state.api || !repo) { + setStatus(t('paint.needConnect'), 'error'); + return; + } + 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'), 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); + setStatus(t('progress.prepare')); + progress(0, dates.length); + try { + const result = await state.api.paint(repo, dates, state.user, (done, total, phase) => { + progress(done, total); + setStatus(phase === 'push' ? t('progress.push') : t('progress.commits', { done, total })); + }); + 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: sent }); + setStatusLink(t('done.body', { n: result.count, repo }), state.user.url, t('done.view')); + } catch (error) { + setStatus(errorText(error), 'error'); + } finally { + progress(0, 0); + setBusy(false); + renderLast(); + } + await loadCalendar(); + }); + + function renderLast() { + const last = store.get('cc.last'); + const show = Boolean(state.api && last); + $('last').hidden = !show; + if (show) $('last-text').textContent = t('last.title', { n: last.count, repo: last.fullName }); + } + + $('last-undo').addEventListener('click', async () => { + const last = store.get('cc.last'); + 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); + store.set('cc.last', null); + remember(); + Object.assign(state.drawing, last.drawing ?? {}); + store.set('cc.drawing', state.drawing); + setStatus(t('undoPaint.done'), 'ok'); + } catch (error) { + setStatus(error.message === 'MOVED' ? t('undoPaint.moved') : errorText(error), 'error'); + } finally { + setBusy(false); + renderLast(); + } + await loadCalendar(); + }); + + $('lang').addEventListener('click', () => { + state.lang = state.lang === 'zh' ? 'en' : 'zh'; + store.set('cc.lang', state.lang); + applyLanguage(); + }); + + // ---------- desktop app ---------- + 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(); + 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.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); + if (saved.token) connect(saved.token, saved.remember); + requestAnimationFrame(() => window.scrollTo(0, 0)); +})(); diff --git a/src/font.js b/ui/src/font.js similarity index 100% rename from src/font.js rename to ui/src/font.js diff --git a/src/github.js b/ui/src/github.js similarity index 84% rename from src/github.js rename to ui/src/github.js index 24a9b8b..4927970 100644 --- a/src/github.js +++ b/ui/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; @@ -76,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) { @@ -85,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). @@ -94,7 +113,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 +125,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/ui/src/i18n.js b/ui/src/i18n.js new file mode 100644 index 0000000..0ac6c18 --- /dev/null +++ b/ui/src/i18n.js @@ -0,0 +1,262 @@ +window.CC_I18N = { + en: { + tagline: 'Draw on your GitHub contribution graph', + '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.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', + 'account.total': '{n} contributions in this range', + + 'repo.none': 'Select a repository…', + 'repo.new': 'New repository', + 'repo.namePlaceholder': 'commit-canvas-art', + 'repo.private': 'Private', + 'repo.create': 'Create', + 'repo.cancel': 'Cancel', + '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.', + + 'range.label': 'Range', + 'range.past': 'Past year', + refresh: 'Reload my contributions', + 'mode.edit': 'Edit', + 'mode.preview': 'Preview result', + 'tool.eraser': 'Eraser', + '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', + + '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 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': '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 贡献图(绿墙)上画画', + '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.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': '退出', + 'account.total': '这段时间共有 {n} 次贡献', + + 'repo.none': '选择一个仓库…', + 'repo.new': '新建仓库', + 'repo.namePlaceholder': 'commit-canvas-art', + 'repo.private': '私有', + 'repo.create': '创建', + 'repo.cancel': '取消', + 'repo.noneFound': '你的账号里还没有用来画画的仓库。', + 'repo.quickCreate': '一键创建「{name}」', + 'repo.groupDrawing': '适合画画的仓库', + 'repo.groupOther': '其他仓库', + 'repo.pick': '从「适合画画的仓库」里选一个即可。', + 'repo.autoPicked': '已自动选好,可以随时更换。', + 'repo.privateHint': '这是私有仓库:需要在 GitHub 个人主页开启「Private contributions」,别人才能看到你的画。', + 'repo.realHint': '建议:用一个专门画画的仓库,避免这些 commit 混进正式项目。', + 'repo.visibilityHint': '公开仓库的贡献会直接显示在绿墙上。', + + 'range.label': '范围', + 'range.past': '最近一年', + refresh: '重新读取我的贡献', + 'mode.edit': '编辑', + 'mode.preview': '预览效果', + 'tool.eraser': '橡皮擦', + '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: '多', + + 'summary.commits': 'commit 数', + 'summary.days': '天数', + 'summary.range': '日期', + 'summary.skipped': '跳过 {n} 天', + 'strength.label': '最深一格的 commit 数', + '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': '上次提交之后仓库有了新的变化,无法自动撤销。', + + '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': '一共 {n} 个 commit,单次请控制在 {max} 个以内。', + 'error.noPush': '你没有这个仓库的推送权限。', + 'error.network': '连不上 GitHub,请检查网络。', + }, +}; diff --git a/ui/styles.css b/ui/styles.css new file mode 100644 index 0000000..6774cdd --- /dev/null +++ b/ui/styles.css @@ -0,0 +1,410 @@ +:root { + --bg: #f6f8fa; + --card: #ffffff; + --text: #1f2328; + --muted: #656d76; + --border: #d0d7de; + --accent: #1f883d; + --accent-hover: #1a7f37; + --accent-soft: #dafbe1; + --danger: #cf222e; + --mark: #ffffff; + --l0: #ebedf0; + --l1: #9be9a8; + --l2: #40c463; + --l3: #30a14e; + --l4: #216e39; + --cell: 12px; + --gap: 3px; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --card: #161b22; + --text: #e6edf3; + --muted: #8d96a0; + --border: #30363d; + --accent: #238636; + --accent-hover: #2ea043; + --accent-soft: #12261e; + --danger: #f85149; + --mark: #0d1117; + --l0: #21262d; + --l1: #0e4429; + --l2: #006d32; + --l3: #26a641; + --l4: #39d353; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif; +} + +a { color: var(--accent); } +h1, h2, h3 { margin: 0; } +[hidden] { display: none !important; } + +.top, main, .foot { + max-width: 940px; + margin: 0 auto; + padding: 0 16px; +} + +/* ---------- header ---------- */ +.top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + 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 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: 10px; + padding: 18px; +} + +.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: 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; } +.row.spread { justify-content: space-between; margin-top: 8px; } +.row.tight { margin: 0; gap: 6px; } +.grow { flex: 1 1 220px; } + +/* ---------- controls ---------- */ +input, select, button { font: inherit; color: inherit; } + +input, select { + height: 34px; + padding: 0 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + min-width: 0; +} +#connect-form input { flex: 1; } +input:focus-visible, select:focus-visible, button:focus-visible, .graph:focus-visible, summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.check { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; } +.check input { height: auto; } + +button { + height: 34px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--card); + cursor: pointer; + white-space: nowrap; +} +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: 34px; padding: 0; } +button.primary { + background: var(--accent); + border-color: transparent; + color: #fff; + font-weight: 600; +} +button.primary:hover:not(:disabled) { background: var(--accent-hover); } +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; } + +/* ---------- 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; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 14px; +} +.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 { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: 5px; + font-style: normal; + font-size: 12px; +} +.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: 150px; } +.templates { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } + +.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: 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; } +.weekday { grid-column: 1; } + +.cell { + position: relative; + display: block; + width: var(--cell); + height: var(--cell); + border-radius: 2px; + background: var(--l0); + outline: 1px solid rgb(27 31 35 / 6%); + outline-offset: -1px; +} +.graph .cell { cursor: crosshair; } +.cell.off { visibility: hidden; } +.cell.l1 { background: var(--l1); } +.cell.l2 { background: var(--l2); } +.cell.l3 { background: var(--l3); } +.cell.l4 { background: var(--l4); } +/* 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 16px; + margin-top: 8px; +} +.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; } + +/* ---------- 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; +} +.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; 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; +} + +/* ---------- 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 { + 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 / 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 1 100%; } + .text-tool input { flex: 1; width: auto; } + .paint-row button.big { width: 100%; } + .stats { grid-template-columns: 1fr 1fr; } + .stats div:last-child { grid-column: 1 / -1; } +}