Compare commits

..

71 commits

Author SHA1 Message Date
leafdbot[bot]
832a610693
chore(main): release app 1.7.5 (#67)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-24 15:57:29 -06:00
Leafd
472867d58f
fix: eliminate duplicate discord definitions 2025-10-24 17:56:07 -04:00
Leafd
d037bdb529
fix: make option enabled by default 2025-10-24 17:53:30 -04:00
leafdbot[bot]
6509fc85e0
chore(main): release app 1.7.4 (#66)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-24 15:44:58 -06:00
Leafd
e84c1d6a37
fix: update hackatime url 2025-10-24 17:43:55 -04:00
leafdbot[bot]
a5746811a9
chore(main): release app 1.7.3 (#65)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-24 15:33:36 -06:00
Leafd
3688e39424
fix: change hackatime icon 2025-10-24 17:32:45 -04:00
Leafd
59008e3849
fix: correct card alignment issues 2025-10-16 12:01:05 -04:00
Leafd
145b3b9422
chore: add security policy
Updated the security policy to include version support and reporting guidelines.
2025-10-14 19:33:46 -06:00
Leafd
ece57e2981
chore: add license 2025-10-14 19:24:52 -06:00
leafdbot[bot]
d0c183e71c
chore(main): release app 1.7.2 (#64)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-10 15:08:23 -06:00
Leafd
1705082522
fix: fix update popup not showing 2025-10-10 17:07:55 -04:00
Leafd
3ec4f3386a
fix: add manual oauth link copy for linux 2025-10-10 16:52:03 -04:00
leafdbot[bot]
0feffa4a50
chore(main): release app 1.7.1 (#63)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-10 13:53:24 -06:00
Leafd
bc8a8121b1
chore: update pnpm lock 2025-10-10 15:51:45 -04:00
leafdbot[bot]
fe6de62c2d
chore(main): release app 1.7.0 (#62)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-10 13:48:02 -06:00
Leafd
48146f0091
fix: adjust project page to api response 2025-10-10 15:47:20 -04:00
Leafd
a266acc5d4
fix: app now hides on closing 2025-10-10 15:36:20 -04:00
Leafd
dab9a807a5
feat: add autostart functionality 2025-10-10 15:30:11 -04:00
Leafd
db732471b7
chore: update readme with download buttons 2025-10-10 12:34:28 -04:00
leafdbot[bot]
8680de90f1
chore(main): release app 1.6.2 (#61)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 16:47:57 -06:00
Leafd
8171e059ae
fix: resolve sentry error reports and improve data validation 2025-10-09 18:46:56 -04:00
leafdbot[bot]
ca898884fc
chore(main): release app 1.6.1 (#60)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 15:05:02 -06:00
Leafd
55d5875078
fix: update settings page layout 2025-10-09 17:04:32 -04:00
leafdbot[bot]
8f6d69afd9
chore(main): release app 1.6.0 (#59)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 15:01:08 -06:00
Leafd
e6aacf5f4d
feat: add update card 2025-10-09 16:56:10 -04:00
Leafd
7516012de4
fix: change border style for consistency 2025-10-09 16:38:27 -04:00
Leafd
93e8fb655b
feat: add filters and search bar to project page 2025-10-09 16:34:19 -04:00
leafdbot[bot]
4a465be82d
chore(main): release app 1.5.1 (#58)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 14:16:19 -06:00
Leafd
50af8ae2ca
fix: make timeout asisignable to number 2025-10-09 16:15:29 -04:00
leafdbot[bot]
444fa8ef44
chore(main): release app 1.5.0 (#57)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 14:06:32 -06:00
Leafd
553ba74304
feat: configure sentry releases 2025-10-09 15:48:53 -04:00
Leafd
d03e8ae14f
fix: add "ph-no-capture" class to profile card and api card 2025-10-09 15:37:20 -04:00
leafdbot[bot]
da5c28b4ef
chore(main): release app 1.4.2 (#55)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 13:33:05 -06:00
Leafd
3b57b270af
fix: solve crash on resize #56 2025-10-09 15:31:43 -04:00
Leafd
ce7dcfa2da
fix: eliminate "coding now" legend when no session is active 2025-10-09 14:30:21 -04:00
leafdbot[bot]
e7baf1b5e7
chore(main): release app 1.4.1 (#54)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 12:21:16 -06:00
Leafd
b86c9c715a
chore(ci): add azure trusted signing 2025-10-09 14:20:44 -04:00
leafdbot[bot]
9e520453ae
chore(main): release app 1.4.0 (#53)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 11:45:54 -06:00
Leafd
547117173b
feat: add login page 2025-10-09 13:44:59 -04:00
leafdbot[bot]
4197762a53
chore(main): release app 1.3.2 (#50)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-09 08:32:25 -06:00
Leafd
ad3e27c859
fix: solve windows deeplink error 2025-10-09 10:31:51 -04:00
Leafd
fa6acaf41f
fix: make windows register deeplinks 2025-10-09 10:19:12 -04:00
leafdbot[bot]
1e9eeb3c2e
chore(main): release app 1.3.1 (#49)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-08 18:47:11 -06:00
Leafd
cd241bd2ac
chore(ci): change ubuntu file path 2025-10-08 19:39:26 -04:00
leafdbot[bot]
02f5664a76
chore(main): release app 1.3.0 (#47)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-08 17:12:37 -06:00
leafdbot[bot]
6f42399224 chore: apply linter fixes 2025-10-08 19:11:58 -04:00
Leafd
e9f9acc976 feat: add motd 2025-10-08 19:11:58 -04:00
Leafd
ad2b79367c feat: add monitoring 2025-10-08 19:11:58 -04:00
Leafd
5bf8fa1f32 fix: eliminate cache for streak 2025-10-08 19:11:58 -04:00
Leafd
e5c5c28e2b fix: change stats calculations 2025-10-08 19:11:58 -04:00
Leafd
fc9c49f7ff fix: make autoupdater check on startup 2025-10-08 19:11:58 -04:00
Leafd
b7516b4ccc
chore(ci): update ubuntu specific dependencies 2025-10-08 10:17:49 -04:00
leafdbot[bot]
a873bace6e
chore(main): release app 1.2.5 (#46)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-07 13:37:01 -06:00
Leafd
2e8ac1f4dd
fix: change api url for wakatime config 2025-10-07 15:34:44 -04:00
leafdbot[bot]
6c5544da90
chore(main): release app 1.2.4 (#45)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-07 13:12:43 -06:00
Leafd
0196715607
fix: fix objective c bool 2025-10-07 15:10:46 -04:00
leafdbot[bot]
bf635f9756
chore(main): release app 1.2.3 (#44)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-07 13:02:20 -06:00
Leafd
216cedd332
fix: change bool to objective c 2025-10-07 15:01:24 -04:00
leafdbot[bot]
6eefa434dc
chore(main): release app 1.2.2 (#43)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-07 12:51:02 -06:00
Leafd
538e31cffa
chore(ci): use default runners for renovate 2025-10-07 14:50:30 -04:00
leafdbot[bot]
6d15b3e4ce
chore(main): release app 1.2.1 (#42)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-07 12:47:54 -06:00
Leafd
bf8b3248d8
fix: remove unused import and variable 2025-10-07 14:46:08 -04:00
leafdbot[bot]
cc39c6b45a
chore(main): release app 1.2.0 (#41)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-07 12:43:19 -06:00
Leafd
aaba534a6d
feat: home page redesign (#40)
* feat: home page redesign

* chore(ci): remove secretlint
2025-10-07 14:42:16 -04:00
leafdbot[bot]
66419ffbb2
chore(main): release app 1.1.12 (#39)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-05 00:46:37 -06:00
Leafd
4bb8d6e226
chore(ci): update pnpm version 2025-10-05 02:45:59 -04:00
leafdbot[bot]
8c452d81b5
chore(deps): update pnpm to v10 (#37)
Signed-off-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-05 00:44:55 -06:00
leafdbot[bot]
a591e9c72c
chore(deps): update pnpm to v10 (#38)
Signed-off-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-05 00:44:43 -06:00
leafdbot[bot]
a415ae0d5b
chore(main): release app 1.1.11 (#36)
Co-authored-by: leafdbot[bot] <192038741+leafdbot[bot]@users.noreply.github.com>
2025-10-05 00:41:39 -06:00
Leafd
f71639892f
chore(ci): install pnpm with action-setup 2025-10-05 02:41:12 -04:00
78 changed files with 8567 additions and 3535 deletions

View file

@ -46,9 +46,9 @@ jobs:
DEFAULT_BRANCH: main
VALIDATE_ALL_CODEBASE: true
ENABLE: RUST,JAVASCRIPT,TYPESCRIPT,JSON,YAML,MARKDOWN,REPOSITORY
ENABLE_LINTERS: RUST_CLIPPY,RUST_RUSTFMT,VUE_ESLINT_PLUGIN_VUE,REPOSITORY_GIT_DIFF,REPOSITORY_SECRETLINT,REPOSITORY_TRIVY_SBOM,REPOSITORY_TRUFFLEHOG,YAML_PRETTIER,YAML_YAMLLINT
ENABLE_LINTERS: RUST_CLIPPY,RUST_RUSTFMT,VUE_ESLINT_PLUGIN_VUE,REPOSITORY_GIT_DIFF,REPOSITORY_TRIVY_SBOM,REPOSITORY_TRUFFLEHOG,YAML_PRETTIER,YAML_YAMLLINT
DISABLE: COPYPASTE,SPELL
DISABLE_LINTERS: REPOSITORY_CHECKOV,REPOSITORY_GITLEAKS
DISABLE_LINTERS: REPOSITORY_CHECKOV,REPOSITORY_GITLEAKS,REPOSITORY_SECRETLINT
SECURITY_LINTERS_ENABLED: true
# Rust linter configuration

View file

@ -45,7 +45,7 @@ jobs:
platform:
- macos-latest
- windows-latest
- ubuntu-22.04
- ubuntu-24.04
runs-on: '${{ matrix.platform }}'
permissions:
id-token: write
@ -94,15 +94,15 @@ jobs:
key: '${{ runner.os }}-rust-target-${{ hashFiles(''**/Cargo.lock'') }}'
restore-keys: |
${{ runner.os }}-rust-target-
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.18.0
- name: Use Node.js
uses: actions/setup-node@v5
with:
node-version: 20
- name: Install pnpm via corepack
shell: bash
run: |
corepack enable
corepack prepare --activate
cache: pnpm
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
@ -116,13 +116,19 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: install dependencies (ubuntu only)
if: 'startsWith(matrix.platform, ''ubuntu'')'
run: >
if: matrix.platform == 'ubuntu-24.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl
wget file libxdo-dev libssl-dev pkg-config
libayatana-appindicator3-dev librsvg2-dev
sudo apt-get install -y \
libwebkit2gtk-4.1-0=2.44.0-2 \
libwebkit2gtk-4.1-dev=2.44.0-2 \
libjavascriptcoregtk-4.1-0=2.44.0-2 \
libjavascriptcoregtk-4.1-dev=2.44.0-2 \
gir1.2-javascriptcoregtk-4.1=2.44.0-2 \
gir1.2-webkit2-4.1=2.44.0-2 \
libappindicator3-dev \
librsvg2-dev \
patchelf
- name: Install frontend dependencies
run: |
pnpm install
@ -142,6 +148,8 @@ jobs:
APPLE_PASSWORD: '${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}'
TAURI_SIGNING_PRIVATE_KEY: '${{ secrets.TAURI_PRIVATE_KEY }}'
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '${{ secrets.TAURI_KEY_PASSWORD }}'
SENTRY_RELEASE: 'hackatime-desktop@${{ needs.release-please.outputs.tag_name }}'
SENTRY_ENVIRONMENT: 'production'
- name: build app
run: >-
pnpm run tauri build
@ -151,6 +159,24 @@ jobs:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
TAURI_SIGNING_PRIVATE_KEY: '${{ secrets.TAURI_PRIVATE_KEY }}'
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '${{ secrets.TAURI_KEY_PASSWORD }}'
SENTRY_RELEASE: 'hackatime-desktop@${{ needs.release-please.outputs.tag_name }}'
SENTRY_ENVIRONMENT: 'production'
- name: Sign files with Trusted Signing
if: 'startsWith(matrix.platform, ''windows'')'
uses: azure/trusted-signing-action@v0.5.1
with:
azure-tenant-id: '${{ secrets.AZURE_TENANT_ID }}'
azure-client-id: '${{ secrets.AZURE_CLIENT_ID }}'
azure-client-secret: '${{ secrets.AZURE_CLIENT_SECRET }}'
endpoint: 'https://eus.codesigning.azure.net/'
trusted-signing-account-name: hackclub
certificate-profile-name: hackatime-desktop
files-folder: '${{ github.workspace }}/src-tauri/target/release/bundle'
files-folder-filter: 'msi,exe'
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: 'http://timestamp.acs.microsoft.com'
timestamp-digest: SHA256
- name: Attest macOS binaries
if: 'startsWith(matrix.platform, ''macos'')'
uses: actions/attest-build-provenance@v3
@ -224,7 +250,7 @@ jobs:
- name: Generate update manifest
id: generate_manifest
env:
DOWNLOAD_URL_BASE: 'https://pub-d35fbe65a5b5426bb6d62ff02a8c7d03.r2.dev'
DOWNLOAD_URL_BASE: 'https://desktop.hackatime.hackclub-assets.com'
VERSION: '${{ steps.get_version.outputs.version }}'
run: >
# Read signatures into variables
@ -233,7 +259,7 @@ jobs:
artifacts/macos-latest/universal-apple-darwin/release/bundle/macos/*.app.tar.gz.sig)
LINUX_SIGNATURE=$(cat
artifacts/ubuntu-22.04/release/bundle/appimage/*.AppImage.tar.gz.sig)
artifacts/ubuntu-24.04/release/bundle/appimage/*.AppImage.tar.gz.sig)
WINDOWS_SIGNATURE=$(cat
"artifacts/windows-latest/release/bundle/nsis/Hackatime Desktop_${VERSION}_x64-setup.nsis.zip.sig")
@ -264,11 +290,11 @@ jobs:
},
"linux-x86_64": {
"signature": "${LINUX_SIGNATURE}",
"url": "${DOWNLOAD_URL_BASE}/releases/${VERSION}/ubuntu-22.04/release/bundle/appimage/Hackatime%20Desktop_${VERSION}_amd64.AppImage.tar.gz",
"url": "${DOWNLOAD_URL_BASE}/releases/${VERSION}/ubuntu-24.04/release/bundle/appimage/Hackatime%20Desktop_${VERSION}_amd64.AppImage.tar.gz",
"install_urls": [
"${DOWNLOAD_URL_BASE}/releases/${VERSION}/ubuntu-22.04/release/bundle/deb/Hackatime%20Desktop_${VERSION}_amd64.deb",
"${DOWNLOAD_URL_BASE}/releases/${VERSION}/ubuntu-22.04/release/bundle/appimage/Hackatime%20Desktop_${VERSION}_amd64.AppImage",
"${DOWNLOAD_URL_BASE}/releases/${VERSION}/ubuntu-22.04/release/bundle/rpm/Hackatime%20Desktop-${VERSION}-1.x86_64.rpm"
"${DOWNLOAD_URL_BASE}/releases/${VERSION}/ubuntu-24.04/release/bundle/deb/Hackatime%20Desktop_${VERSION}_amd64.deb",
"${DOWNLOAD_URL_BASE}/releases/${VERSION}/ubuntu-24.04/release/bundle/appimage/Hackatime%20Desktop_${VERSION}_amd64.AppImage",
"${DOWNLOAD_URL_BASE}/releases/${VERSION}/ubuntu-24.04/release/bundle/rpm/Hackatime%20Desktop-${VERSION}-1.x86_64.rpm"
]
},
"windows-x86_64": {

View file

@ -27,7 +27,7 @@ env:
jobs:
renovate:
name: Renovate
runs-on: blacksmith-2vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- name: Generate Token
uses: tibdex/github-app-token@v2

View file

@ -1,4 +1,4 @@
{
"app": "0.0.0",
".": "1.1.10"
".": "1.7.5"
}

View file

@ -1,5 +1,229 @@
# Changelog
## [1.7.5](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.4...app-v1.7.5) (2025-10-24)
### 🐛 Bugfixes
* eliminate duplicate discord definitions ([472867d](https://github.com/hackclub/hackatime-desktop/commit/472867d58f306d70241b07b5f3135c34055ad555))
* make option enabled by default ([d037bdb](https://github.com/hackclub/hackatime-desktop/commit/d037bdb529a4a078a5d10f7daa68698da9726e5f))
## [1.7.4](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.3...app-v1.7.4) (2025-10-24)
### 🐛 Bugfixes
* update hackatime url ([e84c1d6](https://github.com/hackclub/hackatime-desktop/commit/e84c1d6a37fdef397189e8a9108f68d4ddb11641))
## [1.7.3](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.2...app-v1.7.3) (2025-10-24)
### 🐛 Bugfixes
* change hackatime icon ([3688e39](https://github.com/hackclub/hackatime-desktop/commit/3688e39424c6d3e1c0441fde81e8451feded8178))
* correct card alignment issues ([59008e3](https://github.com/hackclub/hackatime-desktop/commit/59008e3849753ccaac037657d65bad2a70387e3c))
### 👽 Miscellaneous
* add license ([ece57e2](https://github.com/hackclub/hackatime-desktop/commit/ece57e29811ca228a255bef7bfe3117c3e3d236d))
* add security policy ([145b3b9](https://github.com/hackclub/hackatime-desktop/commit/145b3b9422bb5b5095a6e5aa59aa66b749338b5a))
## [1.7.2](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.1...app-v1.7.2) (2025-10-10)
### 🐛 Bugfixes
* add manual oauth link copy for linux ([3ec4f33](https://github.com/hackclub/hackatime-desktop/commit/3ec4f3386a3e2bbf5a2c4bddc80bf28d789b9705))
* fix update popup not showing ([1705082](https://github.com/hackclub/hackatime-desktop/commit/17050825223327da6e603cfe21500cf78c74e215))
## [1.7.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.7.0...app-v1.7.1) (2025-10-10)
### 👽 Miscellaneous
* update pnpm lock ([bc8a812](https://github.com/hackclub/hackatime-desktop/commit/bc8a8121b1a9feb7e6d3a578375e0c7db4b4970f))
## [1.7.0](https://github.com/hackclub/hackatime-desktop/compare/app-v1.6.2...app-v1.7.0) (2025-10-10)
### ✨ Features
* add autostart functionality ([dab9a80](https://github.com/hackclub/hackatime-desktop/commit/dab9a807a52d5cbcdf04022e7d440d59f612a75c))
### 🐛 Bugfixes
* adjust project page to api response ([48146f0](https://github.com/hackclub/hackatime-desktop/commit/48146f009150d13421f82642a7cc7a32ece36810))
* app now hides on closing ([a266acc](https://github.com/hackclub/hackatime-desktop/commit/a266acc5d44f8b6e8319d0f49889c2ee0e13d6e6))
### 👽 Miscellaneous
* update readme with download buttons ([db73247](https://github.com/hackclub/hackatime-desktop/commit/db732471b7f037f4b19ea20b9fdabbc3f5ce51b3))
## [1.6.2](https://github.com/hackclub/hackatime-desktop/compare/app-v1.6.1...app-v1.6.2) (2025-10-09)
### 🐛 Bugfixes
* resolve sentry error reports and improve data validation ([8171e05](https://github.com/hackclub/hackatime-desktop/commit/8171e059ae52aefa180c9c295ab71ba23c3e111f))
## [1.6.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.6.0...app-v1.6.1) (2025-10-09)
### 🐛 Bugfixes
* update settings page layout ([55d5875](https://github.com/hackclub/hackatime-desktop/commit/55d58750785eec0ac627f5c4a1b4a9882c14f2a7))
## [1.6.0](https://github.com/hackclub/hackatime-desktop/compare/app-v1.5.1...app-v1.6.0) (2025-10-09)
### ✨ Features
* add filters and search bar to project page ([93e8fb6](https://github.com/hackclub/hackatime-desktop/commit/93e8fb655ba23b11cda9bfb8ee8e368a9ccfa6b7))
* add update card ([e6aacf5](https://github.com/hackclub/hackatime-desktop/commit/e6aacf5f4d4c21f3863f342c8000a32ccc3e1d01))
### 🐛 Bugfixes
* change border style for consistency ([7516012](https://github.com/hackclub/hackatime-desktop/commit/7516012de4244843af1fb58144acd09b13ddd8a9))
## [1.5.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.5.0...app-v1.5.1) (2025-10-09)
### 🐛 Bugfixes
* make timeout asisignable to number ([50af8ae](https://github.com/hackclub/hackatime-desktop/commit/50af8ae2ca8bee2ef2ae02a2fd26717b1ab31ca7))
## [1.5.0](https://github.com/hackclub/hackatime-desktop/compare/app-v1.4.2...app-v1.5.0) (2025-10-09)
### ✨ Features
* configure sentry releases ([553ba74](https://github.com/hackclub/hackatime-desktop/commit/553ba74304b40f2e55fe355e3e907e2450209d85))
### 🐛 Bugfixes
* add "ph-no-capture" class to profile card and api card ([d03e8ae](https://github.com/hackclub/hackatime-desktop/commit/d03e8ae14fc359cbfd5cf5c350d3895feff33b25))
## [1.4.2](https://github.com/hackclub/hackatime-desktop/compare/app-v1.4.1...app-v1.4.2) (2025-10-09)
### 🐛 Bugfixes
* eliminate "coding now" legend when no session is active ([ce7dcfa](https://github.com/hackclub/hackatime-desktop/commit/ce7dcfa2dae8afc16fe0396d249e71081fb7578b))
* solve crash on resize [#56](https://github.com/hackclub/hackatime-desktop/issues/56) ([3b57b27](https://github.com/hackclub/hackatime-desktop/commit/3b57b270af05938167112eb96408ae265a9e6d51))
## [1.4.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.4.0...app-v1.4.1) (2025-10-09)
### 👽 Miscellaneous
* **ci:** add azure trusted signing ([b86c9c7](https://github.com/hackclub/hackatime-desktop/commit/b86c9c715a0e8a9e6a8ad7b67bca6f354474b1ad))
## [1.4.0](https://github.com/hackclub/hackatime-desktop/compare/app-v1.3.2...app-v1.4.0) (2025-10-09)
### ✨ Features
* add login page ([5471171](https://github.com/hackclub/hackatime-desktop/commit/547117173bdc81eb87e8f6fcebc70dfbe214698e))
## [1.3.2](https://github.com/hackclub/hackatime-desktop/compare/app-v1.3.1...app-v1.3.2) (2025-10-09)
### 🐛 Bugfixes
* make windows register deeplinks ([fa6acaf](https://github.com/hackclub/hackatime-desktop/commit/fa6acaf41fe5e2e44e2b9eca3d66f64f939ba1c4))
* solve windows deeplink error ([ad3e27c](https://github.com/hackclub/hackatime-desktop/commit/ad3e27c859adb3136ed45dc7fe85fa6da47f1ced))
## [1.3.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.3.0...app-v1.3.1) (2025-10-08)
### 👽 Miscellaneous
* **ci:** change ubuntu file path ([cd241bd](https://github.com/hackclub/hackatime-desktop/commit/cd241bd2acf5b29a9c1d359646a4969f18004ad1))
## [1.3.0](https://github.com/hackclub/hackatime-desktop/compare/app-v1.2.5...app-v1.3.0) (2025-10-08)
### ✨ Features
* add monitoring ([ad2b793](https://github.com/hackclub/hackatime-desktop/commit/ad2b79367cf829d8b564f0811e074601e7d58438))
* add motd ([e9f9acc](https://github.com/hackclub/hackatime-desktop/commit/e9f9acc976c8561b5602a49c60b4602bc78062a0))
### 🐛 Bugfixes
* change stats calculations ([e5c5c28](https://github.com/hackclub/hackatime-desktop/commit/e5c5c28e2b4cf5234aeac62609ecf604e93c1cbe))
* eliminate cache for streak ([5bf8fa1](https://github.com/hackclub/hackatime-desktop/commit/5bf8fa1f3223953417435f4fd428ee26a9db29d9))
* make autoupdater check on startup ([fc9c49f](https://github.com/hackclub/hackatime-desktop/commit/fc9c49f7ff5a38d0268d9606a494262afaf41762))
### 👽 Miscellaneous
* apply linter fixes ([6f42399](https://github.com/hackclub/hackatime-desktop/commit/6f42399224a9c1e21613713e675f777389334c2f))
* **ci:** update ubuntu specific dependencies ([b7516b4](https://github.com/hackclub/hackatime-desktop/commit/b7516b4ccc1a42d5e9d3ee833d26ed586893123d))
## [1.2.5](https://github.com/hackclub/hackatime-desktop/compare/app-v1.2.4...app-v1.2.5) (2025-10-07)
### 🐛 Bugfixes
* change api url for wakatime config ([2e8ac1f](https://github.com/hackclub/hackatime-desktop/commit/2e8ac1f4dd2565bfb9d8bbf3a1876a99d26207a6))
## [1.2.4](https://github.com/hackclub/hackatime-desktop/compare/app-v1.2.3...app-v1.2.4) (2025-10-07)
### 🐛 Bugfixes
* fix objective c bool ([0196715](https://github.com/hackclub/hackatime-desktop/commit/019671560766bd86328a43327b55f88a5af172a8))
## [1.2.3](https://github.com/hackclub/hackatime-desktop/compare/app-v1.2.2...app-v1.2.3) (2025-10-07)
### 🐛 Bugfixes
* change bool to objective c ([216cedd](https://github.com/hackclub/hackatime-desktop/commit/216cedd3325e07e9fdf976171bb96ce9e91377b7))
## [1.2.2](https://github.com/hackclub/hackatime-desktop/compare/app-v1.2.1...app-v1.2.2) (2025-10-07)
### 👽 Miscellaneous
* **ci:** use default runners for renovate ([538e31c](https://github.com/hackclub/hackatime-desktop/commit/538e31cffaf181a1f7b00b7ea3e5377f276fd85c))
## [1.2.1](https://github.com/hackclub/hackatime-desktop/compare/app-v1.2.0...app-v1.2.1) (2025-10-07)
### 🐛 Bugfixes
* remove unused import and variable ([bf8b324](https://github.com/hackclub/hackatime-desktop/commit/bf8b3248d86f1f40eef8a4bd6cf363cffe6934aa))
## [1.2.0](https://github.com/hackclub/hackatime-desktop/compare/app-v1.1.12...app-v1.2.0) (2025-10-07)
### ✨ Features
* home page redesign ([#40](https://github.com/hackclub/hackatime-desktop/issues/40)) ([aaba534](https://github.com/hackclub/hackatime-desktop/commit/aaba534a6dfe2dfb3ce33a9aa2ce25ddc9c0cb4c))
## [1.1.12](https://github.com/hackclub/hackatime-desktop/compare/app-v1.1.11...app-v1.1.12) (2025-10-05)
### 👽 Miscellaneous
* **ci:** update pnpm version ([4bb8d6e](https://github.com/hackclub/hackatime-desktop/commit/4bb8d6e226e57afe3be6e833a488ff06fe09219d))
* **deps:** update pnpm to v10 ([#37](https://github.com/hackclub/hackatime-desktop/issues/37)) ([8c452d8](https://github.com/hackclub/hackatime-desktop/commit/8c452d81b5730f664329b415cfea4a88892ac981))
* **deps:** update pnpm to v10 ([#38](https://github.com/hackclub/hackatime-desktop/issues/38)) ([a591e9c](https://github.com/hackclub/hackatime-desktop/commit/a591e9c72cae6f927e12fa3d1744ada2641122ab))
## [1.1.11](https://github.com/hackclub/hackatime-desktop/compare/app-v1.1.10...app-v1.1.11) (2025-10-05)
### 👽 Miscellaneous
* **ci:** install pnpm with action-setup ([f716398](https://github.com/hackclub/hackatime-desktop/commit/f71639892f62cf8db21655cf4b1849207e428d29))
## [1.1.10](https://github.com/hackclub/hackatime-desktop/compare/app-v1.1.9...app-v1.1.10) (2025-10-05)

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Hack Club
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,5 +1,8 @@
# Hackatime Desktop
[![Download for macOS](https://img.shields.io/badge/Download-macOS-blue?style=for-the-badge&logo=apple)](https://hackatimerelease.leafd.workers.dev/latest?platform=macos)
[![Download for Windows](https://img.shields.io/badge/Download-Windows-blue?style=for-the-badge&logo=windows)](https://hackatimerelease.leafd.workers.dev/latest?platform=windows)
[![Release](https://github.com/hackclub/hackatime-desktop/actions/workflows/release.yaml/badge.svg)](https://github.com/hackclub/hackatime-desktop/actions/workflows/release.yaml)
Desktop app for [Hackatime](https://hackatime.hackclub.com). Built with Tauri, Vue 3, TypeScript, and Rust.

26
SECURITY.md Normal file
View file

@ -0,0 +1,26 @@
# Security Policy
> **Note**: This security policy is specifically for the **Hackatime Desktop** application. For vulnerabilities related to the main Hackatime web app, please refer to the [hackclub/hackatime repository](https://github.com/hackclub/hackatime).
## Supported Versions
We are currently providing security updates for the following versions:
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
## Reporting a Vulnerability
If you discover a security vulnerability in this project, please report it through one of the following channels:
- **Email**: sebastian@hackclub.com or security@leafd.dev
- **Hack Club Slack**: Send a direct message to @lfd
Please include as much information as possible in your report:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Any suggested fixes (optional)
Thank you for helping me keep this project secure :)

View file

@ -1,9 +1,9 @@
{
"name": "desktop",
"private": true,
"version": "1.1.10",
"version": "1.7.5",
"type": "module",
"packageManager": "pnpm@9.15.9",
"packageManager": "pnpm@10.18.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
@ -11,18 +11,24 @@
"tauri": "tauri"
},
"dependencies": {
"@sentry/vue": "^10.18.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2",
"@tauri-apps/plugin-deep-link": "^2.4.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-updater": "~2",
"chart.js": "^4.5.0",
"crypto-js": "^4.2.0",
"posthog-js": "^1.273.1",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/cli": "^2",
"@types/crypto-js": "^4.2.2",
"@types/node": "^24.7.1",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.9.0",

176
pnpm-lock.yaml generated
View file

@ -8,9 +8,15 @@ importers:
.:
dependencies:
'@sentry/vue':
specifier: ^10.18.0
version: 10.18.0(vue@3.5.22(typescript@5.9.3))
'@tauri-apps/api':
specifier: ^2
version: 2.8.0
'@tauri-apps/plugin-autostart':
specifier: ^2
version: 2.5.0
'@tauri-apps/plugin-deep-link':
specifier: ^2.4.3
version: 2.4.3
@ -26,6 +32,12 @@ importers:
chart.js:
specifier: ^4.5.0
version: 4.5.0
crypto-js:
specifier: ^4.2.0
version: 4.2.0
posthog-js:
specifier: ^1.273.1
version: 1.273.1
vue:
specifier: ^3.5.13
version: 3.5.22(typescript@5.9.3)
@ -35,13 +47,19 @@ importers:
devDependencies:
'@tailwindcss/vite':
specifier: ^4.1.14
version: 4.1.14(vite@6.3.6(jiti@2.6.1)(lightningcss@1.30.1))
version: 4.1.14(vite@6.3.6(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1))
'@tauri-apps/cli':
specifier: ^2
version: 2.8.4
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
'@types/node':
specifier: ^24.7.1
version: 24.7.1
'@vitejs/plugin-vue':
specifier: ^5.2.1
version: 5.2.4(vite@6.3.6(jiti@2.6.1)(lightningcss@1.30.1))(vue@3.5.22(typescript@5.9.3))
version: 5.2.4(vite@6.3.6(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1))(vue@3.5.22(typescript@5.9.3))
tailwindcss:
specifier: ^4.1.14
version: 4.1.14
@ -50,7 +68,7 @@ importers:
version: 5.9.3
vite:
specifier: ^6.0.3
version: 6.3.6(jiti@2.6.1)(lightningcss@1.30.1)
version: 6.3.6(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)
vue-tsc:
specifier: ^2.1.10
version: 2.2.12(typescript@5.9.3)
@ -253,6 +271,9 @@ packages:
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@posthog/core@1.2.2':
resolution: {integrity: sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg==}
'@rollup/rollup-android-arm-eabi@4.52.3':
resolution: {integrity: sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==}
cpu: [arm]
@ -363,6 +384,40 @@ packages:
cpu: [x64]
os: [win32]
'@sentry-internal/browser-utils@10.18.0':
resolution: {integrity: sha512-6Y5VkNcj5ecIFsKdL8/7hrLt7pCuWR4BRLsKOHAmhdCnXtobf7v6DeBow2Hk5yEYO0AwjP5mqvoBAewbS+h3GA==}
engines: {node: '>=18'}
'@sentry-internal/feedback@10.18.0':
resolution: {integrity: sha512-uuupIivGPCpRStMU1I3sYPgD+pl8PqNV1DSVgVS5LF99h8tqjmRGS1xkCrUaUhVhVmsnxzbnvXb1hsOaCXX7DA==}
engines: {node: '>=18'}
'@sentry-internal/replay-canvas@10.18.0':
resolution: {integrity: sha512-asp1biXA+F5HAKl7RvPbf5s087bg1bpxMB9E69xWc1ECUfFMPrFRNS7mAJ5A8DTd1K74E9cFsLl6zO29HpH4+w==}
engines: {node: '>=18'}
'@sentry-internal/replay@10.18.0':
resolution: {integrity: sha512-ixr3K19q4oTRgM0xANi+8ThDUbxV5iixUIgvJrT7c1L6yyidovIwO0D82ZY3phUfMkgE+mX3cxX46gXTRTglKQ==}
engines: {node: '>=18'}
'@sentry/browser@10.18.0':
resolution: {integrity: sha512-JrPfxjCsuVYUe16U4fo4W2Fn0f9BwRev3G28a4ZIkwKwJo+qSnIk1mT8Eam8nwNCU8MZjB4KNE9w2p0kaoQxvQ==}
engines: {node: '>=18'}
'@sentry/core@10.18.0':
resolution: {integrity: sha512-zlhAlzc/Qpza8f/CMUb7zg/9FOhWouKAm9zyV9jZlx9lL6WceVbUEwQ3rq8ncGgM+LMwlASCOjsz5a728vAhCw==}
engines: {node: '>=18'}
'@sentry/vue@10.18.0':
resolution: {integrity: sha512-SC6vzLtVslNZMWgFU1PHvDWMV8XXC+YCysNGf7dWeSuR8mq/aymEY/ZE1MtNyh+kcKoeUfLDEhYVrMsxXKxwIw==}
engines: {node: '>=18'}
peerDependencies:
pinia: 2.x || 3.x
vue: 2.x || 3.x
peerDependenciesMeta:
pinia:
optional: true
'@tailwindcss/node@4.1.14':
resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==}
@ -527,6 +582,9 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-autostart@2.5.0':
resolution: {integrity: sha512-smSt0vydfVB950AeYRbO2S/c01SZrgMVg4FOrFLQLom0R0amsu/8zYaxgttriBdxcofjBZuHv4hmROBQIBVXmA==}
'@tauri-apps/plugin-deep-link@2.4.3':
resolution: {integrity: sha512-yVCZpVG1ZrtfCvE7K5LRSrGqlyPlCrqlKgoREJHnfjyYdDtUhFmZqScOXpL8XL2PizJHDsoahEweuTaUPEokPA==}
@ -539,9 +597,15 @@ packages:
'@tauri-apps/plugin-updater@2.9.0':
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@24.7.1':
resolution: {integrity: sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==}
'@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -615,6 +679,12 @@ packages:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
core-js@3.45.1:
resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@ -650,6 +720,9 @@ packages:
picomatch:
optional: true
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -767,6 +840,20 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.273.1:
resolution: {integrity: sha512-6w3j6nAWJj7W7/iksWLXRpdLrLZrQA8jTsEQ71bvmyw4bwCqhgPfxutrmeoAUNaxot2FB1JHc9Lagslg35h61g==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
peerDependenciesMeta:
'@rrweb/types':
optional: true
rrweb-snapshot:
optional: true
preact@10.27.2:
resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==}
rollup@4.52.3:
resolution: {integrity: sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@ -796,6 +883,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
vite@6.3.6:
resolution: {integrity: sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@ -859,6 +949,9 @@ packages:
typescript:
optional: true
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
@ -981,6 +1074,8 @@ snapshots:
'@kurkle/color@0.3.4': {}
'@posthog/core@1.2.2': {}
'@rollup/rollup-android-arm-eabi@4.52.3':
optional: true
@ -1047,6 +1142,40 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.52.3':
optional: true
'@sentry-internal/browser-utils@10.18.0':
dependencies:
'@sentry/core': 10.18.0
'@sentry-internal/feedback@10.18.0':
dependencies:
'@sentry/core': 10.18.0
'@sentry-internal/replay-canvas@10.18.0':
dependencies:
'@sentry-internal/replay': 10.18.0
'@sentry/core': 10.18.0
'@sentry-internal/replay@10.18.0':
dependencies:
'@sentry-internal/browser-utils': 10.18.0
'@sentry/core': 10.18.0
'@sentry/browser@10.18.0':
dependencies:
'@sentry-internal/browser-utils': 10.18.0
'@sentry-internal/feedback': 10.18.0
'@sentry-internal/replay': 10.18.0
'@sentry-internal/replay-canvas': 10.18.0
'@sentry/core': 10.18.0
'@sentry/core@10.18.0': {}
'@sentry/vue@10.18.0(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@sentry/browser': 10.18.0
'@sentry/core': 10.18.0
vue: 3.5.22(typescript@5.9.3)
'@tailwindcss/node@4.1.14':
dependencies:
'@jridgewell/remapping': 2.3.5
@ -1111,12 +1240,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.14
'@tailwindcss/oxide-win32-x64-msvc': 4.1.14
'@tailwindcss/vite@4.1.14(vite@6.3.6(jiti@2.6.1)(lightningcss@1.30.1))':
'@tailwindcss/vite@4.1.14(vite@6.3.6(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1))':
dependencies:
'@tailwindcss/node': 4.1.14
'@tailwindcss/oxide': 4.1.14
tailwindcss: 4.1.14
vite: 6.3.6(jiti@2.6.1)(lightningcss@1.30.1)
vite: 6.3.6(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)
'@tauri-apps/api@2.8.0': {}
@ -1167,6 +1296,10 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.8.4
'@tauri-apps/cli-win32-x64-msvc': 2.8.4
'@tauri-apps/plugin-autostart@2.5.0':
dependencies:
'@tauri-apps/api': 2.8.0
'@tauri-apps/plugin-deep-link@2.4.3':
dependencies:
'@tauri-apps/api': 2.8.0
@ -1183,11 +1316,17 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.8.0
'@types/crypto-js@4.2.2': {}
'@types/estree@1.0.8': {}
'@vitejs/plugin-vue@5.2.4(vite@6.3.6(jiti@2.6.1)(lightningcss@1.30.1))(vue@3.5.22(typescript@5.9.3))':
'@types/node@24.7.1':
dependencies:
vite: 6.3.6(jiti@2.6.1)(lightningcss@1.30.1)
undici-types: 7.14.0
'@vitejs/plugin-vue@5.2.4(vite@6.3.6(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1))(vue@3.5.22(typescript@5.9.3))':
dependencies:
vite: 6.3.6(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1)
vue: 3.5.22(typescript@5.9.3)
'@volar/language-core@2.4.15':
@ -1288,6 +1427,10 @@ snapshots:
chownr@3.0.0: {}
core-js@3.45.1: {}
crypto-js@4.2.0: {}
csstype@3.1.3: {}
de-indent@1.0.2: {}
@ -1336,6 +1479,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fflate@0.4.8: {}
fsevents@2.3.3:
optional: true
@ -1420,6 +1565,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-js@1.273.1:
dependencies:
'@posthog/core': 1.2.2
core-js: 3.45.1
fflate: 0.4.8
preact: 10.27.2
web-vitals: 4.2.4
preact@10.27.2: {}
rollup@4.52.3:
dependencies:
'@types/estree': 1.0.8
@ -1469,7 +1624,9 @@ snapshots:
typescript@5.9.3: {}
vite@6.3.6(jiti@2.6.1)(lightningcss@1.30.1):
undici-types@7.14.0: {}
vite@6.3.6(@types/node@24.7.1)(jiti@2.6.1)(lightningcss@1.30.1):
dependencies:
esbuild: 0.25.10
fdir: 6.5.0(picomatch@4.0.3)
@ -1478,6 +1635,7 @@ snapshots:
rollup: 4.52.3
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.7.1
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.1
@ -1505,4 +1663,6 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
web-vitals@4.2.4: {}
yallist@5.0.0: {}

166
src-tauri/Cargo.lock generated
View file

@ -240,6 +240,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.0"
@ -820,6 +831,10 @@ dependencies = [
"base64 0.21.7",
"chrono",
"discord-rich-presence",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
"once_cell",
"open",
"rand 0.9.2",
"reqwest",
@ -829,9 +844,11 @@ dependencies = [
"sqlx",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-deep-link",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tokio",
"urlencoding",
@ -850,13 +867,33 @@ dependencies = [
"subtle",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
@ -867,7 +904,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.1",
]
@ -1007,7 +1044,7 @@ dependencies = [
"rustc_version",
"toml 0.9.7",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@ -2443,7 +2480,7 @@ dependencies = [
"gtk",
"keyboard-types",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"once_cell",
@ -2626,6 +2663,22 @@ dependencies = [
"objc2-exception-helper",
]
[[package]]
name = "objc2-app-kit"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
dependencies = [
"bitflags 2.9.4",
"block2 0.5.1",
"libc",
"objc2 0.5.2",
"objc2-core-data 0.2.2",
"objc2-core-image 0.2.2",
"objc2-foundation 0.2.2",
"objc2-quartz-core 0.2.2",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
@ -2637,10 +2690,10 @@ dependencies = [
"libc",
"objc2 0.6.3",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-data 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-image 0.3.2",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2",
@ -2658,6 +2711,18 @@ dependencies = [
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-data"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
dependencies = [
"bitflags 2.9.4",
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
@ -2693,6 +2758,18 @@ dependencies = [
"objc2-io-surface",
]
[[package]]
name = "objc2-core-image"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
dependencies = [
"block2 0.5.1",
"objc2 0.5.2",
"objc2-foundation 0.2.2",
"objc2-metal",
]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
@ -2809,7 +2886,7 @@ checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.9.4",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
@ -2869,7 +2946,7 @@ dependencies = [
"bitflags 2.9.4",
"block2 0.6.2",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-javascript-core",
@ -3588,6 +3665,17 @@ dependencies = [
"bitflags 2.9.4",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@ -4643,7 +4731,7 @@ dependencies = [
"ndk-context",
"ndk-sys",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"once_cell",
"parking_lot",
@ -4695,7 +4783,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.3",
@ -4709,7 +4797,7 @@ dependencies = [
"mime",
"muda",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit",
"objc2-web-kit",
@ -4746,7 +4834,7 @@ checksum = "9c432ccc9ff661803dab74c6cd78de11026a578a9307610bbc39d3c55be7943f"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@ -4818,6 +4906,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "062cdcd483d5e3148c9a64dabf8c574e239e2aa1193cf208d95cf89a676f87a5"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.3"
@ -4847,7 +4949,7 @@ checksum = "786156aa8e89e03d271fbd3fe642207da8e65f3c961baa9e2930f332bf80a1f5"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"schemars 0.8.22",
@ -4871,6 +4973,21 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb9cac815bf11c4a80fb498666bcdad66d65b89e3ae24669e47806febb76389c"
dependencies = [
"serde",
"serde_json",
"tauri",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
"zbus",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.9.0"
@ -4878,7 +4995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27cbc31740f4d507712550694749572ec0e43bdd66992db7599b89fbfd6b167b"
dependencies = [
"base64 0.22.1",
"dirs",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
@ -4939,7 +5056,7 @@ dependencies = [
"jni",
"log",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"once_cell",
"percent-encoding",
@ -5387,11 +5504,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d92153331e7d02ec09137538996a7786fe679c629c279e82a6be762b7e6fe2"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@ -5918,7 +6035,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c"
dependencies = [
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"raw-window-handle",
@ -6409,6 +6526,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"
@ -6441,7 +6567,7 @@ dependencies = [
"block2 0.6.2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dpi",
"dunce",
"gdkx11",
@ -6454,7 +6580,7 @@ dependencies = [
"libc",
"ndk",
"objc2 0.6.3",
"objc2-app-kit",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-ui-kit",

View file

@ -21,6 +21,8 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-deep-link = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-autostart = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
open = "5"
@ -35,7 +37,13 @@ tauri-plugin-process = "2"
sha2 = "0.10"
base64 = "0.21"
rand = "0.9"
once_cell = "1"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
[target."cfg(target_os = \"macos\")".dependencies]
objc2 = "0.5"
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
objc2-foundation = "0.2"

View file

@ -5,9 +5,17 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"opener:default",
"deep-link:default",
"updater:default",
"process:default"
"process:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
]
}

View file

@ -10,9 +10,26 @@
],
"permissions": [
"core:default",
{
"identifier": "opener:allow-open-url",
"allow": [
{
"url": "https://*"
},
{
"url": "http://*"
},
{
"url": "hackatime://*"
}
]
},
"opener:default",
"deep-link:default",
"updater:default",
"process:default"
"process:default",
"autostart:allow-enable",
"autostart:allow-disable",
"autostart:allow-is-enabled"
]
}

BIN
src-tauri/icons/128x128.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/128x128@2x.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/32x32.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square107x107Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square142x142Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square150x150Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square284x284Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square30x30Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square310x310Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square44x44Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square71x71Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/Square89x89Logo.png (Stored with Git LFS)

Binary file not shown.

BIN
src-tauri/icons/StoreLogo.png (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 45 KiB

BIN
src-tauri/icons/icon.png (Stored with Git LFS)

Binary file not shown.

561
src-tauri/src/auth.rs Normal file
View file

@ -0,0 +1,561 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tauri::State;
use std::sync::Arc;
use sha2::{Sha256, Digest};
use base64::{Engine as _, engine::general_purpose};
use rand::Rng;
use crate::database::{AuthState as DbAuthState, Database};
use crate::push_log;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthState {
pub is_authenticated: bool,
pub access_token: Option<String>,
pub user_info: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PkceState {
pub code_verifier: String,
pub state: String,
pub timestamp: i64,
}
impl PkceState {
pub fn new() -> Self {
let code_verifier = generate_code_verifier();
let state = generate_state();
Self {
code_verifier,
state,
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64,
}
}
pub fn is_expired(&self, max_age_seconds: i64) -> bool {
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
current_time - self.timestamp > max_age_seconds
}
}
fn generate_code_verifier() -> String {
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random()).collect();
general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
}
fn generate_code_challenge(verifier: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let hash = hasher.finalize();
general_purpose::URL_SAFE_NO_PAD.encode(&hash)
}
fn generate_state() -> String {
let mut rng = rand::rng();
(0..32)
.map(|_| {
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let idx = (rng.random::<u8>() as usize) % CHARSET.len();
CHARSET[idx] as char
})
.collect()
}
#[tauri::command]
pub async fn get_auth_state(
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<AuthState, String> {
let auth_state = state.lock().await;
Ok(auth_state.clone())
}
#[tauri::command]
pub async fn authenticate_with_rails(
api_config: crate::config::ApiConfig,
pkce_state: State<'_, Arc<tauri::async_runtime::Mutex<Option<PkceState>>>>,
app_handle: tauri::AppHandle,
) -> Result<String, String> {
let callback_url = "hackatime://auth/callback";
let pkce = PkceState::new();
let code_challenge = generate_code_challenge(&pkce.code_verifier);
{
let mut stored_pkce = pkce_state.lock().await;
*stored_pkce = Some(pkce.clone());
}
push_log("debug", "backend", format!("Generated PKCE parameters - verifier: {}, challenge: {}, state: {}",
pkce.code_verifier, code_challenge, pkce.state));
let auth_url = format!(
"{}/oauth/authorize?client_id=BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ&redirect_uri={}&response_type=code&scope=profile&state={}&code_challenge={}&code_challenge_method=S256",
api_config.base_url,
urlencoding::encode(callback_url),
urlencoding::encode(&pkce.state),
urlencoding::encode(&code_challenge)
);
// Use Tauri's opener plugin for better cross-platform support
use tauri_plugin_opener::OpenerExt;
if let Err(e) = app_handle.opener().open_url(&auth_url, None::<&str>) {
push_log("error", "backend", format!("Failed to open authentication URL: {}", e));
// Return the URL so the frontend can show a fallback button
return Ok(auth_url);
}
push_log("info", "backend", "OAuth authentication URL opened in browser. Waiting for callback...".to_string());
Ok(auth_url)
}
#[tauri::command]
pub async fn handle_auth_callback(
token: String,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<(), String> {
let mut auth_state = state.lock().await;
auth_state.is_authenticated = true;
auth_state.access_token = Some(token);
auth_state.user_info = Some(HashMap::new());
Ok(())
}
#[tauri::command]
pub async fn logout(
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<(), String> {
let mut auth_state = state.lock().await;
auth_state.is_authenticated = false;
auth_state.access_token = None;
auth_state.user_info = None;
if let Err(e) = clear_auth_state().await {
push_log("error", "backend", format!("Failed to clear auth state: {}", e));
}
push_log("info", "backend", "Clearing statistics cache on logout...".to_string());
if let Ok(db) = Database::new().await {
if let Err(e) = db.clear_all_cache().await {
push_log("error", "backend", format!("Failed to clear statistics cache on logout: {}", e));
} else {
push_log("info", "backend", "Statistics cache cleared on logout".to_string());
}
}
Ok(())
}
#[tauri::command]
pub async fn test_auth_callback(
token: String,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<(), String> {
let mut auth_state = state.lock().await;
auth_state.is_authenticated = true;
auth_state.access_token = Some(token);
auth_state.user_info = Some(HashMap::new());
Ok(())
}
#[tauri::command]
pub async fn get_api_key(
api_config: crate::config::ApiConfig,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<String, String> {
let auth_state = state.lock().await;
if !auth_state.is_authenticated {
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
.ok_or("No access token available")?;
let client = reqwest::Client::new();
let response = client
.get(&format!("{}/api/v1/authenticated/api_keys", base_url))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Failed to fetch API key: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("API key request failed: {}", error_text));
}
let api_key_response: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse API key response: {}", e))?;
let api_key = api_key_response["token"]
.as_str()
.ok_or("No token in response")?;
Ok(api_key.to_string())
}
#[tauri::command]
pub async fn authenticate_with_direct_oauth(
oauth_token: String,
api_config: crate::config::ApiConfig,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<(), String> {
let client = reqwest::Client::new();
if oauth_token.starts_with("hackatime://auth/callback") {
if let Some(query_start) = oauth_token.find('?') {
let query = &oauth_token[query_start + 1..];
let params: Vec<&str> = query.split('&').collect();
let mut found_code = None;
let mut found_state = None;
let mut found_error = None;
for param in params {
if param.starts_with("code=") {
found_code = Some(param[5..].to_string());
} else if param.starts_with("state=") {
found_state = Some(param[6..].to_string());
} else if param.starts_with("error=") {
found_error = Some(param[6..].to_string());
}
}
if let Some(error) = found_error {
return Err(format!("OAuth error: {}", error));
}
if let Some(code) = found_code {
push_log("debug", "backend", format!("Extracted authorization code from deep link: {}", code));
return exchange_authorization_code(code, found_state, api_config, state, client).await;
} else {
return Err("No authorization code found in deep link URL".to_string());
}
} else {
return Err("Invalid deep link URL format".to_string());
}
} else {
return validate_access_token(oauth_token, api_config, state, client).await;
}
}
async fn exchange_authorization_code(
code: String,
_state: Option<String>,
api_config: crate::config::ApiConfig,
auth_state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
client: reqwest::Client,
) -> Result<(), String> {
push_log("info", "backend", "Exchanging authorization code for access token".to_string());
let response = client
.post(&format!(
"{}/oauth/token",
api_config.base_url
))
.form(&[
("grant_type", "authorization_code"),
("code", &code),
("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"),
("redirect_uri", "hackatime://auth/callback"),
])
.send()
.await
.map_err(|e| format!("Failed to exchange authorization code: {}", e))?;
push_log("debug", "backend", format!("Token exchange response status: {}", response.status()));
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
push_log("error", "backend", format!("Token exchange failed with error: {}", error_text));
return Err(format!("Token exchange failed: {}", error_text));
}
let token_response: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse token response: {}", e))?;
let access_token = token_response["access_token"]
.as_str()
.ok_or("No access token in response")?;
let user_response = client
.get(&format!("{}/api/v1/authenticated/me", api_config.base_url))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Failed to fetch user info: {}", e))?;
let user_info = if user_response.status().is_success() {
user_response.json::<serde_json::Value>()
.await
.unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
let mut user_info_map = HashMap::new();
if let Some(obj) = user_info.as_object() {
for (key, value) in obj {
user_info_map.insert(key.clone(), value.clone());
}
}
let mut auth_state = auth_state.lock().await;
auth_state.is_authenticated = true;
auth_state.access_token = Some(access_token.to_string());
auth_state.user_info = Some(user_info_map);
let auth_state_to_save = auth_state.clone();
drop(auth_state);
if let Err(e) = save_auth_state(auth_state_to_save).await {
push_log("error", "backend", format!("Failed to save auth state: {}", e));
}
push_log("info", "backend", "Direct OAuth authentication completed successfully!".to_string());
Ok(())
}
async fn validate_access_token(
access_token: String,
api_config: crate::config::ApiConfig,
auth_state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
client: reqwest::Client,
) -> Result<(), String> {
push_log("info", "backend", "Validating access token directly".to_string());
let response = client
.get(&format!("{}/api/v1/authenticated/me", api_config.base_url))
.bearer_auth(&access_token)
.send()
.await
.map_err(|e| format!("Failed to validate access token: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Access token validation failed: {}", error_text));
}
let user_info = response
.json::<serde_json::Value>()
.await
.map_err(|e| format!("Failed to parse user info response: {}", e))?;
let mut user_info_map = HashMap::new();
if let Some(obj) = user_info.as_object() {
for (key, value) in obj {
user_info_map.insert(key.clone(), value.clone());
}
}
let mut auth_state = auth_state.lock().await;
auth_state.is_authenticated = true;
auth_state.access_token = Some(access_token);
auth_state.user_info = Some(user_info_map);
let auth_state_to_save = auth_state.clone();
drop(auth_state);
if let Err(e) = save_auth_state(auth_state_to_save).await {
push_log("error", "backend", format!("Failed to save auth state: {}", e));
}
push_log("info", "backend", "Access token validation completed successfully!".to_string());
Ok(())
}
#[tauri::command]
pub async fn handle_deep_link_callback(
authorization_code: String,
state: String,
api_config: crate::config::ApiConfig,
auth_state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
pkce_state: State<'_, Arc<tauri::async_runtime::Mutex<Option<PkceState>>>>,
) -> Result<(), String> {
let stored_pkce = {
let pkce_guard = pkce_state.lock().await;
pkce_guard.clone()
};
let pkce = match stored_pkce {
Some(pkce) => {
if pkce.is_expired(600) {
return Err("PKCE state expired. Please restart authentication.".to_string());
}
if pkce.state != state {
return Err("State parameter mismatch. Possible CSRF attack.".to_string());
}
pkce
}
None => return Err("No PKCE state found. Please restart authentication.".to_string()),
};
let client = reqwest::Client::new();
let response = client
.post(&format!(
"{}/oauth/token",
api_config.base_url
))
.form(&[
("grant_type", "authorization_code"),
("code", &authorization_code),
("client_id", "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ"),
("redirect_uri", "hackatime://auth/callback"),
("code_verifier", &pkce.code_verifier),
])
.send()
.await
.map_err(|e| format!("Failed to exchange token: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Token exchange failed: {}", error_text));
}
let token_response: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse token response: {}", e))?;
let access_token = token_response["access_token"]
.as_str()
.ok_or("No access token in response")?;
let user_response = client
.get(&format!("{}/api/v1/authenticated/me", api_config.base_url))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Failed to fetch user info: {}", e))?;
let user_info = if user_response.status().is_success() {
user_response.json::<serde_json::Value>()
.await
.unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
let mut user_info_map = HashMap::new();
if let Some(obj) = user_info.as_object() {
for (key, value) in obj {
user_info_map.insert(key.clone(), value.clone());
}
}
let mut auth_state = auth_state.lock().await;
auth_state.is_authenticated = true;
auth_state.access_token = Some(access_token.to_string());
auth_state.user_info = Some(user_info_map);
let auth_state_to_save = auth_state.clone();
drop(auth_state);
if let Err(e) = save_auth_state(auth_state_to_save).await {
push_log("error", "backend", format!("Failed to save auth state: {}", e));
}
{
let mut stored_pkce = pkce_state.lock().await;
*stored_pkce = None;
}
push_log("info", "backend", "OAuth authentication completed successfully!".to_string());
Ok(())
}
#[tauri::command]
pub async fn save_auth_state(auth_state: AuthState) -> Result<(), String> {
push_log("debug", "backend", format!(
"save_auth_state called: authenticated={}, has_token={}",
auth_state.is_authenticated,
auth_state.access_token.is_some()
));
let db = Database::new().await?;
push_log("debug", "backend", "Database connection successful for save".to_string());
let db_auth_state = DbAuthState {
is_authenticated: auth_state.is_authenticated,
access_token: auth_state.access_token,
user_info: auth_state.user_info,
};
let session_id = db.save_session(&db_auth_state).await?;
push_log("info", "backend", format!("Session saved with ID: {}", session_id));
Ok(())
}
#[tauri::command]
pub async fn load_auth_state() -> Result<Option<AuthState>, String> {
push_log("debug", "backend", "load_auth_state called".to_string());
let db = Database::new().await?;
push_log("debug", "backend", "Database connection successful".to_string());
match db.load_latest_session().await? {
Some(db_auth_state) => {
push_log("debug", "backend", format!(
"Found saved session: authenticated={}, has_token={}",
db_auth_state.is_authenticated,
db_auth_state.access_token.is_some()
));
let auth_state = AuthState {
is_authenticated: db_auth_state.is_authenticated,
access_token: db_auth_state.access_token,
user_info: db_auth_state.user_info,
};
Ok(Some(auth_state))
}
None => {
push_log("debug", "backend", "No saved sessions found".to_string());
Ok(None)
}
}
}
#[tauri::command]
pub async fn clear_auth_state() -> Result<(), String> {
let db = Database::new().await?;
db.clear_sessions().await?;
Ok(())
}

39
src-tauri/src/config.rs Normal file
View file

@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ApiConfig {
pub base_url: String,
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
base_url: "https://hackatime.hackclub.com".to_string(),
}
}
}
impl ApiConfig {
pub fn new() -> Self {
Self {
base_url: "https://hackatime.hackclub.com".to_string(),
}
}
}
#[tauri::command]
pub async fn get_api_config(state: State<'_, ApiConfig>) -> Result<ApiConfig, String> {
Ok(state.inner().clone())
}
#[tauri::command]
pub async fn set_api_config(
new_config: ApiConfig,
state: State<'_, tauri::async_runtime::Mutex<ApiConfig>>,
) -> Result<(), String> {
let mut config = state.lock().await;
*config = new_config;
Ok(())
}

View file

@ -6,6 +6,7 @@ use std::env;
use std::fs;
use std::path::Path;
use uuid::Uuid;
use crate::push_log;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AuthState {
@ -20,7 +21,7 @@ pub struct SessionRecord {
pub id: String,
pub is_authenticated: bool,
pub access_token: Option<String>,
pub user_info: Option<String>, // JSON string
pub user_info: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_accessed_at: DateTime<Utc>,
@ -34,16 +35,16 @@ impl Database {
pub async fn new() -> Result<Self, String> {
let db_path = get_hackatime_db_path()?;
// Ensure the hackatime directory exists
if let Some(parent) = db_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create hackatime directory: {}", e))?;
println!("Created directory: {}", parent.display());
push_log("info", "backend", format!("Created directory: {}", parent.display()));
}
}
// Ensure the parent directory exists and is writable
if let Some(parent) = db_path.parent() {
if !parent.exists() {
return Err(format!(
@ -52,7 +53,7 @@ impl Database {
));
}
// Test if we can write to the directory
let test_file = parent.join(".write_test");
if let Err(e) = fs::write(&test_file, "test") {
return Err(format!(
@ -61,19 +62,19 @@ impl Database {
e
));
}
// Clean up test file
let _ = fs::remove_file(&test_file);
println!("Directory is writable: {}", parent.display());
push_log("debug", "backend", format!("Directory is writable: {}", parent.display()));
}
// Create the database file if it doesn't exist
if !db_path.exists() {
println!(
push_log("info", "backend", format!(
"Database file doesn't exist, creating: {}",
db_path.display()
);
// Touch the file to ensure it exists
));
if let Err(e) = fs::write(&db_path, "") {
return Err(format!(
"Cannot create database file {}: {}",
@ -82,26 +83,26 @@ impl Database {
));
}
} else {
println!("Database file already exists: {}", db_path.display());
push_log("debug", "backend", format!("Database file already exists: {}", db_path.display()));
}
// Check file permissions
if let Ok(metadata) = fs::metadata(&db_path) {
println!("Database file metadata: {:?}", metadata);
push_log("debug", "backend", format!("Database file metadata: {:?}", metadata));
}
// Try using SqlitePool::connect_with instead of connect
let database_url = format!("sqlite:{}", db_path.display());
println!("Connecting to database at: {}", database_url);
push_log("info", "backend", format!("Connecting to database at: {}", database_url));
// First try the standard connect method
let pool_result = SqlitePool::connect(&database_url).await;
let pool = match pool_result {
Ok(pool) => pool,
Err(e) => {
println!("Standard connect failed: {}, trying connect_with", e);
// Try with explicit options
push_log("warn", "backend", format!("Standard connect failed: {}, trying connect_with", e));
let options = sqlx::sqlite::SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true);
@ -119,7 +120,7 @@ impl Database {
let db = Database { pool };
db.migrate().await?;
println!("Database initialized successfully");
push_log("info", "backend", "Database initialized successfully".to_string());
Ok(db)
}
@ -141,6 +142,20 @@ impl Database {
.await
.map_err(|e| format!("Failed to create sessions table: {}", e))?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS statistics_cache (
cache_key TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
)
"#,
)
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to create statistics_cache table: {}", e))?;
Ok(())
}
@ -236,13 +251,13 @@ impl Database {
Some(json) => {
match serde_json::from_str::<HashMap<String, serde_json::Value>>(&json) {
Ok(info) => Some(info),
Err(_) => None, // Skip invalid JSON
Err(_) => None,
}
}
None => None,
};
// Update last_accessed_at
self.update_last_accessed(&session_id).await?;
Ok(Some(AuthState {
@ -288,35 +303,114 @@ impl Database {
Ok(())
}
pub async fn get_cached_data(&self, cache_key: &str) -> Result<Option<String>, String> {
let now = Utc::now();
let row = sqlx::query(
r#"
SELECT data, expires_at
FROM statistics_cache
WHERE cache_key = ?
"#,
)
.bind(cache_key)
.fetch_optional(&self.pool)
.await
.map_err(|e| format!("Failed to fetch cached data: {}", e))?;
match row {
Some(row) => {
let expires_at: String = row.get("expires_at");
let expires_at_dt = DateTime::parse_from_rfc3339(&expires_at)
.map_err(|e| format!("Failed to parse expiration date: {}", e))?;
if expires_at_dt > now {
let data: String = row.get("data");
Ok(Some(data))
} else {
sqlx::query("DELETE FROM statistics_cache WHERE cache_key = ?")
.bind(cache_key)
.execute(&self.pool)
.await
.ok();
Ok(None)
}
}
None => Ok(None),
}
}
pub async fn set_cached_data(&self, cache_key: &str, data: &str, ttl_days: i64) -> Result<(), String> {
let now = Utc::now();
let expires_at = now + chrono::Duration::days(ttl_days);
sqlx::query(
r#"
INSERT OR REPLACE INTO statistics_cache (cache_key, data, created_at, expires_at)
VALUES (?, ?, ?, ?)
"#,
)
.bind(cache_key)
.bind(data)
.bind(now.to_rfc3339())
.bind(expires_at.to_rfc3339())
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to cache data: {}", e))?;
Ok(())
}
pub async fn clear_all_cache(&self) -> Result<(), String> {
sqlx::query("DELETE FROM statistics_cache")
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to clear cache: {}", e))?;
Ok(())
}
pub async fn cleanup_expired_cache(&self) -> Result<(), String> {
let now = Utc::now();
sqlx::query("DELETE FROM statistics_cache WHERE expires_at < ?")
.bind(now.to_rfc3339())
.execute(&self.pool)
.await
.map_err(|e| format!("Failed to cleanup expired cache: {}", e))?;
Ok(())
}
}
fn get_hackatime_db_path() -> Result<std::path::PathBuf, String> {
let app_data_dir = get_app_data_dir()?;
let db_path = app_data_dir.join("sessions.db");
println!("Database path: {}", db_path.display());
println!(
push_log("debug", "backend", format!("Database path: {}", db_path.display()));
push_log("debug", "backend", format!(
"Parent directory exists: {}",
db_path.parent().map_or(false, |p| p.exists())
);
));
Ok(db_path)
}
fn get_app_data_dir() -> Result<std::path::PathBuf, String> {
if cfg!(target_os = "windows") {
// Windows: %APPDATA%\.hackatime\
let appdata = env::var("APPDATA").map_err(|_| "Failed to get APPDATA directory")?;
Ok(Path::new(&appdata).join(".hackatime"))
} else if cfg!(target_os = "macos") {
// macOS: ~/Library/Application Support/.hackatime/
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
Ok(Path::new(&home)
.join("Library")
.join("Application Support")
.join(".hackatime"))
} else {
// Linux: ~/.local/share/.hackatime/
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
Ok(Path::new(&home)
.join(".local")
@ -328,7 +422,7 @@ fn get_app_data_dir() -> Result<std::path::PathBuf, String> {
pub fn get_hackatime_config_dir() -> Result<std::path::PathBuf, String> {
let app_data_dir = get_app_data_dir()?;
// Create the directory if it doesn't exist
if !app_data_dir.exists() {
fs::create_dir_all(&app_data_dir)
.map_err(|e| format!("Failed to create hackatime directory: {}", e))?;
@ -341,7 +435,7 @@ pub fn get_hackatime_logs_dir() -> Result<std::path::PathBuf, String> {
let config_dir = get_hackatime_config_dir()?;
let logs_dir = config_dir.join("logs");
// Create the logs directory if it doesn't exist
if !logs_dir.exists() {
fs::create_dir_all(&logs_dir)
.map_err(|e| format!("Failed to create logs directory: {}", e))?;
@ -354,7 +448,7 @@ pub fn get_hackatime_data_dir() -> Result<std::path::PathBuf, String> {
let config_dir = get_hackatime_config_dir()?;
let data_dir = config_dir.join("data");
// Create the data directory if it doesn't exist
if !data_dir.exists() {
fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
@ -363,6 +457,7 @@ pub fn get_hackatime_data_dir() -> Result<std::path::PathBuf, String> {
Ok(data_dir)
}
#[tauri::command]
pub fn get_platform_info() -> Result<serde_json::Value, String> {
let app_data_dir = get_app_data_dir()?;

View file

@ -0,0 +1,86 @@
use crate::database::{get_hackatime_config_dir, get_hackatime_data_dir, get_hackatime_logs_dir, get_platform_info, Database};
use crate::push_log;
#[tauri::command]
pub async fn get_hackatime_directories() -> Result<serde_json::Value, String> {
let config_dir = get_hackatime_config_dir()?;
let logs_dir = get_hackatime_logs_dir()?;
let data_dir = get_hackatime_data_dir()?;
Ok(serde_json::json!({
"config_dir": config_dir.to_string_lossy(),
"logs_dir": logs_dir.to_string_lossy(),
"data_dir": data_dir.to_string_lossy()
}))
}
#[tauri::command]
pub async fn cleanup_old_sessions(days_old: i64) -> Result<(), String> {
let db = Database::new().await?;
db.cleanup_old_sessions(days_old).await?;
Ok(())
}
#[tauri::command]
pub async fn clear_statistics_cache() -> Result<(), String> {
push_log("info", "backend", "Clearing statistics cache...".to_string());
let db = Database::new().await?;
db.cleanup_expired_cache().await?;
db.clear_all_cache().await?;
push_log("info", "backend", "Statistics cache cleared successfully".to_string());
Ok(())
}
#[tauri::command]
pub async fn get_session_stats() -> Result<serde_json::Value, String> {
let platform_info = get_platform_info()?;
Ok(serde_json::json!({
"platform_info": platform_info,
"database_path": get_hackatime_config_dir()?.join("sessions.db").to_string_lossy(),
"directories_created": {
"config": get_hackatime_config_dir()?.exists(),
"logs": get_hackatime_logs_dir()?.exists(),
"data": get_hackatime_data_dir()?.exists()
}
}))
}
#[tauri::command]
pub async fn test_database_connection() -> Result<serde_json::Value, String> {
let config_dir = get_hackatime_config_dir()?;
let logs_dir = get_hackatime_logs_dir()?;
let data_dir = get_hackatime_data_dir()?;
let db_result = Database::new().await;
let db_success = db_result.is_ok();
let db_error = if let Err(e) = db_result {
Some(e)
} else {
None
};
Ok(serde_json::json!({
"directories": {
"config_exists": config_dir.exists(),
"logs_exists": logs_dir.exists(),
"data_exists": data_dir.exists(),
"config_path": config_dir.to_string_lossy(),
"logs_path": logs_dir.to_string_lossy(),
"data_path": data_dir.to_string_lossy()
},
"database": {
"connection_success": db_success,
"error": db_error,
"db_path": config_dir.join("sessions.db").to_string_lossy()
}
}))
}

View file

@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::sync::Mutex;
use crate::session::HeartbeatData;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DiscordRpcState {
pub is_connected: bool,
@ -37,7 +39,7 @@ impl DiscordRpcService {
}
pub fn connect(&mut self, client_id: &str) -> Result<(), String> {
// Close existing connection if any
if self.client.is_some() {
let _ = self.disconnect();
}
@ -50,7 +52,7 @@ impl DiscordRpcService {
self.client = Some(client);
// Update state
let mut state = self.state.lock().unwrap();
state.is_connected = true;
state.client_id = Some(client_id.to_string());
@ -65,7 +67,7 @@ impl DiscordRpcService {
.map_err(|e| format!("Failed to disconnect from Discord: {}", e))?;
}
// Update state
let mut state = self.state.lock().unwrap();
state.is_connected = false;
state.client_id = None;
@ -77,7 +79,7 @@ impl DiscordRpcService {
pub fn set_activity(&mut self, activity: DiscordActivity) -> Result<(), String> {
self.set_activity_internal(activity.clone())?;
// Update state
let mut state = self.state.lock().unwrap();
state.current_activity = Some(activity);
@ -87,7 +89,7 @@ impl DiscordRpcService {
fn set_activity_internal(&mut self, activity: DiscordActivity) -> Result<(), String> {
let client = self.client.as_mut().ok_or("Discord client not connected")?;
// Build details string
let mut details_parts = Vec::new();
if let Some(language) = &activity.language {
@ -109,20 +111,20 @@ impl DiscordRpcService {
None
};
// Create activity with all components
let mut discord_activity = activity::Activity::new().state(&activity.project_name);
if let Some(details) = &details_string {
discord_activity = discord_activity.details(details);
}
// Set start time if provided
if let Some(start_time) = activity.start_time {
discord_activity =
discord_activity.timestamps(activity::Timestamps::new().start(start_time));
}
// Add assets
discord_activity = discord_activity.assets(
activity::Assets::new()
.large_image("kubetime")
@ -145,7 +147,7 @@ impl DiscordRpcService {
.clear_activity()
.map_err(|e| format!("Failed to clear Discord activity: {}", e))?;
// Update state
let mut state = self.state.lock().unwrap();
state.current_activity = None;
@ -162,7 +164,7 @@ impl DiscordRpcService {
pub fn update_activity_from_heartbeat(
&mut self,
heartbeat_data: &crate::HeartbeatData,
heartbeat_data: &HeartbeatData,
) -> Result<(), String> {
let activity = DiscordActivity {
project_name: heartbeat_data
@ -180,7 +182,7 @@ impl DiscordRpcService {
pub fn update_activity_from_session(
&mut self,
heartbeat_data: &crate::HeartbeatData,
heartbeat_data: &HeartbeatData,
session_start_time: i64,
) -> Result<(), String> {
let activity = DiscordActivity {
@ -208,3 +210,76 @@ impl Drop for DiscordRpcService {
let _ = self.disconnect();
}
}
use tauri::State;
#[tauri::command]
pub async fn discord_rpc_connect(
client_id: String,
state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<(), String> {
let mut rpc_service = state.lock().await;
rpc_service.connect(&client_id)
}
#[tauri::command]
pub async fn discord_rpc_disconnect(
state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<(), String> {
let mut rpc_service = state.lock().await;
rpc_service.disconnect()
}
#[tauri::command]
pub async fn discord_rpc_set_activity(
activity: DiscordActivity,
state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<(), String> {
let mut rpc_service = state.lock().await;
rpc_service.set_activity(activity)
}
#[tauri::command]
pub async fn discord_rpc_clear_activity(
state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<(), String> {
let mut rpc_service = state.lock().await;
rpc_service.clear_activity()
}
#[tauri::command]
pub async fn discord_rpc_get_state(
state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<DiscordRpcState, String> {
let rpc_service = state.lock().await;
Ok(rpc_service.get_state())
}
#[tauri::command]
pub async fn discord_rpc_update_from_heartbeat(
heartbeat_data: HeartbeatData,
state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<(), String> {
let mut rpc_service = state.lock().await;
rpc_service.update_activity_from_heartbeat(&heartbeat_data)
}
#[tauri::command]
pub async fn discord_rpc_auto_connect(
client_id: String,
discord_rpc_state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<(), String> {
let mut rpc_service = discord_rpc_state.lock().await;
rpc_service.connect(&client_id)
}
#[tauri::command]
pub async fn discord_rpc_auto_disconnect(
discord_rpc_state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<(), String> {
let mut rpc_service = discord_rpc_state.lock().await;
rpc_service.disconnect()
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {

86
src-tauri/src/menu.rs Normal file
View file

@ -0,0 +1,86 @@
use tauri::{AppHandle, Manager};
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
use crate::push_log;
pub fn setup_app_menu(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let quit_item = PredefinedMenuItem::quit(app, Some("Quit Hackatime"))?;
let about_menu = Submenu::with_items(
app,
"Hackatime",
true,
&[
&quit_item,
],
)?;
let file_new = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>)?;
let file_hide = MenuItem::with_id(app, "hide", "Hide Window", true, None::<&str>)?;
let file_menu = Submenu::with_items(
app,
"File",
true,
&[
&file_new,
&file_hide,
],
)?;
let edit_undo = PredefinedMenuItem::undo(app, Some("Undo"))?;
let edit_redo = PredefinedMenuItem::redo(app, Some("Redo"))?;
let edit_cut = PredefinedMenuItem::cut(app, Some("Cut"))?;
let edit_copy = PredefinedMenuItem::copy(app, Some("Copy"))?;
let edit_paste = PredefinedMenuItem::paste(app, Some("Paste"))?;
let edit_select_all = PredefinedMenuItem::select_all(app, Some("Select All"))?;
let edit_menu = Submenu::with_items(
app,
"Edit",
true,
&[
&edit_undo,
&edit_redo,
&PredefinedMenuItem::separator(app)?,
&edit_cut,
&edit_copy,
&edit_paste,
&PredefinedMenuItem::separator(app)?,
&edit_select_all,
],
)?;
let help_item = MenuItem::with_id(app, "help", "Help", true, None::<&str>)?;
let help_menu = Submenu::with_items(app, "Help", true, &[&help_item])?;
let app_menu = Menu::with_items(app, &[&about_menu, &file_menu, &edit_menu, &help_menu])?;
app.set_menu(app_menu)?;
app.on_menu_event(|app, event| {
match event.id.as_ref() {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"hide" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
"help" => {
push_log("info", "backend", "📖 Help: Window closes to tray. Use the menu bar or tray icon to reopen.".to_string());
}
_ => {}
}
});
Ok(())
}

View file

@ -0,0 +1,134 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::AppHandle;
use tauri_plugin_autostart::ManagerExt;
use crate::database::get_hackatime_config_dir;
use crate::push_log;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Preferences {
pub autostart_enabled: bool,
pub notifications_enabled: bool,
pub discord_rpc_enabled: bool,
}
impl Default for Preferences {
fn default() -> Self {
Self {
autostart_enabled: true,
notifications_enabled: true,
discord_rpc_enabled: true,
}
}
}
fn get_preferences_path() -> Result<PathBuf, String> {
let config_dir = get_hackatime_config_dir()?;
Ok(config_dir.join("preferences.json"))
}
pub fn load_preferences() -> Result<Preferences, String> {
let path = get_preferences_path()?;
if !path.exists() {
push_log("info", "backend", "No preferences file found, using defaults".to_string());
return Ok(Preferences::default());
}
let contents = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read preferences file: {}", e))?;
let preferences: Preferences = serde_json::from_str(&contents)
.map_err(|e| format!("Failed to parse preferences: {}", e))?;
push_log("info", "backend", "Loaded preferences successfully".to_string());
Ok(preferences)
}
pub fn save_preferences(preferences: &Preferences) -> Result<(), String> {
let path = get_preferences_path()?;
let contents = serde_json::to_string_pretty(preferences)
.map_err(|e| format!("Failed to serialize preferences: {}", e))?;
fs::write(&path, contents)
.map_err(|e| format!("Failed to write preferences file: {}", e))?;
push_log("info", "backend", "Saved preferences successfully".to_string());
Ok(())
}
#[tauri::command]
pub fn get_preferences() -> Result<Preferences, String> {
load_preferences()
}
#[tauri::command]
pub fn set_autostart_enabled(app: AppHandle, enabled: bool) -> Result<(), String> {
let mut preferences = load_preferences().unwrap_or_default();
preferences.autostart_enabled = enabled;
save_preferences(&preferences)?;
let autolaunch_manager = app.autolaunch();
if enabled {
autolaunch_manager.enable()
.map_err(|e| format!("Failed to enable autostart: {}", e))?;
push_log("info", "backend", "Autostart enabled".to_string());
} else {
autolaunch_manager.disable()
.map_err(|e| format!("Failed to disable autostart: {}", e))?;
push_log("info", "backend", "Autostart disabled".to_string());
}
Ok(())
}
#[tauri::command]
pub fn get_autostart_enabled() -> Result<bool, String> {
let preferences = load_preferences().unwrap_or_default();
Ok(preferences.autostart_enabled)
}
#[tauri::command]
pub fn set_notifications_enabled(enabled: bool) -> Result<(), String> {
let mut preferences = load_preferences().unwrap_or_default();
preferences.notifications_enabled = enabled;
save_preferences(&preferences)?;
if enabled {
push_log("info", "backend", "Notifications enabled".to_string());
} else {
push_log("info", "backend", "Notifications disabled".to_string());
}
Ok(())
}
#[tauri::command]
pub fn get_notifications_enabled() -> Result<bool, String> {
let preferences = load_preferences().unwrap_or_default();
Ok(preferences.notifications_enabled)
}
#[tauri::command]
pub fn set_discord_rpc_enabled(enabled: bool) -> Result<(), String> {
let mut preferences = load_preferences().unwrap_or_default();
preferences.discord_rpc_enabled = enabled;
save_preferences(&preferences)?;
if enabled {
push_log("info", "backend", "Discord RPC enabled".to_string());
} else {
push_log("info", "backend", "Discord RPC disabled".to_string());
}
Ok(())
}
#[tauri::command]
pub fn get_discord_rpc_enabled() -> Result<bool, String> {
let preferences = load_preferences().unwrap_or_default();
Ok(preferences.discord_rpc_enabled)
}

117
src-tauri/src/projects.rs Normal file
View file

@ -0,0 +1,117 @@
use std::sync::Arc;
use tauri::State;
use crate::auth::AuthState;
use crate::config::ApiConfig;
use crate::push_log;
#[tauri::command]
pub async fn get_projects(
api_config: ApiConfig,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<serde_json::Value, String> {
let auth_state = state.lock().await;
if !auth_state.is_authenticated {
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
.ok_or("No access token available")?;
let client = reqwest::Client::new();
let response = client
.get(&format!(
"{}/api/v1/authenticated/projects",
base_url
))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Failed to fetch projects: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Projects request failed: {}", error_text));
}
let projects_response: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse projects response: {}", e))?;
push_log(
"info",
"backend",
format!(
"RAW PROJECTS API RESPONSE: {}",
serde_json::to_string_pretty(&projects_response)
.unwrap_or_else(|_| "Failed to serialize".to_string())
),
);
Ok(projects_response)
}
#[tauri::command]
pub async fn get_project_details(
project_name: String,
api_config: ApiConfig,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
) -> Result<serde_json::Value, String> {
let auth_state = state.lock().await;
if !auth_state.is_authenticated {
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
.ok_or("No access token available")?;
let client = reqwest::Client::new();
let response = client
.get(&format!(
"{}/api/v1/authenticated/projects/{}",
base_url,
urlencoding::encode(&project_name)
))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Failed to fetch project details: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(format!("Project details request failed: {}", error_text));
}
let project_response: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse project response: {}", e))?;
Ok(project_response)
}

313
src-tauri/src/session.rs Normal file
View file

@ -0,0 +1,313 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::State;
use crate::auth::AuthState;
use crate::config::ApiConfig;
use crate::discord_rpc::DiscordRpcService;
use crate::push_log;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HeartbeatData {
pub id: u32,
pub project: Option<String>,
pub editor: Option<String>,
pub language: Option<String>,
pub entity: Option<String>,
pub time: f64,
#[serde(default)]
pub timestamp: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub operating_system: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub machine: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SessionState {
pub is_active: bool,
pub start_time: Option<i64>,
pub last_heartbeat_id: Option<u32>,
pub heartbeat_count: u32,
pub project: Option<String>,
pub editor: Option<String>,
pub language: Option<String>,
pub entity: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HeartbeatResponse {
pub heartbeat: Option<HeartbeatData>,
}
#[tauri::command]
pub async fn get_latest_heartbeat(
api_config: ApiConfig,
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
discord_rpc_state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
session_state: State<'_, Arc<tauri::async_runtime::Mutex<SessionState>>>,
) -> Result<HeartbeatResponse, String> {
let auth_state = state.lock().await;
if !auth_state.is_authenticated {
return Err("Not authenticated".to_string());
}
let base_url = if api_config.base_url.is_empty() {
"https://hackatime.hackclub.com"
} else {
&api_config.base_url
};
let access_token = auth_state
.access_token
.as_ref()
.ok_or("No access token available")?;
let client = reqwest::Client::new();
let request_url = format!(
"{}/api/v1/authenticated/heartbeats/latest",
base_url
);
push_log("info", "backend", format!("Fetching latest heartbeat from: {}", request_url));
let response = client
.get(&request_url)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| {
push_log("error", "backend", format!("HTTP request failed: {}", e));
format!("Failed to get latest heartbeat: {}", e)
})?;
let status = response.status();
push_log("info", "backend", format!("API response status: {} {}", status.as_u16(), status.canonical_reason().unwrap_or("")));
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
push_log("error", "backend", format!("API returned error response: {}", error_text));
if status == 429 {
push_log("warn", "backend", "Rate limited, will retry later".to_string());
return Err(format!("Rate limited: {}", error_text));
}
return Err(format!("Failed to get latest heartbeat: {}", error_text));
}
let response_text = response
.text()
.await
.map_err(|e| {
push_log("error", "backend", format!("Failed to read response body: {}", e));
format!("Failed to read response: {}", e)
})?;
push_log("info", "backend", format!("Raw API response: {}", response_text));
let heartbeat_data: Option<HeartbeatData> = if response_text.trim() == "null" || response_text.trim().is_empty() {
None
} else {
match serde_json::from_str::<HeartbeatData>(&response_text) {
Ok(mut data) => {
if data.timestamp == 0 {
data.timestamp = data.time as i64;
}
push_log("info", "backend", format!("Successfully parsed heartbeat data: {:?}", data));
Some(data)
}
Err(e) => {
push_log("error", "backend", format!("Failed to parse heartbeat JSON: {}", e));
None
}
}
};
let heartbeat_response = HeartbeatResponse {
heartbeat: heartbeat_data,
};
push_log("info", "backend", format!("Latest heartbeat response: {:?}", heartbeat_response));
if let Some(heartbeat) = &heartbeat_response.heartbeat {
push_log("info", "backend", format!(
"Heartbeat details - ID: {}, Project: {}, Language: {}, Editor: {}, Time: {}",
heartbeat.id,
heartbeat.project.as_ref().unwrap_or(&"None".to_string()),
heartbeat.language.as_ref().unwrap_or(&"None".to_string()),
heartbeat.editor.as_ref().unwrap_or(&"None".to_string()),
heartbeat.timestamp
));
let mut session = session_state.lock().await;
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let heartbeat_age = current_time - heartbeat.timestamp;
let is_recent = heartbeat_age < 180;
push_log("debug", "backend", format!(
"Heartbeat age: {} seconds, is_recent: {}",
heartbeat_age,
is_recent
));
let is_duplicate = session.last_heartbeat_id == Some(heartbeat.id);
if is_duplicate && is_recent {
push_log("debug", "backend", "Duplicate heartbeat but still recent, continuing session".to_string());
} else if is_duplicate && !is_recent {
push_log("info", "backend", "Duplicate heartbeat and too old, ending session".to_string());
session.is_active = false;
session.start_time = None;
session.last_heartbeat_id = None;
session.heartbeat_count = 0;
session.project = None;
session.editor = None;
session.language = None;
session.entity = None;
let mut discord_rpc = discord_rpc_state.lock().await;
if discord_rpc.is_connected() {
let _ = discord_rpc.clear_activity();
}
} else if is_recent && !session.is_active {
push_log("info", "backend", "Recent heartbeat detected, starting new session".to_string());
session.is_active = true;
session.start_time = Some(heartbeat.timestamp);
session.last_heartbeat_id = Some(heartbeat.id);
session.heartbeat_count = 1;
session.project = heartbeat.project.clone();
session.editor = heartbeat.editor.clone();
session.language = heartbeat.language.clone();
session.entity = heartbeat.entity.clone();
let mut discord_rpc = discord_rpc_state.lock().await;
if discord_rpc.is_connected() {
if let Err(e) =
discord_rpc.update_activity_from_session(heartbeat, heartbeat.timestamp)
{
push_log("warn", "backend", format!("Failed to update Discord RPC: {}", e));
}
}
} else if is_recent && session.is_active {
session.last_heartbeat_id = Some(heartbeat.id);
session.heartbeat_count += 1;
session.project = heartbeat.project.clone();
session.editor = heartbeat.editor.clone();
session.language = heartbeat.language.clone();
session.entity = heartbeat.entity.clone();
let mut discord_rpc = discord_rpc_state.lock().await;
if discord_rpc.is_connected() {
if let Err(e) = discord_rpc.update_activity_from_session(
heartbeat,
session.start_time.unwrap_or(heartbeat.timestamp),
) {
push_log("warn", "backend", format!("Failed to update Discord RPC: {}", e));
}
}
} else if !is_recent && session.is_active {
push_log("info", "backend", "Heartbeat too old, ending session".to_string());
session.is_active = false;
session.start_time = None;
session.last_heartbeat_id = None;
session.heartbeat_count = 0;
session.project = None;
session.editor = None;
session.language = None;
session.entity = None;
let mut discord_rpc = discord_rpc_state.lock().await;
if discord_rpc.is_connected() {
let _ = discord_rpc.clear_activity();
}
}
} else {
push_log("info", "backend", "No heartbeat data in response (heartbeat is null)".to_string());
let mut session = session_state.lock().await;
if session.is_active {
push_log("info", "backend", "No heartbeat data, ending session".to_string());
session.is_active = false;
session.start_time = None;
session.last_heartbeat_id = None;
session.heartbeat_count = 0;
session.project = None;
session.editor = None;
session.language = None;
session.entity = None;
let mut discord_rpc = discord_rpc_state.lock().await;
if discord_rpc.is_connected() {
let _ = discord_rpc.clear_activity();
}
}
}
Ok(heartbeat_response)
}
#[tauri::command]
pub async fn get_current_session(
session_state: State<'_, Arc<tauri::async_runtime::Mutex<SessionState>>>,
) -> Result<SessionState, String> {
let session = session_state.lock().await;
Ok(session.clone())
}
#[tauri::command]
pub async fn get_app_status(
auth_state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
session_state: State<'_, Arc<tauri::async_runtime::Mutex<SessionState>>>,
discord_rpc_state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
) -> Result<serde_json::Value, String> {
let auth = auth_state.lock().await;
let session = session_state.lock().await;
let discord_rpc = discord_rpc_state.lock().await;
Ok(serde_json::json!({
"authenticated": auth.is_authenticated,
"session_active": session.is_active,
"session_duration": if session.is_active && session.start_time.is_some() {
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
current_time - session.start_time.unwrap()
} else {
0
},
"project": session.project.clone().unwrap_or_else(|| "No project".to_string()),
"editor": session.editor.clone().unwrap_or_else(|| "No editor".to_string()),
"language": session.language.clone().unwrap_or_else(|| "No language".to_string()),
"discord_connected": discord_rpc.is_connected(),
"heartbeat_count": session.heartbeat_count
}))
}

250
src-tauri/src/setup.rs Normal file
View file

@ -0,0 +1,250 @@
use std::fs;
use std::path::Path;
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
pub struct WakatimeConfigCheck {
pub exists: bool,
pub matches: bool,
pub expected_content: String,
pub actual_content: Option<String>,
pub config_path: String,
}
fn get_wakatime_config_path() -> Result<String, String> {
#[cfg(target_os = "windows")]
{
let userprofile = std::env::var("USERPROFILE")
.map_err(|_| "Failed to get USERPROFILE directory")?;
Ok(format!("{}\\.wakatime.cfg", userprofile))
}
#[cfg(not(target_os = "windows"))]
{
let home_dir = std::env::var("HOME")
.map_err(|_| "Failed to get home directory")?;
Ok(format!("{}/.wakatime.cfg", home_dir))
}
}
fn get_expected_config_content(api_key: &str, api_url: &str) -> String {
#[cfg(target_os = "windows")]
{
format!(
"[settings]\r\napi_url = {}\r\napi_key = {}\r\nheartbeat_rate_limit_seconds = 30\r\n",
api_url, api_key
)
}
#[cfg(not(target_os = "windows"))]
{
format!(
"[settings]\napi_url = {}\napi_key = {}\nheartbeat_rate_limit_seconds = 30\n",
api_url, api_key
)
}
}
fn check_config_has_required_values(content: &str, api_key: &str, api_url: &str) -> bool {
let normalized = content.replace("\r\n", "\n");
let mut found_api_url = false;
let mut found_api_key = false;
for line in normalized.lines() {
let trimmed = line.trim();
if trimmed.starts_with("api_url") {
if let Some(value) = trimmed.split('=').nth(1) {
let value = value.trim();
if value == api_url {
found_api_url = true;
}
}
} else if trimmed.starts_with("api_key") {
if let Some(value) = trimmed.split('=').nth(1) {
let value = value.trim();
if value == api_key {
found_api_key = true;
}
}
}
}
found_api_url && found_api_key
}
#[tauri::command]
pub async fn check_wakatime_config(api_key: String, api_url: String) -> Result<WakatimeConfigCheck, String> {
let config_path = get_wakatime_config_path()?;
let expected_content = get_expected_config_content(&api_key, &api_url);
let exists = Path::new(&config_path).exists();
let actual_content = if exists {
match fs::read_to_string(&config_path) {
Ok(content) => Some(content),
Err(e) => return Err(format!("Failed to read config file: {}", e)),
}
} else {
None
};
let matches = if let Some(ref actual) = actual_content {
check_config_has_required_values(actual, &api_key, &api_url)
} else {
false
};
Ok(WakatimeConfigCheck {
exists,
matches,
expected_content,
actual_content,
config_path,
})
}
#[tauri::command]
pub async fn apply_wakatime_config(api_key: String, api_url: String) -> Result<String, String> {
let config_path = get_wakatime_config_path()?;
let backup_path = format!("{}.bak", config_path);
if Path::new(&config_path).exists() {
if let Err(e) = fs::copy(&config_path, &backup_path) {
return Err(format!("Failed to backup existing config: {}", e));
}
}
let config_content = get_expected_config_content(&api_key, &api_url);
if let Err(e) = fs::write(&config_path, &config_content) {
return Err(format!("Failed to write config file: {}", e));
}
Ok(format!("Config file successfully written to {}", config_path))
}
#[tauri::command]
pub async fn setup_hackatime_macos_linux(api_key: String, api_url: String) -> Result<String, String> {
let home_dir = std::env::var("HOME").map_err(|_| "Failed to get home directory")?;
let config_path = format!("{}/.wakatime.cfg", home_dir);
let backup_path = format!("{}/.wakatime.cfg.bak", home_dir);
if Path::new(&config_path).exists() {
if let Err(e) = fs::rename(&config_path, &backup_path) {
return Err(format!("Failed to backup existing config: {}", e));
}
}
let config_content = format!(
"[settings]\napi_url = {}\napi_key = {}\nheartbeat_rate_limit_seconds = 30\n",
api_url, api_key
);
if let Err(e) = fs::write(&config_path, config_content) {
return Err(format!("Failed to write config file: {}", e));
}
if !Path::new(&config_path).exists() {
return Err("Config file was not created".to_string());
}
let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?;
if !check_config_has_required_values(&config_content, &api_key, &api_url) {
return Err("Config file is missing required api_url and api_key values".to_string());
}
Ok(format!(
"Config file created successfully at {}",
config_path
))
}
#[tauri::command]
pub async fn setup_hackatime_windows(api_key: String, api_url: String) -> Result<String, String> {
let userprofile =
std::env::var("USERPROFILE").map_err(|_| "Failed to get USERPROFILE directory")?;
let config_path = format!("{}\\.wakatime.cfg", userprofile);
let backup_path = format!("{}\\.wakatime.cfg.bak", userprofile);
if Path::new(&config_path).exists() {
if let Err(e) = fs::rename(&config_path, &backup_path) {
return Err(format!("Failed to backup existing config: {}", e));
}
}
let config_content = format!(
"[settings]\r\napi_url = {}\r\napi_key = {}\r\nheartbeat_rate_limit_seconds = 30\r\n",
api_url, api_key
);
if let Err(e) = fs::write(&config_path, config_content) {
return Err(format!("Failed to write config file: {}", e));
}
if !Path::new(&config_path).exists() {
return Err("Config file was not created".to_string());
}
let config_content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?;
if !check_config_has_required_values(&config_content, &api_key, &api_url) {
return Err("Config file is missing required api_url and api_key values".to_string());
}
Ok(format!(
"Config file created successfully at {}",
config_path
))
}
#[tauri::command]
pub async fn test_hackatime_heartbeat(api_key: String, api_url: String) -> Result<String, String> {
let client = reqwest::Client::new();
let current_time = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let heartbeat_data = serde_json::json!([{
"type": "file",
"time": current_time,
"entity": "test.txt",
"language": "Text"
}]);
let response = client
.post(&format!("{}/users/current/heartbeats", api_url))
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&heartbeat_data)
.send()
.await
.map_err(|e| format!("Failed to send heartbeat: {}", e))?;
if response.status().is_success() {
Ok("Test heartbeat sent successfully!".to_string())
} else {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
Err(format!("Heartbeat failed: {}", error_text))
}
}
#[tauri::command]
pub async fn setup_hackatime_complete(api_key: String, api_url: String) -> Result<String, String> {
if cfg!(target_os = "windows") {
setup_hackatime_windows(api_key, api_url).await
} else {
setup_hackatime_macos_linux(api_key, api_url).await
}
}

1130
src-tauri/src/statistics.rs Normal file

File diff suppressed because it is too large Load diff

90
src-tauri/src/tray.rs Normal file
View file

@ -0,0 +1,90 @@
use tauri::{AppHandle, Manager};
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
let status_text = {
let state = app.state::<std::sync::Arc<tauri::async_runtime::Mutex<crate::SessionState>>>();
let state_arc = state.inner().clone();
tauri::async_runtime::block_on(async {
let guard = state_arc.lock().await;
if guard.is_active {
let project = guard.project.clone().unwrap_or_else(|| "Unknown".to_string());
format!("Status: Active — {}", project)
} else {
"No active session".to_string()
}
})
};
let status_item = MenuItem::with_id(app, "status", &status_text, false, None::<&str>)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[
&status_item,
&tauri::menu::PredefinedMenuItem::separator(app)?,
&quit_item,
])?;
let icon = app.default_window_icon()
.ok_or("No default window icon found")?
.clone();
let _tray_icon = TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.show_menu_on_left_click(true)
.on_menu_event(|app, event| {
match event.id.as_ref() {
"quit" => {
app.exit(0);
}
_ => {}
}
})
.on_tray_icon_event(|tray, event| {
match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
} else {
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
let _ = window.show();
let _ = window.set_focus();
}
}
}
TrayIconEvent::DoubleClick {
button: MouseButton::Left,
..
} => {
let app = tray.app_handle();
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {
}
}
})
.build(app)?;
Ok(())
}

57
src-tauri/src/window.rs Normal file
View file

@ -0,0 +1,57 @@
use tauri::Manager;
#[tauri::command]
pub async fn show_window(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
if let Some(window) = app.get_webview_window("main") {
window
.show()
.map_err(|e| format!("Failed to show window: {}", e))?;
window
.set_focus()
.map_err(|e| format!("Failed to focus window: {}", e))?;
}
Ok(())
}
#[tauri::command]
pub async fn hide_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
window
.hide()
.map_err(|e| format!("Failed to hide window: {}", e))?;
}
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
Ok(())
}
#[tauri::command]
pub async fn toggle_window(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
window
.hide()
.map_err(|e| format!("Failed to hide window: {}", e))?;
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Accessory);
} else {
#[cfg(target_os = "macos")]
let _ = app.set_activation_policy(tauri::ActivationPolicy::Regular);
window
.show()
.map_err(|e| format!("Failed to show window: {}", e))?;
window
.set_focus()
.map_err(|e| format!("Failed to focus window: {}", e))?;
}
}
Ok(())
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hackatime Desktop",
"version": "1.1.10",
"version": "1.7.5",
"identifier": "com.hackclub.hackatime",
"build": {
"beforeDevCommand": "pnpm dev",
@ -13,21 +13,25 @@
"windows": [
{
"title": "Hackatime Desktop",
"width": 800,
"height": 600,
"width": 1100,
"height": 700,
"minWidth": 1100,
"minHeight": 700,
"visible": true,
"skipTaskbar": false,
"decorations": true,
"decorations": false,
"alwaysOnTop": false,
"resizable": true,
"minimizable": true,
"maximizable": true,
"closable": true
"closable": true,
"hiddenTitle": true
}
],
"security": {
"csp": null
}
"csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' ipc: http://ipc.localhost https://hackatime.hackclub.com https://desktop.hackatime.hackclub-assets.com wss://*.ingest.us.sentry.io https://us.i.posthog.com https://fonts.googleapis.com https://fonts.gstatic.com; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; worker-src 'self' blob:;"
},
"withGlobalTauri": true
},
"bundle": {
"active": true,
@ -60,7 +64,7 @@
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdERjg4QTFCNTJFMDk0MUQKUldRZGxPQlNHNHI0ZlRkMDN0MGI1MnllY1dUVStZalV3dVdhcTFuREx5SGtBc0txQ2xnTWs3WU4K",
"endpoints": [
"https://pub-d35fbe65a5b5426bb6d62ff02a8c7d03.r2.dev/update-manifest.json"
"https://desktop.hackatime.hackclub-assets.com/update-manifest.json"
]
}
}

View file

@ -2,15 +2,51 @@
import { ref, onMounted, onUnmounted, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import { api } from "./api";
import { useTheme } from "./composables/useTheme";
import Home from "./views/Home.vue";
import Projects from "./views/Projects.vue";
import Settings from "./views/Settings.vue";
import Statistics from "./views/Statistics.vue";
import PresenceCard from "./components/PresenceCard.vue";
import TrendCard from "./components/TrendCard.vue";
import WeeklyChart from "./components/WeeklyChart.vue";
import Login from "./views/Login.vue";
import UserProfileCard from "./components/UserProfileCard.vue";
import CustomTitlebar from "./components/CustomTitlebar.vue";
import WakatimeSetupModal from "./components/WakatimeSetupModal.vue";
import UpdateNotification from "./components/UpdateNotification.vue";
import UpdateModal from "./components/UpdateModal.vue";
if (!(window as any).__hackatimeConsoleWrapped) {
(window as any).__hackatimeConsoleWrapped = true;
const originalConsole = { ...console } as any;
['debug','info','warn','error'].forEach((lvl) => {
const orig = (originalConsole as any)[lvl] || originalConsole.log;
(console as any)[lvl] = (...args: any[]) => {
try { orig.apply(originalConsole, args); } catch (_) {}
};
});
console.info('[CONSOLE] Console wrapper initialized - logs will be captured');
}
if (!(window as any).__hackatimeErrorHandlerSet) {
(window as any).__hackatimeErrorHandlerSet = true;
window.addEventListener('unhandledrejection', (event) => {
console.error('[UNHANDLED REJECTION]', event.reason);
const errorMessage = event.reason?.message || String(event.reason);
if (errorMessage.includes('callbackId') ||
errorMessage.includes('IPC') ||
errorMessage.includes('Load failed')) {
event.preventDefault();
console.warn('[IPC] Suppressed IPC-related error that was already logged');
}
});
window.addEventListener('error', (event) => {
console.error('[UNHANDLED ERROR]', event.error);
});
}
interface AuthState {
is_authenticated: boolean;
@ -43,18 +79,30 @@ const showApiKey = ref(false);
const hackatimeDirectories = ref<any>(null);
const sessionStats = ref<any>(null);
const presenceData = ref<any>(null);
const presenceRefreshInterval = ref<number | null>(null);
const presenceRefreshInterval = ref<ReturnType<typeof setInterval> | null>(null);
const presenceFetchInProgress = ref(false);
const updateCheckInterval = ref<ReturnType<typeof setInterval> | null>(null);
const oauthUrl = ref<string | null>(null);
const nextPresenceFetchAllowedAt = ref<number>(0);
const lastPresenceFetchAt = ref<number>(0);
const currentOs = ref<string | null>(null);
// Navigation state
const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home');
// Theme management
const { currentTheme, toggleTheme } = useTheme();
const showWakatimeSetupModal = ref(false);
const wakatimeConfigCheck = ref<any>(null);
const hasCheckedConfigThisSession = ref(false);
const updateAvailable = ref(false);
const updateVersion = ref<string>('');
const updateData = ref<any>(null);
const showUpdateModal = ref(false);
const isInstallingUpdate = ref(false);
const currentVersion = ref<string>('1.5.1');
const lastUpdateCheckTime = ref<number>(0);
const updateCheckInProgress = ref(false);
// Computed property for weekly chart data
const weeklyChartData = computed(() => {
if (!userStats.value?.weekly_stats?.daily_hours) {
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
@ -74,7 +122,6 @@ const weeklyChartData = computed(() => {
const dailyHours = userStats.value.weekly_stats.daily_hours;
const maxHours = Math.max(...Object.values(dailyHours).map((day: any) => day.hours), 1);
// Convert object to array and sort by date
return Object.values(dailyHours)
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map((day: any) => ({
@ -83,97 +130,89 @@ const weeklyChartData = computed(() => {
}));
});
// Computed property for trend data
const weeklyTrend = computed(() => {
if (!userStats.value?.weekly_stats) return null;
const currentWeekHours = (userStats.value.weekly_stats.time_coded_seconds || 0) / 3600;
const lastWeekHours = currentWeekHours * 0.85; // Simulate 15% increase
const change = ((currentWeekHours - lastWeekHours) / lastWeekHours * 100);
return {
title: change > 0 ? "You coded more than last week" : change < 0 ? "You coded less than last week" : "Same as last week",
change: change > 0 ? `+${Math.round(change)}%` : `${Math.round(change)}%`,
changeType: change > 0 ? 'increase' : change < 0 ? 'decrease' : 'neutral',
period: "vs last week",
icon: change > 0 ? "📈" : change < 0 ? "📉" : "➡️"
};
});
onMounted(async () => {
await loadAuthState();
await loadApiConfig();
await loadHackatimeInfo();
await loadCurrentOs();
// Detect if we're in development mode
// Check if we're running on localhost (development) or have debug features
isDevMode.value = apiConfig.value.base_url.includes('localhost') ||
apiConfig.value.base_url.includes('127.0.0.1') ||
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
try {
const appVersion = await invoke("get_app_version") as string;
currentVersion.value = appVersion;
} catch (error) {
console.warn("Failed to get app version:", error);
}
isDevMode.value = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.protocol === 'http:';
// Check if app was started via deep link
try {
const startUrls = await getCurrent();
if (startUrls && startUrls.length > 0) {
console.log("App started with deep link:", startUrls);
// Check if it's an OAuth callback
const hasOAuthCallback = startUrls.some(url =>
url.startsWith('hackatime://auth/callback')
);
if (hasOAuthCallback) {
console.log("OAuth callback detected, refreshing auth state...");
// The Rust backend will handle the deep link processing
// We just need to refresh the auth state after processing
setTimeout(async () => {
await loadAuthState();
}, 1000); // Give the backend time to process the deep link
}, 1000);
}
}
} catch (error) {
console.error("Failed to get current deep link:", error);
}
// Listen for deep link events when app is already running
try {
await onOpenUrl((urls) => {
console.log("Deep link received in frontend:", urls);
// Check if it's an OAuth callback
const hasOAuthCallback = urls.some(url =>
url.startsWith('hackatime://auth/callback')
);
if (hasOAuthCallback) {
console.log("OAuth callback detected, refreshing auth state...");
// The Rust backend handles the actual processing
// We just need to refresh the auth state
setTimeout(async () => {
await loadAuthState();
}, 1000); // Give the backend time to process the deep link
}, 1000);
}
});
} catch (error) {
console.error("Failed to set up deep link listener:", error);
}
// Listen for window focus events to refresh auth state after popup closes
window.addEventListener('focus', async () => {
await loadAuthState();
if (authState.value.is_authenticated) {
checkForUpdatesAndInstall();
}
});
// Also listen for visibility change (when tab becomes active)
document.addEventListener('visibilitychange', async () => {
if (!document.hidden) {
await loadAuthState();
if (authState.value.is_authenticated) {
checkForUpdatesAndInstall();
}
}
});
if (authState.value.is_authenticated) {
await loadPresenceData();
startPresenceRefresh();
}
checkForUpdatesAndInstall();
startUpdateChecks();
});
// Cleanup on unmount
onUnmounted(() => {
stopPresenceRefresh();
stopUpdateChecks();
});
async function loadAuthState() {
@ -188,12 +227,10 @@ async function loadAuthState() {
await loadUserData();
} else {
// No saved state or not authenticated, get current state
console.log("No saved auth state found, getting current state");
authState.value = await invoke("get_auth_state");
console.log("Current auth state:", authState.value);
// If we have an authenticated state, save it to disk
if (authState.value.is_authenticated) {
try {
await invoke("save_auth_state", { authState: authState.value });
@ -205,7 +242,6 @@ async function loadAuthState() {
}
} catch (error) {
console.error("Failed to load auth state:", error);
// Fallback to current state on error
try {
authState.value = await invoke("get_auth_state");
} catch (fallbackError) {
@ -219,16 +255,18 @@ async function loadUserData() {
await api.initialize();
userData.value = await api.getCurrentUser();
// Load user dashboard stats (getStats now returns dashboard stats)
try {
userStats.value = await api.getStats();
userStats.value = await invoke("get_dashboard_stats", { apiConfig: apiConfig.value });
} catch (error) {
console.error("Failed to load user dashboard stats:", error);
}
await loadApiKey();
// Load presence data and start refresh
await new Promise(resolve => setTimeout(resolve, 500));
await checkWakatimeConfig();
await loadPresenceData();
startPresenceRefresh();
} catch (error) {
@ -236,6 +274,49 @@ async function loadUserData() {
}
}
async function checkWakatimeConfig(forceShowModal = false) {
if (!authState.value.is_authenticated || !apiKey.value) {
return;
}
const apiUrl = apiConfig.value.base_url ? `${apiConfig.value.base_url}/api/hackatime/v1` : "https://hackatime.hackclub.com/api/hackatime/v1";
if (!apiUrl || apiUrl.trim() === "") {
console.warn("API URL is not set, skipping wakatime config check");
return;
}
try {
const check = await invoke("check_wakatime_config", {
apiKey: apiKey.value,
apiUrl: apiUrl,
}) as any;
wakatimeConfigCheck.value = check;
if (forceShowModal || (!hasCheckedConfigThisSession.value && !check.matches)) {
showWakatimeSetupModal.value = true;
hasCheckedConfigThisSession.value = true;
}
} catch (error) {
console.error("Failed to check wakatime config:", error);
}
}
async function handleWakatimeConfigApplied() {
showWakatimeSetupModal.value = false;
await checkWakatimeConfig(false);
if (wakatimeConfigCheck.value && !wakatimeConfigCheck.value.matches) {
alert("Configuration was applied but still doesn't match. Please check the error logs.");
}
}
async function openWakatimeConfigModal() {
hasCheckedConfigThisSession.value = true;
await checkWakatimeConfig(true);
}
async function loadApiKey() {
try {
apiKey.value = await invoke("get_api_key", { apiConfig: apiConfig.value });
@ -264,30 +345,35 @@ async function loadHackatimeInfo() {
}
}
async function loadCurrentOs() {
try {
currentOs.value = await invoke("get_current_os") as string;
console.log("Current OS detected:", currentOs.value);
} catch (error) {
console.error("Failed to detect current OS:", error);
currentOs.value = null;
}
}
async function loadPresenceData() {
const now = Date.now();
if (presenceFetchInProgress.value) {
return; // Skip if a fetch is already in flight
return;
}
const now = Date.now();
if (now < nextPresenceFetchAllowedAt.value) {
return; // Respect backoff window
}
// Enforce hard minimum interval of 60s between network calls
if (now - lastPresenceFetchAt.value < 60_000) {
return;
}
presenceFetchInProgress.value = true;
try {
await api.initialize();
// Use the Rust backend's get_latest_heartbeat which includes session logic
presenceData.value = await invoke("get_latest_heartbeat", {
apiConfig: apiConfig.value
});
lastPresenceFetchAt.value = Date.now();
console.log("Heartbeat data fetched from backend:", presenceData.value);
} catch (error: any) {
console.error("Failed to load presence data:", error);
// If we hit rate limit, back off for 60s
const message = error?.message || "";
if (typeof message === "string" && message.includes("429")) {
nextPresenceFetchAllowedAt.value = Date.now() + 60_000;
@ -299,13 +385,12 @@ async function loadPresenceData() {
}
function startPresenceRefresh() {
// Ensure only one interval is active
if (presenceRefreshInterval.value) {
clearInterval(presenceRefreshInterval.value);
presenceRefreshInterval.value = null;
}
// Refresh presence data every 60 seconds (1 minute)
presenceRefreshInterval.value = setInterval(loadPresenceData, 60000);
console.log("Started heartbeat refresh interval (every 60 seconds)");
}
function stopPresenceRefresh() {
@ -315,14 +400,32 @@ function stopPresenceRefresh() {
}
}
function startUpdateChecks() {
if (updateCheckInterval.value) {
clearInterval(updateCheckInterval.value);
updateCheckInterval.value = null;
}
updateCheckInterval.value = setInterval(() => {
checkForUpdatesAndInstall();
}, 60 * 60 * 1000); // Check every hour
console.log("Started periodic update checks (every 60 minutes)");
}
function stopUpdateChecks() {
if (updateCheckInterval.value) {
clearInterval(updateCheckInterval.value);
updateCheckInterval.value = null;
}
}
async function authenticate() {
isLoading.value = true;
oauthUrl.value = null;
try {
await invoke("authenticate_with_rails", { apiConfig: apiConfig.value });
// Show instructions for OAuth completion
alert(`OAuth authentication opened in browser!\n\nInstructions:\n1. Complete the OAuth flow in your browser\n2. The app will automatically handle the callback\n3. If the callback doesn't work, you can manually paste the authorization code from the URL\n\nFor manual entry:\n- Copy the 'code' parameter from the callback URL\n- Use the "Direct OAuth" field below to paste it`);
const url = await invoke("authenticate_with_rails", { apiConfig: apiConfig.value });
oauthUrl.value = url as string;
console.log("OAuth URL:", url);
} catch (error) {
console.error("Authentication failed:", error);
alert("Authentication failed: " + (error instanceof Error ? error.message : String(error)));
@ -331,10 +434,28 @@ async function authenticate() {
}
}
async function openOAuthUrlManually() {
if (oauthUrl.value) {
try {
const { openUrl } = await import("@tauri-apps/plugin-opener");
await openUrl(oauthUrl.value);
} catch (error) {
console.error("Failed to open URL manually:", error);
try {
await navigator.clipboard.writeText(oauthUrl.value);
alert("Failed to open link. URL copied to clipboard!");
} catch (clipError) {
alert(`Failed to open link. Please visit: ${oauthUrl.value}`);
}
}
}
}
async function logout() {
try {
stopPresenceRefresh();
await invoke("logout");
hasCheckedConfigThisSession.value = false;
await loadAuthState();
} catch (error) {
console.error("Logout failed:", error);
@ -367,8 +488,10 @@ async function copyApiKey() {
}
async function handleDirectOAuthAuth() {
if (!directOAuthToken.value.trim()) {
async function handleDirectOAuthAuth(token?: string) {
const tokenToUse = token || directOAuthToken.value;
if (!tokenToUse.trim()) {
alert("Please enter an OAuth authorization code or access token");
return;
}
@ -376,20 +499,18 @@ async function handleDirectOAuthAuth() {
try {
isLoading.value = true;
console.log("Attempting direct OAuth auth with token:", directOAuthToken.value);
console.log("Token length:", directOAuthToken.value.length);
console.log("Attempting direct OAuth auth with token:", tokenToUse);
console.log("Token length:", tokenToUse.length);
console.log("API config:", apiConfig.value);
// Authenticate with the direct OAuth token
await invoke("authenticate_with_direct_oauth", {
oauthToken: directOAuthToken.value,
oauthToken: tokenToUse,
apiConfig: apiConfig.value
});
console.log("Direct OAuth auth successful!");
await loadAuthState();
// Ensure the auth state is saved after successful authentication
if (authState.value.is_authenticated) {
try {
await invoke("save_auth_state", { authState: authState.value });
@ -399,7 +520,7 @@ async function handleDirectOAuthAuth() {
}
}
directOAuthToken.value = ""; // Clear the input
directOAuthToken.value = "";
alert("Authentication successful! You are now logged in.");
} catch (error) {
console.error("Direct OAuth auth failed:", error);
@ -409,169 +530,278 @@ async function handleDirectOAuthAuth() {
}
}
function getPageTitle(): string {
switch (currentPage.value) {
case 'home':
return 'Home';
case 'projects':
return 'Projects';
case 'statistics':
return 'Statistics';
case 'settings':
return 'Settings';
default:
return 'Home';
async function checkForUpdatesAndInstall(retryCount = 0) {
if (updateCheckInProgress.value) {
console.info('[AUTO-UPDATE] Update check already in progress, skipping');
return;
}
const now = Date.now();
const timeSinceLastCheck = now - lastUpdateCheckTime.value;
const minInterval = 5 * 60 * 1000; // 5 minutes minimum between checks
if (timeSinceLastCheck < minInterval && lastUpdateCheckTime.value > 0) {
console.info(`[AUTO-UPDATE] Skipping update check, last check was ${Math.round(timeSinceLastCheck / 1000)}s ago`);
return;
}
updateCheckInProgress.value = true;
lastUpdateCheckTime.value = now;
try {
console.info('[AUTO-UPDATE] Checking for updates...');
const update = await check();
if (update) {
console.info(`[AUTO-UPDATE] Update available: ${update.version}`);
updateData.value = update;
updateVersion.value = update.version;
updateAvailable.value = true;
} else {
console.info('[AUTO-UPDATE] No updates available - app is up to date');
}
} catch (error) {
console.error('[AUTO-UPDATE] Auto-update check failed:', error);
if (retryCount < 2) {
console.info(`[AUTO-UPDATE] Retrying update check in 10 seconds (attempt ${retryCount + 1}/3)`);
setTimeout(() => {
updateCheckInProgress.value = false;
checkForUpdatesAndInstall(retryCount + 1);
}, 10000);
return;
} else {
console.error('[AUTO-UPDATE] Failed to check for updates after 3 attempts');
}
} finally {
updateCheckInProgress.value = false;
}
}
async function installUpdate() {
if (!updateData.value || isInstallingUpdate.value) return;
try {
isInstallingUpdate.value = true;
console.info('[AUTO-UPDATE] Downloading and installing update...');
let downloaded = 0;
let contentLength = 0;
await updateData.value.downloadAndInstall((event: any) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength ?? 0;
console.info(`[AUTO-UPDATE] Started downloading ${event.data.contentLength} bytes`);
break;
case 'Progress':
downloaded += event.data.chunkLength;
const percentage = contentLength > 0 ? Math.round((downloaded / contentLength) * 100) : 0;
console.info(`[AUTO-UPDATE] Download progress: ${percentage}% (${downloaded} / ${contentLength} bytes)`);
break;
case 'Finished':
console.info('[AUTO-UPDATE] Download finished');
break;
}
});
console.info('[AUTO-UPDATE] Update installed successfully. Restarting app...');
await relaunch();
} catch (error) {
console.error('[AUTO-UPDATE] Update installation failed:', error);
alert('Failed to install update: ' + error);
isInstallingUpdate.value = false;
}
}
function dismissUpdate() {
updateAvailable.value = false;
showUpdateModal.value = false;
}
function showMoreInfo() {
showUpdateModal.value = true;
}
async function handleInstallNow() {
showUpdateModal.value = false;
await installUpdate();
}
</script>
<template>
<div class="flex h-screen text-text-primary font-sans outfit" style="background-color: #0A0101;">
<!-- Left Sidebar -->
<aside class="w-64 flex flex-col p-0 shadow-xl rounded-r-2xl" style="background-color: #191415;">
<div class="p-6" style="background-color: #191415;">
<h1 class="text-2xl font-bold text-accent-primary m-0 text-center">Hackatime</h1>
</div>
<nav class="flex-1 py-4">
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'home' }" @click.prevent="currentPage = 'home'" style="color: #B0BAC4;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
<span class="font-medium">Home</span>
</a>
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'projects' }" @click.prevent="currentPage = 'projects'" style="color: #B0BAC4;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
<span class="font-medium">Projects</span>
</a>
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'statistics' }" @click.prevent="currentPage = 'statistics'" style="color: #B0BAC4;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<span class="font-medium">Statistics</span>
</a>
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'settings' }" @click.prevent="currentPage = 'settings'" style="color: #B0BAC4;">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="font-medium">Settings</span>
</a>
</nav>
<div class="p-6" style="background-color: #191415;">
<button v-if="authState.is_authenticated" @click="logout" class="flex items-center gap-3 w-full px-3 py-3 bg-transparent border border-accent-danger rounded-xl text-accent-danger cursor-pointer transition-all duration-200 text-sm hover:bg-accent-danger hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
<span class="font-medium">Logout</span>
</button>
</div>
</aside>
<!-- Main Content Area -->
<main class="flex-1 p-6 overflow-y-auto">
<!-- Home Page Layout -->
<div v-if="currentPage === 'home'" class="flex flex-col h-full gap-6">
<!-- This Week Card -->
<div v-if="authState.is_authenticated && userStats" class="rounded-2xl shadow-card mb-6 p-6 flex flex-col" style="background-color: #191415;">
<!-- This Week Title -->
<h3 class="text-text-primary font-semibold text-lg mb-4">This Week</h3>
<div class="flex gap-8 flex-1 items-center mb-4">
<!-- Left Section - Streak & Hours Display (2/3 width) -->
<div class="flex justify-center items-center space-x-20" style="flex: 2;">
<!-- Streak Section -->
<div class="flex flex-col items-center">
<div class="relative">
<img src="/flame-icon.svg" alt="Streak" class="w-20 h-20" />
<div class="absolute inset-0 flex items-end justify-center pb-2">
<div class="text-white drop-shadow-lg font-bold" :class="{
'text-4xl': (userStats.current_streak || 0) < 10,
'text-3xl': (userStats.current_streak || 0) >= 10 && (userStats.current_streak || 0) < 100,
'text-2xl': (userStats.current_streak || 0) >= 100 && (userStats.current_streak || 0) < 1000,
'text-xl': (userStats.current_streak || 0) >= 1000
}">
{{ userStats.current_streak || 0 }}
</div>
</div>
</div>
<div class="text-center mt-2">
<div class="text-text-secondary text-xl font-semibold">day streak</div>
</div>
</div>
<!-- Hours Section -->
<div class="flex flex-col items-center">
<div class="text-4xl font-bold text-accent-primary">
{{ Math.round((userStats.weekly_stats?.time_coded_seconds || 0) / 3600 * 10) / 10 }}
</div>
<div class="text-center mt-3">
<div class="text-text-secondary text-xl font-semibold">hours this week</div>
</div>
</div>
</div>
<!-- Right Section - Weekly Chart.js Chart (1/3 width) -->
<div class="flex flex-col justify-center pl-6" style="flex: 1;">
<WeeklyChart :data="weeklyChartData" />
<div class="flex flex-col h-screen text-text-primary font-sans outfit app-window" style="background-color: #322433;">
<CustomTitlebar />
<!-- Show login screen when not authenticated -->
<div v-if="!authState.is_authenticated" class="flex-1 overflow-hidden">
<Login
:isLoading="isLoading"
:isDevMode="isDevMode"
:oauthUrl="oauthUrl"
:currentOs="currentOs"
@authenticate="authenticate"
@handleDirectOAuthAuth="handleDirectOAuthAuth"
@openOAuthUrlManually="openOAuthUrlManually"
/>
</div>
<div v-else class="flex flex-1 overflow-hidden">
<aside class="w-64 min-w-64 flex flex-col p-0 shadow-xl relative overflow-hidden" style="background-color: #3D2C3E;">
<div class="absolute left-0 top-[76px] w-full pointer-events-none z-0">
<div class="absolute left-[63px] top-[616.5px] text-[36px] text-black opacity-20 font-light whitespace-nowrap" style="font-family: 'Outfit', sans-serif;">
01:55:58
</div>
<img src="/src/assets/suits-icons.svg" alt="" class="absolute left-[200px] top-0 w-[84px] h-[17.778px]" />
<img src="/src/assets/decorative-lines.svg" alt="" class="absolute left-0 top-[377px] w-[16px] h-[207px]" />
<img src="/src/assets/decorative-lines.svg" alt="" class="absolute left-[284px] top-[377px] w-[16px] h-[207px]" />
</div>
<div class="relative z-10 flex flex-col h-full">
<div class="p-6" style="background-color: #3D2C3E;">
<div class="flex justify-center items-center">
<img src="/src/assets/bird-illustration.svg" alt="Hackatime" class="h-12 w-auto" />
</div>
</div>
<!-- Trend Card -->
<div v-if="weeklyTrend" class="mt-4">
<TrendCard
:title="weeklyTrend.title"
:change="weeklyTrend.change"
:change-type="weeklyTrend.changeType"
:period="weeklyTrend.period"
:icon="weeklyTrend.icon"
<nav class="flex-1 py-4 px-6 space-y-5">
<button
@click="currentPage = 'home'"
class="pushable w-full"
:class="currentPage === 'home' ? 'pushable-active' : 'pushable-inactive'"
style="font-family: 'Outfit', sans-serif;"
>
<span
class="front w-full h-16 rounded-lg border-2 border-[rgba(0,0,0,0.35)] flex items-center px-4 text-xl font-bold"
:style="currentPage === 'home' ? 'background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;' : 'background-color: #543c55; color: white;'"
>
<svg class="w-8 h-8 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
<span class="ml-auto">home</span>
</span>
</button>
<!-- Projects button -->
<button
@click="currentPage = 'projects'"
class="pushable w-full"
:class="currentPage === 'projects' ? 'pushable-active' : 'pushable-inactive'"
style="font-family: 'Outfit', sans-serif;"
>
<span
class="front w-full h-16 rounded-lg border-2 border-[rgba(0,0,0,0.35)] flex items-center px-4 text-xl font-bold"
:style="currentPage === 'projects' ? 'background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;' : 'background-color: #543c55; color: white;'"
>
<svg class="w-8 h-8 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
<span class="ml-auto">projects</span>
</span>
</button>
<!-- Statistics button (renamed from friends in Figma, keeping your existing page) -->
<button
@click="currentPage = 'statistics'"
class="pushable w-full"
:class="currentPage === 'statistics' ? 'pushable-active' : 'pushable-inactive'"
style="font-family: 'Outfit', sans-serif;"
>
<span
class="front w-full h-16 rounded-lg border-2 border-[rgba(0,0,0,0.35)] flex items-center px-4 text-xl font-bold"
:style="currentPage === 'statistics' ? 'background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;' : 'background-color: #543c55; color: white;'"
>
<svg class="w-8 h-8 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
<span class="ml-auto">statistics</span>
</span>
</button>
</nav>
<div class="p-6 mt-auto" style="background-color: #3D2C3E;">
<UserProfileCard
:authState="authState"
:userData="userData"
:presenceData="presenceData"
:apiConfig="apiConfig"
@openSettings="currentPage = 'settings'"
/>
</div>
</div>
</aside>
<!-- Current Session Card -->
<div v-if="authState.is_authenticated" class="mb-6">
<PresenceCard :authState="authState" :presenceData="presenceData" :apiConfig="apiConfig" />
<!-- Main Content Area -->
<main class="flex-1 p-6 overflow-y-auto min-w-0">
<!-- Home Page Layout -->
<div v-if="currentPage === 'home'" class="flex h-full gap-6 min-h-0 responsive-stack">
<!-- Main Home Content (Left Side - 2/3) -->
<div class="flex-1 flex flex-col min-w-0">
<Home
:authState="authState"
:apiConfig="apiConfig"
:userData="userData"
:userStats="userStats"
:weeklyChartData="weeklyChartData"
:isLoading="isLoading"
:isDevMode="isDevMode"
:oauthUrl="oauthUrl"
v-model:directOAuthToken="directOAuthToken"
@authenticate="authenticate"
@handleDirectOAuthAuth="handleDirectOAuthAuth"
@openOAuthUrlManually="openOAuthUrlManually"
/>
</div>
<!-- Leaderboard Sidebar (Right Side - 1/3) -->
<div v-if="authState.is_authenticated && userStats" class="w-64 min-w-64 flex flex-col responsive-full-width">
<div class="card-3d-app h-full">
<div class="rounded-[8px] border border-black p-4 card-3d-app-front h-full flex flex-col" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white text-[16px] font-bold italic m-0" style="font-family: 'Outfit', sans-serif;">
leaderboard
</h2>
<div class="flex gap-2 text-[10px]" style="font-family: 'Outfit', sans-serif;">
<span class="text-white underline cursor-pointer">friends</span>
<span class="text-white cursor-pointer">global</span>
</div>
</div>
<div class="flex items-center justify-center h-full">
<p class="text-white text-[18px] font-semibold opacity-60" style="font-family: 'Outfit', sans-serif;">
Coming Soon...
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Home Component -->
<Home
:authState="authState"
:apiConfig="apiConfig"
:userData="userData"
:userStats="userStats"
:isLoading="isLoading"
:isDevMode="isDevMode"
v-model:directOAuthToken="directOAuthToken"
@authenticate="authenticate"
@handleDirectOAuthAuth="handleDirectOAuthAuth"
/>
</div>
<!-- Statistics Page Layout (full page) -->
<div v-else-if="currentPage === 'statistics'" class="flex flex-col h-full">
<Statistics :apiConfig="apiConfig" />
</div>
<!-- Other Pages Layout (single card) -->
<div v-else class="flex flex-col h-full">
<div class="bg-bg-card border border-border-primary rounded-2xl overflow-hidden shadow-card flex flex-col min-h-96">
<div class="flex justify-between items-center px-6 py-5 border-b border-border-primary bg-bg-card-tertiary">
<h2 class="m-0 text-xl font-semibold text-text-primary">{{ getPageTitle() }}</h2>
</div>
<div class="p-6 flex-1 overflow-y-auto">
<Projects v-if="currentPage === 'projects'" :currentTheme="currentTheme" :toggleTheme="toggleTheme" :apiConfig="apiConfig" />
<Settings v-if="currentPage === 'settings'" :currentTheme="currentTheme" :toggleTheme="toggleTheme" :apiKey="apiKey" v-model:showApiKey="showApiKey" @copyApiKey="copyApiKey" />
</div>
<!-- Statistics Page Layout (full page) -->
<div v-else-if="currentPage === 'statistics'" class="flex flex-col h-full">
<Statistics :apiConfig="apiConfig" />
</div>
</div>
</main>
<!-- Settings Page Layout (no outer card) -->
<div v-else-if="currentPage === 'settings'" class="flex flex-col h-full">
<Settings
:apiKey="apiKey"
v-model:showApiKey="showApiKey"
@copyApiKey="copyApiKey"
@logout="logout"
@checkWakatimeConfig="openWakatimeConfigModal"
/>
</div>
<!-- Projects Page Layout -->
<div v-else class="flex flex-col h-full">
<Projects :apiConfig="apiConfig" />
</div>
</main>
</div>
<!-- Configuration Modal -->
<div v-if="isConfigOpen" class="fixed inset-0 bg-black/70 flex justify-center items-center z-50" @click="isConfigOpen = false">
@ -593,7 +823,96 @@ function getPageTitle(): string {
</div>
</div>
</div>
<!-- Wakatime Setup Modal -->
<WakatimeSetupModal
v-if="showWakatimeSetupModal && wakatimeConfigCheck && apiKey"
:api-key="apiKey"
:api-url="apiConfig.base_url ? `${apiConfig.base_url}/api/hackatime/v1` : 'https://hackatime.hackclub.com/api/hackatime/v1'"
:config-check="wakatimeConfigCheck"
@close="showWakatimeSetupModal = false"
@applied="handleWakatimeConfigApplied"
/>
</div>
<!-- Update components teleported to body to avoid layout issues -->
<Teleport to="body">
<!-- Update Notification -->
<UpdateNotification
v-if="updateAvailable && !showUpdateModal"
:version="updateVersion"
@installNow="handleInstallNow"
@moreInfo="showMoreInfo"
@dismiss="dismissUpdate"
/>
<!-- Update Modal -->
<UpdateModal
v-if="showUpdateModal"
:version="updateVersion"
:current-version="currentVersion"
@installNow="handleInstallNow"
@installLater="showUpdateModal = false"
/>
</Teleport>
</template>
<!-- All styles now handled by Tailwind CSS -->
<style scoped>
.app-window {
border-radius: 12px;
overflow: hidden;
height: 100vh;
}
.pushable {
border-radius: 12px;
border: none;
padding: 0;
cursor: pointer;
outline-offset: 4px;
position: relative;
}
.pushable-active {
background: linear-gradient(135deg, #B85E6D 0%, #B85E6D 33%, #B5546F 66%, #B55389 100%);
}
.pushable-inactive {
background-color: #2A1F2B;
}
.front {
display: flex;
align-items: center;
border-radius: 12px;
transform: translateY(-6px);
transition: transform 0.1s ease;
position: relative;
}
.pushable:active .front {
transform: translateY(-2px);
}
/* 3D Card Effect for App-level cards */
.card-3d-app {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d-app::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background-color: #2A1F2B;
z-index: 0;
}
.card-3d-app-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
}
</style>

View file

@ -13,7 +13,6 @@ interface AuthState {
export class KubeTimeApi {
private baseUrl: string = 'https://hackatime.hackclub.com'
private accessToken: string | null = null
private latestPresenceCache: { data: any | null, fetchedAt: number } = { data: null, fetchedAt: 0 }
async initialize () {
try {
@ -148,12 +147,6 @@ export class KubeTimeApi {
}
try {
// Return cached result if fetched within last 60s
const now = Date.now()
if (now - this.latestPresenceCache.fetchedAt < 60_000 && this.latestPresenceCache.data !== null) {
return this.latestPresenceCache.data
}
const response = await fetch(`${this.baseUrl}/api/v1/authenticated/heartbeats/latest`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
@ -166,7 +159,6 @@ export class KubeTimeApi {
}
const json = await response.json()
this.latestPresenceCache = { data: json, fetchedAt: Date.now() }
return json
} catch (error) {
console.error('Failed to fetch current presence:', error)

BIN
src/assets/bird-illustration.svg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/assets/decorative-lines.svg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/assets/suits-icons.svg (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/assets/vue.svg (Stored with Git LFS)

Binary file not shown.

View file

@ -1,13 +1,15 @@
<template>
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
<div class="card-3d">
<div class="rounded-[8px] border border-black p-6 card-3d-front h-full flex flex-col" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
<div class="text-sm text-text-secondary">{{ period }}</div>
</div>
<div class="h-64">
<div class="h-64 flex-1">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
</div>
</template>
@ -31,7 +33,7 @@ import {
PieController
} from 'chart.js';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
@ -64,7 +66,7 @@ let chartInstance: ChartJS | null = null;
const createChart = () => {
if (!chartCanvas.value) return;
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy();
}
@ -145,3 +147,9 @@ watch(() => props.data, () => {
createChart();
}, { deep: true });
</script>
<style scoped>
.card-3d { position: relative; border-radius: 8px; padding: 0; }
.card-3d::before { content: ''; position: absolute; inset: 0; border-radius: 8px; background: #2A1F2B; z-index: 0; }
.card-3d-front { position: relative; transform: translateY(-6px); z-index: 1; }
</style>

View file

@ -0,0 +1,291 @@
<template>
<div class="custom-titlebar" :class="{ 'macOS': isMacOS }">
<!-- macOS Traffic Lights (left side on macOS) -->
<div v-if="isMacOS" class="macos-traffic-lights">
<button
class="traffic-light close-light"
@click="closeWindow"
title="Close"
aria-label="Close"
>
<svg width="8" height="8" viewBox="0 0 8 8" class="traffic-light-icon">
<path d="M1 1L7 7M7 1L1 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<button
class="traffic-light minimize-light"
@click="minimizeWindow"
title="Minimize"
aria-label="Minimize"
>
<svg width="8" height="2" viewBox="0 0 8 2" class="traffic-light-icon">
<rect width="8" height="1.5" fill="currentColor"/>
</svg>
</button>
<button
class="traffic-light maximize-light"
@click="toggleMaximize"
title="Maximize"
aria-label="Maximize"
>
<svg width="8" height="8" viewBox="0 0 8 8" class="traffic-light-icon">
<path d="M1 3L4 6L7 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M1 5L4 2L7 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</button>
</div>
<!-- Window controls (left side on Windows/Linux, hidden on macOS) -->
<div v-if="!isMacOS" class="titlebar-controls">
<!-- Minimize button -->
<button
class="titlebar-button minimize-button"
@click="minimizeWindow"
title="Minimize"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="5.5" width="8" height="1" fill="currentColor"/>
</svg>
</button>
<!-- Maximize/Restore button -->
<button
class="titlebar-button maximize-button"
@click="toggleMaximize"
title="Maximize"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="10" height="10" stroke="currentColor" stroke-width="1" fill="none"/>
</svg>
</button>
<!-- Close button -->
<button
class="titlebar-button close-button"
@click="closeWindow"
title="Close"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- Drag region - Center area with app title -->
<div class="titlebar-drag-region" data-tauri-drag-region @dblclick="handleDoubleClick">
<div class="app-title">
<svg class="app-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="app-name">Hackatime Desktop</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const isMacOS = ref(false);
onMounted(() => {
isMacOS.value = navigator.userAgent.includes('Mac');
});
const handleDoubleClick = async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().toggleMaximize();
} catch (error) {
console.error('Failed to toggle maximize:', error);
}
};
const minimizeWindow = async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().minimize();
} catch (error) {
console.error('Failed to minimize window:', error);
}
};
const toggleMaximize = async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().toggleMaximize();
} catch (error) {
console.error('Failed to toggle maximize:', error);
}
};
const closeWindow = async () => {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
await getCurrentWindow().close();
} catch (error) {
console.error('Failed to close window:', error);
}
};
</script>
<style scoped>
.custom-titlebar {
height: 56px;
background-color: #59405C;
border-bottom: 4px solid #47334A;
display: flex;
align-items: center;
user-select: none;
position: relative;
z-index: 1000;
padding: 0 16px;
border-radius: 12px 12px 0 0;
}
/* macOS Traffic Lights */
.macos-traffic-lights {
display: flex;
gap: 8px;
padding: 0 12px;
align-items: center;
}
.traffic-light {
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
padding: 0;
}
.traffic-light-icon {
opacity: 0;
transition: opacity 0.2s ease;
}
.traffic-light:hover .traffic-light-icon {
opacity: 1;
}
.close-light {
background-color: #ff5f57;
}
.close-light:hover {
background-color: #ff4841;
}
.close-light .traffic-light-icon {
color: #6e0a00;
}
.minimize-light {
background-color: #ffbd2e;
}
.minimize-light:hover {
background-color: #ffaa00;
}
.minimize-light .traffic-light-icon {
color: #8b5d00;
}
.maximize-light {
background-color: #28c940;
}
.maximize-light:hover {
background-color: #1fb835;
}
.maximize-light .traffic-light-icon {
color: #0d5917;
}
.titlebar-drag-region {
flex: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* On macOS, align to left */
.macOS .titlebar-drag-region {
justify-content: flex-start;
}
.app-title {
display: flex;
align-items: center;
gap: 8px;
color: #ffffff;
font-size: 14px;
font-weight: 500;
}
.app-icon {
color: #E99682;
flex-shrink: 0;
}
.app-name {
font-family: 'Outfit', sans-serif;
}
.titlebar-controls {
display: flex;
align-items: center;
gap: 4px;
}
.titlebar-button {
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #ffffff;
transition: background-color 0.2s ease;
-webkit-app-region: no-drag;
}
.titlebar-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.minimize-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.maximize-button:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.close-button:hover {
background-color: #e81123;
color: #ffffff;
}
.titlebar-button:active {
background-color: rgba(255, 255, 255, 0.3);
}
.close-button:active {
background-color: #c50e1f;
}
</style>

View file

@ -1,16 +1,18 @@
<template>
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
<div class="card-3d">
<div class="rounded-[8px] border border-black p-6 card-3d-front h-full flex flex-col" style="background-color: #3D2C3E;">
<div class="flex items-start space-x-4">
<div class="text-3xl">{{ icon }}</div>
<div class="flex-1">
<div class="text-3xl flex-shrink-0">{{ icon }}</div>
<div class="flex-1 flex flex-col min-w-0">
<h3 class="text-lg font-semibold text-text-primary mb-2">{{ title }}</h3>
<p class="text-text-secondary mb-3">{{ description }}</p>
<div class="flex items-center justify-between">
<div class="text-2xl font-bold" :style="{ color: color }">{{ value }}</div>
<p class="text-text-secondary mb-4 text-sm line-clamp-2">{{ description }}</p>
<div class="mt-auto">
<div class="text-2xl font-bold mb-1" :style="{ color: color }">{{ value }}</div>
<div class="text-sm text-text-secondary">{{ trend }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
@ -25,4 +27,11 @@ interface Props {
}
defineProps<Props>();
</script>
<style scoped>
.card-3d { position: relative; border-radius: 8px; padding: 0; }
.card-3d::before { content: ''; position: absolute; inset: 0; border-radius: 8px; background: #2A1F2B; z-index: 0; }
.card-3d-front { position: relative; transform: translateY(-6px); z-index: 1; }
</style>

View file

@ -1,189 +0,0 @@
<template>
<div class="rounded-2xl shadow-card p-6" style="background-color: #191415;">
<!-- Presence Title -->
<h3 class="text-text-primary font-semibold text-lg mb-4">Current Session</h3>
<!-- Loading State -->
<div v-if="isLoading" class="text-center py-8">
<div class="w-16 h-16 bg-bg-secondary rounded-full flex items-center justify-center mx-auto mb-4">
<div class="w-6 h-6 border-2 border-text-secondary border-t-transparent rounded-full animate-spin"></div>
</div>
<div class="text-text-secondary text-lg font-medium mb-2">Loading session data...</div>
</div>
<!-- Active Session Display -->
<div v-else-if="sessionState.is_active" class="space-y-4">
<!-- Project and Editor Info -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
<div>
<div class="text-text-primary font-medium text-lg">
{{ sessionState.project || 'Unknown Project' }}
</div>
<div class="text-text-secondary text-sm">
{{ sessionState.editor || 'Unknown Editor' }}
</div>
</div>
</div>
<div class="text-right">
<div class="text-text-secondary text-sm">Language</div>
<div class="text-text-primary font-medium">
{{ sessionState.language || 'Unknown' }}
</div>
</div>
</div>
<!-- File being worked on -->
<div v-if="sessionState.entity" class="bg-bg-secondary rounded-lg p-3">
<div class="text-text-secondary text-xs mb-1">Currently editing</div>
<div class="text-text-primary font-mono text-sm truncate">
{{ sessionState.entity }}
</div>
</div>
<!-- Session duration -->
<div class="flex items-center justify-between text-sm">
<div class="text-text-secondary">Session started</div>
<div class="text-text-primary font-medium">
{{ formatTime(sessionState.start_time) }}
</div>
</div>
<!-- Heartbeat count -->
<div class="flex items-center justify-between text-sm">
<div class="text-text-secondary">Heartbeats</div>
<div class="text-text-primary font-medium">
{{ sessionState.heartbeat_count }}
</div>
</div>
</div>
<!-- No active session -->
<div v-else class="text-center py-8">
<div class="w-16 h-16 bg-bg-secondary rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
</svg>
</div>
<div class="text-text-secondary text-lg font-medium mb-2">No active coding session</div>
<div class="text-text-secondary text-sm">Start coding in your editor to see your current session here</div>
<div class="text-text-secondary text-xs mt-2 opacity-75">Make sure your editor has the WakaTime plugin installed and configured</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
interface AuthState {
is_authenticated: boolean;
access_token: string | null;
user_info: Record<string, any> | null;
}
interface SessionState {
is_active: boolean;
start_time: number | null;
last_heartbeat_id: number | null;
heartbeat_count: number;
project: string | null;
editor: string | null;
language: string | null;
entity: string | null;
}
const props = defineProps<{
authState: AuthState;
presenceData: any;
apiConfig: any;
}>();
const sessionState = ref<SessionState>({
is_active: false,
start_time: null,
last_heartbeat_id: null,
heartbeat_count: 0,
project: null,
editor: null,
language: null,
entity: null,
});
const isLoading = ref(true);
let sessionRefreshInterval: number | null = null;
// Format timestamp to relative time
function formatTime(timestamp: number | null): string {
if (!timestamp) return 'Unknown';
const now = Math.floor(Date.now() / 1000);
const diff = now - timestamp;
if (diff < 60) {
return 'Just now';
} else if (diff < 3600) {
const minutes = Math.floor(diff / 60);
return `${minutes}m ago`;
} else if (diff < 86400) {
const hours = Math.floor(diff / 3600);
return `${hours}h ago`;
} else {
const days = Math.floor(diff / 86400);
return `${days}d ago`;
}
}
async function loadSessionState() {
if (!props.authState.is_authenticated) {
isLoading.value = false;
return;
}
try {
// Get the current session state
const session = await invoke("get_current_session");
console.log("Session state loaded:", session);
sessionState.value = session as SessionState;
isLoading.value = false;
} catch (error) {
console.error("Failed to load session state:", error);
isLoading.value = false;
}
}
function startSessionRefresh() {
if (sessionRefreshInterval) {
clearInterval(sessionRefreshInterval);
}
// Refresh session state every 10 seconds
sessionRefreshInterval = setInterval(loadSessionState, 10000);
}
function stopSessionRefresh() {
if (sessionRefreshInterval) {
clearInterval(sessionRefreshInterval);
sessionRefreshInterval = null;
}
}
// Watch for changes in presence data to update session state
watch(() => props.presenceData, () => {
if (props.authState.is_authenticated) {
loadSessionState();
}
}, { deep: true });
onMounted(() => {
loadSessionState();
startSessionRefresh();
});
onUnmounted(() => {
stopSessionRefresh();
});
</script>
<!-- All styles now handled by Tailwind CSS -->

View file

@ -0,0 +1,147 @@
<template>
<div class="flex items-center justify-center h-64">
<div :class="['loader', variantClass]"></div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const variant = Math.floor(Math.random() * 5) + 1
const variantClass = computed(() => `loader-v${variant}`)
</script>
<style scoped>
/* Variant 1 */
.loader.loader-v1 {
height: 60px;
aspect-ratio: 1;
display: grid;
background:
radial-gradient(farthest-side,#000 15%,#0000 18%),
radial-gradient(50% 100% at 50% 160%,#fff 95%,#0000) top/100% 50%,
radial-gradient(50% 100% at 50% -60%,#fff 95%,#0000) bottom/100% 50%;
background-repeat: no-repeat;
}
.loader.loader-v1:before {
content: "";
background: inherit;
opacity: 0.6;
animation: l1 1s infinite;
}
@keyframes l1 {
to {transform:scale(3);opacity:0}
}
/* Variant 2 */
.loader.loader-v2 {
display: inline-flex;
gap: 20px;
}
.loader.loader-v2:before,
.loader.loader-v2:after {
content: "";
height: 40px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(farthest-side,#000 95%,#0000) 35% 35%/12px 12px no-repeat
#fff;
animation: l5 3s infinite;
}
@keyframes l5 {
0%,11% {background-position:35% 35%}
14%,36% {background-position:65% 35%}
38%,61% {background-position:65% 65%}
64%,86% {background-position:35% 65%}
88%,100% {background-position:35% 35%}
}
/* Variant 3 */
.loader.loader-v3 {
display: inline-flex;
gap: 20px;
}
.loader.loader-v3:before,
.loader.loader-v3:after {
content: "";
height: 40px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(farthest-side,#000 95%,#0000) 35% 35%/12px 12px no-repeat
#fff;
transform: scaleX(var(--s,1)) rotate(0deg);
animation: l6 1s infinite linear;
}
.loader.loader-v3:after {
--s: -1;
animation-delay:-0.1s;
}
@keyframes l6 {
100% {transform:scaleX(var(--s,1)) rotate(360deg);}
}
/* Variant 4 */
.loader.loader-v4 {
display: inline-flex;
gap: 20px;
}
.loader.loader-v4:before,
.loader.loader-v4:after {
content: "";
height: 40px;
aspect-ratio: 1;
border-radius: 50%;
background:
linear-gradient(#222 0 0) top/100% 40% no-repeat,
radial-gradient(farthest-side,#000 95%,#0000) 50%/16px 16px no-repeat
#fff;
animation: 2.5s infinite;
animation-name: l8-1, l8-2;
}
@keyframes l8-1 {
0%,
40%,
100%{background-size:100% 40%,16px 16px}
50%,
80% {background-size:100% 0% ,16px 16px}
}
@keyframes l8-2 {
0%,
50% {background-position:top,50% 50%}
60%,
65%{background-position:top,70% 50%}
70%,
75%{background-position:top,30% 50%}
90%,
100%{background-position:top,50% 50%}
}
/* Variant 5 */
.loader.loader-v5 {
display: inline-flex;
gap: 20px;
}
.loader.loader-v5:before,
.loader.loader-v5:after {
content: "";
height: 40px;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(farthest-side,#000 95%,#0000) 50%/16px 16px no-repeat
#fff;
animation: l10 1.5s infinite alternate;
}
.loader.loader-v5:after {
--s:-1;
}
@keyframes l10 {
0% ,20% {transform:scaleX(var(--s,1)) rotate(0deg) ;clip-path:inset(0)}
60%,100%{transform:scaleX(var(--s,1)) rotate(30deg);clip-path:inset(40% 0 0)}
}
</style>

View file

@ -1,11 +1,12 @@
<template>
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
<div class="card-3d">
<div class="rounded-[8px] border border-black p-6 card-3d-front h-full flex flex-col" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
<div class="text-2xl">{{ icon }}</div>
</div>
<div class="space-y-4">
<div class="space-y-4 flex-1">
<div class="flex items-center justify-between">
<div>
<div class="text-3xl font-bold text-text-primary">{{ value }}</div>
@ -21,6 +22,7 @@
</div>
</div>
</div>
</div>
</div>
</template>
@ -42,12 +44,18 @@ const props = defineProps<Props>();
const changeClass = computed(() => {
switch (props.changeType) {
case 'increase':
return 'bg-green-100 text-green-800';
return 'bg-[rgba(34,197,94,0.15)] text-[#22c55e]';
case 'decrease':
return 'bg-red-100 text-red-800';
return 'bg-[rgba(236,59,72,0.15)] text-[#ec3b48]';
case 'neutral':
default:
return 'bg-gray-100 text-gray-800';
return 'bg-[rgba(255,255,255,0.08)] text-[#f5e6e8]';
}
});
</script>
<style scoped>
.card-3d { position: relative; border-radius: 8px; padding: 0; }
.card-3d::before { content: ''; position: absolute; inset: 0; border-radius: 8px; background: #2A1F2B; z-index: 0; }
.card-3d-front { position: relative; transform: translateY(-6px); z-index: 1; }
</style>

View file

@ -1,9 +1,9 @@
<template>
<div v-if="statisticsData" class="space-y-6">
<div v-if="statisticsData" class="space-y-8">
<!-- Trends Section -->
<div>
<h2 class="text-xl font-semibold text-text-primary mb-4">Trends</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<h2 class="text-xl font-semibold text-text-primary mb-8">Trends</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<StatisticsCard
v-for="trend in statisticsData.trends"
:key="trend.title"
@ -20,8 +20,8 @@
<!-- Charts Section -->
<div>
<h2 class="text-xl font-semibold text-text-primary mb-4">Analytics</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<h2 class="text-xl font-semibold text-text-primary mb-8">Analytics</h2>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
<ChartComponent
v-for="chart in statisticsData.charts"
:key="chart.id"
@ -36,8 +36,9 @@
<!-- Programmer Class Section -->
<div>
<h2 class="text-xl font-semibold text-text-primary mb-4">Your Programmer Class</h2>
<div class="bg-bg-card border border-border-primary rounded-2xl p-6">
<h2 class="text-xl font-semibold text-text-primary mb-8">Your Programmer Class</h2>
<div class="card-3d mt-4">
<div class="rounded-[8px] border border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-2xl font-bold text-text-primary">{{ statisticsData.programmer_class.class_name }}</h3>
@ -53,18 +54,19 @@
<span
v-for="tech in statisticsData.programmer_class.technologies"
:key="tech"
class="px-3 py-1 bg-bg-secondary text-text-primary rounded-lg text-sm font-medium"
class="px-3 py-1 bg-[rgba(255,255,255,0.06)] text-text-primary rounded-lg text-sm font-medium"
>
{{ tech }}
</span>
</div>
</div>
</div>
</div>
<!-- Insights Section -->
<div>
<h2 class="text-xl font-semibold text-text-primary mb-4">Insights</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<h2 class="text-xl font-semibold text-text-primary mb-8">Insights</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
<InsightCard
v-for="insight in statisticsData.insights"
:key="insight.title"
@ -80,18 +82,7 @@
</div>
<!-- Loading State -->
<div v-else-if="isLoading" class="space-y-6">
<div class="animate-pulse">
<div class="h-6 bg-bg-secondary rounded w-32 mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i" class="bg-bg-card border border-border-primary rounded-2xl p-6">
<div class="h-4 bg-bg-secondary rounded w-24 mb-4"></div>
<div class="h-8 bg-bg-secondary rounded w-16 mb-2"></div>
<div class="h-3 bg-bg-secondary rounded w-20"></div>
</div>
</div>
</div>
</div>
<RandomLoader v-else-if="isLoading" />
<!-- Error State -->
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-2xl p-6">
@ -111,6 +102,7 @@ import { invoke } from '@tauri-apps/api/core';
import StatisticsCard from './StatisticsCard.vue';
import ChartComponent from './ChartComponent.vue';
import InsightCard from './InsightCard.vue';
import RandomLoader from './RandomLoader.vue';
interface StatisticsData {
trends: Array<{
@ -171,7 +163,7 @@ const loadStatistics = async () => {
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
console.error('Failed to load statistics:', err);
// Set some default data to prevent crashes
statisticsData.value = {
trends: [],
charts: [],
@ -193,3 +185,9 @@ onMounted(() => {
loadStatistics();
});
</script>
<style scoped>
.card-3d { position: relative; border-radius: 8px; padding: 0; }
.card-3d::before { content: ''; position: absolute; inset: 0; border-radius: 8px; background: #2A1F2B; z-index: 0; }
.card-3d-front { position: relative; transform: translateY(-6px); z-index: 1; }
</style>

View file

@ -0,0 +1,362 @@
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue';
import { openUrl } from '@tauri-apps/plugin-opener';
const props = defineProps<{
version: string;
currentVersion: string;
}>();
const emit = defineEmits<{
installNow: [];
installLater: [];
}>();
const releaseInfo = ref<any>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const releaseUrl = computed(() =>
`https://github.com/hackclub/hackatime-desktop/releases/tag/app-v${props.version}`
);
const fetchReleaseInfo = async () => {
loading.value = true;
error.value = null;
try {
const response = await fetch(
`https://api.github.com/repos/hackclub/hackatime-desktop/releases/tags/app-v${props.version}`
);
if (!response.ok) {
throw new Error('Failed to fetch release information');
}
releaseInfo.value = await response.json();
} catch (err) {
console.error('Error fetching release info:', err);
error.value = 'Failed to load release information';
} finally {
loading.value = false;
}
};
const formattedDate = computed(() => {
if (!releaseInfo.value?.published_at) return '';
return new Date(releaseInfo.value.published_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
const formattedBody = computed(() => {
if (!releaseInfo.value?.body) return 'No release notes available.';
let text = releaseInfo.value.body;
text = text.replace(/### (.*?)$/gm, '<h3>$1</h3>');
text = text.replace(/## (.*?)$/gm, '<h2>$1</h2>');
text = text.replace(/# (.*?)$/gm, '<h2>$1</h2>');
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
text = text.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" data-external-link>$1</a>');
text = text.replace(/^[\*\-] (.+)$/gm, '<li>$1</li>');
text = text.replace(/(<li>.*<\/li>\n?)+/g, (match: string) => `<ul>${match}</ul>`);
text = text.replace(/\n\n+/g, '</p><p>');
text = `<p>${text}</p>`;
text = text.replace(/<p>\s*<\/p>/g, '');
text = text.replace(/<p>(<h[23]>)/g, '$1');
text = text.replace(/(<\/h[23]>)<\/p>/g, '$1');
text = text.replace(/<p>(<ul>)/g, '$1');
text = text.replace(/(<\/ul>)<\/p>/g, '$1');
return text;
});
onMounted(async () => {
await fetchReleaseInfo();
await nextTick();
const links = document.querySelectorAll('[data-external-link]');
links.forEach(link => {
link.addEventListener('click', async (e) => {
e.preventDefault();
const href = (e.target as HTMLAnchorElement).getAttribute('href');
if (href) {
try {
await openUrl(href);
} catch (error) {
console.error('Failed to open link:', error);
}
}
});
});
});
const handleInstallNow = () => {
emit('installNow');
};
const handleInstallLater = () => {
emit('installLater');
};
const openReleaseUrl = async () => {
try {
await openUrl(releaseUrl.value);
} catch (error) {
console.error('Failed to open release URL:', error);
}
};
</script>
<template>
<div
class="fixed inset-0 bg-black/80 flex justify-center items-center p-8"
style="z-index: 10000;"
@click="handleInstallLater"
>
<div class="card-3d max-w-3xl w-full max-h-[90vh]" @click.stop>
<div class="rounded-[8px] border border-black card-3d-front flex flex-col max-h-[90vh]" style="background-color: #3D2C3E;">
<div class="p-6 border-b border-[rgba(0,0,0,0.2)] flex-shrink-0">
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h2 class="text-3xl font-bold text-white m-0 mb-2" style="font-family: 'Outfit', sans-serif;">
Update Available
</h2>
<div class="flex items-center gap-4 text-white/60 flex-wrap" style="font-family: 'Outfit', sans-serif;">
<span class="text-base">Version {{ currentVersion }} {{ version }}</span>
</div>
</div>
<button
@click="handleInstallLater"
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[rgba(255,255,255,0.1)] transition-colors"
>
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-6 space-y-6 min-h-0" style="font-family: 'Outfit', sans-serif;">
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-2 border-[#EB9182] border-t-transparent"></div>
</div>
<div v-else-if="error" class="text-center py-12">
<p class="text-red-400 mb-4">{{ error }}</p>
<button
@click="openReleaseUrl"
class="pushable pushable-active inline-block"
style="font-family: 'Outfit', sans-serif;"
>
<span class="front px-6 py-2 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold" style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;">
View release on GitHub
</span>
</button>
</div>
<div v-else-if="releaseInfo">
<div class="bg-[rgba(42,31,43,0.5)] border-2 border-[rgba(0,0,0,0.3)] rounded-lg p-4 mb-6">
<div class="flex items-center justify-between">
<div>
<div class="text-white/60 text-sm mb-1">Release</div>
<div class="text-2xl font-bold text-white">
{{ releaseInfo.name || `v${version}` }}
</div>
</div>
<div class="text-right">
<div class="text-white/60 text-sm mb-1">Published</div>
<div class="text-white font-medium">{{ formattedDate }}</div>
</div>
</div>
</div>
<div v-if="releaseInfo.body">
<h3 class="text-white text-lg font-bold mb-3">Release Notes</h3>
<div class="release-notes text-white/80 text-sm leading-relaxed">
<div v-html="formattedBody"></div>
</div>
</div>
<div class="pt-4">
<button
@click="openReleaseUrl"
class="text-[#E99682] hover:text-[#E88592] text-sm flex items-center gap-2 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
<span>View full release notes on GitHub</span>
</button>
</div>
</div>
</div>
<div class="p-6 border-t border-[rgba(0,0,0,0.2)] flex gap-3 flex-shrink-0">
<button
@click="handleInstallLater"
class="pushable pushable-inactive flex-1"
style="font-family: 'Outfit', sans-serif;"
>
<span class="front w-full px-6 py-3 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold text-center" style="background-color: #543c55; color: white;">
Install Later
</span>
</button>
<button
@click="handleInstallNow"
class="pushable pushable-active flex-1"
style="font-family: 'Outfit', sans-serif;"
>
<span class="front w-full px-6 py-3 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold text-center" style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;">
Install Now
</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.card-3d {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background: #2A1F2B;
z-index: 0;
}
.card-3d-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
box-shadow: 0 6px 0 #2A1F2B;
}
.pushable {
border-radius: 12px;
border: none;
padding: 0;
cursor: pointer;
outline-offset: 4px;
position: relative;
}
.pushable-active {
background: linear-gradient(135deg, #B85E6D 0%, #B85E6D 33%, #B5546F 66%, #B55389 100%);
}
.pushable-inactive {
background-color: #2A1F2B;
}
.front {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
transform: translateY(-4px);
transition: transform 0.1s ease;
position: relative;
}
.pushable:active .front {
transform: translateY(-1px);
}
.release-notes :deep(h2) {
color: white;
font-size: 1.125rem;
font-weight: bold;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.release-notes :deep(h3) {
color: white;
font-size: 1rem;
font-weight: bold;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.release-notes :deep(p) {
margin-bottom: 0.75rem;
line-height: 1.6;
}
.release-notes :deep(ul) {
list-style-type: disc;
padding-left: 1.5rem;
margin: 0.75rem 0;
}
.release-notes :deep(li) {
margin: 0.25rem 0;
line-height: 1.5;
}
.release-notes :deep(code) {
background: rgba(0, 0, 0, 0.3);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-family: monospace;
color: #EB9182;
}
.release-notes :deep(strong) {
color: white;
font-weight: 600;
}
.release-notes :deep(a) {
color: #E99682;
text-decoration: underline;
cursor: pointer;
}
.release-notes :deep(a:hover) {
color: #E88592;
}
.overflow-y-auto::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: rgba(42, 31, 43, 0.5);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: rgba(233, 150, 130, 0.3);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: rgba(233, 150, 130, 0.5);
}
</style>

View file

@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref } from 'vue';
defineProps<{
version: string;
}>();
const emit = defineEmits<{
installNow: [];
moreInfo: [];
dismiss: [];
}>();
const isVisible = ref(true);
const handleInstallNow = () => {
emit('installNow');
};
const handleMoreInfo = () => {
emit('moreInfo');
};
const handleDismiss = () => {
isVisible.value = false;
emit('dismiss');
};
</script>
<template>
<transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-x-full opacity-0"
enter-to-class="translate-x-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-x-0 opacity-100"
leave-to-class="translate-x-full opacity-0"
>
<div
v-if="isVisible"
class="card-3d-update pointer-events-auto"
style="position: fixed; z-index: 9999; bottom: 20px; right: 20px; width: 320px;"
>
<div class="rounded-lg border-2 border-black p-4 card-3d-update-front" style="background-color: #3D2C3E;">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[#EB9182]" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clip-rule="evenodd"></path>
</svg>
<h3 class="text-white text-sm font-bold m-0" style="font-family: 'Outfit', sans-serif;">
Update Available
</h3>
</div>
<button
@click="handleDismiss"
class="text-white/60 hover:text-white transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<p class="text-white/80 text-xs mb-4 leading-relaxed" style="font-family: 'Outfit', sans-serif;">
Version <span class="font-semibold text-[#EB9182]">{{ version }}</span> is ready to install
</p>
<div class="flex gap-2">
<button
@click="handleMoreInfo"
class="flex-1 px-3 py-2 rounded-lg text-xs font-medium transition-all duration-200"
style="background-color: #543c55; color: white; font-family: 'Outfit', sans-serif; border: 1px solid rgba(0,0,0,0.3);"
>
More Info
</button>
<button
@click="handleInstallNow"
class="flex-1 px-3 py-2 rounded-lg text-xs font-bold transition-all duration-200"
style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white; font-family: 'Outfit', sans-serif; border: 1px solid rgba(0,0,0,0.3);"
>
Install Now
</button>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.card-3d-update {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d-update::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background-color: #2A1F2B;
z-index: 0;
}
.card-3d-update-front {
position: relative;
transform: translateY(-4px);
z-index: 1;
}
button:hover {
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
</style>

View file

@ -0,0 +1,244 @@
<template>
<div class="card-3d ph-no-capture">
<div class="rounded-[8px] border-2 border-black p-4 card-3d-front" style="background: #f9e9b5;">
<!-- Header with User Info -->
<div class="flex items-center gap-3 mb-3">
<div class="relative">
<img
:src="gravatarUrl"
:alt="userName"
class="w-12 h-12 rounded-lg border-2 border-[#594d37] bg-white"
/>
</div>
<div class="flex-1 min-w-0">
<div class="font-bold text-[15px] leading-tight truncate" style="color: #594d37; font-family: 'Outfit', sans-serif;">
{{ userName }}
</div>
<div v-if="!isLoading && sessionState.is_active" class="text-[10px] font-semibold uppercase tracking-wider opacity-70 mt-0.5" style="color: #594d37; font-family: 'Outfit', sans-serif;">
Coding Now
</div>
</div>
</div>
<!-- Current Session Section -->
<div class="mb-3">
<!-- Loading State -->
<div v-if="isLoading" class="rounded-lg p-3 text-center border-2" style="background-color: rgba(89, 77, 55, 0.15); border-color: rgba(89, 77, 55, 0.2);">
<div class="w-5 h-5 border-2 border-[#594d37] border-t-transparent rounded-full animate-spin mx-auto"></div>
<div class="text-[11px] font-semibold mt-2 uppercase tracking-wide" style="color: #594d37; font-family: 'Outfit', sans-serif;">Loading</div>
</div>
<!-- Active Session Display -->
<div v-else-if="sessionState.is_active" class="rounded-lg p-3 space-y-2 border-2" style="background-color: rgba(89, 77, 55, 0.15); border-color: rgba(89, 77, 55, 0.2);">
<!-- Project -->
<div v-if="sessionState.project" class="flex items-center gap-2">
<div class="flex-shrink-0 w-5 h-5 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="#594d37" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg>
</div>
<span class="text-[12px] font-bold truncate" style="color: #594d37; font-family: 'Outfit', sans-serif;">{{ sessionState.project }}</span>
</div>
<!-- Language -->
<div v-if="sessionState.language" class="flex items-center gap-2">
<div class="flex-shrink-0 w-5 h-5 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="#594d37" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
</div>
<span class="text-[12px] font-bold truncate" style="color: #594d37; font-family: 'Outfit', sans-serif;">{{ sessionState.language }}</span>
</div>
<!-- File -->
<div v-if="sessionState.entity" class="flex items-center gap-2">
<div class="flex-shrink-0 w-5 h-5 flex items-center justify-center">
<svg class="w-4 h-4" fill="none" stroke="#594d37" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<span class="text-[11px] font-semibold truncate opacity-90" style="color: #594d37; font-family: 'Outfit', sans-serif;">{{ sessionState.entity.split('/').pop() || sessionState.entity }}</span>
</div>
</div>
<!-- No active session -->
<div v-else class="rounded-lg p-4 text-center border-2" style="background-color: rgba(89, 77, 55, 0.15); border-color: rgba(89, 77, 55, 0.2);">
<div class="w-10 h-10 mx-auto mb-2 flex items-center justify-center rounded-lg" style="background-color: rgba(89, 77, 55, 0.15);">
<svg class="w-5 h-5" fill="none" stroke="#594d37" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
</div>
<div class="text-[11px] font-bold uppercase tracking-wide" style="color: #594d37; font-family: 'Outfit', sans-serif;">No Active Session</div>
</div>
</div>
<!-- Settings Button - Bottom right corner -->
<div class="flex justify-end">
<button
@click="$emit('openSettings')"
class="w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-200 cursor-pointer hover:scale-105 border-2"
style="background: rgba(89, 77, 55, 0.15); border-color: rgba(89, 77, 55, 0.25);"
title="Settings"
>
<svg class="w-4 h-4" fill="none" stroke="#594d37" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import CryptoJS from "crypto-js";
interface AuthState {
is_authenticated: boolean;
access_token: string | null;
user_info: Record<string, any> | null;
}
interface UserData {
emails: string[];
slack_id: string;
trust_factor: {
trust_level: string;
trust_value: number;
};
}
interface SessionState {
is_active: boolean;
start_time: number | null;
last_heartbeat_id: number | null;
heartbeat_count: number;
project: string | null;
editor: string | null;
language: string | null;
entity: string | null;
}
const props = defineProps<{
authState: AuthState;
userData: UserData | null;
presenceData: any;
apiConfig: any;
}>();
defineEmits<{
openSettings: [];
}>();
const sessionState = ref<SessionState>({
is_active: false,
start_time: null,
last_heartbeat_id: null,
heartbeat_count: 0,
project: null,
editor: null,
language: null,
entity: null,
});
const isLoading = ref(true);
let sessionRefreshInterval: ReturnType<typeof setInterval> | null = null;
const userEmail = computed(() => {
if (!props.userData?.emails || props.userData.emails.length === 0) {
return 'user@example.com';
}
return props.userData.emails[0];
});
const userName = computed(() => {
const email = userEmail.value;
return email.split('@')[0];
});
const gravatarUrl = computed(() => {
const email = userEmail.value.trim().toLowerCase();
const hash = CryptoJS.MD5(email).toString();
return `https://www.gravatar.com/avatar/${hash}?d=identicon&s=128`;
});
async function loadSessionState() {
if (!props.authState.is_authenticated) {
isLoading.value = false;
return;
}
try {
const session = await invoke("get_current_session");
sessionState.value = session as SessionState;
isLoading.value = false;
} catch (error) {
console.error("Failed to load session state:", error);
isLoading.value = false;
}
}
function startSessionRefresh() {
if (sessionRefreshInterval) {
clearInterval(sessionRefreshInterval);
}
sessionRefreshInterval = setInterval(loadSessionState, 10000);
}
function stopSessionRefresh() {
if (sessionRefreshInterval) {
clearInterval(sessionRefreshInterval);
sessionRefreshInterval = null;
}
}
watch(() => props.presenceData, () => {
if (props.authState.is_authenticated) {
loadSessionState();
}
}, { deep: true });
onMounted(() => {
loadSessionState();
startSessionRefresh();
});
onUnmounted(() => {
stopSessionRefresh();
});
</script>
<style scoped>
.card-3d {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background: #d4c48a;
z-index: 0;
}
.card-3d-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
}
</style>

View file

@ -0,0 +1,446 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { invoke } from '@tauri-apps/api/core';
interface Props {
apiKey: string;
apiUrl: string;
configCheck: any;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
applied: [];
}>();
const isApplying = ref(false);
const showExplanation = ref(false);
interface DiffLine {
type: 'same' | 'removed' | 'added' | 'header';
content: string;
lineNumber?: number;
}
const diffLines = computed(() => {
const lines: DiffLine[] = [];
if (!props.configCheck) {
return lines;
}
const expectedLines = props.configCheck.expected_content.split('\n');
const actualLines = props.configCheck.actual_content
? props.configCheck.actual_content.split('\n')
: [];
lines.push({
type: 'header',
content: props.configCheck.config_path,
});
if (!props.configCheck.exists) {
lines.push({
type: 'header',
content: 'New file',
});
expectedLines.forEach((line: string, index: number) => {
lines.push({
type: 'added',
content: line,
lineNumber: index + 1,
});
});
} else {
const maxLines = Math.max(expectedLines.length, actualLines.length);
for (let i = 0; i < maxLines; i++) {
const expectedLine = expectedLines[i] || '';
const actualLine = actualLines[i] || '';
if (expectedLine === actualLine) {
lines.push({
type: 'same',
content: expectedLine,
lineNumber: i + 1,
});
} else {
if (actualLine) {
lines.push({
type: 'removed',
content: actualLine,
lineNumber: i + 1,
});
}
if (expectedLine) {
lines.push({
type: 'added',
content: expectedLine,
lineNumber: i + 1,
});
}
}
}
}
return lines;
});
async function applyConfig() {
isApplying.value = true;
try {
const apiUrl = props.apiUrl || "https://hackatime.hackclub.com/api/hackatime/v1";
await invoke('apply_wakatime_config', {
apiKey: props.apiKey,
apiUrl: apiUrl,
});
emit('applied');
} catch (error) {
console.error('Failed to apply config:', error);
alert('Failed to apply configuration: ' + error);
} finally {
isApplying.value = false;
}
}
</script>
<template>
<div class="fixed inset-0 bg-black/80 flex justify-center items-start z-50 pt-16 pb-8 px-8" @click="emit('close')">
<div class="bg-[#3D2C3E] border-2 border-[rgba(0,0,0,0.35)] rounded-2xl shadow-2xl max-w-5xl w-full flex-1 max-h-full flex flex-col" @click.stop>
<!-- Header -->
<div class="p-6 border-b border-[rgba(0,0,0,0.2)]">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full flex items-center justify-center" style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%);">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<div>
<h2 class="text-3xl font-bold text-white m-0 leading-none" style="font-family: 'Outfit', sans-serif;">
get started
</h2>
<p class="text-white/50 text-sm mt-1" style="font-family: 'Outfit', sans-serif;">
Step 1 of 1
</p>
</div>
</div>
</div>
<p class="text-white/80 text-base" style="font-family: 'Outfit', sans-serif;">
<template v-if="configCheck?.matches">
Your system is already configured correctly! You can review the current configuration below.
</template>
<template v-else>
We need to configure your system to connect with Hackatime. This will only take a moment.
</template>
</p>
</div>
<!-- Content Area -->
<div class="flex-1 overflow-hidden p-8 flex items-center justify-center min-h-0">
<!-- Configuration Preview Card -->
<div class="card-3d w-full max-w-4xl h-full">
<div class="rounded-[8px] border-2 border-black card-3d-front h-full flex flex-col" style="background-color: #3D2C3E;">
<div class="p-5 border-b border-[rgba(0,0,0,0.2)] flex-shrink-0 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background-color: rgba(233, 150, 130, 0.15);">
<svg class="w-5 h-5 text-[#E99682]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
</div>
<div>
<h4 class="text-white font-bold text-base m-0" style="font-family: 'Outfit', sans-serif;">
<template v-if="configCheck?.matches">
Current Configuration
</template>
<template v-else>
{{ configCheck?.exists ? 'Configuration Changes' : 'New Configuration' }}
</template>
</h4>
<p class="text-white/50 text-xs mt-0.5" style="font-family: 'Outfit', sans-serif;">
{{ configCheck?.config_path }}
</p>
</div>
</div>
<div v-if="configCheck?.exists && !configCheck?.matches" class="flex items-center gap-2 px-3 py-1.5 rounded-lg" style="background-color: rgba(134, 239, 172, 0.1);">
<svg class="w-3.5 h-3.5 text-[#86efac]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-[#86efac] text-xs font-medium" style="font-family: 'Outfit', sans-serif;">Backup will be created</span>
</div>
</div>
<div class="flex-1 overflow-y-auto p-5" style="background-color: rgba(42, 31, 43, 0.3);">
<div class="rounded-lg overflow-hidden border border-[rgba(0,0,0,0.35)]" style="background-color: #2A1F2B;">
<div class="diff-viewer font-mono text-xs">
<div
v-for="(line, index) in diffLines"
:key="index"
:class="{
'diff-header': line.type === 'header',
'diff-same': line.type === 'same',
'diff-removed': line.type === 'removed',
'diff-added': line.type === 'added',
}"
class="diff-line"
>
<span v-if="line.type !== 'header'" class="line-number">{{ line.lineNumber || '' }}</span>
<span class="line-prefix">
<template v-if="line.type === 'removed'">-</template>
<template v-else-if="line.type === 'added'">+</template>
<template v-else-if="line.type === 'same'"> </template>
</span>
<span class="line-content">{{ line.content }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="border-t border-[rgba(0,0,0,0.2)]" style="background-color: rgba(42, 31, 43, 0.3);">
<!-- Collapsible Explanation -->
<div class="border-b border-[rgba(0,0,0,0.15)]">
<button
@click="showExplanation = !showExplanation"
class="w-full px-6 py-3 flex items-center justify-between hover:bg-[rgba(255,255,255,0.03)] transition-colors"
>
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-[#E99682]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-white/80 text-sm font-medium" style="font-family: 'Outfit', sans-serif;">
How does this work?
</span>
</div>
<svg
class="w-4 h-4 text-white/50 transition-transform duration-200"
:class="{ 'rotate-180': showExplanation }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-[400px]"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100 max-h-[400px]"
leave-to-class="opacity-0 max-h-0"
>
<div v-show="showExplanation" class="overflow-hidden">
<div class="px-6 pb-4 pt-2">
<div class="grid grid-cols-3 gap-3 text-xs" style="font-family: 'Outfit', sans-serif;">
<div class="p-3 rounded-lg" style="background-color: rgba(233, 150, 130, 0.1);">
<div class="flex items-center gap-2 mb-2">
<div class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: rgba(233, 150, 130, 0.2);">
<span class="text-[#E99682] font-bold text-xs">1</span>
</div>
<p class="text-white font-semibold text-xs">Editor Plugins</p>
</div>
<p class="text-white/60 text-xs leading-relaxed">
WakaTime plugins in your editors monitor your coding sessions
</p>
</div>
<div class="p-3 rounded-lg" style="background-color: rgba(232, 133, 146, 0.1);">
<div class="flex items-center gap-2 mb-2">
<div class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: rgba(232, 133, 146, 0.2);">
<span class="text-[#E88592] font-bold text-xs">2</span>
</div>
<p class="text-white font-semibold text-xs">Config File</p>
</div>
<p class="text-white/60 text-xs leading-relaxed">
Directs data to Hackatime's server instead of WakaTime's
</p>
</div>
<div class="p-3 rounded-lg" style="background-color: rgba(232, 131, 174, 0.1);">
<div class="flex items-center gap-2 mb-2">
<div class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: rgba(232, 131, 174, 0.2);">
<span class="text-[#E883AE] font-bold text-xs">3</span>
</div>
<p class="text-white font-semibold text-xs">Privacy First</p>
</div>
<p class="text-white/60 text-xs leading-relaxed">
Only metadata tracked. Code content is never sent
</p>
</div>
</div>
</div>
</div>
</transition>
</div>
<!-- Action Area -->
<div class="p-6">
<div class="flex justify-end">
<template v-if="configCheck?.matches">
<button
@click="emit('close')"
class="pushable pushable-active"
style="font-family: 'Outfit', sans-serif;"
>
<span class="front px-8 py-3 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold" style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;">
Close
</span>
</button>
</template>
<template v-else>
<button
@click="applyConfig"
:disabled="isApplying"
class="pushable pushable-active"
style="font-family: 'Outfit', sans-serif;"
>
<span
class="front px-8 py-3 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold flex items-center gap-2"
:style="isApplying ? 'background-color: #666; color: white;' : 'background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;'"
>
<template v-if="!isApplying">
<span>Continue</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
</svg>
</template>
<template v-else>
<svg class="animate-spin h-5 w-5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Applying configuration...</span>
</template>
</span>
</button>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 3D Card Effect */
.card-3d {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background-color: #2A1F2B;
z-index: 0;
}
.card-3d-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
}
/* Pushable Buttons */
.pushable {
border-radius: 12px;
border: none;
padding: 0;
cursor: pointer;
outline-offset: 4px;
position: relative;
background: linear-gradient(135deg, #B85E6D 0%, #B85E6D 33%, #B5546F 66%, #B55389 100%);
}
.pushable:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.front {
display: inline-flex;
align-items: center;
border-radius: 12px;
transform: translateY(-4px);
transition: transform 0.1s ease;
position: relative;
}
.pushable:active:not(:disabled) .front {
transform: translateY(-1px);
}
.diff-viewer {
overflow-x: auto;
}
.diff-line {
display: flex;
padding: 0.2rem 0.75rem;
line-height: 1.6;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
}
.diff-header {
background-color: rgba(100, 100, 100, 0.2);
color: #e0e0e0;
font-weight: bold;
padding: 0.5rem 0.75rem;
}
.diff-same {
background-color: transparent;
color: #d0d0d0;
}
.diff-removed {
background-color: rgba(220, 38, 38, 0.15);
color: #fca5a5;
}
.diff-added {
background-color: rgba(34, 197, 94, 0.15);
color: #86efac;
}
.line-number {
min-width: 2.5rem;
text-align: right;
color: rgba(255, 255, 255, 0.3);
margin-right: 0.75rem;
user-select: none;
font-size: 0.7rem;
}
.line-prefix {
min-width: 1.25rem;
font-weight: bold;
}
.diff-removed .line-prefix {
color: #fca5a5;
}
.diff-added .line-prefix {
color: #86efac;
}
.line-content {
white-space: pre;
flex: 1;
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<div class="h-32">
<canvas ref="chartCanvas"></canvas>
<div class="w-full h-40 overflow-hidden">
<canvas ref="chartCanvas" class="w-full h-full"></canvas>
</div>
</template>
@ -16,7 +16,7 @@ import {
Legend
} from 'chart.js';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
@ -42,7 +42,7 @@ let chartInstance: ChartJS | null = null;
const createChart = () => {
if (!chartCanvas.value) return;
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy();
}
@ -50,23 +50,23 @@ const createChart = () => {
const ctx = chartCanvas.value.getContext('2d');
if (!ctx) return;
// Prepare data for Chart.js
const labels = props.data.map(day => day.day_name);
const chartData = props.data.map(day => day.hours);
// Calculate colors for each bar
const maxHours = Math.max(...chartData, 1);
const maxHours = Math.max(...chartData, 0);
const minHours = Math.min(...chartData, 0);
const mostColor = { r: 232, g: 131, b: 174 };
const leastColor = { r: 233, g: 150, b: 130 };
const colors = chartData.map(hours => {
if (hours === 0) return '#3d2b2e';
const intensity = hours / maxHours;
const startColor = { r: 237, g: 141, b: 75 }; // #ED8D4B (lighter)
const endColor = { r: 251, g: 75, b: 32 }; // #FB4B20 (darker)
const r = Math.round(startColor.r + (endColor.r - startColor.r) * intensity);
const g = Math.round(startColor.g + (endColor.g - startColor.g) * intensity);
const b = Math.round(startColor.b + (endColor.b - startColor.b) * intensity);
const t = maxHours === minHours ? 0.5 : (hours - minHours) / (Math.max(maxHours - minHours, 1e-6));
const r = Math.round(leastColor.r + (mostColor.r - leastColor.r) * t);
const g = Math.round(leastColor.g + (mostColor.g - leastColor.g) * t);
const b = Math.round(leastColor.b + (mostColor.b - leastColor.b) * t);
return `rgb(${r}, ${g}, ${b})`;
});
@ -79,28 +79,38 @@ const createChart = () => {
backgroundColor: colors,
borderColor: colors,
borderWidth: 0,
borderRadius: 4,
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
resizeDelay: 0,
layout: {
padding: {
top: 10,
bottom: 28,
left: 10,
right: 10
}
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: '#191415',
backgroundColor: '#1F1617',
titleColor: '#FFFFFF',
bodyColor: '#B0BAC4',
borderColor: '#FB4B20',
bodyColor: '#F5E6E8',
borderColor: '#2A1F2B',
borderWidth: 1,
cornerRadius: 8,
displayColors: false,
padding: 10,
callbacks: {
label: function(context: any) {
return `${context.parsed.y}h`;
return `${context.parsed.y.toFixed(1)}h`;
}
}
}
@ -112,15 +122,39 @@ const createChart = () => {
display: false
},
ticks: {
color: '#B0BAC4',
color: '#F5E6E8',
font: {
size: 10,
family: 'Inter, system-ui, sans-serif'
}
size: 11,
family: 'Outfit, system-ui, sans-serif',
weight: 500
},
padding: 0
}
},
y: {
display: false
display: true,
beginAtZero: true,
suggestedMin: 0,
grace: '10%',
grid: {
color: '#2A1F2B',
drawBorder: false,
display: false
},
border: {
display: false
},
ticks: {
color: '#F5E6E8',
font: {
size: 10,
family: 'Outfit, system-ui, sans-serif'
},
padding: 4,
callback: function(value: any) {
return value + 'h';
}
}
}
}
}

View file

@ -0,0 +1,70 @@
import posthog from 'posthog-js'
export function usePostHog() {
posthog.init('phc_xwC3ygQBfstlwaJ3i1lRtz6bON6CR5lHFz7UhlSW6SZ', {
api_host: 'https://at.leafd.dev',
ui_host: 'https://at.leafd.dev',
person_profiles: 'identified_only',
session_recording: {
maskAllInputs: true,
maskInputOptions: {
password: true,
email: true,
},
maskTextSelector: '*',
maskTextFn: (text, element) => {
if (text.trim().length === 0) {
return text
}
if (element?.dataset?.['record'] === 'true') {
return text
}
const emailRegex = /(\S+)@(\S+\.\S+)/g
if (emailRegex.test(text)) {
return text.replace(emailRegex, (_match, g1, g2) => {
return '*'.repeat(g1.length) + '@' + '*'.repeat(g2.length)
})
}
const tokenRegex = /[a-zA-Z0-9_-]{20,}/g
if (tokenRegex.test(text)) {
return text.replace(tokenRegex, (match) => '*'.repeat(match.length))
}
return text
},
maskInputFn: (text, element) => {
const passwordRelated = ['password', 'pwd', 'pass']
const elementId = (element?.attributes?.['id' as any]?.value as string)?.toLowerCase() || ''
const elementName = (element?.attributes?.['name' as any]?.value as string)?.toLowerCase() || ''
if (
passwordRelated.some(p => elementId.includes(p) || elementName.includes(p))
) {
return '*'.repeat(text.length)
}
if (element?.attributes?.['type' as any]?.value === 'search') {
return text
}
return '*'.repeat(text.length)
},
},
})
return { posthog }
}

View file

@ -0,0 +1,13 @@
import { inject } from 'vue'
import type { PostHog } from 'posthog-js'
export function usePostHogInstance() {
const posthog = inject<PostHog>('posthog')
if (!posthog) {
throw new Error('PostHog is not initialized')
}
return posthog
}

View file

@ -1,36 +0,0 @@
import { ref, onMounted } from 'vue'
export type Theme = 'dark' | 'light'
const currentTheme = ref<Theme>('dark')
export function useTheme () {
const setTheme = (theme: Theme) => {
currentTheme.value = theme
document.documentElement.className = theme
localStorage.setItem('theme', theme)
}
const toggleTheme = () => {
const newTheme = currentTheme.value === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
}
const initTheme = () => {
// Check localStorage first, then default to dark
const savedTheme = localStorage.getItem('theme') as Theme
const theme = savedTheme || 'dark'
setTheme(theme)
}
onMounted(() => {
initTheme()
})
return {
currentTheme,
setTheme,
toggleTheme,
initTheme
}
}

View file

@ -1,5 +1,92 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
import * as Sentry from '@sentry/vue'
import { usePostHog } from './composables/usePostHog'
createApp(App).mount('#app')
const app = createApp(App)
const { posthog } = usePostHog()
app.provide('posthog', posthog)
Sentry.init({
app,
dsn: "https://d67e5cceba1b80139ca09c806efc616a@o4509680631087104.ingest.us.sentry.io/4510156240060417",
release: __SENTRY_RELEASE__,
environment: __SENTRY_ENVIRONMENT__,
sendDefaultPii: true,
debug: __SENTRY_ENVIRONMENT__ === 'development',
maxBreadcrumbs: 100,
attachStacktrace: true,
sampleRate: 1.0,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
tracesSampleRate: __SENTRY_ENVIRONMENT__ === 'production' ? 0.1 : 1.0,
profilesSampleRate: __SENTRY_ENVIRONMENT__ === 'production' ? 0.1 : 1.0,
enableLogs: true,
beforeSend(event, hint) {
const error = hint.originalException as Error | undefined
const errorMessage = (error as any)?.message || error?.toString() || ''
if (
errorMessage.includes('callbackId') ||
errorMessage.includes('IPC custom protocol failed') ||
errorMessage.includes('Tauri will now use the postMessage interface') ||
errorMessage.includes('Load failed') && errorMessage.includes('localhost') ||
errorMessage.includes('ipc://localhost') ||
errorMessage.includes('project.editors.some') ||
errorMessage.includes('project.total_heartbeats.toLocaleString') ||
errorMessage.includes('el.__vnode') ||
errorMessage.includes('patchElement') && errorMessage.includes('null') ||
event.exception?.values?.some(value =>
value.value?.includes('callbackId') ||
value.value?.includes('[callbackId, data]') ||
value.value?.includes('project.editors.some') ||
value.value?.includes('project.total_heartbeats') ||
value.value?.includes('el.__vnode') ||
(value.value?.includes('patchElement') && value.value?.includes('null'))
)
) {
console.log('[SENTRY] Filtered out known benign error:', errorMessage)
return null
}
return event
},
ignoreErrors: [
'IPC custom protocol failed',
'callbackId',
'Load failed',
'ipc://localhost',
'undefined is not an object (evaluating \'[callbackId, data]\')',
'undefined is not an object (evaluating \'project.editors.some\')',
'undefined is not an object (evaluating \'project.total_heartbeats.toLocaleString\')',
'null is not an object (evaluating \'el.__vnode = n2\')',
'null is not an object (evaluating \'el.__vnode\')',
/IPC custom protocol/,
/callbackId/,
/project\.editors\.some/,
/project\.total_heartbeats/,
/el\.__vnode/,
/patchElement/,
]
})
app.mount('#app')

154
src/motd.json Normal file
View file

@ -0,0 +1,154 @@
{
"regular": [
"every hour brings you power.",
"also try terraria!",
"also try minecraft!",
"also try siege.hackclub.com!",
"in the last hour, 59 minutes have passed!",
"in the last minute, 60 seconds have occurred!",
"now with 20% more tracking!",
"batteries not included!",
"as seen on TV!",
"imagine a world where everyone ships code daily.",
"made with love <3.",
"powered by orpheus!",
"ship it like it's hot!",
"one more feature couldn't hurt... right?",
"contains no bugs! (that we know of)",
"404: sleep not found.",
"ctrl+z is your best friend.",
"it's dangerous to code alone, take this tracker!",
"doctors hate this one weird time tracking trick!",
"still faster than internet explorer.",
"this app contains traces of rust.",
"gluten-free, vegan-friendly code tracking!",
"zach would be proud!",
"orpheus approved™",
"hcb integration wen?",
"imagine a world where you track time ironically.",
"sheesh! that's a lot of hours!",
"respectfully, go ship something.",
"brb, tracking time.",
"ngl this slaps.",
"built different.",
"in my productive era.",
"touch grass (after you ship).",
"10/10 would track again.",
"i am inevitable (so is your deadline).",
"i can do this all day (said no developer ever)."
],
"holidays": {
"01-01": {
"name": "New Year's Day",
"messages": [
"new year, who dis?",
"Happy New Year!",
"YOU HAVEN'T TOUCHED GRASS SINCE LAST YEAR????"
]
},
"02-14": {
"name": "Valentine's Day",
"messages": [
"roses are red, violets are blue, undefined is not a function, line 42.",
"be my valentine.js?"
]
},
"03-17": {
"name": "St. Patrick's Day",
"messages": ["feeling lucky?"]
},
"04-01": {
"name": "April Fools' Day",
"messages": [
"this isn't a bug, it's an undocumented feature.",
"works on my machine. ¯\\_(ツ)_/¯",
"debugging: being the detective in a crime movie where you're also the murderer.",
"psych! your code actually works today!",
"imagine a world where april fools never ends (aka production).",
"also try believing everything you read today!",
"in the last hour, 58 minutes passed! wait...",
"this message is a lie."
]
},
"05-04": {
"name": "Star Wars Day",
"messages": [
"may the code be with you.",
"use the source, luke.",
"do or do not push --force. there is no try.",
"these aren't the bugs you're looking for.",
"i find your lack of tests disturbing.",
"imagine a world far, far away... but still with deadlines.",
"also try the dark side (vim)!"
]
},
"07-04": {
"name": "Independence Day (US)",
"messages": [
"celebrating freedom with open source code.",
"declaring independence from legacy code.",
"liberty, justice, and bug-free code for all.",
"freedom from technical debt!",
"imagine a world where all code is free.",
"also try not setting off fireworks near your server rack!"
]
},
"10-31": {
"name": "Halloween",
"messages": [
"beware of scary bugs in production tonight.",
"no tricks, just treats... and code.",
"debugging in the dark. spooky.",
"zombie processes everywhere!",
"boo! did i scare you? (not as scary as production errors)",
"imagine a world without halloween candy crashes.",
"also try not pushing to prod tonight!",
"in the last hour, several ghosts have haunted your codebase!"
]
},
"11-28": {
"name": "Thanksgiving (US)",
"messages": [
"thankful for stackoverflow and caffeine.",
"grateful for that one person who writes good documentation.",
"counting blessings and closed issues.",
"imagine a world where we give thanks for working code.",
"also try spending time with family (after this commit)!",
"turkey && code ? happiness : confusion"
]
},
"12-24": {
"name": "Christmas Eve",
"messages": [
"all i want for christmas is no merge conflicts.",
"santa's checking your commit history twice.",
"deck the halls with lines of code.",
"imagine a world where santa delivers features.",
"also try not deploying tonight!",
"twas the night before christmas and all through the repo..."
]
},
"12-25": {
"name": "Christmas",
"messages": [
"merry codemas! unwrapping new features.",
"ho ho ho! time to commit some joy.",
"spreading holiday cheer and clean code.",
"imagine a world where every day feels like opening a PR.",
"also try spending time away from your computer!",
"you've been nice this year (mostly bug-free)!"
]
},
"12-31": {
"name": "New Year's Eve",
"messages": [
"counting down to midnight and that final commit.",
"ending the year strong with one last push.",
"new year's resolution: write better docs.",
"imagine a world that starts fresh in 3... 2... 1...",
"also try not deploying during the countdown!",
"in the last hour of the year, 59 minutes have almost passed!"
]
}
}
}

View file

@ -1,8 +1,7 @@
@import "tailwindcss";
/* CSS Variables for Theme System */
/* CSS Variables */
:root {
/* Dark theme (default) */
--bg-primary: #2a1f21;
--bg-secondary: #3d2b2e;
--bg-tertiary: #4a2d31;
@ -22,27 +21,6 @@
--border-secondary: #4a2d31;
}
.light {
/* Light theme */
--bg-primary: #fdf7f8;
--bg-secondary: #ffffff;
--bg-tertiary: #f8e8ea;
--bg-card: #ffffff;
--bg-card-secondary: #f8e8ea;
--bg-card-tertiary: #ffffff;
--bg-sidebar: #fdf7f8;
--text-primary: #5d3a3f;
--text-secondary: #5d3a3f;
--text-muted: #8b2635;
--accent-primary: #c8394f;
--accent-secondary: #a12d3e;
--accent-danger: #ec3750;
--accent-warning: #ff8c37;
--accent-info: #33d6a6;
--border-primary: #f8e8ea;
--border-secondary: #ffffff;
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
@ -86,7 +64,7 @@ html, body {
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
background: var(--bg-primary);
background: transparent;
color: var(--text-primary);
overflow: hidden;
transition: background-color 0.3s ease, color 0.3s ease;
@ -97,6 +75,12 @@ html, body {
width: 100vw;
}
/* Custom titlebar styles */
.custom-titlebar {
flex-shrink: 0;
z-index: 1000;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
margin: 0;
@ -173,3 +157,27 @@ a:hover {
font-weight: 700;
font-style: normal;
}
/* Responsive layout utilities */
@media (max-width: 1024px) {
/* Stack layout vertically on smaller screens */
.responsive-stack {
flex-direction: column !important;
}
.responsive-full-width {
width: 100% !important;
min-width: 100% !important;
}
}
@media (max-width: 768px) {
/* Further adjustments for mobile */
.responsive-padding {
padding: 1rem !important;
}
.responsive-text {
font-size: 0.875rem !important;
}
}

View file

@ -3,7 +3,7 @@
<div v-if="!authState.is_authenticated" class="flex items-center justify-center min-h-96">
<div class="text-center max-w-md">
<h3 class="text-2xl mb-4 text-text-primary">Welcome to Hackatime</h3>
<p class="text-text-secondary mb-8 leading-relaxed">Connect to your KubeTime account to start tracking your coding time.</p>
<p class="text-text-secondary mb-8 leading-relaxed">Connect to your Hackatime account to start tracking your coding time.</p>
<!-- Production authentication (deep link) -->
<template v-if="!isDevMode">
@ -12,9 +12,17 @@
:disabled="isLoading"
class="bg-accent-primary text-white border-0 px-8 py-4 rounded-xl text-base font-medium cursor-pointer transition-all duration-200 my-4 w-full hover:bg-accent-secondary hover:shadow-card-hover disabled:bg-text-muted disabled:cursor-not-allowed disabled:transform-none"
>
{{ isLoading ? 'Opening Login...' : 'Login with KubeTime' }}
{{ isLoading ? 'Opening Login...' : 'Login with Hackatime' }}
</button>
<p class="text-text-secondary text-sm mt-2">This will open your browser for OAuth authentication.</p>
<button
v-if="oauthUrl && !isLoading"
@click="openOAuthUrlManually"
class="bg-bg-secondary text-text-primary border border-border-secondary px-6 py-3 rounded-xl text-sm font-medium cursor-pointer transition-all duration-200 mt-4 w-full hover:bg-bg-tertiary hover:border-accent-primary"
>
Link didn't open? Click here
</button>
</template>
<!-- Development authentication options -->
@ -28,6 +36,14 @@
{{ isLoading ? 'Opening Login...' : 'Open Browser Login' }}
</button>
<p class="text-text-secondary text-sm mt-2">This will open your browser for OAuth authentication.</p>
<button
v-if="oauthUrl && !isLoading"
@click="openOAuthUrlManually"
class="bg-bg-secondary text-text-primary border border-border-secondary px-6 py-3 rounded-xl text-sm font-medium cursor-pointer transition-all duration-200 mt-4 w-full hover:bg-bg-tertiary hover:border-accent-primary"
>
Link didn't open? Click here
</button>
</div>
<div class="mt-8 pt-8 border-t border-border-primary text-left">
@ -55,16 +71,117 @@
</div>
</div>
<!-- Fallback when authenticated but no user stats available -->
<div v-else-if="!userStats" class="mb-8">
<div class="bg-bg-secondary p-6 rounded-xl border border-border-primary text-center">
<h4 class="text-text-primary mb-2 text-lg">No Stats Available</h4>
<p class="text-text-secondary">Start coding to see your statistics here!</p>
<!-- Authenticated Content -->
<div v-else-if="userStats" class="flex flex-col h-full min-h-0">
<!-- Welcome Header -->
<div class="mb-6">
<h1 class="text-[40px] sm:text-[32px] lg:text-[40px] font-bold italic text-white m-0 mb-2" style="font-family: 'Outfit', sans-serif;">
welcome back, {{ userData?.emails?.[0]?.split('@')[0] || 'user' }}
</h1>
<p class="text-[20px] sm:text-[16px] lg:text-[20px] text-white m-0" style="font-family: 'Outfit', sans-serif;">
{{ motd }}
</p>
</div>
<!-- Streak Card -->
<div class="card-3d mb-6 flex-shrink-0">
<div class="relative rounded-[8px] overflow-hidden border-2 border-black card-3d-front" style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%);">
<div class="flex items-center p-4 relative z-10 flex-wrap gap-4">
<!-- Flame icon with streak -->
<div class="relative">
<img src="/flame-icon.svg" alt="Streak" class="w-16 h-16" />
<div class="absolute inset-0 flex items-end justify-center pb-1.5">
<div class="text-white drop-shadow-lg font-bold" :class="{
'text-3xl': (userStats.current_streak || 0) < 10,
'text-2xl': (userStats.current_streak || 0) >= 10 && (userStats.current_streak || 0) < 100,
'text-xl': (userStats.current_streak || 0) >= 100 && (userStats.current_streak || 0) < 1000,
'text-lg': (userStats.current_streak || 0) >= 1000
}">
{{ userStats.current_streak || 0 }}
</div>
</div>
</div>
<!-- Text -->
<div class="flex-1 min-w-0">
<p class="text-white text-[13px] m-0" style="font-family: 'Outfit', sans-serif;">you have a</p>
<p class="text-white text-[26px] font-bold m-0 leading-tight" style="font-family: 'Outfit', sans-serif;">
{{ userStats.current_streak || 0 }} days streak
</p>
</div>
<!-- Hours Coded Box -->
<div class="backdrop-blur-[2px] bg-[rgba(166,82,14,0.5)] border-2 border-[rgba(166,82,14,0.35)] rounded-[4px] h-[65px] w-[100px] flex flex-col items-center justify-center flex-shrink-0">
<p class="text-white text-[32px] font-bold m-0 leading-none" style="font-family: 'Outfit', sans-serif;">
{{ Math.round((userStats.weekly_stats?.time_coded_seconds || 0) / 3600) }}
</p>
<p class="text-white text-[10px] font-bold m-0 mt-1 px-1 text-center leading-tight" style="font-family: 'Outfit', sans-serif;">
HOURS CODED
</p>
</div>
<!-- Rank Box -->
<div class="backdrop-blur-[2px] bg-[rgba(166,82,14,0.5)] border-2 border-[rgba(166,82,14,0.35)] rounded-[4px] h-[65px] w-[100px] flex flex-col items-center justify-center flex-shrink-0">
<p class="text-white text-[32px] font-bold m-0 leading-none" style="font-family: 'Outfit', sans-serif;">
#1
</p>
<p class="text-white text-[10px] font-bold m-0 mt-1 px-1 text-center leading-tight" style="font-family: 'Outfit', sans-serif;">
AMONG FRIENDS
</p>
</div>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="card-3d card-3d-stats flex-1 min-h-0">
<div class="rounded-[8px] border border-black p-6 card-3d-front h-full flex flex-col" style="background-color: #3D2C3E;">
<!-- Weekly Coding Time Card -->
<div class="rounded-lg px-4 py-2 mb-4 border-2" :style="getWeeklyCardStyle(userStats?.calculated_metrics?.weekly_change_percent || 0)">
<div class="flex items-center gap-4">
<div class="flex items-center gap-3">
<div class="text-white text-[28px] font-bold leading-none" style="font-family: 'Outfit', sans-serif;">
{{ (userStats?.calculated_metrics?.weekly_change_percent || 0) > 0 ? '+' : '' }}{{ (userStats?.calculated_metrics?.weekly_change_percent || 0).toFixed(0) }}%
</div>
<p class="text-white text-[14px] font-semibold m-0 opacity-95 tracking-wide" style="font-family: 'Outfit', sans-serif;">
Weekly Coding Time vs last week
</p>
</div>
<div class="ml-auto">
<p class="text-white text-[12px] m-0 opacity-85 text-right" style="font-family: 'Outfit', sans-serif;">
{{ (userStats?.calculated_metrics?.weekly_hours || 0).toFixed(1) }}h
</p>
</div>
</div>
</div>
<!-- Weekly Chart -->
<div class="bg-[rgba(50,36,51,0.15)] border-2 border-[rgba(50,36,51,0.25)] rounded-lg p-5 mt-2 flex-1 flex flex-col min-h-0">
<p class="text-white text-[12px] m-0 mb-4 opacity-80" style="font-family: 'Outfit', sans-serif; letter-spacing: 0.2px;">
Last 7 Days Activity
</p>
<div class="mt-1 flex-1 min-h-0">
<WeeklyChart :data="weeklyChartData" />
</div>
</div>
</div>
</div>
</div>
<!-- Fallback when authenticated but no user stats available -->
<div v-else class="flex items-center justify-center min-h-96">
<RandomLoader />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import WeeklyChart from '../components/WeeklyChart.vue';
import RandomLoader from '../components/RandomLoader.vue';
import motdData from '../motd.json';
interface AuthState {
is_authenticated: boolean;
access_token: string | null;
@ -80,17 +197,49 @@ defineProps<{
apiConfig: ApiConfig;
userData: any;
userStats: any;
weeklyChartData: Array<{
date: string;
day_name: string;
hours: number;
percentage: number;
}>;
isLoading: boolean;
isDevMode: boolean;
directOAuthToken: string;
oauthUrl: string | null;
}>();
const emit = defineEmits<{
authenticate: [];
handleDirectOAuthAuth: [];
openOAuthUrlManually: [];
'update:directOAuthToken': [value: string];
}>();
const motd = ref<string>('');
onMounted(() => {
motd.value = getMotd();
});
function getMotd(): string {
const today = new Date();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateKey = `${month}-${day}`;
const holidayData = (motdData.holidays as Record<string, { name: string; messages: string[] }>)[dateKey];
if (holidayData && holidayData.messages.length > 0) {
return holidayData.messages[Math.floor(Math.random() * holidayData.messages.length)];
}
return motdData.regular[Math.floor(Math.random() * motdData.regular.length)];
}
async function authenticate() {
emit('authenticate');
}
@ -99,6 +248,56 @@ async function handleDirectOAuthAuth() {
emit('handleDirectOAuthAuth');
}
async function openOAuthUrlManually() {
emit('openOAuthUrlManually');
}
function getWeeklyCardStyle(percentage: number): string {
const positiveColor = { r: 52, g: 148, b: 230 };
const negativeColor = { r: 236, g: 110, b: 173 };
const intensity = Math.min(Math.abs(percentage) / 100, 1);
let color;
if (percentage >= 0) {
color = positiveColor;
} else {
const t = intensity;
color = {
r: Math.round(positiveColor.r + (negativeColor.r - positiveColor.r) * t),
g: Math.round(positiveColor.g + (negativeColor.g - positiveColor.g) * t),
b: Math.round(positiveColor.b + (negativeColor.b - positiveColor.b) * t)
};
}
return `background-color: rgb(${color.r}, ${color.g}, ${color.b}); border-color: rgba(0,0,0,0.25);`;
}
</script>
<!-- All styles now handled by Tailwind CSS -->
<style scoped>
.card-3d {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background: linear-gradient(135deg, #B85E6D 0%, #B85E6D 33%, #B5546F 66%, #B55389 100%);
z-index: 0;
}
.card-3d-stats::before {
background: #2A1F2B !important;
}
.card-3d-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
}
</style>

260
src/views/Login.vue Normal file
View file

@ -0,0 +1,260 @@
<template>
<div class="flex items-center justify-center h-full w-full" style="background-color: #322433;">
<div class="max-w-md w-full px-8">
<div v-if="!authInProgress" class="flex justify-center mb-8">
<img src="/src/assets/bird-illustration.svg" alt="Hackatime" class="h-24 w-auto" />
</div>
<div class="card-3d">
<div class="rounded-[12px] border-2 border-black card-3d-front p-8" style="background-color: #3D2C3E;">
<div class="text-center">
<h1 v-if="!authInProgress" class="text-[32px] font-bold text-white mb-8" style="font-family: 'Outfit', sans-serif;">
Welcome to Hackatime
</h1>
<div v-if="!authInProgress">
<button
@click="handleLogin"
:disabled="isLoading"
class="pushable w-full mt-8"
:class="isLoading ? '' : 'pushable-active'"
style="font-family: 'Outfit', sans-serif;"
>
<span
class="front w-full h-16 px-8 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold text-xl flex items-center justify-center gap-3"
:style="isLoading ? 'background-color: #543c55; color: white;' : 'background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;'"
>
<svg v-if="!isLoading" class="w-7 h-7 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
<svg v-else class="animate-spin h-7 w-7 flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span class="flex-shrink-0">{{ isLoading ? 'Initializing...' : 'Sign in with Hackatime' }}</span>
</span>
</button>
<div v-if="isDevMode" class="mt-8 pt-8 border-t border-white/20">
<p class="text-white/60 text-sm mb-3" style="font-family: 'Outfit', sans-serif;">
Developer Mode: Paste token directly
</p>
<div class="flex gap-2">
<input
v-model="directToken"
type="text"
placeholder="Paste token..."
class="flex-1 p-3 bg-[#2A1F2B] border border-white/20 rounded-lg text-white font-mono text-sm focus:outline-none focus:border-[#E99682] transition-colors"
@keyup.enter="handleDirectAuth"
/>
<button
@click="handleDirectAuth"
:disabled="!directToken.trim() || isLoading"
class="px-4 py-3 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold text-sm transition-all"
:class="!directToken.trim() || isLoading ? 'bg-gray-600 text-white/50 cursor-not-allowed' : 'bg-[#E99682] text-white hover:bg-[#d88672]'"
style="font-family: 'Outfit', sans-serif;"
>
Go
</button>
</div>
</div>
</div>
<div v-else class="text-center py-4">
<div class="mb-8 loader-container">
<RandomLoader />
</div>
<h2 class="text-[28px] font-bold text-white mb-5" style="font-family: 'Outfit', sans-serif;">
Opening in your browser
</h2>
<p class="text-white/70 text-[16px]" style="font-family: 'Outfit', sans-serif;">
Complete authentication in the browser window that just opened
</p>
<button
@click="handleManualOpen"
class="pushable w-full mt-10 mb-6"
style="font-family: 'Outfit', sans-serif; background-color: #2A1F2B;"
>
<span
class="front w-full h-14 px-6 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-medium text-base flex items-center justify-center"
style="background-color: #543c55; color: white;"
>
Didn't work? Click here to open manually
</span>
</button>
<!-- Linux-specific OAuth URL copy section -->
<div v-if="currentOs === 'linux' && oauthUrl" class="mt-6 mb-6 p-4 bg-[#2A1F2B] border border-white/20 rounded-lg">
<p class="text-white/70 text-sm mb-3" style="font-family: 'Outfit', sans-serif;">
<strong>Linux:</strong> Copy the link to open in your browser manually
</p>
<div class="flex gap-2">
<input
:value="oauthUrl"
readonly
class="flex-1 p-3 bg-[#3D2C3E] border border-white/20 rounded-lg text-white font-mono text-xs focus:outline-none focus:border-[#E99682] transition-colors select-all"
@click="($event.target as HTMLInputElement)?.select()"
/>
<button
@click="copyOAuthUrl"
class="px-4 py-3 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold text-sm transition-all bg-[#E99682] text-white hover:bg-[#d88672]"
style="font-family: 'Outfit', sans-serif;"
>
Copy
</button>
</div>
</div>
<button
@click="cancelAuth"
class="text-white/60 text-base hover:text-white transition-colors font-medium"
style="font-family: 'Outfit', sans-serif;"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import RandomLoader from '../components/RandomLoader.vue';
const emit = defineEmits<{
authenticate: [];
handleDirectOAuthAuth: [token: string];
openOAuthUrlManually: [];
}>();
const authInProgress = ref(false);
const directToken = ref('');
async function handleLogin() {
emit('authenticate');
setTimeout(() => {
authInProgress.value = true;
}, 500);
}
function handleManualOpen() {
emit('openOAuthUrlManually');
}
function cancelAuth() {
authInProgress.value = false;
}
function handleDirectAuth() {
if (directToken.value.trim()) {
emit('handleDirectOAuthAuth', directToken.value.trim());
directToken.value = '';
}
}
const props = defineProps<{
isLoading: boolean;
isDevMode: boolean;
oauthUrl: string | null;
currentOs: string | null;
}>();
async function copyOAuthUrl() {
if (!props.oauthUrl) return;
try {
await navigator.clipboard.writeText(props.oauthUrl);
alert("OAuth URL copied to clipboard!");
} catch (error) {
console.error("Failed to copy OAuth URL:", error);
alert("Failed to copy OAuth URL to clipboard");
}
}
</script>
<style scoped>
.card-3d {
position: relative;
border-radius: 12px;
padding: 0;
}
.card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
background-color: #2A1F2B;
z-index: 0;
}
.card-3d-front {
position: relative;
transform: translateY(-8px);
z-index: 1;
}
.pushable {
border-radius: 12px;
border: none;
padding: 0;
cursor: pointer;
outline-offset: 4px;
position: relative;
}
.pushable-active {
background: linear-gradient(135deg, #B85E6D 0%, #B85E6D 33%, #B5546F 66%, #B55389 100%);
}
.pushable:not(.pushable-active) {
background-color: #2A1F2B;
}
.pushable:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.front {
display: flex;
align-items: center;
border-radius: 12px;
transform: translateY(-6px);
transition: transform 0.1s ease;
position: relative;
}
.pushable:active:not(:disabled) .front {
transform: translateY(-2px);
}
/* Bounce Animation */
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.animate-bounce {
animation: bounce 1s infinite;
}
.loader-container {
height: 120px;
display: flex;
align-items: center;
justify-content: center;
}
.loader-container :deep(.flex) {
height: 120px !important;
}
</style>

View file

@ -1,99 +1,189 @@
<template>
<div class="min-h-72">
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center h-64">
<div class="flex items-center gap-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-primary"></div>
<p class="text-text-secondary">Loading projects...</p>
</div>
<div class="flex flex-col h-full min-h-0">
<!-- Header -->
<div class="mb-6">
<h1 class="text-[40px] sm:text-[32px] lg:text-[40px] font-bold italic text-white m-0 mb-2" style="font-family: 'Outfit', sans-serif;">
projects
</h1>
<p class="text-[20px] sm:text-[16px] lg:text-[20px] text-white m-0" style="font-family: 'Outfit', sans-serif;">
explore what you've been building.
</p>
</div>
<!-- Loading State -->
<RandomLoader v-if="isLoading" />
<!-- Error State -->
<div v-else-if="error" class="flex items-center justify-center h-64">
<div class="text-center">
<p class="text-accent-danger mb-4">{{ error }}</p>
<button
@click="loadProjects"
class="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-accent-secondary transition-colors"
class="pushable pushable-active"
style="font-family: 'Outfit', sans-serif;"
>
Retry
<span class="front px-6 py-2 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold" style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;">
Retry
</span>
</button>
</div>
</div>
<!-- Projects List -->
<div v-else-if="projects && projects.length > 0" class="space-y-4">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-semibold text-text-primary">Your Projects</h3>
<div class="text-sm text-text-secondary">
{{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }}
<div v-else-if="allProjects && allProjects.length > 0" class="flex flex-col h-full min-h-0">
<!-- Search and Filter Controls -->
<div class="mb-6 space-y-4 flex-shrink-0">
<!-- Search Bar -->
<div class="relative">
<input
v-model="searchQuery"
type="text"
placeholder="Search projects..."
class="w-full p-3 pl-10 bg-[#3D2C3E] border border-black rounded-lg text-white text-base box-border focus:outline-none focus:border-[#E99682] transition-colors"
style="font-family: 'Outfit', sans-serif;"
/>
<svg class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-white/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<!-- Filters Bar -->
<div class="flex flex-wrap gap-3 items-center">
<!-- Sort Dropdown -->
<div class="relative" ref="sortDropdownRef">
<button
@click.stop="sortDropdownOpen = !sortDropdownOpen"
class="px-4 py-2 bg-[rgba(61,44,62,0.5)] border border-[rgba(255,255,255,0.1)] rounded-lg text-white text-sm cursor-pointer hover:bg-[rgba(61,44,62,0.8)] transition-colors flex items-center gap-2"
style="font-family: 'Outfit', sans-serif;"
>
<span class="text-white/60">Sort:</span>
<span>{{ sortByLabel }}</span>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': sortDropdownOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div
v-if="sortDropdownOpen"
class="absolute top-full left-0 mt-2 w-48 bg-[#3D2C3E] border-2 border-black rounded-lg shadow-lg overflow-hidden z-50 max-h-60 overflow-y-auto"
>
<button
v-for="option in sortOptions"
:key="option.value"
@click.stop="selectSort(option.value)"
class="w-full px-4 py-2 text-left text-white text-sm hover:bg-[rgba(233,150,130,0.2)] transition-colors"
:class="{ 'bg-[rgba(233,150,130,0.1)] text-[#E99682] font-medium': sortBy === option.value }"
style="font-family: 'Outfit', sans-serif;"
>
{{ option.label }}
</button>
</div>
</div>
<!-- Language Filter -->
<div class="relative" ref="languageDropdownRef" v-if="allLanguages.length > 0">
<button
@click.stop="languageDropdownOpen = !languageDropdownOpen"
class="px-4 py-2 bg-[rgba(61,44,62,0.5)] border border-[rgba(255,255,255,0.1)] rounded-lg text-white text-sm cursor-pointer hover:bg-[rgba(61,44,62,0.8)] transition-colors flex items-center gap-2"
style="font-family: 'Outfit', sans-serif;"
>
<span class="text-white/60">Language:</span>
<span>{{ filterLanguage || 'All' }}</span>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': languageDropdownOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div
v-if="languageDropdownOpen"
class="absolute top-full left-0 mt-2 w-48 bg-[#3D2C3E] border-2 border-black rounded-lg shadow-lg overflow-hidden z-50 max-h-60 overflow-y-auto custom-scrollbar"
>
<button
@click.stop="selectLanguage('')"
class="w-full px-4 py-2 text-left text-white text-sm hover:bg-[rgba(233,150,130,0.2)] transition-colors"
:class="{ 'bg-[rgba(233,150,130,0.1)] text-[#E99682] font-medium': filterLanguage === '' }"
style="font-family: 'Outfit', sans-serif;"
>
All Languages
</button>
<button
v-for="lang in allLanguages"
:key="lang"
@click.stop="selectLanguage(lang)"
class="w-full px-4 py-2 text-left text-white text-sm hover:bg-[rgba(233,150,130,0.2)] transition-colors"
:class="{ 'bg-[rgba(233,150,130,0.1)] text-[#E99682] font-medium': filterLanguage === lang }"
style="font-family: 'Outfit', sans-serif;"
>
{{ lang }}
</button>
</div>
</div>
<!-- Results Count -->
<div class="ml-auto text-sm text-white/60" style="font-family: 'Outfit', sans-serif;">
{{ filteredProjects.length }} of {{ allProjects.length }} project{{ allProjects.length !== 1 ? 's' : '' }}
</div>
</div>
</div>
<div class="grid gap-4">
<div
v-for="project in projects"
:key="project.name"
class="bg-bg-secondary border border-border-primary rounded-xl p-4 hover:border-accent-primary transition-colors cursor-pointer"
@click="selectProject(project)"
>
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<h4 class="text-text-primary font-medium text-lg mb-1">{{ project.name }}</h4>
<div class="flex items-center gap-4 text-sm text-text-secondary">
<span>{{ project.total_heartbeats }} heartbeats</span>
<span>{{ formatDuration(project.total_seconds) }}</span>
<span v-if="project.recent_activity_seconds > 0" class="text-accent-primary">
Active recently
<!-- Projects Grid with Virtual Scrolling -->
<div class="flex-1 overflow-y-auto min-h-0" ref="scrollContainer">
<div class="grid gap-4 pt-2 pb-4">
<div
v-for="(project, index) in paginatedProjects"
:key="`${project?.name || 'unnamed'}-${index}`"
class="card-3d"
@click="selectProject(project)"
>
<div class="rounded-[8px] border border-black p-4 card-3d-front cursor-pointer hover:bg-[#4a3a4b] transition-colors" style="background-color: #3D2C3E;">
<div class="flex justify-between items-start mb-3">
<div class="flex-1 min-w-0">
<h4 class="text-white font-semibold text-lg mb-1 truncate" style="font-family: 'Outfit', sans-serif;">{{ project?.name || 'Unnamed' }}</h4>
<div class="flex items-center gap-4 text-sm text-white/60 flex-wrap" style="font-family: 'Outfit', sans-serif;">
<span>{{ formatDuration(project?.total_seconds ?? 0) }}</span>
<span v-if="project?.most_recent_heartbeat" class="text-white/40">
Last active: {{ formatDate(project.most_recent_heartbeat) }}
</span>
</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-xl font-bold text-[#E99682]" style="font-family: 'Outfit', sans-serif;">
{{ ((project?.total_seconds ?? 0) / 3600).toFixed(1) }}h
</div>
</div>
</div>
<!-- Languages and Editors -->
<div class="flex flex-wrap gap-2">
<span
v-for="(language, langIndex) in (project?.languages || []).slice(0, 3)"
:key="`${project?.name}-lang-${langIndex}-${language}`"
class="px-2 py-1 bg-[rgba(233,150,130,0.15)] text-[#E99682] text-xs rounded-md font-medium"
style="font-family: 'Outfit', sans-serif;"
>
{{ language }}
</span>
<span
v-if="(project?.languages || []).length > 3"
class="px-2 py-1 bg-[rgba(50,36,51,0.15)] text-white/60 text-xs rounded-md"
style="font-family: 'Outfit', sans-serif;"
>
+{{ (project?.languages || []).length - 3 }} more
</span>
</div>
</div>
<div class="text-right">
<div class="text-lg font-semibold text-accent-primary">
{{ (project.total_seconds / 3600).toFixed(1) }}h
</div>
</div>
</div>
</div>
<!-- Languages and Editors -->
<div class="flex flex-wrap gap-2 mb-3">
<span
v-for="language in project.languages.slice(0, 3)"
:key="language"
class="px-2 py-1 bg-bg-tertiary text-text-primary text-xs rounded-md"
>
{{ language }}
<!-- Load More Button -->
<div v-if="hasMoreProjects" class="flex justify-center py-4">
<button
@click="loadMore"
class="pushable pushable-active"
style="font-family: 'Outfit', sans-serif;"
>
<span class="front px-6 py-2 rounded-lg border-2 border-[rgba(0,0,0,0.35)] font-bold" style="background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;">
Load More
</span>
<span
v-if="project.languages.length > 3"
class="px-2 py-1 bg-bg-tertiary text-text-secondary text-xs rounded-md"
>
+{{ project.languages.length - 3 }} more
</span>
</div>
<!-- Time Range -->
<div class="text-xs text-text-secondary">
<span v-if="project.first_heartbeat">
First: {{ formatDate(project.first_heartbeat) }}
</span>
<span v-if="project.last_heartbeat" class="ml-4">
Last: {{ formatDate(project.last_heartbeat) }}
</span>
</div>
<!-- Repo Link -->
<div v-if="project.repo_url" class="mt-2">
<a
:href="project.repo_url"
target="_blank"
rel="noopener noreferrer"
class="text-accent-primary text-sm hover:underline"
@click.stop
>
View Repository
</a>
</div>
</button>
</div>
</div>
</div>
@ -101,30 +191,93 @@
<!-- Empty State -->
<div v-else class="flex items-center justify-center h-64">
<div class="text-center">
<p class="text-text-secondary mb-4">No projects found</p>
<p class="text-sm text-text-secondary">
<svg class="w-16 h-16 mx-auto mb-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
<p class="text-white/60 mb-2 text-lg" style="font-family: 'Outfit', sans-serif;">No projects found</p>
<p class="text-sm text-white/40" style="font-family: 'Outfit', sans-serif;">
Start coding to see your projects appear here!
</p>
</div>
</div>
<!-- Project Detail Modal -->
<div
v-if="selectedProject"
class="fixed inset-0 bg-black/80 flex justify-center items-center z-50 p-8"
@click="closeModal"
>
<div class="card-3d max-w-3xl w-full max-h-[90vh]" @click.stop>
<div class="rounded-[8px] border border-black card-3d-front flex flex-col max-h-[90vh]" style="background-color: #3D2C3E;">
<!-- Modal Header -->
<div class="p-6 border-b border-[rgba(0,0,0,0.2)] flex-shrink-0">
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<h2 class="text-3xl font-bold text-white m-0 mb-2 truncate" style="font-family: 'Outfit', sans-serif;">
{{ selectedProject?.name || 'Unnamed' }}
</h2>
<div class="flex items-center gap-4 text-white/60 flex-wrap" style="font-family: 'Outfit', sans-serif;">
<span class="text-base">{{ formatDuration(selectedProject?.total_seconds ?? 0) }}</span>
<span v-if="selectedProject?.most_recent_heartbeat" class="text-sm text-white/40">
Last active: {{ formatDate(selectedProject.most_recent_heartbeat) }}
</span>
</div>
</div>
<button
@click="closeModal"
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg hover:bg-[rgba(255,255,255,0.1)] transition-colors"
>
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- Modal Content -->
<div class="flex-1 overflow-y-auto p-6 space-y-6 min-h-0">
<!-- Stats Grid -->
<div class="bg-[rgba(42,31,43,0.5)] border-2 border-[rgba(0,0,0,0.3)] rounded-lg p-6">
<div class="text-white/60 text-sm mb-1" style="font-family: 'Outfit', sans-serif;">Total Time</div>
<div class="text-4xl font-bold text-white mb-2" style="font-family: 'Outfit', sans-serif;">
{{ ((selectedProject?.total_seconds ?? 0) / 3600).toFixed(1) }}h
</div>
<div class="text-white/40 text-base" style="font-family: 'Outfit', sans-serif;">
{{ formatDuration(selectedProject?.total_seconds ?? 0) }}
</div>
</div>
<!-- Languages Section -->
<div v-if="selectedProject?.languages && selectedProject.languages.length > 0">
<h3 class="text-white text-lg font-bold mb-3" style="font-family: 'Outfit', sans-serif;">Languages</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="(language, langIndex) in selectedProject.languages"
:key="`modal-lang-${langIndex}-${language}`"
class="px-3 py-2 bg-[rgba(233,150,130,0.15)] text-[#E99682] text-sm rounded-lg font-medium border-2 border-[rgba(233,150,130,0.3)]"
style="font-family: 'Outfit', sans-serif;"
>
{{ language }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, computed, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import RandomLoader from "../components/RandomLoader.vue";
interface Project {
name: string;
total_seconds: number;
total_heartbeats: number;
languages: string[];
editors: string[];
first_heartbeat: string | null;
last_heartbeat: string | null;
repo_url: string | null;
recent_activity_seconds: number;
recent_activity_formatted: string;
most_recent_heartbeat: string | null;
}
interface ProjectsResponse {
@ -136,44 +289,178 @@ interface ProjectsResponse {
};
}
const projects = ref<Project[]>([]);
const allProjects = ref<Project[]>([]);
const isLoading = ref(false);
const error = ref<string | null>(null);
const selectedProject = ref<Project | null>(null);
const searchQuery = ref("");
const sortBy = ref("recent");
const filterLanguage = ref("");
const sortDropdownOpen = ref(false);
const languageDropdownOpen = ref(false);
const sortDropdownRef = ref<HTMLElement | null>(null);
const languageDropdownRef = ref<HTMLElement | null>(null);
const sortOptions = [
{ value: 'recent', label: 'Most Recent' },
{ value: 'time', label: 'Most Time' },
{ value: 'name', label: 'Name (A-Z)' }
];
const itemsPerPage = ref(20);
const currentPage = ref(1);
const scrollContainer = ref<HTMLElement | null>(null);
// Props
const props = defineProps<{
currentTheme: string;
toggleTheme: () => void;
apiConfig: {
base_url: string;
};
}>();
// Load projects data
const sortByLabel = computed(() => {
const option = sortOptions.find(opt => opt.value === sortBy.value);
return option ? option.label : 'Sort';
});
const allLanguages = computed(() => {
const languages = new Set<string>();
allProjects.value.forEach(project => {
if (project && project.languages && Array.isArray(project.languages)) {
project.languages.forEach(lang => {
if (lang && typeof lang === 'string') {
languages.add(lang);
}
});
}
});
return Array.from(languages).sort();
});
const filteredProjects = computed(() => {
let filtered = [...allProjects.value].filter(project => project && typeof project === 'object');
if (searchQuery.value.trim()) {
const query = searchQuery.value.toLowerCase();
filtered = filtered.filter(project => {
if (!project) return false;
const nameMatch = project.name?.toLowerCase().includes(query);
const languageMatch = Array.isArray(project.languages) &&
project.languages.some(lang => lang && typeof lang === 'string' && lang.toLowerCase().includes(query));
return nameMatch || languageMatch;
});
}
if (filterLanguage.value) {
filtered = filtered.filter(project =>
project && Array.isArray(project.languages) && project.languages.includes(filterLanguage.value)
);
}
switch (sortBy.value) {
case "recent":
filtered.sort((a, b) => {
const dateA = a?.most_recent_heartbeat ? new Date(a.most_recent_heartbeat).getTime() : 0;
const dateB = b?.most_recent_heartbeat ? new Date(b.most_recent_heartbeat).getTime() : 0;
return dateB - dateA;
});
break;
case "time":
filtered.sort((a, b) => (b?.total_seconds || 0) - (a?.total_seconds || 0));
break;
case "name":
filtered.sort((a, b) => (a?.name || '').localeCompare(b?.name || ''));
break;
}
return filtered;
});
const paginatedProjects = computed(() => {
const end = currentPage.value * itemsPerPage.value;
return filteredProjects.value.slice(0, end);
});
const hasMoreProjects = computed(() => {
return paginatedProjects.value.length < filteredProjects.value.length;
});
function normalizeProject(project: any): Project {
return {
name: project?.name || 'Unnamed Project',
total_seconds: Number(project?.total_seconds) || 0,
languages: Array.isArray(project?.languages) ? project.languages : [],
most_recent_heartbeat: project?.most_recent_heartbeat || null
};
}
async function loadProjects() {
isLoading.value = true;
error.value = null;
const timeout = setTimeout(() => {
if (isLoading.value) {
console.error("Projects loading timed out after 30 seconds");
isLoading.value = false;
error.value = "Loading timed out. Please try again.";
}
}, 30000);
try {
console.log("Loading projects with config:", props.apiConfig);
const response = await invoke("get_projects", {
apiConfig: props.apiConfig
}) as ProjectsResponse;
projects.value = response.projects || [];
console.log("Projects loaded:", response);
const projects = response?.projects || [];
allProjects.value = projects.map(normalizeProject).filter(p => p.name && p.name !== 'Unnamed Project');
} catch (err) {
console.error("Failed to load projects:", err);
error.value = err instanceof Error ? err.message : "Failed to load projects";
error.value = err instanceof Error ? err.message : String(err);
} finally {
clearTimeout(timeout);
isLoading.value = false;
}
}
// Select a project (for future detailed view)
function selectProject(project: Project) {
console.log("Selected project:", project.name);
// TODO: Implement project details view
selectedProject.value = project;
}
function closeModal() {
selectedProject.value = null;
}
function selectSort(value: string) {
sortBy.value = value;
sortDropdownOpen.value = false;
}
function selectLanguage(lang: string) {
filterLanguage.value = lang;
languageDropdownOpen.value = false;
}
function loadMore() {
currentPage.value++;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node;
if (sortDropdownRef.value && !sortDropdownRef.value.contains(target)) {
sortDropdownOpen.value = false;
}
if (languageDropdownRef.value && !languageDropdownRef.value.contains(target)) {
languageDropdownOpen.value = false;
}
}
// Format duration helper
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return "0m";
@ -187,20 +474,100 @@ function formatDuration(seconds: number): string {
}
}
// Format date helper
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Load projects on mount
onMounted(() => {
loadProjects();
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<!-- All styles now handled by Tailwind CSS -->
<style scoped>
.card-3d {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background: #2A1F2B;
z-index: 0;
}
.card-3d-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
box-shadow: 0 6px 0 #2A1F2B;
}
.pushable {
border-radius: 12px;
border: none;
padding: 0;
cursor: pointer;
outline-offset: 4px;
position: relative;
}
.pushable-active {
background: linear-gradient(135deg, #B85E6D 0%, #B85E6D 33%, #B5546F 66%, #B55389 100%);
}
.pushable-inactive {
background-color: #2A1F2B;
}
.front {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 12px;
transform: translateY(-4px);
transition: transform 0.1s ease;
position: relative;
}
.pushable:active .front {
transform: translateY(-1px);
}
.overflow-y-auto::-webkit-scrollbar,
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.overflow-y-auto::-webkit-scrollbar-track,
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(42, 31, 43, 0.5);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb,
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(233, 150, 130, 0.3);
border-radius: 4px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover,
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(233, 150, 130, 0.5);
}
</style>

View file

@ -1,178 +1,268 @@
<template>
<div class="min-h-72">
<div class="space-y-8">
<!-- Theme Settings -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-text-primary">Appearance</h3>
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Theme</h4>
<p class="text-sm text-text-secondary">Choose between dark and light mode</p>
</div>
<div class="flex items-center gap-3">
<span class="text-sm text-text-secondary">{{ currentTheme === 'dark' ? 'Dark' : 'Light' }}</span>
<button
@click="toggleTheme"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
:class="currentTheme === 'dark' ? 'bg-accent-primary' : 'bg-border-primary'"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="currentTheme === 'dark' ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
<div class="flex flex-col h-full min-h-0">
<!-- Header -->
<div class="mb-6">
<h1 class="text-[40px] sm:text-[32px] lg:text-[40px] font-bold italic text-white m-0 mb-2" style="font-family: 'Outfit', sans-serif;">
settings
</h1>
<p class="text-[20px] sm:text-[16px] lg:text-[20px] text-white m-0" style="font-family: 'Outfit', sans-serif;">
tune your experience.
</p>
</div>
<!-- Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="flex flex-col gap-6">
<!-- Preferences -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<h3 class="text-white text-[16px] font-bold m-0 mb-4" style="font-family: 'Outfit', sans-serif;">Preferences</h3>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Auto-start</h4>
<p class="text-sm text-text-secondary">Start with system</p>
</div>
<label class="switch" :class="{ 'opacity-50 cursor-not-allowed': isLoading }">
<input type="checkbox" :checked="autostartEnabled" :disabled="isLoading" @change="toggleAutostart">
<span class="slider" :class="{ 'animate-pulse': isLoading }"></span>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Discord RPC</h4>
<p class="text-sm text-text-secondary">Show coding activity in Discord</p>
</div>
<label class="switch" :class="{ 'opacity-50 cursor-not-allowed': isLoading }">
<input type="checkbox" :checked="discordRpcEnabled" :disabled="isLoading" @change="toggleDiscordRpc">
<span class="slider" :class="{ 'animate-pulse': isLoading }"></span>
</label>
</div>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Notifications</h4>
<p class="text-sm text-text-secondary">Show desktop notifications</p>
</div>
<label class="switch" :class="{ 'opacity-50 cursor-not-allowed': isLoading }">
<input type="checkbox" :checked="notificationsEnabled" :disabled="isLoading" @change="toggleNotifications">
<span class="slider" :class="{ 'animate-pulse': isLoading }"></span>
</label>
</div>
</div>
</div>
</div>
</div>
<!-- App Information -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-text-primary">About</h3>
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-text-secondary">Version</span>
<span class="text-text-primary font-medium">1.0.0</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Build</span>
<span class="text-text-primary font-medium">Development</span>
</div>
<div class="flex justify-between items-center">
<span class="text-text-secondary">Updates</span>
<div class="flex items-center gap-2">
<span v-if="updateStatus === 'checking'" class="text-text-secondary text-sm">Checking...</span>
<span v-else-if="updateStatus === 'available'" class="text-accent-primary text-sm">Update available</span>
<span v-else-if="updateStatus === 'latest'" class="text-green-500 text-sm">Up to date</span>
<span v-else-if="updateStatus === 'error'" class="text-red-500 text-sm">Check failed</span>
<button
@click="checkForUpdates"
:disabled="updateStatus === 'checking'"
class="px-3 py-1 text-xs bg-accent-primary text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ updateStatus === 'checking' ? 'Checking...' : 'Check for Updates' }}
<!-- API Access -->
<div v-if="apiKey" class="card-3d ph-no-capture">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<h3 class="text-white text-[16px] font-bold m-0 mb-4" style="font-family: 'Outfit', sans-serif;">Your API Key</h3>
<p class="text-sm text-text-secondary mb-4">Use this key to authenticate with the KubeTime API</p>
<div class="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
<input
:type="showApiKey ? 'text' : 'password'"
:value="apiKey"
readonly
class="flex-1 p-3 bg-[rgba(20,15,21,0.3)] border border-[rgba(50,36,51,0.4)] rounded-xl text-text-primary font-mono text-sm min-w-0 break-all"
/>
<div class="flex gap-2 flex-shrink-0">
<button @click="$emit('update:showApiKey', !showApiKey)" class="p-3 border border-[rgba(50,36,51,0.4)] rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-[rgba(20,15,21,0.3)] text-text-secondary hover:bg-bg-primary hover:text-text-primary hover:border-accent-primary">
<svg v-if="showApiKey" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878L12 12m-2.122-2.122l1.415 1.415M12 12l2.122 2.122m-2.122-2.122L12 12m2.122 2.122l-1.415-1.415M12 12l-2.122-2.122"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
<button @click="copyApiKey" class="p-3 border border-accent-info rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-accent-info text-white hover:bg-blue-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
</div>
<div v-if="updateInfo" class="mt-4 p-4 bg-bg-tertiary rounded-lg border border-border-secondary">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-text-secondary text-sm">New Version</span>
<span class="text-text-primary text-sm font-medium">{{ updateInfo.version }}</span>
</div>
<div v-if="updateInfo.notes" class="text-text-secondary text-sm">
<p class="font-medium mb-1">Release Notes:</p>
<p class="whitespace-pre-wrap">{{ updateInfo.notes }}</p>
</div>
<div class="flex gap-2 mt-3">
</div>
</div>
</div>
<!-- Right Column -->
<div class="flex flex-col gap-6">
<!-- About -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<h3 class="text-white text-[16px] font-bold m-0 mb-4" style="font-family: 'Outfit', sans-serif;">About</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-text-secondary">Version</span>
<span
class="text-text-primary font-medium cursor-pointer select-none"
title="Tap 5 times for debug"
@click="handleVersionClick"
>
{{ appVersion }}
</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Build</span>
<span class="text-text-primary font-medium">Development</span>
</div>
<div class="flex justify-between items-center">
<span class="text-text-secondary">Updates</span>
<div class="flex items-center gap-2">
<span v-if="updateStatus === 'checking'" class="text-text-secondary text-sm">Checking...</span>
<span v-else-if="updateStatus === 'available'" class="text-accent-primary text-sm">Update available</span>
<span v-else-if="updateStatus === 'latest'" class="text-green-500 text-sm">Up to date</span>
<span v-else-if="updateStatus === 'error'" class="text-red-500 text-sm">Check failed</span>
<button
@click="downloadAndInstallUpdate"
:disabled="isInstallingUpdate"
class="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
@click="checkForUpdates"
:disabled="updateStatus === 'checking'"
class="px-3 py-1 text-xs bg-accent-primary text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ isInstallingUpdate ? 'Installing...' : 'Install Update' }}
</button>
<button
@click="updateInfo = null"
class="px-4 py-2 bg-bg-primary border border-border-secondary text-text-primary rounded-lg hover:bg-bg-secondary transition-colors text-sm"
>
Later
{{ updateStatus === 'checking' ? 'Checking...' : 'Check for Updates' }}
</button>
</div>
</div>
<div v-if="updateInfo" class="mt-4 p-4 rounded-lg border border-[rgba(50,36,51,0.4)]" style="background-color: #2A1F2B;">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-text-secondary text-sm">New Version</span>
<span class="text-text-primary text-sm font-medium">{{ updateInfo.version }}</span>
</div>
<div v-if="updateInfo.notes" class="text-text-secondary text-sm">
<p class="font-medium mb-1">Release Notes:</p>
<p class="whitespace-pre-wrap">{{ updateInfo.notes }}</p>
</div>
<div class="flex gap-2 mt-3">
<button
@click="downloadAndInstallUpdate"
:disabled="isInstallingUpdate"
class="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
{{ isInstallingUpdate ? 'Installing...' : 'Install Update' }}
</button>
<button
@click="updateInfo = null"
class="px-4 py-2 bg-bg-primary border border-border-secondary text-text-primary rounded-lg hover:bg-bg-secondary transition-colors text-sm"
>
Later
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- API Key -->
<div v-if="apiKey" class="space-y-4">
<h3 class="text-lg font-semibold text-text-primary">API Access</h3>
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
<div class="space-y-4">
<div>
<h4 class="font-medium text-text-primary mb-2">Your API Key</h4>
<p class="text-sm text-text-secondary mb-4">Use this key to authenticate with the KubeTime API</p>
<div class="flex gap-3 items-center">
<input
:type="showApiKey ? 'text' : 'password'"
:value="apiKey"
readonly
class="flex-1 p-3 bg-bg-tertiary border border-border-secondary rounded-xl text-text-primary font-mono text-sm"
/>
<div class="flex gap-2">
<button @click="$emit('update:showApiKey', !showApiKey)" class="p-3 border border-border-secondary rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-bg-tertiary text-text-secondary hover:bg-bg-primary hover:text-text-primary hover:border-accent-primary">
<svg v-if="showApiKey" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878L12 12m-2.122-2.122l1.415 1.415M12 12l2.122 2.122m-2.122-2.122L12 12m2.122 2.122l-1.415-1.415M12 12l-2.122-2.122"></path>
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
<button @click="copyApiKey" class="p-3 border border-accent-info rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-accent-info text-white hover:bg-blue-400">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<!-- WakaTime Configuration -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Setup</h4>
<p class="text-sm text-text-secondary">Verify and update your .wakatime.cfg file</p>
</div>
<button
@click="$emit('checkWakatimeConfig')"
class="px-4 py-2 bg-gradient-to-r from-[#E99682] via-[#E88592] to-[#E883AE] text-white rounded-lg hover:opacity-90 transition-opacity font-medium"
>
Check Config
</button>
</div>
</div>
</div>
<!-- Cache Management -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Statistics Cache</h4>
<p class="text-sm text-text-secondary">Clear cached statistics data (stored for 30 days)</p>
</div>
<button
@click="clearCache"
:disabled="isClearingCache"
class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isClearingCache ? 'Clearing...' : 'Clear Cache' }}
</button>
</div>
</div>
</div>
<!-- Account -->
<div class="card-3d">
<div class="rounded-[8px] border border-black p-5 card-3d-front" style="background-color: #3D2C3E;">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Sign Out</h4>
<p class="text-sm text-text-secondary">Log out of your account</p>
</div>
<button
@click="$emit('logout')"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
>
Logout
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Preferences -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-text-primary">Preferences</h3>
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Auto-start</h4>
<p class="text-sm text-text-secondary">Start with system</p>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full bg-border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white translate-x-1 transition-transform" />
<!-- Debug Modal -->
<div v-if="showDebugModal" class="modal-backdrop" @click="showDebugModal = false">
<div class="modal-card-3d" @click.stop>
<div class="rounded-[8px] border border-black p-6 modal-card-3d-front" style="background-color: #3D2C3E;">
<div class="flex items-start justify-between mb-4">
<h3 class="text-white text-[16px] font-bold italic m-0" style="font-family: 'Outfit', sans-serif;">debug console</h3>
<div class="flex items-center gap-3">
<button @click="refreshLogs" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors duration-200 shadow-md">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
refresh
</button>
<button @click="clearAllLogs" class="px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg text-sm font-medium transition-colors duration-200 shadow-md">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
clear
</button>
<button @click="showDebugModal = false" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors duration-200 shadow-md">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
close
</button>
</div>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Discord RPC</h4>
<p class="text-sm text-text-secondary">Show coding activity in Discord</p>
</div>
<button
@click="toggleDiscordRpc"
:disabled="isLoading"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
:class="discordRpcEnabled ? 'bg-accent-primary' : 'bg-border-primary'"
</div>
<!-- Console Output -->
<div ref="consoleContainer" class="console-container bg-black rounded-md p-4 font-mono text-[12px] max-h-[60vh] overflow-auto">
<div v-if="consoleMessages.length === 0" class="text-gray-400">Console ready. Interact with the app or click refresh.</div>
<div v-for="(message, idx) in consoleMessages" :key="idx" class="console-line flex items-start gap-2 py-0.5">
<span class="text-gray-500 text-[10px] flex-shrink-0">{{ message.timestamp }}</span>
<span
class="level-badge px-1.5 py-0.5 rounded text-[10px] font-semibold flex-shrink-0"
:class="getLevelColor(message.level)"
>
<span
v-if="isLoading"
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform animate-pulse"
:class="discordRpcEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
<span
v-else
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="discordRpcEnabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="flex items-center justify-between">
<div>
<h4 class="font-medium text-text-primary mb-1">Notifications</h4>
<p class="text-sm text-text-secondary">Show desktop notifications</p>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full bg-accent-primary transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
{{ message.level.toUpperCase() }}
</span>
<span
class="source-badge px-1.5 py-0.5 rounded text-[10px] font-semibold flex-shrink-0"
:class="message.source === 'backend' ? 'bg-blue-900 text-blue-200' : 'bg-purple-900 text-purple-200'"
>
<span class="inline-block h-4 w-4 transform rounded-full bg-white translate-x-6 transition-transform" />
</button>
{{ message.source }}
</span>
<span
class="message-content flex-1 break-words whitespace-pre-wrap"
:class="getMessageColor(message.level)"
>
{{ message.message }}
</span>
</div>
</div>
</div>
@ -182,15 +272,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, onMounted, watch, nextTick } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from '@tauri-apps/api/app';
import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
import type { Theme } from '../composables/useTheme'
defineProps<{
currentTheme: Theme
toggleTheme: () => void
apiKey: string | null
showApiKey: boolean
}>()
@ -198,12 +285,17 @@ defineProps<{
const emit = defineEmits<{
copyApiKey: []
'update:showApiKey': [value: boolean]
logout: []
checkWakatimeConfig: []
}>()
const discordRpcEnabled = ref(false);
const autostartEnabled = ref(false);
const notificationsEnabled = ref(false);
const isLoading = ref(false);
const appVersion = ref('...');
const isClearingCache = ref(false);
// Update functionality
const updateStatus = ref<'idle' | 'checking' | 'available' | 'latest' | 'error'>('idle');
const updateInfo = ref<{
version: string;
@ -212,6 +304,252 @@ const updateInfo = ref<{
} | null>(null);
const isInstallingUpdate = ref(false);
const versionTapCount = ref(0);
const showDebugModal = ref(false);
const userIdForDebug = ref<string | null>(null);
const backendLogsError = ref(false);
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
type LogSource = 'frontend' | 'backend';
type LogEntry = { ts: number; level: LogLevel; source: LogSource; message: string };
const frontendLogs = ref<LogEntry[]>([]);
const backendLogs = ref<LogEntry[]>([]);
const platformInfo = ref<any>(null);
const consoleContainer = ref<HTMLElement | null>(null);
const consoleMessages = ref<Array<{
timestamp: string;
level: LogLevel;
source: LogSource;
message: string;
}>>([]);
function getAsciiArt(): string {
return [
" ,'.r?!,'!'",
" ':?,:' ':'.r:",
" ''',` .:r'",
" .r'' \`r?",
" ,:}.' '~~[[.\` \`:[",
" !r'S.~Y ,?:7'C:~ '![",
" :yYd?~} '7.C:,~\` :r",
" N. ',\` '! ~: ,.}",
" ![\` .:, :}",
" \`~!' .r! [:'",
" :~. !,.'. !'.,r?: r~",
" ',:'', '!. \`r'\`.\`:\` YY",
" .?!.,:[~~}: ,[ ,!",
" , r: ~kC, :~ !!:",
" .}! ,[' ~rr\` \`!' }:",
" ~[~ :~ \`!}' ![' !~",
" ~~ [r\` :?~ \`[r '[.",
" C'. ,?~\` .?, '[' ?:",
" :,7?!' \`!,[[ ~7'",
" 7. \`:},. '7?' 7,",
" .?}' ,\`r7:' ::. ~? .!",
" \`::: ,r7:' '!: !r !~: ':Y~",
" \`,~[!' [! \`[~' ':C \`'!r,:",
" '~[rr, 'r~ !d'! :!!\`~ '",
" r!YY~ ,[, \`7,,' ':7, '.'",
" ,~ .[! !'! ![[\` :~",
" ![ \`~r' 7[? \`.?, .~:",
" C [.\` ::y: ,!' ,}",
" !. \`, !7~~Y ':! :r\`",
" 7[ \`rr Yr,~\` .r\`",
" r: !7\` }! \`}:",
" :k' k\` 7[ !Y \`:r",
" }' \`\`k}.~~,?r?::, ~r \`?[ '!~",
" ~[ ':k!. !~[?7: 7~ .~~",
" ?[ \`':}!,` \`:!,~ r: \`.7:",
" '!, \`,!:}[~\` Y .,~\`",
" [' :[,''",
" \`.,,r: '! ,'7.\`",
" :\`.\`!!:!':'` :r~ ?! !,~",
" \`.,,~!...:` \`[? ![ ,!\`",
" :,~,' '\` \`r:' '[r:,'",
" 'y'.\`!,!\` .[,' \`.,'.r r[:",
" !!7 \`'rr \`~::' '::.\`7,' :~.",
" \`!~ .r? ,!7\`\` !!\` ,:,",
" '.\`![` \`[} ',~[,\` \`}:\` '\`~!",
" '}:.:} ''.~~,. .!! \`}}",
" '':~'}~!~ rr'r!r~!''' \`![",
" \`\`\`.\` y:. '.'?[\`':[",
" ..[!,. \`~!![",
" ''.r?!??~.["
].join('\n');
}
const originalConsole = { ...console } as any;
function pushFront(level: LogLevel, args: any[]) {
try {
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
frontendLogs.value.push({ ts: Date.now(), level, source: 'frontend', message });
} catch (_) {}
}
if (!(window as any).__hackatimeConsoleWrapped) {
(window as any).__hackatimeConsoleWrapped = true;
['debug','info','warn','error'].forEach((lvl) => {
const key = lvl as LogLevel;
const orig = (originalConsole as any)[key] || originalConsole.log;
(console as any)[key] = (...args: any[]) => {
pushFront(key, args);
try { orig.apply(originalConsole, args); } catch (_) {}
};
});
}
async function scrollToBottom() {
await nextTick();
if (consoleContainer.value) {
consoleContainer.value.scrollTop = consoleContainer.value.scrollHeight;
}
}
watch([backendLogs, frontendLogs], () => {
updateConsoleMessages();
scrollToBottom();
}, { deep: true });
function formatTs(ts: number): string {
const d = new Date(ts);
return d.toLocaleTimeString();
}
function updateConsoleMessages() {
const messages: Array<{
timestamp: string;
level: LogLevel;
source: LogSource;
message: string;
}> = [];
messages.push({
timestamp: formatTs(Date.now()),
level: 'info',
source: 'frontend',
message: getAsciiArt()
});
messages.push({
timestamp: formatTs(Date.now()),
level: 'info',
source: 'frontend',
message: `Hackatime started on ${platformInfo.value?.platform || 'unknown'}${platformInfo.value?.description || 'unknown platform'} · app v${appVersion.value}`
});
if (platformInfo.value?.app_data_dir) {
messages.push({
timestamp: formatTs(Date.now()),
level: 'debug',
source: 'frontend',
message: `app_data_dir: ${platformInfo.value.app_data_dir}`
});
}
[...backendLogs.value, ...frontendLogs.value]
.sort((a, b) => a.ts - b.ts)
.slice(-500)
.forEach(entry => {
messages.push({
timestamp: formatTs(entry.ts),
level: entry.level,
source: entry.source,
message: entry.message
});
});
consoleMessages.value = messages;
}
function getLevelColor(level: LogLevel): string {
switch (level) {
case 'debug': return 'bg-green-900 text-green-200';
case 'info': return 'bg-blue-900 text-blue-200';
case 'warn': return 'bg-yellow-900 text-yellow-200';
case 'error': return 'bg-red-900 text-red-200';
default: return 'bg-gray-900 text-gray-200';
}
}
function getMessageColor(level: LogLevel): string {
switch (level) {
case 'debug': return 'text-green-300';
case 'info': return 'text-white';
case 'warn': return 'text-yellow-300';
case 'error': return 'text-red-300';
default: return 'text-gray-300';
}
}
function clearAllLogs() {
frontendLogs.value = [];
backendLogs.value = [];
consoleMessages.value = [];
setTimeout(() => {
updateConsoleMessages();
}, 10);
}
async function refreshLogs() {
backendLogsError.value = false;
try {
const logs = await invoke('get_recent_logs');
if (Array.isArray(logs)) {
backendLogs.value = logs.map((l: any) => ({
ts: Number(l.ts) || Date.now(),
level: (l.level || 'info') as LogLevel,
source: 'backend',
message: typeof l.message === 'string' ? l.message : JSON.stringify(l.message)
}));
} else {
backendLogsError.value = true;
}
} catch (_) {
backendLogsError.value = true;
}
}
async function loadPlatformInfo() {
try {
platformInfo.value = await invoke('get_platform_info');
} catch (_) {
platformInfo.value = null;
}
}
window.addEventListener('error', (e) => {
pushFront('error', [e.message || 'window.onerror']);
});
window.addEventListener('unhandledrejection', (e: PromiseRejectionEvent) => {
try {
const reason = (e as any)?.reason;
pushFront('error', [typeof reason === 'string' ? reason : JSON.stringify(reason)]);
} catch (_) {
pushFront('error', ['unhandledrejection']);
}
});
watch(showDebugModal, (open) => {
if (open) {
console.info('debug modal opened');
loadPlatformInfo();
refreshLogs();
setTimeout(() => {
updateConsoleMessages();
}, 100);
}
});
function handleVersionClick() {
versionTapCount.value += 1;
if (versionTapCount.value >= 5) {
versionTapCount.value = 0;
showDebugModal.value = true;
}
}
async function loadDiscordRpcState() {
try {
discordRpcEnabled.value = await invoke("get_discord_rpc_enabled");
@ -220,6 +558,39 @@ async function loadDiscordRpcState() {
}
}
async function loadAutostartState() {
try {
autostartEnabled.value = await invoke("get_autostart_enabled");
} catch (error) {
console.error("Failed to load autostart state:", error);
}
}
async function loadNotificationsState() {
try {
notificationsEnabled.value = await invoke("get_notifications_enabled");
} catch (error) {
console.error("Failed to load notifications state:", error);
}
}
async function toggleAutostart() {
if (isLoading.value) return;
isLoading.value = true;
try {
const newState = !autostartEnabled.value;
await invoke("set_autostart_enabled", { enabled: newState });
autostartEnabled.value = newState;
} catch (error) {
console.error("Failed to toggle autostart:", error);
autostartEnabled.value = !autostartEnabled.value;
} finally {
isLoading.value = false;
}
}
async function toggleDiscordRpc() {
if (isLoading.value) return;
@ -230,18 +601,55 @@ async function toggleDiscordRpc() {
discordRpcEnabled.value = newState;
} catch (error) {
console.error("Failed to toggle Discord RPC:", error);
// Revert the UI state on error
discordRpcEnabled.value = !discordRpcEnabled.value;
} finally {
isLoading.value = false;
}
}
async function toggleNotifications() {
if (isLoading.value) return;
isLoading.value = true;
try {
const newState = !notificationsEnabled.value;
await invoke("set_notifications_enabled", { enabled: newState });
notificationsEnabled.value = newState;
} catch (error) {
console.error("Failed to toggle notifications:", error);
notificationsEnabled.value = !notificationsEnabled.value;
} finally {
isLoading.value = false;
}
}
function copyApiKey() {
emit('copyApiKey')
}
// Update functions
async function clearCache() {
if (isClearingCache.value) return;
isClearingCache.value = true;
console.info('Starting to clear statistics cache...');
try {
await invoke('clear_statistics_cache');
console.info('Statistics cache cleared successfully! Fresh data will be fetched on next request.');
if (showDebugModal.value) {
await refreshLogs();
}
} catch (error) {
console.error('Failed to clear cache:', error);
} finally {
isClearingCache.value = false;
}
}
async function checkForUpdates() {
if (updateStatus.value === 'checking') return;
@ -303,10 +711,154 @@ async function downloadAndInstallUpdate() {
}
}
// Load Discord RPC state on mount
onMounted(() => {
onMounted(async () => {
loadDiscordRpcState();
loadAutostartState();
loadNotificationsState();
try {
appVersion.value = await getVersion();
} catch (error) {
console.error('Failed to get app version:', error);
appVersion.value = '1.0.0';
}
try {
const auth = await invoke('get_auth_state');
const email = (auth as any)?.user_info?.emails?.[0];
userIdForDebug.value = email || (auth as any)?.user_info?.id || 'anonymous';
} catch (e) {
userIdForDebug.value = 'unknown';
}
});
</script>
<!-- All styles now handled by Tailwind CSS -->
<style scoped>
.card-3d {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background: #2A1F2B;
z-index: 0;
}
.card-3d-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
}
/* Debug modal styles */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-card-3d {
position: relative;
border-radius: 8px;
padding: 0;
max-width: 720px;
width: 92vw;
}
.modal-card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background: #2A1F2B;
z-index: 0;
}
.modal-card-3d-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
}
/* Custom slider styles */
.slider {
background-color: #ffffff2b;
border-radius: 100px;
padding: 1px;
margin: 10px;
cursor: pointer;
transition: box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s;
align-items: center;
position: relative;
display: block;
width: 51px;
height: 29px;
box-shadow: rgba(0, 0, 0, 0.62) 0px 0px 5px inset, rgba(0, 0, 0, 0.21) 0px 0px 0px 24px inset,
#22cc3f 0px 0px 0px 0px inset, rgba(224, 224, 224, 0.45) 0px 1px 0px 0px;
}
.slider::after {
content: "";
display: flex;
top: 2.3px;
left: 2px;
width: 26px;
height: 26px;
background-color: #e3e3e3;
border-radius: 200px;
position: absolute;
box-shadow: transparent 0px 0px 0px 2px, rgba(0, 0, 0, 0.3) 0px 6px 6px;
transition: left 300ms cubic-bezier(0.4, 0, 0.2, 1) 0s, background-color 300ms cubic-bezier(0.4, 0, 0.2, 1) 0s;
will-change: left, background-color;
}
.switch input[type="checkbox"]:checked + .slider {
box-shadow: rgba(0, 0, 0, 0.62) 0px 0px 5px inset, #22cc3f 0px 0px 0px 2px inset, #22cc3f 0px 0px 0px 24px inset,
rgba(224, 224, 224, 0.45) 0px 1px 0px 0px;
}
.switch input[type="checkbox"]:checked + .slider::after {
left: 24px;
}
.switch input[type="checkbox"] {
display: none;
}
/* Console styles */
.console-container {
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
border: 1px solid #333;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
}
.console-line {
transition: background-color 0.1s ease;
}
.console-line:hover {
background-color: rgba(255,255,255,0.05);
}
.level-badge, .source-badge {
font-size: 9px;
line-height: 1.2;
min-width: 50px;
text-align: center;
}
.message-content {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
line-height: 1.4;
}
</style>

View file

@ -1,12 +1,21 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-text-primary">Statistics</h1>
<div class="text-sm text-text-secondary">Your coding insights and trends</div>
<div class="flex flex-col h-full min-h-0">
<!-- Header styling aligned to Home.vue -->
<div class="mb-6">
<h1 class="text-[40px] sm:text-[32px] lg:text-[40px] font-bold italic text-white m-0 mb-2" style="font-family: 'Outfit', sans-serif;">
statistics
</h1>
<p class="text-[20px] sm:text-[16px] lg:text-[20px] text-white m-0" style="font-family: 'Outfit', sans-serif;">
your insights at a glance.
</p>
</div>
<!-- Statistics Dashboard -->
<StatisticsDashboard :apiConfig="apiConfig" />
<!-- Statistics Dashboard in 3D card style -->
<div class="card-3d card-3d-stats">
<div class="rounded-[8px] border border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
<StatisticsDashboard :apiConfig="apiConfig" />
</div>
</div>
</div>
</template>
@ -21,3 +30,31 @@ defineProps<{
apiConfig: ApiConfig;
}>();
</script>
<style scoped>
.card-3d {
position: relative;
border-radius: 8px;
padding: 0;
}
.card-3d::before {
content: '';
position: absolute;
inset: 0;
border-radius: 8px;
background: #2A1F2B;
z-index: 0;
}
.card-3d-stats::before {
background: #2A1F2B !important;
}
.card-3d-front {
position: relative;
transform: translateY(-6px);
z-index: 1;
box-shadow: 0 6px 0 #2A1F2B;
}
</style>

3
src/vite-env.d.ts vendored
View file

@ -1,4 +1,5 @@
/// <reference types="vite/client" />
declare const __SENTRY_RELEASE__: string;
declare const __SENTRY_ENVIRONMENT__: string;
declare module '*.vue' {
import type { DefineComponent } from 'vue'

View file

@ -4,12 +4,11 @@ export default {
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Dark theme colors
'dark': {
'theme': {
'sidebar-bg': '#2a1f21',
'sidebar-text': '#f5e6e8',
'sidebar-unread-text': '#ffffff',
@ -34,33 +33,7 @@ export default {
'mention-highlight-bg': '#4a2d31',
'mention-highlight-link': '#ff9aaa',
},
// Light theme colors
'light': {
'sidebar-bg': '#fdf7f8',
'sidebar-text': '#5d3a3f',
'sidebar-unread-text': '#2c1a1d',
'sidebar-text-hover-bg': '#f8e8ea',
'sidebar-text-active-border': '#c8394f',
'sidebar-text-active-color': '#8b2635',
'sidebar-header-bg': '#ffffff',
'sidebar-header-text': '#5d3a3f',
'sidebar-team-bar-bg': '#ffffff',
'online-indicator': '#33d6a6',
'away-indicator': '#ff8c37',
'dnd-indicator': '#ec3750',
'mention-bg': '#c8394f',
'mention-color': '#ffffff',
'center-channel-bg': '#ffffff',
'center-channel-color': '#5d3a3f',
'new-message-separator': '#ff7a8a',
'link-color': '#a12d3e',
'button-bg': '#c8394f',
'button-color': '#ffffff',
'error-text': '#d63c56',
'mention-highlight-bg': '#fce4e6',
'mention-highlight-link': '#a12d3e',
},
// Semantic color mappings
'bg-primary': 'var(--bg-primary)',
'bg-secondary': 'var(--bg-secondary)',
'bg-tertiary': 'var(--bg-tertiary)',

View file

@ -2,18 +2,35 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [vue(), tailwindcss()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
define: {
__SENTRY_RELEASE__: JSON.stringify(process.env.SENTRY_RELEASE || 'development'),
__SENTRY_ENVIRONMENT__: JSON.stringify(process.env.SENTRY_ENVIRONMENT || 'development'),
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-vue': ['vue', 'vue-chartjs'],
'vendor-sentry': ['@sentry/vue'],
'vendor-charts': ['chart.js'],
'vendor-tauri': ['@tauri-apps/api', '@tauri-apps/plugin-deep-link', '@tauri-apps/plugin-opener', '@tauri-apps/plugin-process', '@tauri-apps/plugin-updater'],
'vendor-posthog': ['posthog-js'],
},
},
},
chunkSizeWarningLimit: 600,
},
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
@ -26,7 +43,7 @@ export default defineConfig(async () => ({
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},