mirror of
https://github.com/System-End/hackatime-desktop.git
synced 2026-04-19 22:05:10 +00:00
Compare commits
71 commits
app-v1.1.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
832a610693 | ||
|
|
472867d58f | ||
|
|
d037bdb529 | ||
|
|
6509fc85e0 | ||
|
|
e84c1d6a37 | ||
|
|
a5746811a9 | ||
|
|
3688e39424 | ||
|
|
59008e3849 | ||
|
|
145b3b9422 | ||
|
|
ece57e2981 | ||
|
|
d0c183e71c | ||
|
|
1705082522 | ||
|
|
3ec4f3386a | ||
|
|
0feffa4a50 | ||
|
|
bc8a8121b1 | ||
|
|
fe6de62c2d | ||
|
|
48146f0091 | ||
|
|
a266acc5d4 | ||
|
|
dab9a807a5 | ||
|
|
db732471b7 | ||
|
|
8680de90f1 | ||
|
|
8171e059ae | ||
|
|
ca898884fc | ||
|
|
55d5875078 | ||
|
|
8f6d69afd9 | ||
|
|
e6aacf5f4d | ||
|
|
7516012de4 | ||
|
|
93e8fb655b | ||
|
|
4a465be82d | ||
|
|
50af8ae2ca | ||
|
|
444fa8ef44 | ||
|
|
553ba74304 | ||
|
|
d03e8ae14f | ||
|
|
da5c28b4ef | ||
|
|
3b57b270af | ||
|
|
ce7dcfa2da | ||
|
|
e7baf1b5e7 | ||
|
|
b86c9c715a | ||
|
|
9e520453ae | ||
|
|
547117173b | ||
|
|
4197762a53 | ||
|
|
ad3e27c859 | ||
|
|
fa6acaf41f | ||
|
|
1e9eeb3c2e | ||
|
|
cd241bd2ac | ||
|
|
02f5664a76 | ||
|
|
6f42399224 | ||
|
|
e9f9acc976 | ||
|
|
ad2b79367c | ||
|
|
5bf8fa1f32 | ||
|
|
e5c5c28e2b | ||
|
|
fc9c49f7ff | ||
|
|
b7516b4ccc | ||
|
|
a873bace6e | ||
|
|
2e8ac1f4dd | ||
|
|
6c5544da90 | ||
|
|
0196715607 | ||
|
|
bf635f9756 | ||
|
|
216cedd332 | ||
|
|
6eefa434dc | ||
|
|
538e31cffa | ||
|
|
6d15b3e4ce | ||
|
|
bf8b3248d8 | ||
|
|
cc39c6b45a | ||
|
|
aaba534a6d | ||
|
|
66419ffbb2 | ||
|
|
4bb8d6e226 | ||
|
|
8c452d81b5 | ||
|
|
a591e9c72c | ||
|
|
a415ae0d5b | ||
|
|
f71639892f |
78 changed files with 8567 additions and 3535 deletions
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
62
.github/workflows/release.yaml
vendored
62
.github/workflows/release.yaml
vendored
|
|
@ -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": {
|
||||
|
|
|
|||
2
.github/workflows/renovate.yaml
vendored
2
.github/workflows/renovate.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"app": "0.0.0",
|
||||
".": "1.1.10"
|
||||
".": "1.7.5"
|
||||
}
|
||||
224
CHANGELOG.md
224
CHANGELOG.md
|
|
@ -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
21
LICENSE
Normal 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.
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
# Hackatime Desktop
|
||||
|
||||
[](https://hackatimerelease.leafd.workers.dev/latest?platform=macos)
|
||||
[](https://hackatimerelease.leafd.workers.dev/latest?platform=windows)
|
||||
|
||||
[](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
26
SECURITY.md
Normal 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 :)
|
||||
10
package.json
10
package.json
|
|
@ -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
176
pnpm-lock.yaml
generated
|
|
@ -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
166
src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
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)
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)
BIN
src-tauri/icons/32x32.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square107x107Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square107x107Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square142x142Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square142x142Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square150x150Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square150x150Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square284x284Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square284x284Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square30x30Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square30x30Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square310x310Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square310x310Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square44x44Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square44x44Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square71x71Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square71x71Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/Square89x89Logo.png
(Stored with Git LFS)
BIN
src-tauri/icons/Square89x89Logo.png
(Stored with Git LFS)
Binary file not shown.
BIN
src-tauri/icons/StoreLogo.png
(Stored with Git LFS)
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)
BIN
src-tauri/icons/icon.png
(Stored with Git LFS)
Binary file not shown.
561
src-tauri/src/auth.rs
Normal file
561
src-tauri/src/auth.rs
Normal 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
39
src-tauri/src/config.rs
Normal 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(())
|
||||
}
|
||||
|
||||
|
|
@ -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()?;
|
||||
|
||||
|
|
|
|||
86
src-tauri/src/db_commands.rs
Normal file
86
src-tauri/src/db_commands.rs
Normal 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()
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
2964
src-tauri/src/lib.rs
2964
src-tauri/src/lib.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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
86
src-tauri/src/menu.rs
Normal 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(())
|
||||
}
|
||||
|
||||
|
||||
134
src-tauri/src/preferences.rs
Normal file
134
src-tauri/src/preferences.rs
Normal 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
117
src-tauri/src/projects.rs
Normal 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
313
src-tauri/src/session.rs
Normal 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
250
src-tauri/src/setup.rs
Normal 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
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
90
src-tauri/src/tray.rs
Normal 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
57
src-tauri/src/window.rs
Normal 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(())
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
767
src/App.vue
767
src/App.vue
|
|
@ -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>
|
||||
|
|
@ -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
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
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
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)
BIN
src/assets/vue.svg
(Stored with Git LFS)
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
291
src/components/CustomTitlebar.vue
Normal file
291
src/components/CustomTitlebar.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
147
src/components/RandomLoader.vue
Normal file
147
src/components/RandomLoader.vue
Normal 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>
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
362
src/components/UpdateModal.vue
Normal file
362
src/components/UpdateModal.vue
Normal 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>
|
||||
|
||||
119
src/components/UpdateNotification.vue
Normal file
119
src/components/UpdateNotification.vue
Normal 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>
|
||||
|
||||
244
src/components/UserProfileCard.vue
Normal file
244
src/components/UserProfileCard.vue
Normal 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>
|
||||
446
src/components/WakatimeSetupModal.vue
Normal file
446
src/components/WakatimeSetupModal.vue
Normal 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>
|
||||
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
70
src/composables/usePostHog.ts
Normal file
70
src/composables/usePostHog.ts
Normal 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 }
|
||||
}
|
||||
|
||||
13
src/composables/usePostHogInstance.ts
Normal file
13
src/composables/usePostHogInstance.ts
Normal 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
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
89
src/main.ts
89
src/main.ts
|
|
@ -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
154
src/motd.json
Normal 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!"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
260
src/views/Login.vue
Normal 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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
3
src/vite-env.d.ts
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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/**"],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue