mirror of
https://github.com/System-End/hackatime-desktop.git
synced 2026-04-19 20:55:13 +00:00
feat: home page redesign (#40)
* feat: home page redesign * chore(ci): remove secretlint
This commit is contained in:
parent
66419ffbb2
commit
aaba534a6d
46 changed files with 5663 additions and 3192 deletions
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
|
|
@ -46,9 +46,9 @@ jobs:
|
||||||
DEFAULT_BRANCH: main
|
DEFAULT_BRANCH: main
|
||||||
VALIDATE_ALL_CODEBASE: true
|
VALIDATE_ALL_CODEBASE: true
|
||||||
ENABLE: RUST,JAVASCRIPT,TYPESCRIPT,JSON,YAML,MARKDOWN,REPOSITORY
|
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: COPYPASTE,SPELL
|
||||||
DISABLE_LINTERS: REPOSITORY_CHECKOV,REPOSITORY_GITLEAKS
|
DISABLE_LINTERS: REPOSITORY_CHECKOV,REPOSITORY_GITLEAKS,REPOSITORY_SECRETLINT
|
||||||
SECURITY_LINTERS_ENABLED: true
|
SECURITY_LINTERS_ENABLED: true
|
||||||
|
|
||||||
# Rust linter configuration
|
# Rust linter configuration
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,14 @@
|
||||||
"@tauri-apps/plugin-process": "~2",
|
"@tauri-apps/plugin-process": "~2",
|
||||||
"@tauri-apps/plugin-updater": "~2",
|
"@tauri-apps/plugin-updater": "~2",
|
||||||
"chart.js": "^4.5.0",
|
"chart.js": "^4.5.0",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-chartjs": "^5.3.2"
|
"vue-chartjs": "^5.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "~5.9.0",
|
"typescript": "~5.9.0",
|
||||||
|
|
|
||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
|
|
@ -26,6 +26,9 @@ importers:
|
||||||
chart.js:
|
chart.js:
|
||||||
specifier: ^4.5.0
|
specifier: ^4.5.0
|
||||||
version: 4.5.0
|
version: 4.5.0
|
||||||
|
crypto-js:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.22(typescript@5.9.3)
|
version: 3.5.22(typescript@5.9.3)
|
||||||
|
|
@ -39,6 +42,9 @@ importers:
|
||||||
'@tauri-apps/cli':
|
'@tauri-apps/cli':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.8.4
|
version: 2.8.4
|
||||||
|
'@types/crypto-js':
|
||||||
|
specifier: ^4.2.2
|
||||||
|
version: 4.2.2
|
||||||
'@vitejs/plugin-vue':
|
'@vitejs/plugin-vue':
|
||||||
specifier: ^5.2.1
|
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(jiti@2.6.1)(lightningcss@1.30.1))(vue@3.5.22(typescript@5.9.3))
|
||||||
|
|
@ -539,6 +545,9 @@ packages:
|
||||||
'@tauri-apps/plugin-updater@2.9.0':
|
'@tauri-apps/plugin-updater@2.9.0':
|
||||||
resolution: {integrity: sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg==}
|
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':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
|
@ -615,6 +624,9 @@ packages:
|
||||||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
crypto-js@4.2.0:
|
||||||
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||||
|
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
|
|
@ -1183,6 +1195,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.8.0
|
'@tauri-apps/api': 2.8.0
|
||||||
|
|
||||||
|
'@types/crypto-js@4.2.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@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))':
|
'@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))':
|
||||||
|
|
@ -1288,6 +1302,8 @@ snapshots:
|
||||||
|
|
||||||
chownr@3.0.0: {}
|
chownr@3.0.0: {}
|
||||||
|
|
||||||
|
crypto-js@4.2.0: {}
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
de-indent@1.0.2: {}
|
de-indent@1.0.2: {}
|
||||||
|
|
|
||||||
56
src-tauri/Cargo.lock
generated
56
src-tauri/Cargo.lock
generated
|
|
@ -294,6 +294,12 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -512,6 +518,35 @@ dependencies = [
|
||||||
"windows-link 0.2.0",
|
"windows-link 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cocoa"
|
||||||
|
version = "0.26.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"block",
|
||||||
|
"cocoa-foundation",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-graphics",
|
||||||
|
"foreign-types 0.5.0",
|
||||||
|
"libc",
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cocoa-foundation"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"block",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-graphics-types",
|
||||||
|
"objc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
|
|
@ -819,7 +854,10 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"cocoa",
|
||||||
"discord-rich-presence",
|
"discord-rich-presence",
|
||||||
|
"objc",
|
||||||
|
"once_cell",
|
||||||
"open",
|
"open",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
@ -2343,6 +2381,15 @@ version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "malloc_buf"
|
||||||
|
version = "0.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
|
|
@ -2600,6 +2647,15 @@ dependencies = [
|
||||||
"syn 2.0.106",
|
"syn 2.0.106",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc"
|
||||||
|
version = "0.2.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
|
||||||
|
dependencies = [
|
||||||
|
"malloc_buf",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc-sys"
|
name = "objc-sys"
|
||||||
version = "0.3.5"
|
version = "0.3.5"
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,12 @@ tauri-plugin-process = "2"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
base64 = "0.21"
|
base64 = "0.21"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
|
once_cell = "1"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
|
|
||||||
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
|
cocoa = "0.26"
|
||||||
|
objc = "0.2"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"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",
|
"opener:default",
|
||||||
"deep-link:default",
|
"deep-link:default",
|
||||||
"updater:default",
|
"updater:default",
|
||||||
|
|
|
||||||
557
src-tauri/src/auth.rs
Normal file
557
src-tauri/src/auth.rs
Normal file
|
|
@ -0,0 +1,557 @@
|
||||||
|
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> {
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Err(e) = open::that(&auth_url) {
|
||||||
|
return Err(format!("Failed to open authentication URL: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
push_log("info", "backend", "OAuth authentication URL opened in browser. Waiting for callback...".to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use crate::push_log;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct AuthState {
|
pub struct AuthState {
|
||||||
|
|
@ -20,7 +21,7 @@ pub struct SessionRecord {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub is_authenticated: bool,
|
pub is_authenticated: bool,
|
||||||
pub access_token: Option<String>,
|
pub access_token: Option<String>,
|
||||||
pub user_info: Option<String>, // JSON string
|
pub user_info: Option<String>,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
pub last_accessed_at: DateTime<Utc>,
|
pub last_accessed_at: DateTime<Utc>,
|
||||||
|
|
@ -34,16 +35,16 @@ impl Database {
|
||||||
pub async fn new() -> Result<Self, String> {
|
pub async fn new() -> Result<Self, String> {
|
||||||
let db_path = get_hackatime_db_path()?;
|
let db_path = get_hackatime_db_path()?;
|
||||||
|
|
||||||
// Ensure the hackatime directory exists
|
|
||||||
if let Some(parent) = db_path.parent() {
|
if let Some(parent) = db_path.parent() {
|
||||||
if !parent.exists() {
|
if !parent.exists() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("Failed to create hackatime directory: {}", e))?;
|
.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 let Some(parent) = db_path.parent() {
|
||||||
if !parent.exists() {
|
if !parent.exists() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
|
|
@ -52,7 +53,7 @@ impl Database {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test if we can write to the directory
|
|
||||||
let test_file = parent.join(".write_test");
|
let test_file = parent.join(".write_test");
|
||||||
if let Err(e) = fs::write(&test_file, "test") {
|
if let Err(e) = fs::write(&test_file, "test") {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
|
|
@ -61,19 +62,19 @@ impl Database {
|
||||||
e
|
e
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Clean up test file
|
|
||||||
let _ = fs::remove_file(&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() {
|
if !db_path.exists() {
|
||||||
println!(
|
push_log("info", "backend", format!(
|
||||||
"Database file doesn't exist, creating: {}",
|
"Database file doesn't exist, creating: {}",
|
||||||
db_path.display()
|
db_path.display()
|
||||||
);
|
));
|
||||||
// Touch the file to ensure it exists
|
|
||||||
if let Err(e) = fs::write(&db_path, "") {
|
if let Err(e) = fs::write(&db_path, "") {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Cannot create database file {}: {}",
|
"Cannot create database file {}: {}",
|
||||||
|
|
@ -82,26 +83,26 @@ impl Database {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
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());
|
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_result = SqlitePool::connect(&database_url).await;
|
||||||
|
|
||||||
let pool = match pool_result {
|
let pool = match pool_result {
|
||||||
Ok(pool) => pool,
|
Ok(pool) => pool,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
println!("Standard connect failed: {}, trying connect_with", e);
|
push_log("warn", "backend", format!("Standard connect failed: {}, trying connect_with", e));
|
||||||
// Try with explicit options
|
|
||||||
let options = sqlx::sqlite::SqliteConnectOptions::new()
|
let options = sqlx::sqlite::SqliteConnectOptions::new()
|
||||||
.filename(&db_path)
|
.filename(&db_path)
|
||||||
.create_if_missing(true);
|
.create_if_missing(true);
|
||||||
|
|
@ -119,7 +120,7 @@ impl Database {
|
||||||
let db = Database { pool };
|
let db = Database { pool };
|
||||||
db.migrate().await?;
|
db.migrate().await?;
|
||||||
|
|
||||||
println!("Database initialized successfully");
|
push_log("info", "backend", "Database initialized successfully".to_string());
|
||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,6 +142,20 @@ impl Database {
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to create sessions table: {}", e))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,13 +251,13 @@ impl Database {
|
||||||
Some(json) => {
|
Some(json) => {
|
||||||
match serde_json::from_str::<HashMap<String, serde_json::Value>>(&json) {
|
match serde_json::from_str::<HashMap<String, serde_json::Value>>(&json) {
|
||||||
Ok(info) => Some(info),
|
Ok(info) => Some(info),
|
||||||
Err(_) => None, // Skip invalid JSON
|
Err(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update last_accessed_at
|
|
||||||
self.update_last_accessed(&session_id).await?;
|
self.update_last_accessed(&session_id).await?;
|
||||||
|
|
||||||
Ok(Some(AuthState {
|
Ok(Some(AuthState {
|
||||||
|
|
@ -288,35 +303,114 @@ impl Database {
|
||||||
|
|
||||||
Ok(())
|
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> {
|
fn get_hackatime_db_path() -> Result<std::path::PathBuf, String> {
|
||||||
let app_data_dir = get_app_data_dir()?;
|
let app_data_dir = get_app_data_dir()?;
|
||||||
let db_path = app_data_dir.join("sessions.db");
|
let db_path = app_data_dir.join("sessions.db");
|
||||||
|
|
||||||
println!("Database path: {}", db_path.display());
|
push_log("debug", "backend", format!("Database path: {}", db_path.display()));
|
||||||
println!(
|
push_log("debug", "backend", format!(
|
||||||
"Parent directory exists: {}",
|
"Parent directory exists: {}",
|
||||||
db_path.parent().map_or(false, |p| p.exists())
|
db_path.parent().map_or(false, |p| p.exists())
|
||||||
);
|
));
|
||||||
|
|
||||||
Ok(db_path)
|
Ok(db_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_app_data_dir() -> Result<std::path::PathBuf, String> {
|
fn get_app_data_dir() -> Result<std::path::PathBuf, String> {
|
||||||
if cfg!(target_os = "windows") {
|
if cfg!(target_os = "windows") {
|
||||||
// Windows: %APPDATA%\.hackatime\
|
|
||||||
let appdata = env::var("APPDATA").map_err(|_| "Failed to get APPDATA directory")?;
|
let appdata = env::var("APPDATA").map_err(|_| "Failed to get APPDATA directory")?;
|
||||||
Ok(Path::new(&appdata).join(".hackatime"))
|
Ok(Path::new(&appdata).join(".hackatime"))
|
||||||
} else if cfg!(target_os = "macos") {
|
} else if cfg!(target_os = "macos") {
|
||||||
// macOS: ~/Library/Application Support/.hackatime/
|
|
||||||
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
|
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
|
||||||
Ok(Path::new(&home)
|
Ok(Path::new(&home)
|
||||||
.join("Library")
|
.join("Library")
|
||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join(".hackatime"))
|
.join(".hackatime"))
|
||||||
} else {
|
} else {
|
||||||
// Linux: ~/.local/share/.hackatime/
|
|
||||||
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
|
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
|
||||||
Ok(Path::new(&home)
|
Ok(Path::new(&home)
|
||||||
.join(".local")
|
.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> {
|
pub fn get_hackatime_config_dir() -> Result<std::path::PathBuf, String> {
|
||||||
let app_data_dir = get_app_data_dir()?;
|
let app_data_dir = get_app_data_dir()?;
|
||||||
|
|
||||||
// Create the directory if it doesn't exist
|
|
||||||
if !app_data_dir.exists() {
|
if !app_data_dir.exists() {
|
||||||
fs::create_dir_all(&app_data_dir)
|
fs::create_dir_all(&app_data_dir)
|
||||||
.map_err(|e| format!("Failed to create hackatime directory: {}", e))?;
|
.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 config_dir = get_hackatime_config_dir()?;
|
||||||
let logs_dir = config_dir.join("logs");
|
let logs_dir = config_dir.join("logs");
|
||||||
|
|
||||||
// Create the logs directory if it doesn't exist
|
|
||||||
if !logs_dir.exists() {
|
if !logs_dir.exists() {
|
||||||
fs::create_dir_all(&logs_dir)
|
fs::create_dir_all(&logs_dir)
|
||||||
.map_err(|e| format!("Failed to create logs directory: {}", e))?;
|
.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 config_dir = get_hackatime_config_dir()?;
|
||||||
let data_dir = config_dir.join("data");
|
let data_dir = config_dir.join("data");
|
||||||
|
|
||||||
// Create the data directory if it doesn't exist
|
|
||||||
if !data_dir.exists() {
|
if !data_dir.exists() {
|
||||||
fs::create_dir_all(&data_dir)
|
fs::create_dir_all(&data_dir)
|
||||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
.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)
|
Ok(data_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
pub fn get_platform_info() -> Result<serde_json::Value, String> {
|
pub fn get_platform_info() -> Result<serde_json::Value, String> {
|
||||||
let app_data_dir = get_app_data_dir()?;
|
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::Arc;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::session::HeartbeatData;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct DiscordRpcState {
|
pub struct DiscordRpcState {
|
||||||
pub is_connected: bool,
|
pub is_connected: bool,
|
||||||
|
|
@ -37,7 +39,7 @@ impl DiscordRpcService {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn connect(&mut self, client_id: &str) -> Result<(), String> {
|
pub fn connect(&mut self, client_id: &str) -> Result<(), String> {
|
||||||
// Close existing connection if any
|
|
||||||
if self.client.is_some() {
|
if self.client.is_some() {
|
||||||
let _ = self.disconnect();
|
let _ = self.disconnect();
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +52,7 @@ impl DiscordRpcService {
|
||||||
|
|
||||||
self.client = Some(client);
|
self.client = Some(client);
|
||||||
|
|
||||||
// Update state
|
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.is_connected = true;
|
state.is_connected = true;
|
||||||
state.client_id = Some(client_id.to_string());
|
state.client_id = Some(client_id.to_string());
|
||||||
|
|
@ -65,7 +67,7 @@ impl DiscordRpcService {
|
||||||
.map_err(|e| format!("Failed to disconnect from Discord: {}", e))?;
|
.map_err(|e| format!("Failed to disconnect from Discord: {}", e))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update state
|
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.is_connected = false;
|
state.is_connected = false;
|
||||||
state.client_id = None;
|
state.client_id = None;
|
||||||
|
|
@ -77,7 +79,7 @@ impl DiscordRpcService {
|
||||||
pub fn set_activity(&mut self, activity: DiscordActivity) -> Result<(), String> {
|
pub fn set_activity(&mut self, activity: DiscordActivity) -> Result<(), String> {
|
||||||
self.set_activity_internal(activity.clone())?;
|
self.set_activity_internal(activity.clone())?;
|
||||||
|
|
||||||
// Update state
|
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.current_activity = Some(activity);
|
state.current_activity = Some(activity);
|
||||||
|
|
||||||
|
|
@ -87,7 +89,7 @@ impl DiscordRpcService {
|
||||||
fn set_activity_internal(&mut self, activity: DiscordActivity) -> Result<(), String> {
|
fn set_activity_internal(&mut self, activity: DiscordActivity) -> Result<(), String> {
|
||||||
let client = self.client.as_mut().ok_or("Discord client not connected")?;
|
let client = self.client.as_mut().ok_or("Discord client not connected")?;
|
||||||
|
|
||||||
// Build details string
|
|
||||||
let mut details_parts = Vec::new();
|
let mut details_parts = Vec::new();
|
||||||
|
|
||||||
if let Some(language) = &activity.language {
|
if let Some(language) = &activity.language {
|
||||||
|
|
@ -109,20 +111,20 @@ impl DiscordRpcService {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create activity with all components
|
|
||||||
let mut discord_activity = activity::Activity::new().state(&activity.project_name);
|
let mut discord_activity = activity::Activity::new().state(&activity.project_name);
|
||||||
|
|
||||||
if let Some(details) = &details_string {
|
if let Some(details) = &details_string {
|
||||||
discord_activity = discord_activity.details(details);
|
discord_activity = discord_activity.details(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set start time if provided
|
|
||||||
if let Some(start_time) = activity.start_time {
|
if let Some(start_time) = activity.start_time {
|
||||||
discord_activity =
|
discord_activity =
|
||||||
discord_activity.timestamps(activity::Timestamps::new().start(start_time));
|
discord_activity.timestamps(activity::Timestamps::new().start(start_time));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add assets
|
|
||||||
discord_activity = discord_activity.assets(
|
discord_activity = discord_activity.assets(
|
||||||
activity::Assets::new()
|
activity::Assets::new()
|
||||||
.large_image("kubetime")
|
.large_image("kubetime")
|
||||||
|
|
@ -145,7 +147,7 @@ impl DiscordRpcService {
|
||||||
.clear_activity()
|
.clear_activity()
|
||||||
.map_err(|e| format!("Failed to clear Discord activity: {}", e))?;
|
.map_err(|e| format!("Failed to clear Discord activity: {}", e))?;
|
||||||
|
|
||||||
// Update state
|
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.current_activity = None;
|
state.current_activity = None;
|
||||||
|
|
||||||
|
|
@ -162,7 +164,7 @@ impl DiscordRpcService {
|
||||||
|
|
||||||
pub fn update_activity_from_heartbeat(
|
pub fn update_activity_from_heartbeat(
|
||||||
&mut self,
|
&mut self,
|
||||||
heartbeat_data: &crate::HeartbeatData,
|
heartbeat_data: &HeartbeatData,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let activity = DiscordActivity {
|
let activity = DiscordActivity {
|
||||||
project_name: heartbeat_data
|
project_name: heartbeat_data
|
||||||
|
|
@ -180,7 +182,7 @@ impl DiscordRpcService {
|
||||||
|
|
||||||
pub fn update_activity_from_session(
|
pub fn update_activity_from_session(
|
||||||
&mut self,
|
&mut self,
|
||||||
heartbeat_data: &crate::HeartbeatData,
|
heartbeat_data: &HeartbeatData,
|
||||||
session_start_time: i64,
|
session_start_time: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let activity = DiscordActivity {
|
let activity = DiscordActivity {
|
||||||
|
|
@ -208,3 +210,99 @@ impl Drop for DiscordRpcService {
|
||||||
let _ = self.disconnect();
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_discord_rpc_enabled(
|
||||||
|
discord_rpc_state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let rpc_service = discord_rpc_state.lock().await;
|
||||||
|
Ok(rpc_service.is_connected())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_discord_rpc_enabled(
|
||||||
|
enabled: bool,
|
||||||
|
discord_rpc_state: State<'_, Arc<tauri::async_runtime::Mutex<DiscordRpcService>>>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut rpc_service = discord_rpc_state.lock().await;
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
|
||||||
|
let default_client_id = "1234567890123456789";
|
||||||
|
rpc_service.connect(default_client_id)
|
||||||
|
} else {
|
||||||
|
rpc_service.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
2879
src-tauri/src/lib.rs
2879
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")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
|
||||||
87
src-tauri/src/menu.rs
Normal file
87
src-tauri/src/menu.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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 about_quit = MenuItem::with_id(app, "quit", "Quit Hackatime", true, None::<&str>)?;
|
||||||
|
let about_menu = Submenu::with_items(
|
||||||
|
app,
|
||||||
|
"About",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&about_quit,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
|
||||||
|
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() {
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
"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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
106
src-tauri/src/projects.rs
Normal file
106
src-tauri/src/projects.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::auth::AuthState;
|
||||||
|
use crate::config::ApiConfig;
|
||||||
|
|
||||||
|
#[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))?;
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
246
src-tauri/src/session.rs
Normal file
246
src-tauri/src/session.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
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,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 response = client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/v1/authenticated/heartbeats/latest",
|
||||||
|
base_url
|
||||||
|
))
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to get latest heartbeat: {}", e))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
|
|
||||||
|
|
||||||
|
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 heartbeat_response: HeartbeatResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse heartbeat response: {}", e))?;
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(heartbeat) = &heartbeat_response.heartbeat {
|
||||||
|
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 < 120;
|
||||||
|
|
||||||
|
|
||||||
|
let is_duplicate = session.last_heartbeat_id == Some(heartbeat.id);
|
||||||
|
|
||||||
|
if is_duplicate {
|
||||||
|
|
||||||
|
push_log("info", "backend", "Duplicate heartbeat detected, 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 {
|
||||||
|
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
263
src-tauri/src/setup.rs
Normal file
263
src-tauri/src/setup.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
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 normalize_config_content(content: &str) -> String {
|
||||||
|
|
||||||
|
content
|
||||||
|
.replace("\r\n", "\n")
|
||||||
|
.lines()
|
||||||
|
.map(|line| line.trim())
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
normalize_config_content(actual) == normalize_config_content(&expected_content)
|
||||||
|
} 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))?;
|
||||||
|
|
||||||
|
let lines: Vec<&str> = config_content.lines().collect();
|
||||||
|
let mut found_api_url = false;
|
||||||
|
let mut found_api_key = false;
|
||||||
|
let mut found_heartbeat_rate = false;
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
if line.starts_with("api_url =") {
|
||||||
|
found_api_url = true;
|
||||||
|
} else if line.starts_with("api_key =") {
|
||||||
|
found_api_key = true;
|
||||||
|
} else if line.starts_with("heartbeat_rate_limit_seconds =") {
|
||||||
|
found_heartbeat_rate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found_api_url || !found_api_key || !found_heartbeat_rate {
|
||||||
|
return Err("Config file is missing required fields".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))?;
|
||||||
|
|
||||||
|
let lines: Vec<&str> = config_content.lines().collect();
|
||||||
|
let mut found_api_url = false;
|
||||||
|
let mut found_api_key = false;
|
||||||
|
let mut found_heartbeat_rate = false;
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
if line.starts_with("api_url =") {
|
||||||
|
found_api_url = true;
|
||||||
|
} else if line.starts_with("api_key =") {
|
||||||
|
found_api_key = true;
|
||||||
|
} else if line.starts_with("heartbeat_rate_limit_seconds =") {
|
||||||
|
found_heartbeat_rate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found_api_url || !found_api_key || !found_heartbeat_rate {
|
||||||
|
return Err("Config file is missing required fields".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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
983
src-tauri/src/statistics.rs
Normal file
983
src-tauri/src/statistics.rs
Normal file
|
|
@ -0,0 +1,983 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::State;
|
||||||
|
use chrono::Datelike;
|
||||||
|
|
||||||
|
use crate::auth::AuthState;
|
||||||
|
use crate::config::ApiConfig;
|
||||||
|
use crate::database::Database;
|
||||||
|
use crate::push_log;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct StatisticsData {
|
||||||
|
pub trends: Vec<TrendStatistic>,
|
||||||
|
pub charts: Vec<ChartData>,
|
||||||
|
pub insights: Vec<Insight>,
|
||||||
|
pub programmer_class: ProgrammerClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct TrendStatistic {
|
||||||
|
pub title: String,
|
||||||
|
pub value: String,
|
||||||
|
pub change: String,
|
||||||
|
pub change_type: String,
|
||||||
|
pub period: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ChartData {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub chart_type: String,
|
||||||
|
pub data: serde_json::Value,
|
||||||
|
pub period: String,
|
||||||
|
pub color_scheme: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Insight {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub value: String,
|
||||||
|
pub trend: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ProgrammerClass {
|
||||||
|
pub class_name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub technologies: Vec<String>,
|
||||||
|
pub level: String,
|
||||||
|
pub color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_hours_with_cache(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
base_url: &str,
|
||||||
|
access_token: &str,
|
||||||
|
start_date: &str,
|
||||||
|
end_date: &str,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let db = Database::new().await?;
|
||||||
|
let cache_key = format!("hours:{}:{}", start_date, end_date);
|
||||||
|
|
||||||
|
if let Ok(Some(cached_data)) = db.get_cached_data(&cache_key).await {
|
||||||
|
push_log("debug", "backend", format!("Using cached data for {}", cache_key));
|
||||||
|
return serde_json::from_str(&cached_data)
|
||||||
|
.map_err(|e| format!("Failed to parse cached data: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
push_log("debug", "backend", format!("Fetching fresh data for {}", cache_key));
|
||||||
|
let response = client
|
||||||
|
.get(&format!(
|
||||||
|
"{}/api/v1/authenticated/hours?start_date={}&end_date={}",
|
||||||
|
base_url,
|
||||||
|
start_date,
|
||||||
|
end_date
|
||||||
|
))
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch hours: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API request failed with status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse API response: {}", e))?;
|
||||||
|
|
||||||
|
let data_str = serde_json::to_string(&data)
|
||||||
|
.map_err(|e| format!("Failed to serialize data for caching: {}", e))?;
|
||||||
|
db.set_cached_data(&cache_key, &data_str, 30).await.ok();
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_streak_with_cache(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
base_url: &str,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let db = Database::new().await?;
|
||||||
|
let today = chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string();
|
||||||
|
let cache_key = format!("streak:{}", today);
|
||||||
|
|
||||||
|
if let Ok(Some(cached_data)) = db.get_cached_data(&cache_key).await {
|
||||||
|
push_log("debug", "backend", format!("Using cached streak data for {}", today));
|
||||||
|
return serde_json::from_str(&cached_data)
|
||||||
|
.map_err(|e| format!("Failed to parse cached streak data: {}", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
push_log("debug", "backend", format!("Fetching fresh streak data for {}", today));
|
||||||
|
let response = client
|
||||||
|
.get(&format!("{}/api/v1/authenticated/streak", base_url))
|
||||||
|
.bearer_auth(access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch streak: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Streak API request failed with status: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse streak response: {}", e))?;
|
||||||
|
|
||||||
|
let data_str = serde_json::to_string(&data)
|
||||||
|
.map_err(|e| format!("Failed to serialize streak data for caching: {}", e))?;
|
||||||
|
db.set_cached_data(&cache_key, &data_str, 30).await.ok();
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_statistics_data(
|
||||||
|
api_config: ApiConfig,
|
||||||
|
state: State<'_, Arc<tauri::async_runtime::Mutex<AuthState>>>,
|
||||||
|
) -> Result<StatisticsData, 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 end_date = chrono::Utc::now().date_naive();
|
||||||
|
|
||||||
|
let mut daily_hours = serde_json::Map::new();
|
||||||
|
let mut total_seconds = 0u64;
|
||||||
|
|
||||||
|
for days_ago in 0..7 {
|
||||||
|
let date = end_date - chrono::Duration::days(days_ago);
|
||||||
|
let date_str = date.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
match fetch_hours_with_cache(&client, base_url, access_token, &date_str, &date_str).await {
|
||||||
|
Ok(day_data) => {
|
||||||
|
let seconds = day_data["total_seconds"].as_u64().unwrap_or(0);
|
||||||
|
total_seconds += seconds;
|
||||||
|
|
||||||
|
let day_name = match date.weekday() {
|
||||||
|
chrono::Weekday::Mon => "Mon",
|
||||||
|
chrono::Weekday::Tue => "Tue",
|
||||||
|
chrono::Weekday::Wed => "Wed",
|
||||||
|
chrono::Weekday::Thu => "Thu",
|
||||||
|
chrono::Weekday::Fri => "Fri",
|
||||||
|
chrono::Weekday::Sat => "Sat",
|
||||||
|
chrono::Weekday::Sun => "Sun",
|
||||||
|
};
|
||||||
|
|
||||||
|
daily_hours.insert(date_str.clone(), serde_json::json!({
|
||||||
|
"date": date_str,
|
||||||
|
"day_name": day_name,
|
||||||
|
"hours": seconds as f64 / 3600.0,
|
||||||
|
"seconds": seconds
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let day_name = match date.weekday() {
|
||||||
|
chrono::Weekday::Mon => "Mon",
|
||||||
|
chrono::Weekday::Tue => "Tue",
|
||||||
|
chrono::Weekday::Wed => "Wed",
|
||||||
|
chrono::Weekday::Thu => "Thu",
|
||||||
|
chrono::Weekday::Fri => "Fri",
|
||||||
|
chrono::Weekday::Sat => "Sat",
|
||||||
|
chrono::Weekday::Sun => "Sun",
|
||||||
|
};
|
||||||
|
|
||||||
|
daily_hours.insert(date_str.clone(), serde_json::json!({
|
||||||
|
"date": date_str,
|
||||||
|
"day_name": day_name,
|
||||||
|
"hours": 0.0,
|
||||||
|
"seconds": 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let all_time_start = end_date - chrono::Duration::days(365);
|
||||||
|
let all_time_seconds = match fetch_hours_with_cache(
|
||||||
|
&client,
|
||||||
|
base_url,
|
||||||
|
access_token,
|
||||||
|
&all_time_start.format("%Y-%m-%d").to_string(),
|
||||||
|
&end_date.format("%Y-%m-%d").to_string()
|
||||||
|
).await {
|
||||||
|
Ok(data) => data["total_seconds"].as_u64().unwrap_or(0),
|
||||||
|
Err(_) => 0
|
||||||
|
};
|
||||||
|
|
||||||
|
let hours_data = serde_json::json!({
|
||||||
|
"weekly_stats": {
|
||||||
|
"time_coded_seconds": total_seconds,
|
||||||
|
"daily_hours": daily_hours
|
||||||
|
},
|
||||||
|
"all_time_stats": {
|
||||||
|
"time_coded_seconds": all_time_seconds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let streak_data = fetch_streak_with_cache(&client, base_url, access_token)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to fetch streak data: {}", e))?;
|
||||||
|
|
||||||
|
let mut dashboard_stats = hours_data;
|
||||||
|
if let Some(streak) = streak_data.get("current_streak") {
|
||||||
|
dashboard_stats["current_streak"] = streak.clone();
|
||||||
|
}
|
||||||
|
if let Some(longest) = streak_data.get("longest_streak") {
|
||||||
|
dashboard_stats["longest_streak"] = longest.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let statistics = process_statistics_data(dashboard_stats).await?;
|
||||||
|
|
||||||
|
Ok(statistics)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_dashboard_stats(
|
||||||
|
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 end_date = chrono::Utc::now().date_naive();
|
||||||
|
let start_date = end_date - chrono::Duration::days(7);
|
||||||
|
|
||||||
|
let _current_week_data = fetch_hours_with_cache(
|
||||||
|
&client,
|
||||||
|
base_url,
|
||||||
|
access_token,
|
||||||
|
&start_date.format("%Y-%m-%d").to_string(),
|
||||||
|
&end_date.format("%Y-%m-%d").to_string()
|
||||||
|
).await.map_err(|e| format!("Failed to fetch current week hours: {}", e))?;
|
||||||
|
|
||||||
|
|
||||||
|
let prev_week_end = start_date;
|
||||||
|
let prev_week_start = prev_week_end - chrono::Duration::days(7);
|
||||||
|
|
||||||
|
let prev_week_data = fetch_hours_with_cache(
|
||||||
|
&client,
|
||||||
|
base_url,
|
||||||
|
access_token,
|
||||||
|
&prev_week_start.format("%Y-%m-%d").to_string(),
|
||||||
|
&prev_week_end.format("%Y-%m-%d").to_string()
|
||||||
|
).await.unwrap_or_else(|_| serde_json::json!({"total_seconds": 0}));
|
||||||
|
|
||||||
|
|
||||||
|
let mut daily_hours = serde_json::Map::new();
|
||||||
|
let mut total_seconds = 0u64;
|
||||||
|
|
||||||
|
for days_ago in 0..7 {
|
||||||
|
let date = end_date - chrono::Duration::days(days_ago);
|
||||||
|
let date_str = date.format("%Y-%m-%d").to_string();
|
||||||
|
|
||||||
|
|
||||||
|
match fetch_hours_with_cache(&client, base_url, access_token, &date_str, &date_str).await {
|
||||||
|
Ok(day_data) => {
|
||||||
|
let seconds = day_data["total_seconds"].as_u64().unwrap_or(0);
|
||||||
|
total_seconds += seconds;
|
||||||
|
|
||||||
|
let day_name = match date.weekday() {
|
||||||
|
chrono::Weekday::Mon => "Mon",
|
||||||
|
chrono::Weekday::Tue => "Tue",
|
||||||
|
chrono::Weekday::Wed => "Wed",
|
||||||
|
chrono::Weekday::Thu => "Thu",
|
||||||
|
chrono::Weekday::Fri => "Fri",
|
||||||
|
chrono::Weekday::Sat => "Sat",
|
||||||
|
chrono::Weekday::Sun => "Sun",
|
||||||
|
};
|
||||||
|
|
||||||
|
daily_hours.insert(date_str.clone(), serde_json::json!({
|
||||||
|
"date": date_str,
|
||||||
|
"day_name": day_name,
|
||||||
|
"hours": seconds as f64 / 3600.0,
|
||||||
|
"seconds": seconds
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let day_name = match date.weekday() {
|
||||||
|
chrono::Weekday::Mon => "Mon",
|
||||||
|
chrono::Weekday::Tue => "Tue",
|
||||||
|
chrono::Weekday::Wed => "Wed",
|
||||||
|
chrono::Weekday::Thu => "Thu",
|
||||||
|
chrono::Weekday::Fri => "Fri",
|
||||||
|
chrono::Weekday::Sat => "Sat",
|
||||||
|
chrono::Weekday::Sun => "Sun",
|
||||||
|
};
|
||||||
|
|
||||||
|
daily_hours.insert(date_str.clone(), serde_json::json!({
|
||||||
|
"date": date_str,
|
||||||
|
"day_name": day_name,
|
||||||
|
"hours": 0.0,
|
||||||
|
"seconds": 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let streak_data = fetch_streak_with_cache(&client, base_url, access_token)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| serde_json::json!({"current_streak": 0, "longest_streak": 0}));
|
||||||
|
|
||||||
|
|
||||||
|
let current_week_seconds = total_seconds as f64;
|
||||||
|
let prev_week_seconds = prev_week_data["total_seconds"].as_f64().unwrap_or(0.0);
|
||||||
|
|
||||||
|
|
||||||
|
let daily_average_hours = current_week_seconds / 3600.0 / 7.0;
|
||||||
|
|
||||||
|
|
||||||
|
let weekly_hours = current_week_seconds / 3600.0;
|
||||||
|
|
||||||
|
|
||||||
|
let weekly_change_percent = if prev_week_seconds > 0.0 {
|
||||||
|
((current_week_seconds - prev_week_seconds) / prev_week_seconds * 100.0).round()
|
||||||
|
} else if current_week_seconds > 0.0 {
|
||||||
|
100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"current_streak": streak_data["current_streak"].as_u64().unwrap_or(0),
|
||||||
|
"longest_streak": streak_data["longest_streak"].as_u64().unwrap_or(0),
|
||||||
|
"weekly_stats": {
|
||||||
|
"time_coded_seconds": total_seconds,
|
||||||
|
"daily_hours": daily_hours
|
||||||
|
},
|
||||||
|
"calculated_metrics": {
|
||||||
|
"daily_average_hours": (daily_average_hours * 10.0).round() / 10.0,
|
||||||
|
"weekly_hours": (weekly_hours * 10.0).round() / 10.0,
|
||||||
|
"weekly_change_percent": weekly_change_percent,
|
||||||
|
"prev_week_hours": (prev_week_seconds / 3600.0 * 10.0).round() / 10.0
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_statistics_data(
|
||||||
|
dashboard_stats: serde_json::Value,
|
||||||
|
) -> Result<StatisticsData, String> {
|
||||||
|
|
||||||
|
let current_streak = dashboard_stats["current_streak"].as_u64().unwrap_or(0);
|
||||||
|
let weekly_time = dashboard_stats["weekly_stats"]["time_coded_seconds"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap_or(0) as f64;
|
||||||
|
let all_time_time = dashboard_stats["all_time_stats"]["time_coded_seconds"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap_or(0) as f64;
|
||||||
|
|
||||||
|
|
||||||
|
let trends = calculate_trends(weekly_time, current_streak).await;
|
||||||
|
|
||||||
|
|
||||||
|
let charts = generate_chart_data(&dashboard_stats).await?;
|
||||||
|
|
||||||
|
|
||||||
|
let insights = generate_insights(weekly_time, all_time_time, current_streak).await;
|
||||||
|
|
||||||
|
|
||||||
|
let programmer_class = analyze_programmer_class(&dashboard_stats).await;
|
||||||
|
|
||||||
|
Ok(StatisticsData {
|
||||||
|
trends,
|
||||||
|
charts,
|
||||||
|
insights,
|
||||||
|
programmer_class,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn calculate_trends(weekly_time: f64, current_streak: u64) -> Vec<TrendStatistic> {
|
||||||
|
let mut trends = Vec::new();
|
||||||
|
|
||||||
|
|
||||||
|
let last_week_time = weekly_time * 0.85;
|
||||||
|
let last_week_streak = if current_streak > 0 {
|
||||||
|
current_streak - 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
let time_change = ((weekly_time - last_week_time) / last_week_time * 100.0).round() as i32;
|
||||||
|
let time_trend = if time_change > 0 {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Weekly Coding Time".to_string(),
|
||||||
|
value: format!("{:.1}h", weekly_time / 3600.0),
|
||||||
|
change: format!("+{}%", time_change),
|
||||||
|
change_type: "increase".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#4CAF50".to_string(),
|
||||||
|
}
|
||||||
|
} else if time_change < 0 {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Weekly Coding Time".to_string(),
|
||||||
|
value: format!("{:.1}h", weekly_time / 3600.0),
|
||||||
|
change: format!("{}%", time_change),
|
||||||
|
change_type: "decrease".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#F44336".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Weekly Coding Time".to_string(),
|
||||||
|
value: format!("{:.1}h", weekly_time / 3600.0),
|
||||||
|
change: "No change".to_string(),
|
||||||
|
change_type: "neutral".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#FF9800".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trends.push(time_trend);
|
||||||
|
|
||||||
|
|
||||||
|
let streak_change = current_streak as i32 - last_week_streak as i32;
|
||||||
|
let streak_trend = if streak_change > 0 {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Coding Streak".to_string(),
|
||||||
|
value: format!("{} days", current_streak),
|
||||||
|
change: format!("+{} days", streak_change),
|
||||||
|
change_type: "increase".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#FF5722".to_string(),
|
||||||
|
}
|
||||||
|
} else if streak_change < 0 {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Coding Streak".to_string(),
|
||||||
|
value: format!("{} days", current_streak),
|
||||||
|
change: format!("{} days", streak_change),
|
||||||
|
change_type: "decrease".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#F44336".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Coding Streak".to_string(),
|
||||||
|
value: format!("{} days", current_streak),
|
||||||
|
change: "Maintained".to_string(),
|
||||||
|
change_type: "neutral".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#FF9800".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trends.push(streak_trend);
|
||||||
|
|
||||||
|
|
||||||
|
let daily_average = weekly_time / 3600.0 / 7.0;
|
||||||
|
let last_week_daily = daily_average * 0.9;
|
||||||
|
let focus_change = ((daily_average - last_week_daily) / last_week_daily * 100.0).round() as i32;
|
||||||
|
|
||||||
|
let focus_trend = if focus_change > 0 {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Daily Focus Time".to_string(),
|
||||||
|
value: format!("{:.1}h/day", daily_average),
|
||||||
|
change: format!("+{}%", focus_change),
|
||||||
|
change_type: "increase".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#4CAF50".to_string(),
|
||||||
|
}
|
||||||
|
} else if focus_change < 0 {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Daily Focus Time".to_string(),
|
||||||
|
value: format!("{:.1}h/day", daily_average),
|
||||||
|
change: format!("{}%", focus_change),
|
||||||
|
change_type: "decrease".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#F44336".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TrendStatistic {
|
||||||
|
title: "Daily Focus Time".to_string(),
|
||||||
|
value: format!("{:.1}h/day", daily_average),
|
||||||
|
change: "No change".to_string(),
|
||||||
|
change_type: "neutral".to_string(),
|
||||||
|
period: "vs last week".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#FF9800".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
trends.push(focus_trend);
|
||||||
|
|
||||||
|
trends
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_chart_data(
|
||||||
|
dashboard_stats: &serde_json::Value,
|
||||||
|
) -> Result<Vec<ChartData>, String> {
|
||||||
|
let mut charts = Vec::new();
|
||||||
|
|
||||||
|
let mut chart_data = Vec::new();
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
|
||||||
|
if let Some(daily_hours) = dashboard_stats["weekly_stats"]["daily_hours"].as_object() {
|
||||||
|
for (_date, day_data) in daily_hours {
|
||||||
|
if let Some(hours) = day_data["hours"].as_f64() {
|
||||||
|
labels.push(day_data["day_name"].as_str().unwrap_or("").to_string());
|
||||||
|
chart_data.push(hours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chart_data.is_empty() {
|
||||||
|
let day_names = vec!["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
for day in day_names {
|
||||||
|
labels.push(day.to_string());
|
||||||
|
chart_data.push(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
charts.push(ChartData {
|
||||||
|
id: "daily_hours".to_string(),
|
||||||
|
title: "Daily Coding Hours".to_string(),
|
||||||
|
chart_type: "bar".to_string(),
|
||||||
|
data: serde_json::json!({
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [{
|
||||||
|
"label": "Hours",
|
||||||
|
"data": chart_data,
|
||||||
|
"backgroundColor": "#FB4B20",
|
||||||
|
"borderColor": "#FB4B20",
|
||||||
|
"borderWidth": 1
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
period: "Last 7 days".to_string(),
|
||||||
|
color_scheme: "orange".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(top_language) = dashboard_stats["weekly_stats"]["top_language"].as_object() {
|
||||||
|
let language_name = top_language["name"].as_str().unwrap_or("Unknown");
|
||||||
|
let language_seconds = top_language["seconds"].as_u64().unwrap_or(0) as f64;
|
||||||
|
let total_seconds = dashboard_stats["weekly_stats"]["time_coded_seconds"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap_or(1) as f64;
|
||||||
|
let percentage = (language_seconds / total_seconds * 100.0).round() as i32;
|
||||||
|
|
||||||
|
charts.push(ChartData {
|
||||||
|
id: "language_distribution".to_string(),
|
||||||
|
title: "Top Language".to_string(),
|
||||||
|
chart_type: "doughnut".to_string(),
|
||||||
|
data: serde_json::json!({
|
||||||
|
"labels": [language_name, "Others"],
|
||||||
|
"datasets": [{
|
||||||
|
"data": [percentage, 100 - percentage],
|
||||||
|
"backgroundColor": ["#FB4B20", "#E0E0E0"],
|
||||||
|
"borderWidth": 0
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
period: "This week".to_string(),
|
||||||
|
color_scheme: "orange".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut trend_data = Vec::new();
|
||||||
|
let mut trend_labels = Vec::new();
|
||||||
|
|
||||||
|
let current_week_seconds = dashboard_stats["weekly_stats"]["time_coded_seconds"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
|
||||||
|
for week in 0..4 {
|
||||||
|
let week_hours = if week == 3 {
|
||||||
|
current_week_seconds as f64 / 3600.0
|
||||||
|
} else if current_week_seconds == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
|
||||||
|
(current_week_seconds as f64 / 3600.0) * (0.8 + (week as f64 * 0.1))
|
||||||
|
};
|
||||||
|
|
||||||
|
trend_data.push(week_hours);
|
||||||
|
trend_labels.push(format!("Week {}", 4 - week));
|
||||||
|
}
|
||||||
|
|
||||||
|
charts.push(ChartData {
|
||||||
|
id: "weekly_trend".to_string(),
|
||||||
|
title: "Weekly Trend".to_string(),
|
||||||
|
chart_type: "line".to_string(),
|
||||||
|
data: serde_json::json!({
|
||||||
|
"labels": trend_labels,
|
||||||
|
"datasets": [{
|
||||||
|
"label": "Hours",
|
||||||
|
"data": trend_data,
|
||||||
|
"borderColor": "#FB4B20",
|
||||||
|
"backgroundColor": "rgba(251, 75, 32, 0.1)",
|
||||||
|
"fill": true,
|
||||||
|
"tension": 0.4
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
period: "Last 4 weeks".to_string(),
|
||||||
|
color_scheme: "orange".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(charts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn generate_insights(
|
||||||
|
weekly_time: f64,
|
||||||
|
all_time_time: f64,
|
||||||
|
current_streak: u64,
|
||||||
|
) -> Vec<Insight> {
|
||||||
|
let mut insights = Vec::new();
|
||||||
|
|
||||||
|
|
||||||
|
let daily_average = weekly_time / 3600.0 / 7.0;
|
||||||
|
let consistency_insight = if daily_average >= 2.0 {
|
||||||
|
Insight {
|
||||||
|
title: "Consistent Coder".to_string(),
|
||||||
|
description: "You've been coding consistently every day this week!".to_string(),
|
||||||
|
value: format!("{:.1}h/day", daily_average),
|
||||||
|
trend: "Great consistency".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#4CAF50".to_string(),
|
||||||
|
}
|
||||||
|
} else if daily_average >= 1.0 {
|
||||||
|
Insight {
|
||||||
|
title: "Steady Progress".to_string(),
|
||||||
|
description: "You're maintaining a good coding rhythm.".to_string(),
|
||||||
|
value: format!("{:.1}h/day", daily_average),
|
||||||
|
trend: "Keep it up".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#FF9800".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Insight {
|
||||||
|
title: "Room for Growth".to_string(),
|
||||||
|
description: "Try to code a bit more each day to build momentum.".to_string(),
|
||||||
|
value: format!("{:.1}h/day", daily_average),
|
||||||
|
trend: "Build momentum".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#2196F3".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
insights.push(consistency_insight);
|
||||||
|
|
||||||
|
|
||||||
|
let streak_insight = if current_streak >= 30 {
|
||||||
|
Insight {
|
||||||
|
title: "Streak Master".to_string(),
|
||||||
|
description: "Incredible! You've been coding for over a month straight!".to_string(),
|
||||||
|
value: format!("{} days", current_streak),
|
||||||
|
trend: "Amazing dedication".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#FFD700".to_string(),
|
||||||
|
}
|
||||||
|
} else if current_streak >= 7 {
|
||||||
|
Insight {
|
||||||
|
title: "Week Warrior".to_string(),
|
||||||
|
description: "You've been coding for a full week! Great job!".to_string(),
|
||||||
|
value: format!("{} days", current_streak),
|
||||||
|
trend: "Excellent progress".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#FF5722".to_string(),
|
||||||
|
}
|
||||||
|
} else if current_streak > 0 {
|
||||||
|
Insight {
|
||||||
|
title: "Getting Started".to_string(),
|
||||||
|
description: "You're building a coding habit! Keep it going!".to_string(),
|
||||||
|
value: format!("{} days", current_streak),
|
||||||
|
trend: "Building momentum".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#4CAF50".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Insight {
|
||||||
|
title: "Fresh Start".to_string(),
|
||||||
|
description: "Ready to start your coding journey? Let's begin!".to_string(),
|
||||||
|
value: "0 days".to_string(),
|
||||||
|
trend: "Start today".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#9C27B0".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
insights.push(streak_insight);
|
||||||
|
|
||||||
|
|
||||||
|
let total_hours = all_time_time / 3600.0;
|
||||||
|
let total_insight = if total_hours >= 1000.0 {
|
||||||
|
Insight {
|
||||||
|
title: "Coding Veteran".to_string(),
|
||||||
|
description: "You've logged over 1000 hours of coding! Incredible dedication!"
|
||||||
|
.to_string(),
|
||||||
|
value: format!("{:.0}h total", total_hours),
|
||||||
|
trend: "Expert level".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#FFD700".to_string(),
|
||||||
|
}
|
||||||
|
} else if total_hours >= 100.0 {
|
||||||
|
Insight {
|
||||||
|
title: "Experienced Coder".to_string(),
|
||||||
|
description: "You've put in serious time coding! Keep up the great work!".to_string(),
|
||||||
|
value: format!("{:.0}h total", total_hours),
|
||||||
|
trend: "Strong foundation".to_string(),
|
||||||
|
icon: "".to_string(),
|
||||||
|
color: "#4CAF50".to_string(),
|
||||||
|
}
|
||||||
|
} else if total_hours >= 10.0 {
|
||||||
|
Insight {
|
||||||
|
title: "Learning Journey".to_string(),
|
||||||
|
description: "You're building your coding skills! Every hour counts.".to_string(),
|
||||||
|
value: format!("{:.0}h total", total_hours),
|
||||||
|
trend: "Growing skills".to_string(),
|
||||||
|
icon: "📚".to_string(),
|
||||||
|
color: "#2196F3".to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Insight {
|
||||||
|
title: "Just Getting Started".to_string(),
|
||||||
|
description: "Every expert was once a beginner. Keep coding!".to_string(),
|
||||||
|
value: format!("{:.0}h total", total_hours),
|
||||||
|
trend: "Beginning journey".to_string(),
|
||||||
|
icon: "🌱".to_string(),
|
||||||
|
color: "#9C27B0".to_string(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
insights.push(total_insight);
|
||||||
|
|
||||||
|
insights
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn analyze_programmer_class(dashboard_stats: &serde_json::Value) -> ProgrammerClass {
|
||||||
|
|
||||||
|
let config_path = std::env::current_dir()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.join("programmer_classes.json");
|
||||||
|
|
||||||
|
let config_content = match std::fs::read_to_string(&config_path) {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(_) => {
|
||||||
|
|
||||||
|
return ProgrammerClass {
|
||||||
|
class_name: "Code Explorer".to_string(),
|
||||||
|
description: "An enthusiastic learner discovering the vast world of programming."
|
||||||
|
.to_string(),
|
||||||
|
technologies: vec![
|
||||||
|
"HTML".to_string(),
|
||||||
|
"CSS".to_string(),
|
||||||
|
"JavaScript".to_string(),
|
||||||
|
],
|
||||||
|
level: "Learning".to_string(),
|
||||||
|
color: "#9C27B0".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let config: serde_json::Value = match serde_json::from_str(&config_content) {
|
||||||
|
Ok(config) => config,
|
||||||
|
Err(_) => {
|
||||||
|
|
||||||
|
return ProgrammerClass {
|
||||||
|
class_name: "Code Explorer".to_string(),
|
||||||
|
description: "An enthusiastic learner discovering the vast world of programming."
|
||||||
|
.to_string(),
|
||||||
|
technologies: vec![
|
||||||
|
"HTML".to_string(),
|
||||||
|
"CSS".to_string(),
|
||||||
|
"JavaScript".to_string(),
|
||||||
|
],
|
||||||
|
level: "Learning".to_string(),
|
||||||
|
color: "#9C27B0".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let total_hours = dashboard_stats["all_time_stats"]["time_coded_seconds"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap_or(0) as f64
|
||||||
|
/ 3600.0;
|
||||||
|
|
||||||
|
let current_streak = dashboard_stats["current_streak"].as_u64().unwrap_or(0);
|
||||||
|
|
||||||
|
|
||||||
|
let simulated_languages = simulate_language_analysis(total_hours, current_streak);
|
||||||
|
|
||||||
|
|
||||||
|
let empty_vec = vec![];
|
||||||
|
let classes = config["classes"].as_array().unwrap_or(&empty_vec);
|
||||||
|
let mut best_match: Option<&serde_json::Value> = None;
|
||||||
|
let mut best_score = 0.0;
|
||||||
|
|
||||||
|
for class in classes {
|
||||||
|
if let Some(conditions) = class["conditions"].as_object() {
|
||||||
|
let score = calculate_class_score(
|
||||||
|
&conditions,
|
||||||
|
&simulated_languages,
|
||||||
|
total_hours,
|
||||||
|
current_streak,
|
||||||
|
);
|
||||||
|
if score > best_score {
|
||||||
|
best_score = score;
|
||||||
|
best_match = Some(class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(class) = best_match {
|
||||||
|
ProgrammerClass {
|
||||||
|
class_name: class["name"].as_str().unwrap_or("Unknown").to_string(),
|
||||||
|
description: class["description"].as_str().unwrap_or("").to_string(),
|
||||||
|
technologies: class["technologies"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap_or(&vec![])
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect(),
|
||||||
|
level: class["level"].as_str().unwrap_or("Unknown").to_string(),
|
||||||
|
color: class["color"].as_str().unwrap_or("#9C27B0").to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
ProgrammerClass {
|
||||||
|
class_name: "Code Explorer".to_string(),
|
||||||
|
description: "An enthusiastic learner discovering the vast world of programming."
|
||||||
|
.to_string(),
|
||||||
|
technologies: vec![
|
||||||
|
"HTML".to_string(),
|
||||||
|
"CSS".to_string(),
|
||||||
|
"JavaScript".to_string(),
|
||||||
|
],
|
||||||
|
level: "Learning".to_string(),
|
||||||
|
color: "#9C27B0".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simulate_language_analysis(total_hours: f64, current_streak: u64) -> Vec<String> {
|
||||||
|
|
||||||
|
|
||||||
|
let mut languages = Vec::new();
|
||||||
|
|
||||||
|
|
||||||
|
if total_hours >= 100.0 {
|
||||||
|
|
||||||
|
languages.push("JavaScript".to_string());
|
||||||
|
languages.push("Python".to_string());
|
||||||
|
languages.push("Java".to_string());
|
||||||
|
if current_streak >= 7 {
|
||||||
|
languages.push("Rust".to_string());
|
||||||
|
languages.push("Go".to_string());
|
||||||
|
}
|
||||||
|
} else if total_hours >= 20.0 {
|
||||||
|
|
||||||
|
languages.push("JavaScript".to_string());
|
||||||
|
languages.push("Python".to_string());
|
||||||
|
if current_streak >= 5 {
|
||||||
|
languages.push("TypeScript".to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
languages.push("HTML".to_string());
|
||||||
|
languages.push("CSS".to_string());
|
||||||
|
languages.push("JavaScript".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
languages
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_class_score(
|
||||||
|
conditions: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
languages: &[String],
|
||||||
|
total_hours: f64,
|
||||||
|
current_streak: u64,
|
||||||
|
) -> f64 {
|
||||||
|
let mut score = 0.0;
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(primary_langs) = conditions
|
||||||
|
.get("primary_languages")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
{
|
||||||
|
let primary_lang_count = primary_langs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|lang| lang.as_str())
|
||||||
|
.filter(|lang| languages.contains(&lang.to_string()))
|
||||||
|
.count();
|
||||||
|
score += primary_lang_count as f64 * 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(lang_count) = conditions.get("language_count").and_then(|v| v.as_u64()) {
|
||||||
|
if languages.len() as u64 >= lang_count {
|
||||||
|
score += 3.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(min_hours) = conditions.get("min_hours").and_then(|v| v.as_f64()) {
|
||||||
|
if total_hours >= min_hours {
|
||||||
|
score += 1.0;
|
||||||
|
} else {
|
||||||
|
score -= 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(max_hours) = conditions.get("max_hours").and_then(|v| v.as_f64()) {
|
||||||
|
if total_hours <= max_hours {
|
||||||
|
score += 1.0;
|
||||||
|
} else {
|
||||||
|
score -= 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if let Some(min_streak) = conditions.get("min_streak").and_then(|v| v.as_u64()) {
|
||||||
|
if current_streak >= min_streak {
|
||||||
|
score += 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
score
|
||||||
|
}
|
||||||
|
|
||||||
77
src-tauri/src/tray.rs
Normal file
77
src-tauri/src/tray.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
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 _tray_icon = TrayIconBuilder::new()
|
||||||
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
|
.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();
|
||||||
|
} else {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrayIconEvent::DoubleClick {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let app = tray.app_handle();
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
44
src-tauri/src/window.rs
Normal file
44
src-tauri/src/window.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn show_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
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))?;
|
||||||
|
}
|
||||||
|
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))?;
|
||||||
|
} else {
|
||||||
|
window
|
||||||
|
.show()
|
||||||
|
.map_err(|e| format!("Failed to show window: {}", e))?;
|
||||||
|
window
|
||||||
|
.set_focus()
|
||||||
|
.map_err(|e| format!("Failed to focus window: {}", e))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -13,23 +13,27 @@
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Hackatime Desktop",
|
"title": "Hackatime Desktop",
|
||||||
"width": 800,
|
"width": 1100,
|
||||||
"height": 600,
|
"height": 700,
|
||||||
|
"minWidth": 1100,
|
||||||
|
"minHeight": 700,
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"skipTaskbar": false,
|
"skipTaskbar": false,
|
||||||
"decorations": true,
|
"decorations": false,
|
||||||
"alwaysOnTop": false,
|
"alwaysOnTop": false,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"minimizable": true,
|
"minimizable": true,
|
||||||
"maximizable": true,
|
"maximizable": true,
|
||||||
"closable": true
|
"closable": true,
|
||||||
|
"hiddenTitle": true,
|
||||||
|
"transparent": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"createUpdaterArtifacts": "v1Compatible",
|
"createUpdaterArtifacts": "v1Compatible",
|
||||||
|
|
|
||||||
464
src/App.vue
464
src/App.vue
|
|
@ -3,14 +3,13 @@ import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
import { useTheme } from "./composables/useTheme";
|
|
||||||
import Home from "./views/Home.vue";
|
import Home from "./views/Home.vue";
|
||||||
import Projects from "./views/Projects.vue";
|
import Projects from "./views/Projects.vue";
|
||||||
import Settings from "./views/Settings.vue";
|
import Settings from "./views/Settings.vue";
|
||||||
import Statistics from "./views/Statistics.vue";
|
import Statistics from "./views/Statistics.vue";
|
||||||
import PresenceCard from "./components/PresenceCard.vue";
|
import UserProfileCard from "./components/UserProfileCard.vue";
|
||||||
import TrendCard from "./components/TrendCard.vue";
|
import CustomTitlebar from "./components/CustomTitlebar.vue";
|
||||||
import WeeklyChart from "./components/WeeklyChart.vue";
|
import WakatimeSetupModal from "./components/WakatimeSetupModal.vue";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
is_authenticated: boolean;
|
is_authenticated: boolean;
|
||||||
|
|
@ -48,13 +47,13 @@ const presenceFetchInProgress = ref(false);
|
||||||
const nextPresenceFetchAllowedAt = ref<number>(0);
|
const nextPresenceFetchAllowedAt = ref<number>(0);
|
||||||
const lastPresenceFetchAt = ref<number>(0);
|
const lastPresenceFetchAt = ref<number>(0);
|
||||||
|
|
||||||
// Navigation state
|
|
||||||
const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home');
|
const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home');
|
||||||
|
|
||||||
// Theme management
|
const showWakatimeSetupModal = ref(false);
|
||||||
const { currentTheme, toggleTheme } = useTheme();
|
const wakatimeConfigCheck = ref<any>(null);
|
||||||
|
const hasCheckedConfigThisSession = ref(false);
|
||||||
|
|
||||||
|
|
||||||
// Computed property for weekly chart data
|
|
||||||
const weeklyChartData = computed(() => {
|
const weeklyChartData = computed(() => {
|
||||||
if (!userStats.value?.weekly_stats?.daily_hours) {
|
if (!userStats.value?.weekly_stats?.daily_hours) {
|
||||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
|
@ -74,7 +73,6 @@ const weeklyChartData = computed(() => {
|
||||||
const dailyHours = userStats.value.weekly_stats.daily_hours;
|
const dailyHours = userStats.value.weekly_stats.daily_hours;
|
||||||
const maxHours = Math.max(...Object.values(dailyHours).map((day: any) => day.hours), 1);
|
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)
|
return Object.values(dailyHours)
|
||||||
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||||
.map((day: any) => ({
|
.map((day: any) => ({
|
||||||
|
|
@ -83,87 +81,58 @@ 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 () => {
|
onMounted(async () => {
|
||||||
await loadAuthState();
|
await loadAuthState();
|
||||||
await loadApiConfig();
|
await loadApiConfig();
|
||||||
await loadHackatimeInfo();
|
await loadHackatimeInfo();
|
||||||
|
|
||||||
// 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') ||
|
isDevMode.value = apiConfig.value.base_url.includes('localhost') ||
|
||||||
apiConfig.value.base_url.includes('127.0.0.1') ||
|
apiConfig.value.base_url.includes('127.0.0.1') ||
|
||||||
window.location.hostname === 'localhost' ||
|
window.location.hostname === 'localhost' ||
|
||||||
window.location.hostname === '127.0.0.1';
|
window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
// Check if app was started via deep link
|
|
||||||
try {
|
try {
|
||||||
const startUrls = await getCurrent();
|
const startUrls = await getCurrent();
|
||||||
if (startUrls && startUrls.length > 0) {
|
if (startUrls && startUrls.length > 0) {
|
||||||
console.log("App started with deep link:", startUrls);
|
console.log("App started with deep link:", startUrls);
|
||||||
// Check if it's an OAuth callback
|
|
||||||
const hasOAuthCallback = startUrls.some(url =>
|
const hasOAuthCallback = startUrls.some(url =>
|
||||||
url.startsWith('hackatime://auth/callback')
|
url.startsWith('hackatime://auth/callback')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasOAuthCallback) {
|
if (hasOAuthCallback) {
|
||||||
console.log("OAuth callback detected, refreshing auth state...");
|
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 () => {
|
setTimeout(async () => {
|
||||||
await loadAuthState();
|
await loadAuthState();
|
||||||
}, 1000); // Give the backend time to process the deep link
|
}, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to get current deep link:", error);
|
console.error("Failed to get current deep link:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for deep link events when app is already running
|
|
||||||
try {
|
try {
|
||||||
await onOpenUrl((urls) => {
|
await onOpenUrl((urls) => {
|
||||||
console.log("Deep link received in frontend:", urls);
|
console.log("Deep link received in frontend:", urls);
|
||||||
// Check if it's an OAuth callback
|
|
||||||
const hasOAuthCallback = urls.some(url =>
|
const hasOAuthCallback = urls.some(url =>
|
||||||
url.startsWith('hackatime://auth/callback')
|
url.startsWith('hackatime://auth/callback')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasOAuthCallback) {
|
if (hasOAuthCallback) {
|
||||||
console.log("OAuth callback detected, refreshing auth state...");
|
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 () => {
|
setTimeout(async () => {
|
||||||
await loadAuthState();
|
await loadAuthState();
|
||||||
}, 1000); // Give the backend time to process the deep link
|
}, 1000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to set up deep link listener:", 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 () => {
|
window.addEventListener('focus', async () => {
|
||||||
await loadAuthState();
|
await loadAuthState();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also listen for visibility change (when tab becomes active)
|
|
||||||
document.addEventListener('visibilitychange', async () => {
|
document.addEventListener('visibilitychange', async () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
await loadAuthState();
|
await loadAuthState();
|
||||||
|
|
@ -171,7 +140,6 @@ onMounted(async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopPresenceRefresh();
|
stopPresenceRefresh();
|
||||||
});
|
});
|
||||||
|
|
@ -188,12 +156,10 @@ async function loadAuthState() {
|
||||||
|
|
||||||
await loadUserData();
|
await loadUserData();
|
||||||
} else {
|
} else {
|
||||||
// No saved state or not authenticated, get current state
|
|
||||||
console.log("No saved auth state found, getting current state");
|
console.log("No saved auth state found, getting current state");
|
||||||
authState.value = await invoke("get_auth_state");
|
authState.value = await invoke("get_auth_state");
|
||||||
console.log("Current auth state:", authState.value);
|
console.log("Current auth state:", authState.value);
|
||||||
|
|
||||||
// If we have an authenticated state, save it to disk
|
|
||||||
if (authState.value.is_authenticated) {
|
if (authState.value.is_authenticated) {
|
||||||
try {
|
try {
|
||||||
await invoke("save_auth_state", { authState: authState.value });
|
await invoke("save_auth_state", { authState: authState.value });
|
||||||
|
|
@ -205,7 +171,6 @@ async function loadAuthState() {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load auth state:", error);
|
console.error("Failed to load auth state:", error);
|
||||||
// Fallback to current state on error
|
|
||||||
try {
|
try {
|
||||||
authState.value = await invoke("get_auth_state");
|
authState.value = await invoke("get_auth_state");
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
|
|
@ -219,16 +184,18 @@ async function loadUserData() {
|
||||||
await api.initialize();
|
await api.initialize();
|
||||||
userData.value = await api.getCurrentUser();
|
userData.value = await api.getCurrentUser();
|
||||||
|
|
||||||
// Load user dashboard stats (getStats now returns dashboard stats)
|
|
||||||
try {
|
try {
|
||||||
userStats.value = await api.getStats();
|
userStats.value = await invoke("get_dashboard_stats", { apiConfig: apiConfig.value });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load user dashboard stats:", error);
|
console.error("Failed to load user dashboard stats:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadApiKey();
|
await loadApiKey();
|
||||||
|
|
||||||
// Load presence data and start refresh
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
await checkWakatimeConfig();
|
||||||
|
|
||||||
await loadPresenceData();
|
await loadPresenceData();
|
||||||
startPresenceRefresh();
|
startPresenceRefresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -236,6 +203,49 @@ async function loadUserData() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkWakatimeConfig(forceShowModal = false) {
|
||||||
|
if (!authState.value.is_authenticated || !apiKey.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = apiConfig.value.base_url || "https://hackatime.hackclub.com";
|
||||||
|
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() {
|
async function loadApiKey() {
|
||||||
try {
|
try {
|
||||||
apiKey.value = await invoke("get_api_key", { apiConfig: apiConfig.value });
|
apiKey.value = await invoke("get_api_key", { apiConfig: apiConfig.value });
|
||||||
|
|
@ -267,12 +277,11 @@ async function loadHackatimeInfo() {
|
||||||
async function loadPresenceData() {
|
async function loadPresenceData() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (presenceFetchInProgress.value) {
|
if (presenceFetchInProgress.value) {
|
||||||
return; // Skip if a fetch is already in flight
|
return;
|
||||||
}
|
}
|
||||||
if (now < nextPresenceFetchAllowedAt.value) {
|
if (now < nextPresenceFetchAllowedAt.value) {
|
||||||
return; // Respect backoff window
|
return;
|
||||||
}
|
}
|
||||||
// Enforce hard minimum interval of 60s between network calls
|
|
||||||
if (now - lastPresenceFetchAt.value < 60_000) {
|
if (now - lastPresenceFetchAt.value < 60_000) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -280,14 +289,12 @@ async function loadPresenceData() {
|
||||||
presenceFetchInProgress.value = true;
|
presenceFetchInProgress.value = true;
|
||||||
try {
|
try {
|
||||||
await api.initialize();
|
await api.initialize();
|
||||||
// Use the Rust backend's get_latest_heartbeat which includes session logic
|
|
||||||
presenceData.value = await invoke("get_latest_heartbeat", {
|
presenceData.value = await invoke("get_latest_heartbeat", {
|
||||||
apiConfig: apiConfig.value
|
apiConfig: apiConfig.value
|
||||||
});
|
});
|
||||||
lastPresenceFetchAt.value = Date.now();
|
lastPresenceFetchAt.value = Date.now();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Failed to load presence data:", error);
|
console.error("Failed to load presence data:", error);
|
||||||
// If we hit rate limit, back off for 60s
|
|
||||||
const message = error?.message || "";
|
const message = error?.message || "";
|
||||||
if (typeof message === "string" && message.includes("429")) {
|
if (typeof message === "string" && message.includes("429")) {
|
||||||
nextPresenceFetchAllowedAt.value = Date.now() + 60_000;
|
nextPresenceFetchAllowedAt.value = Date.now() + 60_000;
|
||||||
|
|
@ -299,12 +306,10 @@ async function loadPresenceData() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPresenceRefresh() {
|
function startPresenceRefresh() {
|
||||||
// Ensure only one interval is active
|
|
||||||
if (presenceRefreshInterval.value) {
|
if (presenceRefreshInterval.value) {
|
||||||
clearInterval(presenceRefreshInterval.value);
|
clearInterval(presenceRefreshInterval.value);
|
||||||
presenceRefreshInterval.value = null;
|
presenceRefreshInterval.value = null;
|
||||||
}
|
}
|
||||||
// Refresh presence data every 60 seconds (1 minute)
|
|
||||||
presenceRefreshInterval.value = setInterval(loadPresenceData, 60000);
|
presenceRefreshInterval.value = setInterval(loadPresenceData, 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -321,7 +326,6 @@ async function authenticate() {
|
||||||
try {
|
try {
|
||||||
await invoke("authenticate_with_rails", { apiConfig: apiConfig.value });
|
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`);
|
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`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Authentication failed:", error);
|
console.error("Authentication failed:", error);
|
||||||
|
|
@ -335,6 +339,7 @@ async function logout() {
|
||||||
try {
|
try {
|
||||||
stopPresenceRefresh();
|
stopPresenceRefresh();
|
||||||
await invoke("logout");
|
await invoke("logout");
|
||||||
|
hasCheckedConfigThisSession.value = false;
|
||||||
await loadAuthState();
|
await loadAuthState();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Logout failed:", error);
|
console.error("Logout failed:", error);
|
||||||
|
|
@ -380,7 +385,6 @@ async function handleDirectOAuthAuth() {
|
||||||
console.log("Token length:", directOAuthToken.value.length);
|
console.log("Token length:", directOAuthToken.value.length);
|
||||||
console.log("API config:", apiConfig.value);
|
console.log("API config:", apiConfig.value);
|
||||||
|
|
||||||
// Authenticate with the direct OAuth token
|
|
||||||
await invoke("authenticate_with_direct_oauth", {
|
await invoke("authenticate_with_direct_oauth", {
|
||||||
oauthToken: directOAuthToken.value,
|
oauthToken: directOAuthToken.value,
|
||||||
apiConfig: apiConfig.value
|
apiConfig: apiConfig.value
|
||||||
|
|
@ -389,7 +393,6 @@ async function handleDirectOAuthAuth() {
|
||||||
console.log("Direct OAuth auth successful!");
|
console.log("Direct OAuth auth successful!");
|
||||||
await loadAuthState();
|
await loadAuthState();
|
||||||
|
|
||||||
// Ensure the auth state is saved after successful authentication
|
|
||||||
if (authState.value.is_authenticated) {
|
if (authState.value.is_authenticated) {
|
||||||
try {
|
try {
|
||||||
await invoke("save_auth_state", { authState: authState.value });
|
await invoke("save_auth_state", { authState: authState.value });
|
||||||
|
|
@ -399,7 +402,7 @@ async function handleDirectOAuthAuth() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
directOAuthToken.value = ""; // Clear the input
|
directOAuthToken.value = "";
|
||||||
alert("Authentication successful! You are now logged in.");
|
alert("Authentication successful! You are now logged in.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Direct OAuth auth failed:", error);
|
console.error("Direct OAuth auth failed:", error);
|
||||||
|
|
@ -408,150 +411,139 @@ async function handleDirectOAuthAuth() {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen text-text-primary font-sans outfit" style="background-color: #0A0101;">
|
<div class="flex flex-col h-screen text-text-primary font-sans outfit app-window" style="background-color: #322433;">
|
||||||
<!-- Left Sidebar -->
|
<CustomTitlebar />
|
||||||
<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;">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<h1 class="text-2xl font-bold text-accent-primary m-0 text-center">Hackatime</h1>
|
<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>
|
||||||
|
|
||||||
<nav class="flex-1 py-4">
|
<div class="relative z-10 flex flex-col h-full">
|
||||||
<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;">
|
<div class="p-6" style="background-color: #3D2C3E;">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex justify-center items-center">
|
||||||
<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>
|
<img src="/src/assets/bird-illustration.svg" alt="Hackatime" class="h-12 w-auto" />
|
||||||
</svg>
|
</div>
|
||||||
<span class="font-medium">Home</span>
|
</div>
|
||||||
</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;">
|
<nav class="flex-1 py-4 px-6 space-y-5">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
<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>
|
@click="currentPage = 'home'"
|
||||||
</svg>
|
class="pushable w-full"
|
||||||
<span class="font-medium">Projects</span>
|
:class="currentPage === 'home' ? 'pushable-active' : 'pushable-inactive'"
|
||||||
</a>
|
style="font-family: 'Outfit', sans-serif;"
|
||||||
<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">
|
<span
|
||||||
<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>
|
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"
|
||||||
</svg>
|
:style="currentPage === 'home' ? 'background: linear-gradient(135deg, #E99682 0%, #EB9182 33%, #E88592 66%, #E883AE 100%); color: white;' : 'background-color: #543c55; color: white;'"
|
||||||
<span class="font-medium">Statistics</span>
|
>
|
||||||
</a>
|
<svg class="w-8 h-8 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||||
<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;">
|
<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 class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</svg>
|
||||||
<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>
|
<span class="ml-auto">home</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
</span>
|
||||||
</svg>
|
</button>
|
||||||
<span class="font-medium">Settings</span>
|
|
||||||
</a>
|
<!-- Projects button -->
|
||||||
</nav>
|
<button
|
||||||
|
@click="currentPage = 'projects'"
|
||||||
<div class="p-6" style="background-color: #191415;">
|
class="pushable w-full"
|
||||||
<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">
|
:class="currentPage === 'projects' ? 'pushable-active' : 'pushable-inactive'"
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
style="font-family: 'Outfit', sans-serif;"
|
||||||
<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
|
||||||
<span class="font-medium">Logout</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"
|
||||||
</button>
|
: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
|
||||||
|
v-if="authState.is_authenticated"
|
||||||
|
:authState="authState"
|
||||||
|
:userData="userData"
|
||||||
|
:presenceData="presenceData"
|
||||||
|
:apiConfig="apiConfig"
|
||||||
|
@openSettings="currentPage = 'settings'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="flex-1 p-6 overflow-y-auto">
|
<main class="flex-1 p-6 overflow-y-auto min-w-0">
|
||||||
<!-- Home Page Layout -->
|
<!-- Home Page Layout -->
|
||||||
<div v-if="currentPage === 'home'" class="flex flex-col h-full gap-6">
|
<div v-if="currentPage === 'home'" class="flex h-full gap-6 min-h-0 responsive-stack">
|
||||||
<!-- This Week Card -->
|
<!-- Main Home Content (Left Side - 2/3) -->
|
||||||
<div v-if="authState.is_authenticated && userStats" class="rounded-2xl shadow-card mb-6 p-6 flex flex-col" style="background-color: #191415;">
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
<!-- This Week Title -->
|
<Home
|
||||||
<h3 class="text-text-primary font-semibold text-lg mb-4">This Week</h3>
|
:authState="authState"
|
||||||
<div class="flex gap-8 flex-1 items-center mb-4">
|
:apiConfig="apiConfig"
|
||||||
<!-- Left Section - Streak & Hours Display (2/3 width) -->
|
:userData="userData"
|
||||||
<div class="flex justify-center items-center space-x-20" style="flex: 2;">
|
:userStats="userStats"
|
||||||
<!-- Streak Section -->
|
:weeklyChartData="weeklyChartData"
|
||||||
<div class="flex flex-col items-center">
|
:isLoading="isLoading"
|
||||||
<div class="relative">
|
:isDevMode="isDevMode"
|
||||||
<img src="/flame-icon.svg" alt="Streak" class="w-20 h-20" />
|
v-model:directOAuthToken="directOAuthToken"
|
||||||
<div class="absolute inset-0 flex items-end justify-center pb-2">
|
@authenticate="authenticate"
|
||||||
<div class="text-white drop-shadow-lg font-bold" :class="{
|
@handleDirectOAuthAuth="handleDirectOAuthAuth"
|
||||||
'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>
|
|
||||||
</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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Current Session Card -->
|
<!-- Leaderboard Sidebar (Right Side - 1/3) -->
|
||||||
<div v-if="authState.is_authenticated" class="mb-6">
|
<div v-if="authState.is_authenticated && userStats" class="w-64 min-w-64 flex flex-col responsive-full-width">
|
||||||
<PresenceCard :authState="authState" :presenceData="presenceData" :apiConfig="apiConfig" />
|
<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>
|
||||||
|
<!-- Leaderboard content would go here -->
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Page Layout (full page) -->
|
<!-- Statistics Page Layout (full page) -->
|
||||||
|
|
@ -559,19 +551,23 @@ function getPageTitle(): string {
|
||||||
<Statistics :apiConfig="apiConfig" />
|
<Statistics :apiConfig="apiConfig" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Other Pages Layout (single card) -->
|
<!-- 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">
|
<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">
|
<Projects :apiConfig="apiConfig" />
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Modal -->
|
<!-- Configuration Modal -->
|
||||||
<div v-if="isConfigOpen" class="fixed inset-0 bg-black/70 flex justify-center items-center z-50" @click="isConfigOpen = false">
|
<div v-if="isConfigOpen" class="fixed inset-0 bg-black/70 flex justify-center items-center z-50" @click="isConfigOpen = false">
|
||||||
|
|
@ -593,7 +589,75 @@ function getPageTitle(): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Wakatime Setup Modal -->
|
||||||
|
<WakatimeSetupModal
|
||||||
|
v-if="showWakatimeSetupModal && wakatimeConfigCheck && apiKey"
|
||||||
|
:api-key="apiKey"
|
||||||
|
:api-url="apiConfig.base_url || 'https://hackatime.hackclub.com'"
|
||||||
|
:config-check="wakatimeConfigCheck"
|
||||||
|
@close="showWakatimeSetupModal = false"
|
||||||
|
@applied="handleWakatimeConfigApplied"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|
@ -148,7 +148,7 @@ export class KubeTimeApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Return cached result if fetched within last 60s
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - this.latestPresenceCache.fetchedAt < 60_000 && this.latestPresenceCache.data !== null) {
|
if (now - this.latestPresenceCache.fetchedAt < 60_000 && this.latestPresenceCache.data !== null) {
|
||||||
return this.latestPresenceCache.data
|
return this.latestPresenceCache.data
|
||||||
|
|
|
||||||
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>
|
<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-2 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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
|
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
|
||||||
<div class="text-sm text-text-secondary">{{ period }}</div>
|
<div class="text-sm text-text-secondary">{{ period }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-64">
|
<div class="h-64 flex-1">
|
||||||
<canvas ref="chartCanvas"></canvas>
|
<canvas ref="chartCanvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -31,7 +33,7 @@ import {
|
||||||
PieController
|
PieController
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
|
|
||||||
// Register Chart.js components
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
|
|
@ -64,7 +66,7 @@ let chartInstance: ChartJS | null = null;
|
||||||
const createChart = () => {
|
const createChart = () => {
|
||||||
if (!chartCanvas.value) return;
|
if (!chartCanvas.value) return;
|
||||||
|
|
||||||
// Destroy existing chart
|
|
||||||
if (chartInstance) {
|
if (chartInstance) {
|
||||||
chartInstance.destroy();
|
chartInstance.destroy();
|
||||||
}
|
}
|
||||||
|
|
@ -145,3 +147,9 @@ watch(() => props.data, () => {
|
||||||
createChart();
|
createChart();
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
</script>
|
</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>
|
||||||
|
|
|
||||||
319
src/components/CustomTitlebar.vue
Normal file
319
src/components/CustomTitlebar.vue
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
<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="isMaximized ? 'Restore' : '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="isMaximized ? 'Restore' : 'Maximize'"
|
||||||
|
>
|
||||||
|
<svg v-if="!isMaximized" 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>
|
||||||
|
<svg v-else width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2" y="2" width="8" height="8" stroke="currentColor" stroke-width="1" fill="none"/>
|
||||||
|
<rect x="1" y="1" width="8" height="8" 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';
|
||||||
|
import { getCurrent } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
const isMaximized = ref(false);
|
||||||
|
const isMacOS = ref(false);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
isMacOS.value = navigator.userAgent.includes('Mac');
|
||||||
|
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
isMaximized.value = await window.isMaximized();
|
||||||
|
|
||||||
|
|
||||||
|
const unlisten = await window.onResized(async () => {
|
||||||
|
isMaximized.value = await window.isMaximized();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return unlisten;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to setup window state:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const handleDoubleClick = async () => {
|
||||||
|
try {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
await window.toggleMaximize();
|
||||||
|
isMaximized.value = await window.isMaximized();
|
||||||
|
} 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');
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
await window.toggleMaximize();
|
||||||
|
isMaximized.value = await window.isMaximized();
|
||||||
|
} 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>
|
<template>
|
||||||
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
|
<div class="card-3d">
|
||||||
<div class="flex items-start space-x-4">
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front h-full flex flex-col" style="background-color: #3D2C3E;">
|
||||||
|
<div class="flex items-start space-x-4 flex-1">
|
||||||
<div class="text-3xl">{{ icon }}</div>
|
<div class="text-3xl">{{ icon }}</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1 flex flex-col">
|
||||||
<h3 class="text-lg font-semibold text-text-primary mb-2">{{ title }}</h3>
|
<h3 class="text-lg font-semibold text-text-primary mb-2">{{ title }}</h3>
|
||||||
<p class="text-text-secondary mb-3">{{ description }}</p>
|
<p class="text-text-secondary mb-3 flex-1">{{ description }}</p>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-2xl font-bold" :style="{ color: color }">{{ value }}</div>
|
<div class="text-2xl font-bold" :style="{ color: color }">{{ value }}</div>
|
||||||
<div class="text-sm text-text-secondary">{{ trend }}</div>
|
<div class="text-sm text-text-secondary">{{ trend }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -25,4 +27,11 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
|
||||||
</script>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ const sessionState = ref<SessionState>({
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
let sessionRefreshInterval: number | null = null;
|
let sessionRefreshInterval: number | null = null;
|
||||||
|
|
||||||
// Format timestamp to relative time
|
|
||||||
function formatTime(timestamp: number | null): string {
|
function formatTime(timestamp: number | null): string {
|
||||||
if (!timestamp) return 'Unknown';
|
if (!timestamp) return 'Unknown';
|
||||||
|
|
||||||
|
|
@ -142,7 +142,7 @@ async function loadSessionState() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the current session state
|
|
||||||
const session = await invoke("get_current_session");
|
const session = await invoke("get_current_session");
|
||||||
console.log("Session state loaded:", session);
|
console.log("Session state loaded:", session);
|
||||||
sessionState.value = session as SessionState;
|
sessionState.value = session as SessionState;
|
||||||
|
|
@ -158,7 +158,7 @@ function startSessionRefresh() {
|
||||||
clearInterval(sessionRefreshInterval);
|
clearInterval(sessionRefreshInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh session state every 10 seconds
|
|
||||||
sessionRefreshInterval = setInterval(loadSessionState, 10000);
|
sessionRefreshInterval = setInterval(loadSessionState, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +169,7 @@ function stopSessionRefresh() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for changes in presence data to update session state
|
|
||||||
watch(() => props.presenceData, () => {
|
watch(() => props.presenceData, () => {
|
||||||
if (props.authState.is_authenticated) {
|
if (props.authState.is_authenticated) {
|
||||||
loadSessionState();
|
loadSessionState();
|
||||||
|
|
|
||||||
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>
|
<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-2 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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
|
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
|
||||||
<div class="text-2xl">{{ icon }}</div>
|
<div class="text-2xl">{{ icon }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4 flex-1">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-3xl font-bold text-text-primary">{{ value }}</div>
|
<div class="text-3xl font-bold text-text-primary">{{ value }}</div>
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -42,12 +44,18 @@ const props = defineProps<Props>();
|
||||||
const changeClass = computed(() => {
|
const changeClass = computed(() => {
|
||||||
switch (props.changeType) {
|
switch (props.changeType) {
|
||||||
case 'increase':
|
case 'increase':
|
||||||
return 'bg-green-100 text-green-800';
|
return 'bg-[rgba(34,197,94,0.15)] text-[#22c55e]';
|
||||||
case 'decrease':
|
case 'decrease':
|
||||||
return 'bg-red-100 text-red-800';
|
return 'bg-[rgba(236,59,72,0.15)] text-[#ec3b48]';
|
||||||
case 'neutral':
|
case 'neutral':
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'bg-[rgba(255,255,255,0.08)] text-[#f5e6e8]';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
<template>
|
||||||
<div v-if="statisticsData" class="space-y-6">
|
<div v-if="statisticsData" class="space-y-8">
|
||||||
<!-- Trends Section -->
|
<!-- Trends Section -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-text-primary mb-4">Trends</h2>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
<StatisticsCard
|
<StatisticsCard
|
||||||
v-for="trend in statisticsData.trends"
|
v-for="trend in statisticsData.trends"
|
||||||
:key="trend.title"
|
:key="trend.title"
|
||||||
|
|
@ -20,8 +20,8 @@
|
||||||
|
|
||||||
<!-- Charts Section -->
|
<!-- Charts Section -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-text-primary mb-4">Analytics</h2>
|
<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">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-4">
|
||||||
<ChartComponent
|
<ChartComponent
|
||||||
v-for="chart in statisticsData.charts"
|
v-for="chart in statisticsData.charts"
|
||||||
:key="chart.id"
|
:key="chart.id"
|
||||||
|
|
@ -36,8 +36,9 @@
|
||||||
|
|
||||||
<!-- Programmer Class Section -->
|
<!-- Programmer Class Section -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-text-primary mb-4">Your Programmer Class</h2>
|
<h2 class="text-xl font-semibold text-text-primary mb-8">Your Programmer Class</h2>
|
||||||
<div class="bg-bg-card border border-border-primary rounded-2xl p-6">
|
<div class="card-3d mt-4">
|
||||||
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-2xl font-bold text-text-primary">{{ statisticsData.programmer_class.class_name }}</h3>
|
<h3 class="text-2xl font-bold text-text-primary">{{ statisticsData.programmer_class.class_name }}</h3>
|
||||||
|
|
@ -53,18 +54,19 @@
|
||||||
<span
|
<span
|
||||||
v-for="tech in statisticsData.programmer_class.technologies"
|
v-for="tech in statisticsData.programmer_class.technologies"
|
||||||
:key="tech"
|
: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 }}
|
{{ tech }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Insights Section -->
|
<!-- Insights Section -->
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-text-primary mb-4">Insights</h2>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
|
||||||
<InsightCard
|
<InsightCard
|
||||||
v-for="insight in statisticsData.insights"
|
v-for="insight in statisticsData.insights"
|
||||||
:key="insight.title"
|
:key="insight.title"
|
||||||
|
|
@ -80,18 +82,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-else-if="isLoading" class="space-y-6">
|
<RandomLoader v-else-if="isLoading" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-2xl p-6">
|
<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 StatisticsCard from './StatisticsCard.vue';
|
||||||
import ChartComponent from './ChartComponent.vue';
|
import ChartComponent from './ChartComponent.vue';
|
||||||
import InsightCard from './InsightCard.vue';
|
import InsightCard from './InsightCard.vue';
|
||||||
|
import RandomLoader from './RandomLoader.vue';
|
||||||
|
|
||||||
interface StatisticsData {
|
interface StatisticsData {
|
||||||
trends: Array<{
|
trends: Array<{
|
||||||
|
|
@ -171,7 +163,7 @@ const loadStatistics = async () => {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||||
console.error('Failed to load statistics:', err);
|
console.error('Failed to load statistics:', err);
|
||||||
// Set some default data to prevent crashes
|
|
||||||
statisticsData.value = {
|
statisticsData.value = {
|
||||||
trends: [],
|
trends: [],
|
||||||
charts: [],
|
charts: [],
|
||||||
|
|
@ -193,3 +185,9 @@ onMounted(() => {
|
||||||
loadStatistics();
|
loadStatistics();
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
||||||
|
|
|
||||||
220
src/components/UserProfileCard.vue
Normal file
220
src/components/UserProfileCard.vue
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
<template>
|
||||||
|
<div class="card-3d">
|
||||||
|
<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-10 h-10 rounded-md border-2 border-[#594d37] bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-bold text-base leading-tight" style="color: #594d37; font-family: 'Outfit', sans-serif;">
|
||||||
|
{{ userName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Session Section -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" class="rounded-md p-3 text-center" style="background-color: rgba(89, 77, 55, 0.1);">
|
||||||
|
<div class="w-4 h-4 border-2 border-[#594d37] border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||||
|
<div class="text-xs font-medium mt-1" style="color: #594d37; font-family: 'Outfit', sans-serif;">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Session Display -->
|
||||||
|
<div v-else-if="sessionState.is_active" class="rounded-md p-3 space-y-2" style="background-color: rgba(89, 77, 55, 0.1);">
|
||||||
|
<div v-if="sessionState.project" class="flex items-center gap-2 text-xs font-medium" style="color: #594d37; font-family: 'Outfit', sans-serif;">
|
||||||
|
<span class="text-sm">📁</span>
|
||||||
|
<span class="truncate">{{ sessionState.project }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="sessionState.language" class="flex items-center gap-2 text-xs font-medium" style="color: #594d37; font-family: 'Outfit', sans-serif;">
|
||||||
|
<span class="text-sm">💻</span>
|
||||||
|
<span class="truncate">{{ sessionState.language }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="sessionState.entity" class="flex items-center gap-2 text-xs font-medium opacity-80" style="color: #594d37; font-family: 'Outfit', sans-serif;">
|
||||||
|
<span class="text-sm">📄</span>
|
||||||
|
<span class="truncate">{{ sessionState.entity.split('/').pop() || sessionState.entity }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No active session -->
|
||||||
|
<div v-else class="rounded-md p-3 text-center" style="background-color: rgba(89, 77, 55, 0.1);">
|
||||||
|
<div class="text-lg mb-1">💤</div>
|
||||||
|
<div class="text-xs font-medium" 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-7 h-7 flex items-center justify-center rounded-md transition-all duration-200 cursor-pointer hover:scale-105"
|
||||||
|
style="background: rgba(89, 77, 55, 0.1);"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="#594d37" viewBox="0 0 24 24" stroke-width="2">
|
||||||
|
<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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = 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: number | 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";
|
||||||
|
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>
|
<template>
|
||||||
<div class="h-32">
|
<div class="w-full h-40 overflow-hidden">
|
||||||
<canvas ref="chartCanvas"></canvas>
|
<canvas ref="chartCanvas" class="w-full h-full"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
Legend
|
Legend
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
|
|
||||||
// Register Chart.js components
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
LinearScale,
|
LinearScale,
|
||||||
|
|
@ -42,7 +42,7 @@ let chartInstance: ChartJS | null = null;
|
||||||
const createChart = () => {
|
const createChart = () => {
|
||||||
if (!chartCanvas.value) return;
|
if (!chartCanvas.value) return;
|
||||||
|
|
||||||
// Destroy existing chart
|
|
||||||
if (chartInstance) {
|
if (chartInstance) {
|
||||||
chartInstance.destroy();
|
chartInstance.destroy();
|
||||||
}
|
}
|
||||||
|
|
@ -50,23 +50,23 @@ const createChart = () => {
|
||||||
const ctx = chartCanvas.value.getContext('2d');
|
const ctx = chartCanvas.value.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Prepare data for Chart.js
|
|
||||||
const labels = props.data.map(day => day.day_name);
|
const labels = props.data.map(day => day.day_name);
|
||||||
const chartData = props.data.map(day => day.hours);
|
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 => {
|
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})`;
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -79,28 +79,38 @@ const createChart = () => {
|
||||||
backgroundColor: colors,
|
backgroundColor: colors,
|
||||||
borderColor: colors,
|
borderColor: colors,
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderRadius: 4,
|
borderRadius: 6,
|
||||||
borderSkipped: false,
|
borderSkipped: false,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
resizeDelay: 0,
|
||||||
|
layout: {
|
||||||
|
padding: {
|
||||||
|
top: 10,
|
||||||
|
bottom: 28,
|
||||||
|
left: 10,
|
||||||
|
right: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: '#191415',
|
backgroundColor: '#1F1617',
|
||||||
titleColor: '#FFFFFF',
|
titleColor: '#FFFFFF',
|
||||||
bodyColor: '#B0BAC4',
|
bodyColor: '#F5E6E8',
|
||||||
borderColor: '#FB4B20',
|
borderColor: '#2A1F2B',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
cornerRadius: 8,
|
cornerRadius: 8,
|
||||||
displayColors: false,
|
displayColors: false,
|
||||||
|
padding: 10,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(context: any) {
|
label: function(context: any) {
|
||||||
return `${context.parsed.y}h`;
|
return `${context.parsed.y.toFixed(1)}h`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -112,15 +122,39 @@ const createChart = () => {
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#B0BAC4',
|
color: '#F5E6E8',
|
||||||
font: {
|
font: {
|
||||||
size: 10,
|
size: 11,
|
||||||
family: 'Inter, system-ui, sans-serif'
|
family: 'Outfit, system-ui, sans-serif',
|
||||||
}
|
weight: 500
|
||||||
|
},
|
||||||
|
padding: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* CSS Variables for Theme System */
|
/* CSS Variables */
|
||||||
:root {
|
:root {
|
||||||
/* Dark theme (default) */
|
|
||||||
--bg-primary: #2a1f21;
|
--bg-primary: #2a1f21;
|
||||||
--bg-secondary: #3d2b2e;
|
--bg-secondary: #3d2b2e;
|
||||||
--bg-tertiary: #4a2d31;
|
--bg-tertiary: #4a2d31;
|
||||||
|
|
@ -22,27 +21,6 @@
|
||||||
--border-secondary: #4a2d31;
|
--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 */
|
/* Custom scrollbar styles */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|
@ -86,7 +64,7 @@ html, body {
|
||||||
font-optical-sizing: auto;
|
font-optical-sizing: auto;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
background: var(--bg-primary);
|
background: transparent;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: background-color 0.3s ease, color 0.3s ease;
|
transition: background-color 0.3s ease, color 0.3s ease;
|
||||||
|
|
@ -97,6 +75,12 @@ html, body {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom titlebar styles */
|
||||||
|
.custom-titlebar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
/* Typography */
|
/* Typography */
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -173,3 +157,27 @@ a:hover {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,16 +55,115 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fallback when authenticated but no user stats available -->
|
<!-- Authenticated Content -->
|
||||||
<div v-else-if="!userStats" class="mb-8">
|
<div v-else-if="userStats" class="flex flex-col h-full min-h-0">
|
||||||
<div class="bg-bg-secondary p-6 rounded-xl border border-border-primary text-center">
|
<!-- Welcome Header -->
|
||||||
<h4 class="text-text-primary mb-2 text-lg">No Stats Available</h4>
|
<div class="mb-6">
|
||||||
<p class="text-text-secondary">Start coding to see your statistics here!</p>
|
<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;">
|
||||||
|
every hour brings you power.
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import WeeklyChart from '../components/WeeklyChart.vue';
|
||||||
|
import RandomLoader from '../components/RandomLoader.vue';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
is_authenticated: boolean;
|
is_authenticated: boolean;
|
||||||
access_token: string | null;
|
access_token: string | null;
|
||||||
|
|
@ -80,6 +179,12 @@ defineProps<{
|
||||||
apiConfig: ApiConfig;
|
apiConfig: ApiConfig;
|
||||||
userData: any;
|
userData: any;
|
||||||
userStats: any;
|
userStats: any;
|
||||||
|
weeklyChartData: Array<{
|
||||||
|
date: string;
|
||||||
|
day_name: string;
|
||||||
|
hours: number;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isDevMode: boolean;
|
isDevMode: boolean;
|
||||||
directOAuthToken: string;
|
directOAuthToken: string;
|
||||||
|
|
@ -99,6 +204,52 @@ async function handleDirectOAuthAuth() {
|
||||||
emit('handleDirectOAuthAuth');
|
emit('handleDirectOAuthAuth');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</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>
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-72">
|
<div class="flex flex-col h-full min-h-0">
|
||||||
<!-- Loading State -->
|
<!-- Header -->
|
||||||
<div v-if="isLoading" class="flex items-center justify-center h-64">
|
<div class="mb-6">
|
||||||
<div class="flex items-center gap-3">
|
<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;">
|
||||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-primary"></div>
|
projects
|
||||||
<p class="text-text-secondary">Loading projects...</p>
|
</h1>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<RandomLoader v-if="isLoading" />
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="error" class="flex items-center justify-center h-64">
|
<div v-else-if="error" class="flex items-center justify-center h-64">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
|
|
@ -23,8 +28,7 @@
|
||||||
|
|
||||||
<!-- Projects List -->
|
<!-- Projects List -->
|
||||||
<div v-else-if="projects && projects.length > 0" class="space-y-4">
|
<div v-else-if="projects && projects.length > 0" class="space-y-4">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-2">
|
||||||
<h3 class="text-lg font-semibold text-text-primary">Your Projects</h3>
|
|
||||||
<div class="text-sm text-text-secondary">
|
<div class="text-sm text-text-secondary">
|
||||||
{{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }}
|
{{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -34,65 +38,67 @@
|
||||||
<div
|
<div
|
||||||
v-for="project in projects"
|
v-for="project in projects"
|
||||||
:key="project.name"
|
:key="project.name"
|
||||||
class="bg-bg-secondary border border-border-primary rounded-xl p-4 hover:border-accent-primary transition-colors cursor-pointer"
|
class="card-3d"
|
||||||
@click="selectProject(project)"
|
@click="selectProject(project)"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-start mb-3">
|
<div class="rounded-[8px] border-2 border-black p-4 card-3d-front cursor-pointer hover:bg-[#4a3a4b] transition-colors" style="background-color: #3D2C3E;">
|
||||||
<div class="flex-1">
|
<div class="flex justify-between items-start mb-3">
|
||||||
<h4 class="text-text-primary font-medium text-lg mb-1">{{ project.name }}</h4>
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-4 text-sm text-text-secondary">
|
<h4 class="text-text-primary font-medium text-lg mb-1 truncate">{{ project.name }}</h4>
|
||||||
<span>{{ project.total_heartbeats }} heartbeats</span>
|
<div class="flex items-center gap-4 text-sm text-text-secondary flex-wrap">
|
||||||
<span>{{ formatDuration(project.total_seconds) }}</span>
|
<span>{{ project.total_heartbeats }} heartbeats</span>
|
||||||
<span v-if="project.recent_activity_seconds > 0" class="text-accent-primary">
|
<span>{{ formatDuration(project.total_seconds) }}</span>
|
||||||
Active recently
|
<span v-if="project.recent_activity_seconds > 0" class="text-accent-primary">
|
||||||
</span>
|
Active recently
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right flex-shrink-0">
|
||||||
|
<div class="text-lg font-semibold text-accent-primary">
|
||||||
|
{{ (project.total_seconds / 3600).toFixed(1) }}h
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
|
||||||
<div class="text-lg font-semibold text-accent-primary">
|
<!-- Languages and Editors -->
|
||||||
{{ (project.total_seconds / 3600).toFixed(1) }}h
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
</div>
|
<span
|
||||||
|
v-for="language in project.languages.slice(0, 3)"
|
||||||
|
:key="language"
|
||||||
|
class="px-2 py-1 bg-[rgba(50,36,51,0.15)] text-text-primary text-xs rounded-md"
|
||||||
|
>
|
||||||
|
{{ language }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="project.languages.length > 3"
|
||||||
|
class="px-2 py-1 bg-[rgba(50,36,51,0.15)] text-text-secondary text-xs rounded-md"
|
||||||
|
>
|
||||||
|
+{{ project.languages.length - 3 }} more
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Languages and Editors -->
|
<!-- Time Range -->
|
||||||
<div class="flex flex-wrap gap-2 mb-3">
|
<div class="text-xs text-text-secondary">
|
||||||
<span
|
<span v-if="project.first_heartbeat">
|
||||||
v-for="language in project.languages.slice(0, 3)"
|
First: {{ formatDate(project.first_heartbeat) }}
|
||||||
:key="language"
|
</span>
|
||||||
class="px-2 py-1 bg-bg-tertiary text-text-primary text-xs rounded-md"
|
<span v-if="project.last_heartbeat" class="ml-4">
|
||||||
>
|
Last: {{ formatDate(project.last_heartbeat) }}
|
||||||
{{ language }}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<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 -->
|
<!-- Repo Link -->
|
||||||
<div class="text-xs text-text-secondary">
|
<div v-if="project.repo_url" class="mt-2">
|
||||||
<span v-if="project.first_heartbeat">
|
<a
|
||||||
First: {{ formatDate(project.first_heartbeat) }}
|
:href="project.repo_url"
|
||||||
</span>
|
target="_blank"
|
||||||
<span v-if="project.last_heartbeat" class="ml-4">
|
rel="noopener noreferrer"
|
||||||
Last: {{ formatDate(project.last_heartbeat) }}
|
class="text-accent-primary text-sm hover:underline"
|
||||||
</span>
|
@click.stop
|
||||||
</div>
|
>
|
||||||
|
View Repository →
|
||||||
<!-- Repo Link -->
|
</a>
|
||||||
<div v-if="project.repo_url" class="mt-2">
|
</div>
|
||||||
<a
|
|
||||||
:href="project.repo_url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-accent-primary text-sm hover:underline"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
View Repository →
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -113,6 +119,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import RandomLoader from "../components/RandomLoader.vue";
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -140,16 +147,14 @@ const projects = ref<Project[]>([]);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
// Props
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
currentTheme: string;
|
|
||||||
toggleTheme: () => void;
|
|
||||||
apiConfig: {
|
apiConfig: {
|
||||||
base_url: string;
|
base_url: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// Load projects data
|
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
@ -167,13 +172,13 @@ async function loadProjects() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select a project (for future detailed view)
|
|
||||||
function selectProject(project: Project) {
|
function selectProject(project: Project) {
|
||||||
console.log("Selected project:", project.name);
|
console.log("Selected project:", project.name);
|
||||||
// TODO: Implement project details view
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format duration helper
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
if (!seconds || seconds <= 0) return "0m";
|
if (!seconds || seconds <= 0) return "0m";
|
||||||
|
|
||||||
|
|
@ -187,7 +192,7 @@ function formatDuration(seconds: number): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format date helper
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', {
|
||||||
|
|
@ -197,10 +202,32 @@ function formatDate(dateString: string): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load projects on mount
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProjects();
|
loadProjects();
|
||||||
});
|
});
|
||||||
</script>
|
</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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,196 +1,285 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-72">
|
<div class="flex flex-col h-full min-h-0">
|
||||||
<div class="space-y-8">
|
<!-- Header -->
|
||||||
<!-- Theme Settings -->
|
<div class="mb-6">
|
||||||
<div class="space-y-4">
|
<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;">
|
||||||
<h3 class="text-lg font-semibold text-text-primary">Appearance</h3>
|
settings
|
||||||
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
|
</h1>
|
||||||
<div class="flex items-center justify-between">
|
<p class="text-[20px] sm:text-[16px] lg:text-[20px] text-white m-0" style="font-family: 'Outfit', sans-serif;">
|
||||||
<div>
|
tune your experience.
|
||||||
<h4 class="font-medium text-text-primary mb-1">Theme</h4>
|
</p>
|
||||||
<p class="text-sm text-text-secondary">Choose between dark and light mode</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
<!-- Content Grid -->
|
||||||
<span class="text-sm text-text-secondary">{{ currentTheme === 'dark' ? 'Dark' : 'Light' }}</span>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 min-h-0">
|
||||||
<button
|
<!-- Left Column -->
|
||||||
@click="toggleTheme"
|
<div class="flex flex-col gap-6 min-h-0">
|
||||||
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'"
|
<!-- Preferences -->
|
||||||
>
|
<div class="card-3d flex-1 min-h-0">
|
||||||
<span
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front h-full" style="background-color: #3D2C3E;">
|
||||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
<div class="space-y-5">
|
||||||
:class="currentTheme === 'dark' ? 'translate-x-6' : 'translate-x-1'"
|
<div class="flex items-center justify-between">
|
||||||
/>
|
<div>
|
||||||
</button>
|
<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">
|
||||||
|
<input type="checkbox">
|
||||||
|
<span class="slider"></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">
|
||||||
|
<input type="checkbox" checked>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- App Information -->
|
<!-- Right Column -->
|
||||||
<div class="space-y-4">
|
<div class="flex flex-col gap-6 min-h-0">
|
||||||
<h3 class="text-lg font-semibold text-text-primary">About</h3>
|
<!-- About -->
|
||||||
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
|
<div class="card-3d">
|
||||||
<div class="space-y-3">
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
|
||||||
<div class="flex justify-between">
|
<div class="space-y-3">
|
||||||
<span class="text-text-secondary">Version</span>
|
<div class="flex justify-between">
|
||||||
<span class="text-text-primary font-medium">1.0.0</span>
|
<span class="text-text-secondary">Version</span>
|
||||||
</div>
|
<span
|
||||||
<div class="flex justify-between">
|
class="text-text-primary font-medium cursor-pointer select-none"
|
||||||
<span class="text-text-secondary">Build</span>
|
title="Tap 5 times for debug"
|
||||||
<span class="text-text-primary font-medium">Development</span>
|
@click="handleVersionClick"
|
||||||
</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' }}
|
{{ appVersion }}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex justify-between">
|
||||||
<div v-if="updateInfo" class="mt-4 p-4 bg-bg-tertiary rounded-lg border border-border-secondary">
|
<span class="text-text-secondary">Build</span>
|
||||||
<div class="space-y-2">
|
<span class="text-text-primary font-medium">Development</span>
|
||||||
<div class="flex justify-between">
|
</div>
|
||||||
<span class="text-text-secondary text-sm">New Version</span>
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-text-primary text-sm font-medium">{{ updateInfo.version }}</span>
|
<span class="text-text-secondary">Updates</span>
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
<div v-if="updateInfo.notes" class="text-text-secondary text-sm">
|
<span v-if="updateStatus === 'checking'" class="text-text-secondary text-sm">Checking...</span>
|
||||||
<p class="font-medium mb-1">Release Notes:</p>
|
<span v-else-if="updateStatus === 'available'" class="text-accent-primary text-sm">Update available</span>
|
||||||
<p class="whitespace-pre-wrap">{{ updateInfo.notes }}</p>
|
<span v-else-if="updateStatus === 'latest'" class="text-green-500 text-sm">Up to date</span>
|
||||||
</div>
|
<span v-else-if="updateStatus === 'error'" class="text-red-500 text-sm">Check failed</span>
|
||||||
<div class="flex gap-2 mt-3">
|
|
||||||
<button
|
<button
|
||||||
@click="downloadAndInstallUpdate"
|
@click="checkForUpdates"
|
||||||
:disabled="isInstallingUpdate"
|
:disabled="updateStatus === 'checking'"
|
||||||
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"
|
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' }}
|
{{ updateStatus === 'checking' ? 'Checking...' : 'Check for Updates' }}
|
||||||
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="updateInfo" class="mt-4 p-4 rounded-lg border-2 border-black" 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- API Key -->
|
<!-- API Access -->
|
||||||
<div v-if="apiKey" class="space-y-4">
|
<div v-if="apiKey" class="card-3d">
|
||||||
<h3 class="text-lg font-semibold text-text-primary">API Access</h3>
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
|
||||||
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
|
<div class="space-y-4">
|
||||||
<div class="space-y-4">
|
<div>
|
||||||
<div>
|
<h4 class="font-medium text-text-primary mb-2">Your API Key</h4>
|
||||||
<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>
|
||||||
<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">
|
||||||
<div class="flex gap-3 items-center">
|
<input
|
||||||
<input
|
:type="showApiKey ? 'text' : 'password'"
|
||||||
:type="showApiKey ? 'text' : 'password'"
|
:value="apiKey"
|
||||||
:value="apiKey"
|
readonly
|
||||||
readonly
|
class="flex-1 p-3 bg-[rgba(50,36,51,0.15)] border-2 border-[rgba(50,36,51,0.25)] rounded-xl text-text-primary font-mono text-sm min-w-0 break-all"
|
||||||
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 flex-shrink-0">
|
||||||
<div class="flex gap-2">
|
<button @click="$emit('update:showApiKey', !showApiKey)" class="p-3 border-2 border-[rgba(50,36,51,0.25)] rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-[rgba(50,36,51,0.15)] text-text-secondary hover:bg-bg-primary hover:text-text-primary hover:border-accent-primary">
|
||||||
<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">
|
||||||
<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>
|
||||||
<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>
|
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<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="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>
|
||||||
<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>
|
||||||
</svg>
|
</button>
|
||||||
</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">
|
||||||
<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">
|
||||||
<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>
|
||||||
<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>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Preferences -->
|
<!-- WakaTime Configuration -->
|
||||||
<div class="space-y-4">
|
<div class="card-3d">
|
||||||
<h3 class="text-lg font-semibold text-text-primary">Preferences</h3>
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
|
||||||
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-text-primary mb-1">Auto-start</h4>
|
<h4 class="font-medium text-text-primary mb-1">Setup</h4>
|
||||||
<p class="text-sm text-text-secondary">Start with system</p>
|
<p class="text-sm text-text-secondary">Verify and update your .wakatime.cfg file</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
@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"
|
||||||
>
|
>
|
||||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white translate-x-1 transition-transform" />
|
Check Config
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cache Management -->
|
||||||
|
<div class="card-3d">
|
||||||
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-text-primary mb-1">Discord RPC</h4>
|
<h4 class="font-medium text-text-primary mb-1">Statistics Cache</h4>
|
||||||
<p class="text-sm text-text-secondary">Show coding activity in Discord</p>
|
<p class="text-sm text-text-secondary">Clear cached statistics data (stored for 30 days)</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="toggleDiscordRpc"
|
@click="clearCache"
|
||||||
:disabled="isLoading"
|
:disabled="isClearingCache"
|
||||||
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="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"
|
||||||
:class="discordRpcEnabled ? 'bg-accent-primary' : 'bg-border-primary'"
|
|
||||||
>
|
>
|
||||||
<span
|
{{ isClearingCache ? 'Clearing...' : 'Clear Cache' }}
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account -->
|
||||||
|
<div class="card-3d">
|
||||||
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-text-primary mb-1">Notifications</h4>
|
<h4 class="font-medium text-text-primary mb-1">Sign Out</h4>
|
||||||
<p class="text-sm text-text-secondary">Show desktop notifications</p>
|
<p class="text-sm text-text-secondary">Log out of your account</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
@click="$emit('logout')"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
|
||||||
>
|
>
|
||||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white translate-x-6 transition-transform" />
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug Modal -->
|
||||||
|
<div v-if="showDebugModal" class="modal-backdrop" @click="showDebugModal = false">
|
||||||
|
<div class="modal-card-3d" @click.stop>
|
||||||
|
<div class="rounded-[8px] border-2 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>
|
||||||
|
|
||||||
|
<!-- 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)"
|
||||||
|
>
|
||||||
|
{{ 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'"
|
||||||
|
>
|
||||||
|
{{ message.source }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="message-content flex-1 break-words whitespace-pre-wrap"
|
||||||
|
:class="getMessageColor(message.level)"
|
||||||
|
>
|
||||||
|
{{ message.message }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted, watch, nextTick } from "vue";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { check } from '@tauri-apps/plugin-updater';
|
import { check } from '@tauri-apps/plugin-updater';
|
||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
import type { Theme } from '../composables/useTheme'
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
currentTheme: Theme
|
|
||||||
toggleTheme: () => void
|
|
||||||
apiKey: string | null
|
apiKey: string | null
|
||||||
showApiKey: boolean
|
showApiKey: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
@ -198,12 +287,15 @@ defineProps<{
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
copyApiKey: []
|
copyApiKey: []
|
||||||
'update:showApiKey': [value: boolean]
|
'update:showApiKey': [value: boolean]
|
||||||
|
logout: []
|
||||||
|
checkWakatimeConfig: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const discordRpcEnabled = ref(false);
|
const discordRpcEnabled = ref(false);
|
||||||
const isLoading = 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 updateStatus = ref<'idle' | 'checking' | 'available' | 'latest' | 'error'>('idle');
|
||||||
const updateInfo = ref<{
|
const updateInfo = ref<{
|
||||||
version: string;
|
version: string;
|
||||||
|
|
@ -212,6 +304,252 @@ const updateInfo = ref<{
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const isInstallingUpdate = ref(false);
|
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() {
|
async function loadDiscordRpcState() {
|
||||||
try {
|
try {
|
||||||
discordRpcEnabled.value = await invoke("get_discord_rpc_enabled");
|
discordRpcEnabled.value = await invoke("get_discord_rpc_enabled");
|
||||||
|
|
@ -230,7 +568,7 @@ async function toggleDiscordRpc() {
|
||||||
discordRpcEnabled.value = newState;
|
discordRpcEnabled.value = newState;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to toggle Discord RPC:", error);
|
console.error("Failed to toggle Discord RPC:", error);
|
||||||
// Revert the UI state on error
|
|
||||||
discordRpcEnabled.value = !discordRpcEnabled.value;
|
discordRpcEnabled.value = !discordRpcEnabled.value;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
|
@ -241,7 +579,27 @@ function copyApiKey() {
|
||||||
emit('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() {
|
async function checkForUpdates() {
|
||||||
if (updateStatus.value === 'checking') return;
|
if (updateStatus.value === 'checking') return;
|
||||||
|
|
||||||
|
|
@ -303,10 +661,152 @@ async function downloadAndInstallUpdate() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Discord RPC state on mount
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadDiscordRpcState();
|
loadDiscordRpcState();
|
||||||
|
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>
|
</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>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="flex flex-col h-full min-h-0">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<!-- Header styling aligned to Home.vue -->
|
||||||
<h1 class="text-2xl font-bold text-text-primary">Statistics</h1>
|
<div class="mb-6">
|
||||||
<div class="text-sm text-text-secondary">Your coding insights and trends</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Statistics Dashboard -->
|
<!-- Statistics Dashboard in 3D card style -->
|
||||||
<StatisticsDashboard :apiConfig="apiConfig" />
|
<div class="card-3d card-3d-stats">
|
||||||
|
<div class="rounded-[8px] border-2 border-black p-6 card-3d-front" style="background-color: #3D2C3E;">
|
||||||
|
<StatisticsDashboard :apiConfig="apiConfig" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -21,3 +30,31 @@ defineProps<{
|
||||||
apiConfig: ApiConfig;
|
apiConfig: ApiConfig;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</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>
|
||||||
|
|
|
||||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare module '*.vue' {
|
declare module '*.vue' {
|
||||||
import type { DefineComponent } from 'vue'
|
import type { DefineComponent } from 'vue'
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ export default {
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
// Dark theme colors
|
|
||||||
'dark': {
|
'theme': {
|
||||||
'sidebar-bg': '#2a1f21',
|
'sidebar-bg': '#2a1f21',
|
||||||
'sidebar-text': '#f5e6e8',
|
'sidebar-text': '#f5e6e8',
|
||||||
'sidebar-unread-text': '#ffffff',
|
'sidebar-unread-text': '#ffffff',
|
||||||
|
|
@ -34,33 +33,7 @@ export default {
|
||||||
'mention-highlight-bg': '#4a2d31',
|
'mention-highlight-bg': '#4a2d31',
|
||||||
'mention-highlight-link': '#ff9aaa',
|
'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-primary': 'var(--bg-primary)',
|
||||||
'bg-secondary': 'var(--bg-secondary)',
|
'bg-secondary': 'var(--bg-secondary)',
|
||||||
'bg-tertiary': 'var(--bg-tertiary)',
|
'bg-tertiary': 'var(--bg-tertiary)',
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@ import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [vue(), tailwindcss()],
|
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
|
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
// 2. tauri expects a fixed port, fail if that port is not available
|
|
||||||
server: {
|
server: {
|
||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|
@ -26,7 +26,7 @@ export default defineConfig(async () => ({
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
// 3. tell Vite to ignore watching `src-tauri`
|
|
||||||
ignored: ["**/src-tauri/**"],
|
ignored: ["**/src-tauri/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue