mirror of
https://github.com/System-End/hackatime-desktop.git
synced 2026-04-19 15:18:22 +00:00
feat: big bang
This commit is contained in:
commit
ed294cb662
58 changed files with 13692 additions and 0 deletions
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
*.svg filter=lfs diff=lfs merge=lfs -text
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
1
README.md
Normal file
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# hackatime-desktop
|
||||
17
index.html
Normal file
17
index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hackatime</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.3",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"chart.js": "^4.5.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
1488
pnpm-lock.yaml
generated
Normal file
1488
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
BIN
public/flame-icon.svg
(Stored with Git LFS)
Normal file
BIN
public/flame-icon.svg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/tauri.svg
(Stored with Git LFS)
Normal file
BIN
public/tauri.svg
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
public/vite.svg
(Stored with Git LFS)
Normal file
BIN
public/vite.svg
(Stored with Git LFS)
Normal file
Binary file not shown.
7
src-tauri/.gitignore
vendored
Normal file
7
src-tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
6526
src-tauri/Cargo.lock
generated
Normal file
6526
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
src-tauri/Cargo.toml
Normal file
34
src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[package]
|
||||
name = "desktop"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
open = "5"
|
||||
urlencoding = "2"
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
discord-rich-presence = "1.0"
|
||||
|
||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
11
src-tauri/capabilities/default.json
Normal file
11
src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"deep-link:default"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/128x128.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/128x128@2x.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/128x128@2x.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/32x32.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/32x32.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square107x107Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square107x107Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square142x142Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square142x142Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square150x150Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square150x150Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square284x284Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square284x284Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square30x30Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square30x30Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square310x310Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square310x310Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square44x44Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square44x44Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square71x71Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square71x71Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/Square89x89Logo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/Square89x89Logo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/StoreLogo.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/StoreLogo.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.icns
Normal file
Binary file not shown.
BIN
src-tauri/icons/icon.ico
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
(Stored with Git LFS)
Normal file
BIN
src-tauri/icons/icon.png
(Stored with Git LFS)
Normal file
Binary file not shown.
243
src-tauri/programmer_classes.json
Normal file
243
src-tauri/programmer_classes.json
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
{
|
||||
"classes": [
|
||||
{
|
||||
"name": "The Rustacean",
|
||||
"description": "A fearless systems programmer who embraces memory safety and zero-cost abstractions.",
|
||||
"level": "Legendary",
|
||||
"color": "#CE422B",
|
||||
"technologies": ["Rust", "Cargo", "Tokio", "Serde"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Rust"],
|
||||
"min_hours": 50,
|
||||
"min_streak": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Pythonista",
|
||||
"description": "A versatile developer who believes in the beauty of clean, readable code.",
|
||||
"level": "Expert",
|
||||
"color": "#3776AB",
|
||||
"technologies": ["Python", "Django", "Flask", "Pandas", "NumPy"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Python"],
|
||||
"min_hours": 30,
|
||||
"min_streak": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The JavaScript Ninja",
|
||||
"description": "A master of the web who can make anything dance in the browser.",
|
||||
"level": "Advanced",
|
||||
"color": "#F7DF1E",
|
||||
"technologies": ["JavaScript", "Node.js", "React", "Vue.js", "TypeScript"],
|
||||
"conditions": {
|
||||
"primary_languages": ["JavaScript", "TypeScript"],
|
||||
"min_hours": 20,
|
||||
"min_streak": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Go Gopher",
|
||||
"description": "A pragmatic developer who values simplicity and concurrency.",
|
||||
"level": "Advanced",
|
||||
"color": "#00ADD8",
|
||||
"technologies": ["Go", "Goroutines", "Channels", "Gin", "Echo"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Go"],
|
||||
"min_hours": 25,
|
||||
"min_streak": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The C++ Wizard",
|
||||
"description": "A master of performance who wields templates and memory management like a sorcerer.",
|
||||
"level": "Expert",
|
||||
"color": "#00599C",
|
||||
"technologies": ["C++", "STL", "Boost", "CMake", "Conan"],
|
||||
"conditions": {
|
||||
"primary_languages": ["C++", "C"],
|
||||
"min_hours": 40,
|
||||
"min_streak": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Java Architect",
|
||||
"description": "A enterprise developer who builds robust, scalable systems.",
|
||||
"level": "Expert",
|
||||
"color": "#ED8B00",
|
||||
"technologies": ["Java", "Spring", "Maven", "JUnit", "Hibernate"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Java"],
|
||||
"min_hours": 35,
|
||||
"min_streak": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Swift Enthusiast",
|
||||
"description": "An iOS developer who crafts beautiful, performant mobile experiences.",
|
||||
"level": "Advanced",
|
||||
"color": "#FA7343",
|
||||
"technologies": ["Swift", "SwiftUI", "UIKit", "Combine", "Core Data"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Swift"],
|
||||
"min_hours": 20,
|
||||
"min_streak": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Kotlin Craftsman",
|
||||
"description": "A modern Android developer who embraces null safety and functional programming.",
|
||||
"level": "Advanced",
|
||||
"color": "#7F52FF",
|
||||
"technologies": ["Kotlin", "Android", "Jetpack Compose", "Coroutines", "Ktor"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Kotlin"],
|
||||
"min_hours": 25,
|
||||
"min_streak": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Ruby Gem",
|
||||
"description": "A developer who values developer happiness and elegant syntax.",
|
||||
"level": "Intermediate",
|
||||
"color": "#CC342D",
|
||||
"technologies": ["Ruby", "Rails", "RSpec", "Bundler", "Sinatra"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Ruby"],
|
||||
"min_hours": 15,
|
||||
"min_streak": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The PHP Artisan",
|
||||
"description": "A web developer who powers the internet with dynamic content.",
|
||||
"level": "Intermediate",
|
||||
"color": "#777BB4",
|
||||
"technologies": ["PHP", "Laravel", "Composer", "Symfony", "WordPress"],
|
||||
"conditions": {
|
||||
"primary_languages": ["PHP"],
|
||||
"min_hours": 15,
|
||||
"min_streak": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The C# Sharp",
|
||||
"description": "A Microsoft ecosystem developer who builds enterprise solutions.",
|
||||
"level": "Advanced",
|
||||
"color": "#239120",
|
||||
"technologies": ["C#", ".NET", "ASP.NET", "Entity Framework", "Xamarin"],
|
||||
"conditions": {
|
||||
"primary_languages": ["C#"],
|
||||
"min_hours": 25,
|
||||
"min_streak": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Scala Scholar",
|
||||
"description": "A functional programming enthusiast who combines OOP and FP paradigms.",
|
||||
"level": "Expert",
|
||||
"color": "#DC322F",
|
||||
"technologies": ["Scala", "Akka", "Play Framework", "SBT", "Cats"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Scala"],
|
||||
"min_hours": 30,
|
||||
"min_streak": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Haskell Wizard",
|
||||
"description": "A pure functional programmer who thinks in types and monads.",
|
||||
"level": "Legendary",
|
||||
"color": "#5D4F85",
|
||||
"technologies": ["Haskell", "GHC", "Stack", "Cabal", "Lens"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Haskell"],
|
||||
"min_hours": 20,
|
||||
"min_streak": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Clojure Conjurer",
|
||||
"description": "A Lisp enthusiast who embraces immutability and functional programming.",
|
||||
"level": "Advanced",
|
||||
"color": "#5881D8",
|
||||
"technologies": ["Clojure", "ClojureScript", "Leiningen", "Ring", "Reagent"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Clojure"],
|
||||
"min_hours": 15,
|
||||
"min_streak": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Elixir Alchemist",
|
||||
"description": "A concurrent programmer who builds fault-tolerant distributed systems.",
|
||||
"level": "Advanced",
|
||||
"color": "#4B275F",
|
||||
"technologies": ["Elixir", "Phoenix", "OTP", "Mix", "Ecto"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Elixir"],
|
||||
"min_hours": 20,
|
||||
"min_streak": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The R Researcher",
|
||||
"description": "A data scientist who uncovers insights from complex datasets.",
|
||||
"level": "Expert",
|
||||
"color": "#276DC3",
|
||||
"technologies": ["R", "Shiny", "ggplot2", "dplyr", "RStudio"],
|
||||
"conditions": {
|
||||
"primary_languages": ["R"],
|
||||
"min_hours": 25,
|
||||
"min_streak": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Julia Scientist",
|
||||
"description": "A numerical computing expert who combines performance with ease of use.",
|
||||
"level": "Advanced",
|
||||
"color": "#9558B2",
|
||||
"technologies": ["Julia", "Plots.jl", "DataFrames.jl", "Flux.jl", "Genie.jl"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Julia"],
|
||||
"min_hours": 15,
|
||||
"min_streak": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Assembly Artisan",
|
||||
"description": "A low-level programming master who understands the machine at its core.",
|
||||
"level": "Legendary",
|
||||
"color": "#6E4C13",
|
||||
"technologies": ["Assembly", "x86", "ARM", "MASM", "NASM"],
|
||||
"conditions": {
|
||||
"primary_languages": ["Assembly"],
|
||||
"min_hours": 10,
|
||||
"min_streak": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Polyglot",
|
||||
"description": "A versatile developer who speaks many programming languages fluently.",
|
||||
"level": "Expert",
|
||||
"color": "#FF6B6B",
|
||||
"technologies": ["Multiple Languages", "Cross-platform", "Microservices", "DevOps"],
|
||||
"conditions": {
|
||||
"language_count": 5,
|
||||
"min_hours": 50,
|
||||
"min_streak": 7
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "The Code Explorer",
|
||||
"description": "An enthusiastic learner discovering the vast world of programming.",
|
||||
"level": "Learning",
|
||||
"color": "#9C27B0",
|
||||
"technologies": ["HTML", "CSS", "JavaScript", "Git", "VS Code"],
|
||||
"conditions": {
|
||||
"min_hours": 0,
|
||||
"max_hours": 20
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
349
src-tauri/src/database.rs
Normal file
349
src-tauri/src/database.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{sqlite::SqlitePool, Row};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use std::env;
|
||||
use chrono::{DateTime, Utc};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[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)]
|
||||
#[allow(dead_code)]
|
||||
pub struct SessionRecord {
|
||||
pub id: String,
|
||||
pub is_authenticated: bool,
|
||||
pub access_token: Option<String>,
|
||||
pub user_info: Option<String>, // JSON string
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_accessed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub struct Database {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn new() -> Result<Self, String> {
|
||||
let db_path = get_hackatime_db_path()?;
|
||||
|
||||
// Ensure the hackatime directory exists
|
||||
if let Some(parent) = db_path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create hackatime directory: {}", e))?;
|
||||
println!("Created directory: {}", parent.display());
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the parent directory exists and is writable
|
||||
if let Some(parent) = db_path.parent() {
|
||||
if !parent.exists() {
|
||||
return Err(format!("Parent directory does not exist: {}", parent.display()));
|
||||
}
|
||||
|
||||
// Test if we can write to the directory
|
||||
let test_file = parent.join(".write_test");
|
||||
if let Err(e) = fs::write(&test_file, "test") {
|
||||
return Err(format!("Cannot write to directory {}: {}", parent.display(), e));
|
||||
}
|
||||
// Clean up test file
|
||||
let _ = fs::remove_file(&test_file);
|
||||
|
||||
println!("Directory is writable: {}", parent.display());
|
||||
}
|
||||
|
||||
// Create the database file if it doesn't exist
|
||||
if !db_path.exists() {
|
||||
println!("Database file doesn't exist, creating: {}", db_path.display());
|
||||
// Touch the file to ensure it exists
|
||||
if let Err(e) = fs::write(&db_path, "") {
|
||||
return Err(format!("Cannot create database file {}: {}", db_path.display(), e));
|
||||
}
|
||||
} else {
|
||||
println!("Database file already exists: {}", db_path.display());
|
||||
}
|
||||
|
||||
// Check file permissions
|
||||
if let Ok(metadata) = fs::metadata(&db_path) {
|
||||
println!("Database file metadata: {:?}", metadata);
|
||||
}
|
||||
|
||||
// Try using SqlitePool::connect_with instead of connect
|
||||
let database_url = format!("sqlite:{}", db_path.display());
|
||||
println!("Connecting to database at: {}", database_url);
|
||||
|
||||
// First try the standard connect method
|
||||
let pool_result = SqlitePool::connect(&database_url).await;
|
||||
|
||||
let pool = match pool_result {
|
||||
Ok(pool) => pool,
|
||||
Err(e) => {
|
||||
println!("Standard connect failed: {}, trying connect_with", e);
|
||||
// Try with explicit options
|
||||
let options = sqlx::sqlite::SqliteConnectOptions::new()
|
||||
.filename(&db_path)
|
||||
.create_if_missing(true);
|
||||
|
||||
SqlitePool::connect_with(options)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to database at {} with connect_with: {}", db_path.display(), e))?
|
||||
}
|
||||
};
|
||||
|
||||
let db = Database { pool };
|
||||
db.migrate().await?;
|
||||
|
||||
println!("Database initialized successfully");
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn migrate(&self) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
is_authenticated INTEGER NOT NULL DEFAULT 0,
|
||||
access_token TEXT,
|
||||
user_info TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_accessed_at TEXT NOT NULL
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create sessions table: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_session(&self, auth_state: &AuthState) -> Result<String, String> {
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now();
|
||||
|
||||
let user_info_json = match &auth_state.user_info {
|
||||
Some(info) => Some(serde_json::to_string(info)
|
||||
.map_err(|e| format!("Failed to serialize user info: {}", e))?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO sessions (id, is_authenticated, access_token, user_info, created_at, updated_at, last_accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(&session_id)
|
||||
.bind(auth_state.is_authenticated as i32)
|
||||
.bind(&auth_state.access_token)
|
||||
.bind(&user_info_json)
|
||||
.bind(now.to_rfc3339())
|
||||
.bind(now.to_rfc3339())
|
||||
.bind(now.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save session: {}", e))?;
|
||||
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn update_session(&self, session_id: &str, auth_state: &AuthState) -> Result<(), String> {
|
||||
let now = Utc::now();
|
||||
|
||||
let user_info_json = match &auth_state.user_info {
|
||||
Some(info) => Some(serde_json::to_string(info)
|
||||
.map_err(|e| format!("Failed to serialize user info: {}", e))?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE sessions
|
||||
SET is_authenticated = ?, access_token = ?, user_info = ?, updated_at = ?, last_accessed_at = ?
|
||||
WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(auth_state.is_authenticated as i32)
|
||||
.bind(&auth_state.access_token)
|
||||
.bind(&user_info_json)
|
||||
.bind(now.to_rfc3339())
|
||||
.bind(now.to_rfc3339())
|
||||
.bind(session_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update session: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_latest_session(&self) -> Result<Option<AuthState>, String> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, is_authenticated, access_token, user_info, last_accessed_at
|
||||
FROM sessions
|
||||
ORDER BY last_accessed_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load latest session: {}", e))?;
|
||||
|
||||
match row {
|
||||
Some(row) => {
|
||||
let session_id: String = row.get("id");
|
||||
let is_authenticated: i32 = row.get("is_authenticated");
|
||||
let access_token: Option<String> = row.get("access_token");
|
||||
let user_info_json: Option<String> = row.get("user_info");
|
||||
|
||||
let user_info = match user_info_json {
|
||||
Some(json) => {
|
||||
match serde_json::from_str::<HashMap<String, serde_json::Value>>(&json) {
|
||||
Ok(info) => Some(info),
|
||||
Err(_) => None, // Skip invalid JSON
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Update last_accessed_at
|
||||
self.update_last_accessed(&session_id).await?;
|
||||
|
||||
Ok(Some(AuthState {
|
||||
is_authenticated: is_authenticated != 0,
|
||||
access_token,
|
||||
user_info,
|
||||
}))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_last_accessed(&self, session_id: &str) -> Result<(), String> {
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE sessions SET last_accessed_at = ? WHERE id = ?"
|
||||
)
|
||||
.bind(now.to_rfc3339())
|
||||
.bind(session_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update last accessed time: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_sessions(&self) -> Result<(), String> {
|
||||
sqlx::query("DELETE FROM sessions")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to clear sessions: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cleanup_old_sessions(&self, days_old: i64) -> Result<(), String> {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(days_old);
|
||||
|
||||
sqlx::query("DELETE FROM sessions WHERE last_accessed_at < ?")
|
||||
.bind(cutoff.to_rfc3339())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to cleanup old sessions: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_hackatime_db_path() -> Result<std::path::PathBuf, String> {
|
||||
let app_data_dir = get_app_data_dir()?;
|
||||
let db_path = app_data_dir.join("sessions.db");
|
||||
|
||||
println!("Database path: {}", db_path.display());
|
||||
println!("Parent directory exists: {}", db_path.parent().map_or(false, |p| p.exists()));
|
||||
|
||||
Ok(db_path)
|
||||
}
|
||||
|
||||
fn get_app_data_dir() -> Result<std::path::PathBuf, String> {
|
||||
if cfg!(target_os = "windows") {
|
||||
// Windows: %APPDATA%\.hackatime\
|
||||
let appdata = env::var("APPDATA").map_err(|_| "Failed to get APPDATA directory")?;
|
||||
Ok(Path::new(&appdata).join(".hackatime"))
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// macOS: ~/Library/Application Support/.hackatime/
|
||||
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
|
||||
Ok(Path::new(&home).join("Library").join("Application Support").join(".hackatime"))
|
||||
} else {
|
||||
// Linux: ~/.local/share/.hackatime/
|
||||
let home = env::var("HOME").map_err(|_| "Failed to get HOME directory")?;
|
||||
Ok(Path::new(&home).join(".local").join("share").join(".hackatime"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_hackatime_config_dir() -> Result<std::path::PathBuf, String> {
|
||||
let app_data_dir = get_app_data_dir()?;
|
||||
|
||||
// Create the directory if it doesn't exist
|
||||
if !app_data_dir.exists() {
|
||||
fs::create_dir_all(&app_data_dir)
|
||||
.map_err(|e| format!("Failed to create hackatime directory: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(app_data_dir)
|
||||
}
|
||||
|
||||
pub fn get_hackatime_logs_dir() -> Result<std::path::PathBuf, String> {
|
||||
let config_dir = get_hackatime_config_dir()?;
|
||||
let logs_dir = config_dir.join("logs");
|
||||
|
||||
// Create the logs directory if it doesn't exist
|
||||
if !logs_dir.exists() {
|
||||
fs::create_dir_all(&logs_dir)
|
||||
.map_err(|e| format!("Failed to create logs directory: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(logs_dir)
|
||||
}
|
||||
|
||||
pub fn get_hackatime_data_dir() -> Result<std::path::PathBuf, String> {
|
||||
let config_dir = get_hackatime_config_dir()?;
|
||||
let data_dir = config_dir.join("data");
|
||||
|
||||
// Create the data directory if it doesn't exist
|
||||
if !data_dir.exists() {
|
||||
fs::create_dir_all(&data_dir)
|
||||
.map_err(|e| format!("Failed to create data directory: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(data_dir)
|
||||
}
|
||||
|
||||
pub fn get_platform_info() -> Result<serde_json::Value, String> {
|
||||
let app_data_dir = get_app_data_dir()?;
|
||||
|
||||
let platform_info = serde_json::json!({
|
||||
"platform": std::env::consts::OS,
|
||||
"app_data_dir": app_data_dir.to_string_lossy(),
|
||||
"description": if cfg!(target_os = "windows") {
|
||||
"Windows AppData directory"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macOS Application Support directory"
|
||||
} else {
|
||||
"Linux XDG data directory"
|
||||
}
|
||||
});
|
||||
|
||||
Ok(platform_info)
|
||||
}
|
||||
194
src-tauri/src/discord_rpc.rs
Normal file
194
src-tauri/src/discord_rpc.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DiscordRpcState {
|
||||
pub is_connected: bool,
|
||||
pub client_id: Option<String>,
|
||||
pub current_activity: Option<DiscordActivity>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DiscordActivity {
|
||||
pub project_name: String,
|
||||
pub language: Option<String>,
|
||||
pub editor: Option<String>,
|
||||
pub entity: Option<String>,
|
||||
pub start_time: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct DiscordRpcService {
|
||||
client: Option<DiscordIpcClient>,
|
||||
state: Arc<Mutex<DiscordRpcState>>,
|
||||
}
|
||||
|
||||
impl DiscordRpcService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: None,
|
||||
state: Arc::new(Mutex::new(DiscordRpcState {
|
||||
is_connected: false,
|
||||
client_id: None,
|
||||
current_activity: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connect(&mut self, client_id: &str) -> Result<(), String> {
|
||||
// Close existing connection if any
|
||||
if self.client.is_some() {
|
||||
let _ = self.disconnect();
|
||||
}
|
||||
|
||||
let mut client = DiscordIpcClient::new(client_id);
|
||||
|
||||
client.connect()
|
||||
.map_err(|e| format!("Failed to connect to Discord: {}", e))?;
|
||||
|
||||
self.client = Some(client);
|
||||
|
||||
// Update state
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.is_connected = true;
|
||||
state.client_id = Some(client_id.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn disconnect(&mut self) -> Result<(), String> {
|
||||
if let Some(mut client) = self.client.take() {
|
||||
client.close()
|
||||
.map_err(|e| format!("Failed to disconnect from Discord: {}", e))?;
|
||||
}
|
||||
|
||||
// Update state
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.is_connected = false;
|
||||
state.client_id = None;
|
||||
state.current_activity = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_activity(&mut self, activity: DiscordActivity) -> Result<(), String> {
|
||||
self.set_activity_internal(activity.clone())?;
|
||||
|
||||
// Update state
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.current_activity = Some(activity);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_activity_internal(&mut self, activity: DiscordActivity) -> Result<(), String> {
|
||||
let client = self.client.as_mut()
|
||||
.ok_or("Discord client not connected")?;
|
||||
|
||||
// Build details string
|
||||
let mut details_parts = Vec::new();
|
||||
|
||||
if let Some(language) = &activity.language {
|
||||
details_parts.push(format!("Language: {}", language));
|
||||
}
|
||||
|
||||
if let Some(editor) = &activity.editor {
|
||||
details_parts.push(format!("Editor: {}", editor));
|
||||
}
|
||||
|
||||
if let Some(entity) = &activity.entity {
|
||||
let filename = entity.split('/').last().unwrap_or(entity);
|
||||
details_parts.push(format!("File: {}", filename));
|
||||
}
|
||||
|
||||
let details_string = if !details_parts.is_empty() {
|
||||
Some(details_parts.join(" • "))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create activity with all components
|
||||
let mut discord_activity = activity::Activity::new()
|
||||
.state(&activity.project_name);
|
||||
|
||||
if let Some(details) = &details_string {
|
||||
discord_activity = discord_activity.details(details);
|
||||
}
|
||||
|
||||
// Set start time if provided
|
||||
if let Some(start_time) = activity.start_time {
|
||||
discord_activity = discord_activity.timestamps(activity::Timestamps::new().start(start_time));
|
||||
}
|
||||
|
||||
// Add assets
|
||||
discord_activity = discord_activity
|
||||
.assets(activity::Assets::new()
|
||||
.large_image("kubetime")
|
||||
.large_text("KubeTime - Time Tracking")
|
||||
.small_image("coding")
|
||||
.small_text("Coding"));
|
||||
|
||||
client.set_activity(discord_activity)
|
||||
.map_err(|e| format!("Failed to set Discord activity: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_activity(&mut self) -> Result<(), String> {
|
||||
let client = self.client.as_mut()
|
||||
.ok_or("Discord client not connected")?;
|
||||
|
||||
client.clear_activity()
|
||||
.map_err(|e| format!("Failed to clear Discord activity: {}", e))?;
|
||||
|
||||
// Update state
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.current_activity = None;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_state(&self) -> DiscordRpcState {
|
||||
self.state.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.state.lock().unwrap().is_connected
|
||||
}
|
||||
|
||||
pub fn update_activity_from_heartbeat(&mut self, heartbeat_data: &crate::HeartbeatData) -> Result<(), String> {
|
||||
let activity = DiscordActivity {
|
||||
project_name: heartbeat_data.project.clone().unwrap_or_else(|| "Unknown Project".to_string()),
|
||||
language: heartbeat_data.language.clone(),
|
||||
editor: heartbeat_data.editor.clone(),
|
||||
entity: heartbeat_data.entity.clone(),
|
||||
start_time: Some(heartbeat_data.timestamp as i64),
|
||||
};
|
||||
|
||||
self.set_activity(activity)
|
||||
}
|
||||
|
||||
pub fn update_activity_from_session(&mut self, heartbeat_data: &crate::HeartbeatData, session_start_time: i64) -> Result<(), String> {
|
||||
let activity = DiscordActivity {
|
||||
project_name: heartbeat_data.project.clone().unwrap_or_else(|| "Unknown Project".to_string()),
|
||||
language: heartbeat_data.language.clone(),
|
||||
editor: heartbeat_data.editor.clone(),
|
||||
entity: heartbeat_data.entity.clone(),
|
||||
start_time: Some(session_start_time),
|
||||
};
|
||||
|
||||
self.set_activity(activity)
|
||||
}
|
||||
|
||||
pub fn auto_connect(&mut self) -> Result<(), String> {
|
||||
const DEFAULT_CLIENT_ID: &str = "1423077619183779872";
|
||||
self.connect(DEFAULT_CLIENT_ID)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DiscordRpcService {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.disconnect();
|
||||
}
|
||||
}
|
||||
2176
src-tauri/src/lib.rs
Normal file
2176
src-tauri/src/lib.rs
Normal file
File diff suppressed because it is too large
Load diff
6
src-tauri/src/main.rs
Normal file
6
src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
desktop_lib::run()
|
||||
}
|
||||
51
src-tauri/tauri.conf.json
Normal file
51
src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hackatime Desktop",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.hackclub.hackatime",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Hackatime Desktop",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"visible": true,
|
||||
"skipTaskbar": false,
|
||||
"decorations": true,
|
||||
"alwaysOnTop": false,
|
||||
"resizable": true,
|
||||
"minimizable": true,
|
||||
"maximizable": true,
|
||||
"closable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
},
|
||||
"deepLink": {
|
||||
"schemes": ["kubetime"]
|
||||
}
|
||||
}
|
||||
}
|
||||
577
src/App.vue
Normal file
577
src/App.vue
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||
import { api } from "./api";
|
||||
import { useTheme } from "./composables/useTheme";
|
||||
import Home from "./views/Home.vue";
|
||||
import Projects from "./views/Projects.vue";
|
||||
import Settings from "./views/Settings.vue";
|
||||
import Statistics from "./views/Statistics.vue";
|
||||
import PresenceCard from "./components/PresenceCard.vue";
|
||||
import TrendCard from "./components/TrendCard.vue";
|
||||
import WeeklyChart from "./components/WeeklyChart.vue";
|
||||
|
||||
interface AuthState {
|
||||
is_authenticated: boolean;
|
||||
access_token: string | null;
|
||||
user_info: Record<string, any> | null;
|
||||
}
|
||||
|
||||
interface ApiConfig {
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
const authState = ref<AuthState>({
|
||||
is_authenticated: false,
|
||||
access_token: null,
|
||||
user_info: null,
|
||||
});
|
||||
|
||||
const apiConfig = ref<ApiConfig>({
|
||||
base_url: "http://localhost:3000",
|
||||
});
|
||||
|
||||
const isConfigOpen = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const userData = ref<any>(null);
|
||||
const userStats = ref<any>(null);
|
||||
const isDevMode = ref(false);
|
||||
const directOAuthToken = ref("");
|
||||
const apiKey = ref<string | null>(null);
|
||||
const showApiKey = ref(false);
|
||||
const hackatimeDirectories = ref<any>(null);
|
||||
const sessionStats = ref<any>(null);
|
||||
const presenceData = ref<any>(null);
|
||||
const presenceRefreshInterval = ref<number | null>(null);
|
||||
const presenceFetchInProgress = ref(false);
|
||||
const nextPresenceFetchAllowedAt = ref<number>(0);
|
||||
const lastPresenceFetchAt = ref<number>(0);
|
||||
|
||||
// Navigation state
|
||||
const currentPage = ref<'home' | 'projects' | 'statistics' | 'settings'>('home');
|
||||
|
||||
// Theme management
|
||||
const { currentTheme, toggleTheme } = useTheme();
|
||||
|
||||
// Computed property for weekly chart data
|
||||
const weeklyChartData = computed(() => {
|
||||
if (!userStats.value?.weekly_stats?.daily_hours) return [];
|
||||
|
||||
const dailyHours = userStats.value.weekly_stats.daily_hours;
|
||||
const maxHours = Math.max(...Object.values(dailyHours).map((day: any) => day.hours), 1);
|
||||
|
||||
// Convert object to array and sort by date
|
||||
return Object.values(dailyHours)
|
||||
.sort((a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
.map((day: any) => ({
|
||||
...day,
|
||||
percentage: (day.hours / maxHours) * 100
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed property for trend data
|
||||
const weeklyTrend = computed(() => {
|
||||
if (!userStats.value?.weekly_stats) return null;
|
||||
|
||||
const currentWeekHours = (userStats.value.weekly_stats.time_coded_seconds || 0) / 3600;
|
||||
const lastWeekHours = currentWeekHours * 0.85; // Simulate 15% increase
|
||||
const change = ((currentWeekHours - lastWeekHours) / lastWeekHours * 100);
|
||||
|
||||
return {
|
||||
title: change > 0 ? "You coded more than last week" : change < 0 ? "You coded less than last week" : "Same as last week",
|
||||
change: change > 0 ? `+${Math.round(change)}%` : `${Math.round(change)}%`,
|
||||
changeType: change > 0 ? 'increase' : change < 0 ? 'decrease' : 'neutral',
|
||||
period: "vs last week",
|
||||
icon: change > 0 ? "📈" : change < 0 ? "📉" : "➡️"
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAuthState();
|
||||
await loadApiConfig();
|
||||
await loadHackatimeInfo();
|
||||
|
||||
// Detect if we're in development mode
|
||||
// Check if we're running on localhost (development) or have debug features
|
||||
isDevMode.value = apiConfig.value.base_url.includes('localhost') ||
|
||||
apiConfig.value.base_url.includes('127.0.0.1') ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1';
|
||||
|
||||
// Check if app was started via deep link
|
||||
try {
|
||||
const startUrls = await getCurrent();
|
||||
if (startUrls && startUrls.length > 0) {
|
||||
console.log("App started with deep link:", startUrls);
|
||||
// The Rust backend will handle the deep link processing
|
||||
// We just need to refresh the auth state after processing
|
||||
setTimeout(async () => {
|
||||
await loadAuthState();
|
||||
}, 1000); // Give the backend time to process the deep link
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get current deep link:", error);
|
||||
}
|
||||
|
||||
// Listen for deep link events when app is already running
|
||||
try {
|
||||
await onOpenUrl((urls) => {
|
||||
console.log("Deep link received in frontend:", urls);
|
||||
// The Rust backend handles the actual processing
|
||||
// We just need to refresh the auth state
|
||||
setTimeout(async () => {
|
||||
await loadAuthState();
|
||||
}, 1000); // Give the backend time to process the deep link
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to set up deep link listener:", error);
|
||||
}
|
||||
|
||||
// Listen for window focus events to refresh auth state after popup closes
|
||||
window.addEventListener('focus', async () => {
|
||||
await loadAuthState();
|
||||
});
|
||||
|
||||
// Also listen for visibility change (when tab becomes active)
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (!document.hidden) {
|
||||
await loadAuthState();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
stopPresenceRefresh();
|
||||
});
|
||||
|
||||
async function loadAuthState() {
|
||||
try {
|
||||
console.log("Loading authentication state...");
|
||||
const savedAuthState = await invoke("load_auth_state");
|
||||
console.log("Saved auth state result:", savedAuthState);
|
||||
|
||||
if (savedAuthState && (savedAuthState as AuthState).is_authenticated) {
|
||||
authState.value = savedAuthState as AuthState;
|
||||
console.log("Loaded saved authentication state:", authState.value);
|
||||
|
||||
// If authenticated, load user data, stats, and API keys
|
||||
await loadUserData();
|
||||
await loadApiKey();
|
||||
await registerPresenceConnection();
|
||||
} else {
|
||||
// No saved state or not authenticated, get current state
|
||||
console.log("No saved auth state found, getting current state");
|
||||
authState.value = await invoke("get_auth_state");
|
||||
console.log("Current auth state:", authState.value);
|
||||
|
||||
// If we have an authenticated state, save it to disk
|
||||
if (authState.value.is_authenticated) {
|
||||
try {
|
||||
await invoke("save_auth_state", { authState: authState.value });
|
||||
console.log("Current auth state saved to disk");
|
||||
} catch (error) {
|
||||
console.error("Failed to save current auth state:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load auth state:", error);
|
||||
// Fallback to current state on error
|
||||
try {
|
||||
authState.value = await invoke("get_auth_state");
|
||||
} catch (fallbackError) {
|
||||
console.error("Failed to get current auth state:", fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserData() {
|
||||
try {
|
||||
await api.initialize();
|
||||
userData.value = await api.getCurrentUser();
|
||||
|
||||
// Load user dashboard stats (getStats now returns dashboard stats)
|
||||
try {
|
||||
userStats.value = await api.getStats();
|
||||
} catch (error) {
|
||||
console.error("Failed to load user dashboard stats:", error);
|
||||
}
|
||||
|
||||
// Load presence data and start refresh
|
||||
await loadPresenceData();
|
||||
startPresenceRefresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to load user data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKey() {
|
||||
try {
|
||||
apiKey.value = await invoke("get_api_key", { apiConfig: apiConfig.value });
|
||||
} catch (error) {
|
||||
console.error("Failed to load API key:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerPresenceConnection() {
|
||||
try {
|
||||
await invoke("register_presence_connection", { apiConfig: apiConfig.value });
|
||||
console.log("Presence connection registered successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to register presence connection:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function loadApiConfig() {
|
||||
try {
|
||||
apiConfig.value = await invoke("get_api_config");
|
||||
} catch (error) {
|
||||
console.error("Failed to load API config:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHackatimeInfo() {
|
||||
try {
|
||||
hackatimeDirectories.value = await invoke("get_hackatime_directories");
|
||||
sessionStats.value = await invoke("get_session_stats");
|
||||
} catch (error) {
|
||||
console.error("Failed to load hackatime info:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPresenceData() {
|
||||
const now = Date.now();
|
||||
if (presenceFetchInProgress.value) {
|
||||
return; // Skip if a fetch is already in flight
|
||||
}
|
||||
if (now < nextPresenceFetchAllowedAt.value) {
|
||||
return; // Respect backoff window
|
||||
}
|
||||
// Enforce hard minimum interval of 60s between network calls
|
||||
if (now - lastPresenceFetchAt.value < 60_000) {
|
||||
return;
|
||||
}
|
||||
|
||||
presenceFetchInProgress.value = true;
|
||||
try {
|
||||
await api.initialize();
|
||||
// Use the Rust backend's get_latest_heartbeat which includes session logic
|
||||
presenceData.value = await invoke("get_latest_heartbeat", {
|
||||
apiConfig: apiConfig.value
|
||||
});
|
||||
lastPresenceFetchAt.value = Date.now();
|
||||
} catch (error: any) {
|
||||
console.error("Failed to load presence data:", error);
|
||||
// If we hit rate limit, back off for 60s
|
||||
const message = error?.message || "";
|
||||
if (typeof message === "string" && message.includes("429")) {
|
||||
nextPresenceFetchAllowedAt.value = Date.now() + 60_000;
|
||||
}
|
||||
presenceData.value = null;
|
||||
} finally {
|
||||
presenceFetchInProgress.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startPresenceRefresh() {
|
||||
// Ensure only one interval is active
|
||||
if (presenceRefreshInterval.value) {
|
||||
clearInterval(presenceRefreshInterval.value);
|
||||
presenceRefreshInterval.value = null;
|
||||
}
|
||||
// Refresh presence data every 60 seconds (1 minute)
|
||||
presenceRefreshInterval.value = setInterval(loadPresenceData, 60000);
|
||||
}
|
||||
|
||||
function stopPresenceRefresh() {
|
||||
if (presenceRefreshInterval.value) {
|
||||
clearInterval(presenceRefreshInterval.value);
|
||||
presenceRefreshInterval.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function authenticate() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await invoke("authenticate_with_rails", { apiConfig: apiConfig.value });
|
||||
|
||||
// Show instructions for manual token entry
|
||||
alert(`Authentication opened in browser!\n\nAfter completing OAuth in the browser:\n1. Copy the temporary token from the URL\n2. Use the "Test Authentication" button below\n3. Enter the token when prompted\n\nFor development, you can also use the test button to simulate authentication.`);
|
||||
} catch (error) {
|
||||
console.error("Authentication failed:", error);
|
||||
alert("Authentication failed: " + (error instanceof Error ? error.message : String(error)));
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
stopPresenceRefresh();
|
||||
await invoke("logout");
|
||||
await loadAuthState();
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveApiConfig() {
|
||||
try {
|
||||
await invoke("set_api_config", { newConfig: apiConfig.value });
|
||||
isConfigOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error("Failed to save API config:", error);
|
||||
alert("Failed to save API config: " + error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
async function copyApiKey() {
|
||||
if (!apiKey.value) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(apiKey.value);
|
||||
alert("API key copied to clipboard!");
|
||||
} catch (error) {
|
||||
console.error("Failed to copy API key:", error);
|
||||
alert("Failed to copy API key to clipboard");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleDirectOAuthAuth() {
|
||||
if (!directOAuthToken.value.trim()) {
|
||||
alert("Please enter an OAuth token");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
console.log("Attempting direct OAuth auth with token:", directOAuthToken.value);
|
||||
console.log("Token length:", directOAuthToken.value.length);
|
||||
console.log("API config:", apiConfig.value);
|
||||
|
||||
// Authenticate with the direct OAuth token
|
||||
await invoke("authenticate_with_direct_oauth", {
|
||||
oauthToken: directOAuthToken.value,
|
||||
apiConfig: apiConfig.value
|
||||
});
|
||||
|
||||
console.log("Direct OAuth auth successful!");
|
||||
await loadAuthState();
|
||||
|
||||
// Ensure the auth state is saved after successful authentication
|
||||
if (authState.value.is_authenticated) {
|
||||
try {
|
||||
await invoke("save_auth_state", { authState: authState.value });
|
||||
console.log("Auth state saved after direct OAuth authentication");
|
||||
} catch (error) {
|
||||
console.error("Failed to save auth state after direct OAuth:", error);
|
||||
}
|
||||
}
|
||||
|
||||
directOAuthToken.value = ""; // Clear the input
|
||||
} catch (error) {
|
||||
console.error("Direct OAuth auth failed:", error);
|
||||
alert("Direct OAuth auth failed: " + error);
|
||||
} finally {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen text-text-primary font-sans outfit" style="background-color: #0A0101;">
|
||||
<!-- Left Sidebar -->
|
||||
<aside class="w-64 flex flex-col p-0 shadow-xl rounded-r-2xl" style="background-color: #191415;">
|
||||
<div class="p-6" style="background-color: #191415;">
|
||||
<h1 class="text-2xl font-bold text-accent-primary m-0 text-center">Hackatime</h1>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 py-4">
|
||||
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'home' }" @click.prevent="currentPage = 'home'" style="color: #B0BAC4;">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
<span class="font-medium">Home</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'projects' }" @click.prevent="currentPage = 'projects'" style="color: #B0BAC4;">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
<span class="font-medium">Projects</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'statistics' }" @click.prevent="currentPage = 'statistics'" style="color: #B0BAC4;">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
<span class="font-medium">Statistics</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-6 py-3 no-underline transition-all duration-200 border-l-4 border-transparent hover:bg-bg-secondary hover:text-text-primary hover:border-l-accent-primary" :class="{ 'bg-bg-secondary text-accent-primary border-l-accent-primary': currentPage === 'settings' }" @click.prevent="currentPage = 'settings'" style="color: #B0BAC4;">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span class="font-medium">Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="p-6" style="background-color: #191415;">
|
||||
<button v-if="authState.is_authenticated" @click="logout" class="flex items-center gap-3 w-full px-3 py-3 bg-transparent border border-accent-danger rounded-xl text-accent-danger cursor-pointer transition-all duration-200 text-sm hover:bg-accent-danger hover:text-white">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
<span class="font-medium">Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 p-6 overflow-y-auto">
|
||||
<!-- Home Page Layout -->
|
||||
<div v-if="currentPage === 'home'" class="flex flex-col h-full gap-6">
|
||||
<!-- This Week Card -->
|
||||
<div v-if="authState.is_authenticated && userStats" class="rounded-2xl shadow-card mb-6 p-6 flex flex-col" style="background-color: #191415;">
|
||||
<!-- This Week Title -->
|
||||
<h3 class="text-text-primary font-semibold text-lg mb-4">This Week</h3>
|
||||
<div class="flex gap-8 flex-1 items-center mb-4">
|
||||
<!-- Left Section - Streak & Hours Display (2/3 width) -->
|
||||
<div class="flex justify-center items-center space-x-20" style="flex: 2;">
|
||||
<!-- Streak Section -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="relative">
|
||||
<img src="/flame-icon.svg" alt="Streak" class="w-20 h-20" />
|
||||
<div class="absolute inset-0 flex items-end justify-center pb-2">
|
||||
<div class="text-white drop-shadow-lg font-bold" :class="{
|
||||
'text-4xl': (userStats.current_streak || 0) < 10,
|
||||
'text-3xl': (userStats.current_streak || 0) >= 10 && (userStats.current_streak || 0) < 100,
|
||||
'text-2xl': (userStats.current_streak || 0) >= 100 && (userStats.current_streak || 0) < 1000,
|
||||
'text-xl': (userStats.current_streak || 0) >= 1000
|
||||
}">
|
||||
{{ userStats.current_streak || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-2">
|
||||
<div class="text-text-secondary text-xl font-semibold">day streak</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hours Section -->
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-4xl font-bold text-accent-primary">
|
||||
{{ Math.round((userStats.weekly_stats?.time_coded_seconds || 0) / 3600 * 10) / 10 }}
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<div class="text-text-secondary text-xl font-semibold">hours this week</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Section - Weekly Chart.js Chart (1/3 width) -->
|
||||
<div class="flex flex-col justify-center pl-6" style="flex: 1;">
|
||||
<WeeklyChart :data="weeklyChartData" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Current Session Card -->
|
||||
<div v-if="authState.is_authenticated" class="mb-6">
|
||||
<PresenceCard :authState="authState" :presenceData="presenceData" :apiConfig="apiConfig" />
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Home Component -->
|
||||
<Home
|
||||
:authState="authState"
|
||||
:apiConfig="apiConfig"
|
||||
:userData="userData"
|
||||
:userStats="userStats"
|
||||
:isLoading="isLoading"
|
||||
:isDevMode="isDevMode"
|
||||
v-model:directOAuthToken="directOAuthToken"
|
||||
@authenticate="authenticate"
|
||||
@handleDirectOAuthAuth="handleDirectOAuthAuth"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Statistics Page Layout (full page) -->
|
||||
<div v-else-if="currentPage === 'statistics'" class="flex flex-col h-full">
|
||||
<Statistics :apiConfig="apiConfig" />
|
||||
</div>
|
||||
|
||||
<!-- Other Pages Layout (single card) -->
|
||||
<div v-else class="flex flex-col h-full">
|
||||
<div class="bg-bg-card border border-border-primary rounded-2xl overflow-hidden shadow-card flex flex-col min-h-96">
|
||||
<div class="flex justify-between items-center px-6 py-5 border-b border-border-primary bg-bg-card-tertiary">
|
||||
<h2 class="m-0 text-xl font-semibold text-text-primary">{{ getPageTitle() }}</h2>
|
||||
</div>
|
||||
<div class="p-6 flex-1 overflow-y-auto">
|
||||
<Projects v-if="currentPage === 'projects'" :currentTheme="currentTheme" :toggleTheme="toggleTheme" :apiConfig="apiConfig" />
|
||||
<Settings v-if="currentPage === 'settings'" :currentTheme="currentTheme" :toggleTheme="toggleTheme" :apiKey="apiKey" :showApiKey="showApiKey" @copyApiKey="copyApiKey" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Configuration Modal -->
|
||||
<div v-if="isConfigOpen" class="fixed inset-0 bg-black/70 flex justify-center items-center z-50" @click="isConfigOpen = false">
|
||||
<div class="bg-bg-card border border-border-primary p-8 rounded-2xl shadow-secondary max-w-md w-11/12" @click.stop>
|
||||
<h3 class="mt-0 text-text-primary mb-6 text-lg font-semibold">API Configuration</h3>
|
||||
<div class="my-4">
|
||||
<label for="api-url" class="block mb-2 font-medium text-text-primary">API Base URL:</label>
|
||||
<input
|
||||
id="api-url"
|
||||
v-model="apiConfig.base_url"
|
||||
type="url"
|
||||
placeholder="http://localhost:3000"
|
||||
class="w-full p-3 bg-bg-secondary border border-border-secondary rounded-xl text-text-primary text-base box-border focus:outline-none focus:border-accent-primary focus:shadow-[0_0_0_2px_rgba(200,57,79,0.2)]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-4 justify-end mt-6">
|
||||
<button @click="isConfigOpen = false" class="px-6 py-3 rounded-xl cursor-pointer text-base font-medium transition-all duration-200 bg-transparent text-text-secondary border border-border-secondary hover:bg-bg-secondary hover:text-text-primary hover:border-border-primary">Cancel</button>
|
||||
<button @click="saveApiConfig" class="px-6 py-3 rounded-xl cursor-pointer text-base font-medium transition-all duration-200 bg-accent-primary text-white border-0 hover:bg-accent-secondary hover:shadow-card-hover">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- All styles now handled by Tailwind CSS -->
|
||||
160
src/api.ts
Normal file
160
src/api.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface ApiConfig {
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
is_authenticated: boolean;
|
||||
access_token: string | null;
|
||||
user_info: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export class KubeTimeApi {
|
||||
private baseUrl: string = "http://localhost:3000";
|
||||
private accessToken: string | null = null;
|
||||
private latestPresenceCache: { data: any | null; fetchedAt: number } = { data: null, fetchedAt: 0 };
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
const config: ApiConfig = await invoke("get_api_config");
|
||||
this.baseUrl = config.base_url;
|
||||
|
||||
const authState: AuthState = await invoke("get_auth_state");
|
||||
this.accessToken = authState.access_token;
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize API:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/authenticated/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch current user:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
try {
|
||||
// Call the current user's dashboard stats endpoint
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/authenticated/dashboard_stats`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch stats:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getMyHeartbeats() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/my/heartbeats`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch heartbeats:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDashboardStats(username: string) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/users/${username}/dashboard_stats`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user dashboard stats:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentPresence() {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
try {
|
||||
// Return cached result if fetched within last 60s
|
||||
const now = Date.now();
|
||||
if (now - this.latestPresenceCache.fetchedAt < 60_000 && this.latestPresenceCache.data !== null) {
|
||||
return this.latestPresenceCache.data;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/v1/presence/latest_heartbeat`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
this.latestPresenceCache = { data: json, fetchedAt: Date.now() };
|
||||
return json;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch current presence:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new KubeTimeApi();
|
||||
BIN
src/assets/vue.svg
(Stored with Git LFS)
Normal file
BIN
src/assets/vue.svg
(Stored with Git LFS)
Normal file
Binary file not shown.
147
src/components/ChartComponent.vue
Normal file
147
src/components/ChartComponent.vue
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
|
||||
<div class="text-sm text-text-secondary">{{ period }}</div>
|
||||
</div>
|
||||
|
||||
<div class="h-64">
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
BarController,
|
||||
LineController,
|
||||
DoughnutController,
|
||||
PieController
|
||||
} from 'chart.js';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
BarController,
|
||||
LineController,
|
||||
DoughnutController,
|
||||
PieController
|
||||
);
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
chartType: string;
|
||||
data: any;
|
||||
period: string;
|
||||
colorScheme: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
let chartInstance: ChartJS | null = null;
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value) return;
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
const ctx = chartCanvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const config = {
|
||||
type: props.chartType,
|
||||
data: props.data,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: props.chartType === 'doughnut' || props.chartType === 'pie',
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
color: '#B0BAC4',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#191415',
|
||||
titleColor: '#FFFFFF',
|
||||
bodyColor: '#B0BAC4',
|
||||
borderColor: '#FB4B20',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: true
|
||||
}
|
||||
},
|
||||
scales: props.chartType !== 'doughnut' && props.chartType !== 'pie' ? {
|
||||
x: {
|
||||
grid: {
|
||||
color: '#2A2A2A',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#B0BAC4',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif'
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
color: '#2A2A2A',
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#B0BAC4',
|
||||
font: {
|
||||
family: 'Inter, system-ui, sans-serif'
|
||||
}
|
||||
}
|
||||
}
|
||||
} : undefined
|
||||
}
|
||||
};
|
||||
|
||||
chartInstance = new ChartJS(ctx, config);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
createChart();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.data, () => {
|
||||
createChart();
|
||||
}, { deep: true });
|
||||
</script>
|
||||
28
src/components/InsightCard.vue
Normal file
28
src/components/InsightCard.vue
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<template>
|
||||
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="text-3xl">{{ icon }}</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-text-primary mb-2">{{ title }}</h3>
|
||||
<p class="text-text-secondary mb-3">{{ description }}</p>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-2xl font-bold" :style="{ color: color }">{{ value }}</div>
|
||||
<div class="text-sm text-text-secondary">{{ trend }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
value: string;
|
||||
trend: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
189
src/components/PresenceCard.vue
Normal file
189
src/components/PresenceCard.vue
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
<template>
|
||||
<div class="rounded-2xl shadow-card p-6" style="background-color: #191415;">
|
||||
<!-- Presence Title -->
|
||||
<h3 class="text-text-primary font-semibold text-lg mb-4">Current Session</h3>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center py-8">
|
||||
<div class="w-16 h-16 bg-bg-secondary rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<div class="w-6 h-6 border-2 border-text-secondary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<div class="text-text-secondary text-lg font-medium mb-2">Loading session data...</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Session Display -->
|
||||
<div v-else-if="sessionState.is_active" class="space-y-4">
|
||||
<!-- Project and Editor Info -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<div>
|
||||
<div class="text-text-primary font-medium text-lg">
|
||||
{{ sessionState.project || 'Unknown Project' }}
|
||||
</div>
|
||||
<div class="text-text-secondary text-sm">
|
||||
{{ sessionState.editor || 'Unknown Editor' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-text-secondary text-sm">Language</div>
|
||||
<div class="text-text-primary font-medium">
|
||||
{{ sessionState.language || 'Unknown' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File being worked on -->
|
||||
<div v-if="sessionState.entity" class="bg-bg-secondary rounded-lg p-3">
|
||||
<div class="text-text-secondary text-xs mb-1">Currently editing</div>
|
||||
<div class="text-text-primary font-mono text-sm truncate">
|
||||
{{ sessionState.entity }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session duration -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="text-text-secondary">Session started</div>
|
||||
<div class="text-text-primary font-medium">
|
||||
{{ formatTime(sessionState.start_time) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Heartbeat count -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="text-text-secondary">Heartbeats</div>
|
||||
<div class="text-text-primary font-medium">
|
||||
{{ sessionState.heartbeat_count }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No active session -->
|
||||
<div v-else class="text-center py-8">
|
||||
<div class="w-16 h-16 bg-bg-secondary rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-text-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-text-secondary text-lg font-medium mb-2">No active coding session</div>
|
||||
<div class="text-text-secondary text-sm">Start coding in your editor to see your current session here</div>
|
||||
<div class="text-text-secondary text-xs mt-2 opacity-75">Make sure your editor has the WakaTime plugin installed and configured</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface AuthState {
|
||||
is_authenticated: boolean;
|
||||
access_token: string | null;
|
||||
user_info: Record<string, any> | null;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
is_active: boolean;
|
||||
start_time: number | null;
|
||||
last_heartbeat_id: number | null;
|
||||
heartbeat_count: number;
|
||||
project: string | null;
|
||||
editor: string | null;
|
||||
language: string | null;
|
||||
entity: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
authState: AuthState;
|
||||
presenceData: any;
|
||||
apiConfig: any;
|
||||
}>();
|
||||
|
||||
const sessionState = ref<SessionState>({
|
||||
is_active: false,
|
||||
start_time: null,
|
||||
last_heartbeat_id: null,
|
||||
heartbeat_count: 0,
|
||||
project: null,
|
||||
editor: null,
|
||||
language: null,
|
||||
entity: null,
|
||||
});
|
||||
|
||||
const isLoading = ref(true);
|
||||
let sessionRefreshInterval: number | null = null;
|
||||
|
||||
// Format timestamp to relative time
|
||||
function formatTime(timestamp: number | null): string {
|
||||
if (!timestamp) return 'Unknown';
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const diff = now - timestamp;
|
||||
|
||||
if (diff < 60) {
|
||||
return 'Just now';
|
||||
} else if (diff < 3600) {
|
||||
const minutes = Math.floor(diff / 60);
|
||||
return `${minutes}m ago`;
|
||||
} else if (diff < 86400) {
|
||||
const hours = Math.floor(diff / 3600);
|
||||
return `${hours}h ago`;
|
||||
} else {
|
||||
const days = Math.floor(diff / 86400);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionState() {
|
||||
if (!props.authState.is_authenticated) {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the current session state
|
||||
const session = await invoke("get_current_session");
|
||||
console.log("Session state loaded:", session);
|
||||
sessionState.value = session as SessionState;
|
||||
isLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error("Failed to load session state:", error);
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startSessionRefresh() {
|
||||
if (sessionRefreshInterval) {
|
||||
clearInterval(sessionRefreshInterval);
|
||||
}
|
||||
|
||||
// Refresh session state every 10 seconds
|
||||
sessionRefreshInterval = setInterval(loadSessionState, 10000);
|
||||
}
|
||||
|
||||
function stopSessionRefresh() {
|
||||
if (sessionRefreshInterval) {
|
||||
clearInterval(sessionRefreshInterval);
|
||||
sessionRefreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in presence data to update session state
|
||||
watch(() => props.presenceData, () => {
|
||||
if (props.authState.is_authenticated) {
|
||||
loadSessionState();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
onMounted(() => {
|
||||
loadSessionState();
|
||||
startSessionRefresh();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSessionRefresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- All styles now handled by Tailwind CSS -->
|
||||
53
src/components/StatisticsCard.vue
Normal file
53
src/components/StatisticsCard.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<div class="bg-bg-card border border-border-primary rounded-2xl p-6 shadow-card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-text-primary">{{ title }}</h3>
|
||||
<div class="text-2xl">{{ icon }}</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-3xl font-bold text-text-primary">{{ value }}</div>
|
||||
<div class="text-sm text-text-secondary">{{ period }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div
|
||||
class="text-sm font-medium px-3 py-1 rounded-full"
|
||||
:class="changeClass"
|
||||
>
|
||||
{{ change }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string;
|
||||
change: string;
|
||||
changeType: string;
|
||||
period: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const changeClass = computed(() => {
|
||||
switch (props.changeType) {
|
||||
case 'increase':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'decrease':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'neutral':
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
195
src/components/StatisticsDashboard.vue
Normal file
195
src/components/StatisticsDashboard.vue
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<div v-if="statisticsData" class="space-y-6">
|
||||
<!-- Trends Section -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-text-primary mb-4">Trends</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatisticsCard
|
||||
v-for="trend in statisticsData.trends"
|
||||
:key="trend.title"
|
||||
:title="trend.title"
|
||||
:value="trend.value"
|
||||
:change="trend.change"
|
||||
:change-type="trend.change_type"
|
||||
:period="trend.period"
|
||||
:icon="trend.icon"
|
||||
:color="trend.color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-text-primary mb-4">Analytics</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<ChartComponent
|
||||
v-for="chart in statisticsData.charts"
|
||||
:key="chart.id"
|
||||
:title="chart.title"
|
||||
:chart-type="chart.chart_type"
|
||||
:data="chart.data"
|
||||
:period="chart.period"
|
||||
:color-scheme="chart.color_scheme"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Programmer Class Section -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-text-primary mb-4">Your Programmer Class</h2>
|
||||
<div class="bg-bg-card border border-border-primary rounded-2xl p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-text-primary">{{ statisticsData.programmer_class.class_name }}</h3>
|
||||
<p class="text-text-secondary">{{ statisticsData.programmer_class.description }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-semibold px-3 py-1 rounded-full" :style="{ backgroundColor: statisticsData.programmer_class.color + '20', color: statisticsData.programmer_class.color }">
|
||||
{{ statisticsData.programmer_class.level }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="tech in statisticsData.programmer_class.technologies"
|
||||
:key="tech"
|
||||
class="px-3 py-1 bg-bg-secondary text-text-primary rounded-lg text-sm font-medium"
|
||||
>
|
||||
{{ tech }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insights Section -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-text-primary mb-4">Insights</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<InsightCard
|
||||
v-for="insight in statisticsData.insights"
|
||||
:key="insight.title"
|
||||
:title="insight.title"
|
||||
:description="insight.description"
|
||||
:value="insight.value"
|
||||
:trend="insight.trend"
|
||||
:icon="insight.icon"
|
||||
:color="insight.color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-else-if="isLoading" class="space-y-6">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-6 bg-bg-secondary rounded w-32 mb-4"></div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div v-for="i in 3" :key="i" class="bg-bg-card border border-border-primary rounded-2xl p-6">
|
||||
<div class="h-4 bg-bg-secondary rounded w-24 mb-4"></div>
|
||||
<div class="h-8 bg-bg-secondary rounded w-16 mb-2"></div>
|
||||
<div class="h-3 bg-bg-secondary rounded w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-2xl p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="text-red-500 text-xl mr-3">⚠️</div>
|
||||
<div>
|
||||
<h3 class="text-red-800 font-semibold">Failed to load statistics</h3>
|
||||
<p class="text-red-600 text-sm">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import StatisticsCard from './StatisticsCard.vue';
|
||||
import ChartComponent from './ChartComponent.vue';
|
||||
import InsightCard from './InsightCard.vue';
|
||||
|
||||
interface StatisticsData {
|
||||
trends: Array<{
|
||||
title: string;
|
||||
value: string;
|
||||
change: string;
|
||||
change_type: string;
|
||||
period: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}>;
|
||||
charts: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
chart_type: string;
|
||||
data: any;
|
||||
period: string;
|
||||
color_scheme: string;
|
||||
}>;
|
||||
insights: Array<{
|
||||
title: string;
|
||||
description: string;
|
||||
value: string;
|
||||
trend: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}>;
|
||||
programmer_class: {
|
||||
class_name: string;
|
||||
description: string;
|
||||
technologies: string[];
|
||||
level: string;
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
apiConfig: {
|
||||
base_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const statisticsData = ref<StatisticsData | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const loadStatistics = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const data = await invoke<StatisticsData>('get_statistics_data', {
|
||||
apiConfig: props.apiConfig
|
||||
});
|
||||
statisticsData.value = data;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Failed to load statistics:', err);
|
||||
// Set some default data to prevent crashes
|
||||
statisticsData.value = {
|
||||
trends: [],
|
||||
charts: [],
|
||||
insights: [],
|
||||
programmer_class: {
|
||||
class_name: "Future Coder",
|
||||
description: "Ready to embark on an exciting journey into the world of programming.",
|
||||
technologies: ["HTML", "CSS", "JavaScript"],
|
||||
level: "Beginner",
|
||||
color: "#607D8B"
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadStatistics();
|
||||
});
|
||||
</script>
|
||||
64
src/components/TrendCard.vue
Normal file
64
src/components/TrendCard.vue
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<template>
|
||||
<div class="rounded-xl p-4 flex items-center space-x-3 relative overflow-hidden" :style="cardStyle">
|
||||
<div class="text-2xl">{{ icon }}</div>
|
||||
<div class="flex-1">
|
||||
<div class="text-sm text-white font-medium">{{ title }}</div>
|
||||
<div class="text-xs text-white/70">{{ period }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div
|
||||
class="text-2xl font-bold px-3 py-2 rounded-lg"
|
||||
:class="changeClass"
|
||||
>
|
||||
{{ change }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
change: string;
|
||||
changeType: string;
|
||||
period: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const cardStyle = computed(() => {
|
||||
switch (props.changeType) {
|
||||
case 'increase':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #FB4C21 0%, #FF6B35 100%)',
|
||||
border: '1px solid #FB4C21'
|
||||
};
|
||||
case 'decrease':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #4568DC 0%, #5A7BE8 100%)',
|
||||
border: '1px solid #4568DC'
|
||||
};
|
||||
case 'neutral':
|
||||
default:
|
||||
return {
|
||||
background: 'linear-gradient(135deg, #6B7280 0%, #9CA3AF 100%)',
|
||||
border: '1px solid #6B7280'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const changeClass = computed(() => {
|
||||
switch (props.changeType) {
|
||||
case 'increase':
|
||||
return 'bg-white/20 text-white';
|
||||
case 'decrease':
|
||||
return 'bg-white/20 text-white';
|
||||
case 'neutral':
|
||||
default:
|
||||
return 'bg-white/20 text-white';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
145
src/components/WeeklyChart.vue
Normal file
145
src/components/WeeklyChart.vue
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<template>
|
||||
<div class="h-32">
|
||||
<canvas ref="chartCanvas"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
BarController,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
BarController,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
interface Props {
|
||||
data: Array<{
|
||||
date: string;
|
||||
day_name: string;
|
||||
hours: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const chartCanvas = ref<HTMLCanvasElement | null>(null);
|
||||
let chartInstance: ChartJS | null = null;
|
||||
|
||||
const createChart = () => {
|
||||
if (!chartCanvas.value) return;
|
||||
|
||||
// Destroy existing chart
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
const ctx = chartCanvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Prepare data for Chart.js
|
||||
const labels = props.data.map(day => day.day_name);
|
||||
const chartData = props.data.map(day => day.hours);
|
||||
|
||||
// Calculate colors for each bar
|
||||
const maxHours = Math.max(...chartData, 1);
|
||||
const 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);
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
});
|
||||
|
||||
const config = {
|
||||
type: 'bar' as const,
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: chartData,
|
||||
backgroundColor: colors,
|
||||
borderColor: colors,
|
||||
borderWidth: 0,
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#191415',
|
||||
titleColor: '#FFFFFF',
|
||||
bodyColor: '#B0BAC4',
|
||||
borderColor: '#FB4B20',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 8,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
return `${context.parsed.y}h`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#B0BAC4',
|
||||
font: {
|
||||
size: 10,
|
||||
family: 'Inter, system-ui, sans-serif'
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartInstance = new ChartJS(ctx, config);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
createChart();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.data, () => {
|
||||
createChart();
|
||||
}, { deep: true });
|
||||
</script>
|
||||
36
src/composables/useTheme.ts
Normal file
36
src/composables/useTheme.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
175
src/style.css
Normal file
175
src/style.css
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* CSS Variables for Theme System */
|
||||
:root {
|
||||
/* Dark theme (default) */
|
||||
--bg-primary: #2a1f21;
|
||||
--bg-secondary: #3d2b2e;
|
||||
--bg-tertiary: #4a2d31;
|
||||
--bg-card: #3d2b2e;
|
||||
--bg-card-secondary: #4a2d31;
|
||||
--bg-card-tertiary: #1f1617;
|
||||
--bg-sidebar: #2a1f21;
|
||||
--text-primary: #f5e6e8;
|
||||
--text-secondary: #f5e6e8;
|
||||
--text-muted: #a0a0a0;
|
||||
--accent-primary: #ff7a8a;
|
||||
--accent-secondary: #c8394f;
|
||||
--accent-danger: #ec3750;
|
||||
--accent-warning: #f1c40f;
|
||||
--accent-info: #33d6a6;
|
||||
--border-primary: #3d2b2e;
|
||||
--border-secondary: #4a2d31;
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Light theme */
|
||||
--bg-primary: #fdf7f8;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #f8e8ea;
|
||||
--bg-card: #ffffff;
|
||||
--bg-card-secondary: #f8e8ea;
|
||||
--bg-card-tertiary: #ffffff;
|
||||
--bg-sidebar: #fdf7f8;
|
||||
--text-primary: #5d3a3f;
|
||||
--text-secondary: #5d3a3f;
|
||||
--text-muted: #8b2635;
|
||||
--accent-primary: #c8394f;
|
||||
--accent-secondary: #a12d3e;
|
||||
--accent-danger: #ec3750;
|
||||
--accent-warning: #ff8c37;
|
||||
--accent-info: #33d6a6;
|
||||
--border-primary: #f8e8ea;
|
||||
--border-secondary: #ffffff;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-secondary);
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
::selection {
|
||||
background: rgba(200, 57, 79, 0.3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus {
|
||||
outline: 2px solid var(--accent-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Global styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Button and input reset */
|
||||
button, input, textarea, select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* Link styles */
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-secondary);
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme changes */
|
||||
* {
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Outfit font class as per Google Fonts documentation */
|
||||
.outfit {
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Outfit font weight utilities */
|
||||
.outfit-light {
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.outfit-normal {
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.outfit-medium {
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.outfit-semibold {
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.outfit-bold {
|
||||
font-family: "Outfit", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
104
src/views/Home.vue
Normal file
104
src/views/Home.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<template>
|
||||
<!-- Authentication Section -->
|
||||
<div v-if="!authState.is_authenticated" class="flex items-center justify-center min-h-96">
|
||||
<div class="text-center max-w-md">
|
||||
<h3 class="text-2xl mb-4 text-text-primary">Welcome to Hackatime</h3>
|
||||
<p class="text-text-secondary mb-8 leading-relaxed">Connect to your KubeTime account to start tracking your coding time.</p>
|
||||
|
||||
<!-- Production authentication (deep link) -->
|
||||
<template v-if="!isDevMode">
|
||||
<button
|
||||
@click="authenticate"
|
||||
:disabled="isLoading"
|
||||
class="bg-accent-primary text-white border-0 px-8 py-4 rounded-xl text-base font-medium cursor-pointer transition-all duration-200 my-4 w-full hover:bg-accent-secondary hover:shadow-card-hover disabled:bg-text-muted disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
{{ isLoading ? 'Opening Login...' : 'Login with KubeTime' }}
|
||||
</button>
|
||||
<p class="text-text-secondary text-sm mt-2">This will open your browser for OAuth authentication.</p>
|
||||
</template>
|
||||
|
||||
<!-- Development authentication options -->
|
||||
<template v-else>
|
||||
<div class="browser-auth-section">
|
||||
<button
|
||||
@click="authenticate"
|
||||
:disabled="isLoading"
|
||||
class="bg-accent-primary text-white border-0 px-8 py-4 rounded-xl text-base font-medium cursor-pointer transition-all duration-200 my-4 w-full hover:bg-accent-secondary hover:shadow-card-hover disabled:bg-text-muted disabled:cursor-not-allowed disabled:transform-none"
|
||||
>
|
||||
{{ isLoading ? 'Opening Login...' : 'Open Browser Login' }}
|
||||
</button>
|
||||
<p class="text-text-secondary text-sm mt-2">This will open your browser for OAuth authentication.</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-border-primary text-left">
|
||||
<h4 class="text-text-primary mb-2 text-lg">Or paste your token directly</h4>
|
||||
<p class="text-text-secondary mb-4 text-sm">Paste your token from the browser callback URL:</p>
|
||||
<div class="flex gap-3 mb-4">
|
||||
<input
|
||||
:value="directOAuthToken"
|
||||
@input="$emit('update:directOAuthToken', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
placeholder="Paste your token here..."
|
||||
class="flex-1 p-3 bg-bg-secondary border border-border-secondary rounded-xl text-text-primary font-mono text-sm focus:outline-none focus:border-accent-primary focus:shadow-[0_0_0_2px_rgba(200,57,79,0.2)]"
|
||||
@keyup.enter="handleDirectOAuthAuth"
|
||||
/>
|
||||
<button
|
||||
@click="handleDirectOAuthAuth"
|
||||
:disabled="isLoading || !directOAuthToken.trim()"
|
||||
class="bg-accent-primary text-white border-0 px-6 py-3 rounded-xl text-sm font-medium cursor-pointer whitespace-nowrap transition-all duration-200 hover:bg-accent-secondary disabled:bg-text-muted disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ isLoading ? 'Authenticating...' : 'Authenticate' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback when authenticated but no user stats available -->
|
||||
<div v-else-if="!userStats" class="mb-8">
|
||||
<div class="bg-bg-secondary p-6 rounded-xl border border-border-primary text-center">
|
||||
<h4 class="text-text-primary mb-2 text-lg">No Stats Available</h4>
|
||||
<p class="text-text-secondary">Start coding to see your statistics here!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface AuthState {
|
||||
is_authenticated: boolean;
|
||||
access_token: string | null;
|
||||
user_info: Record<string, any> | null;
|
||||
}
|
||||
|
||||
interface ApiConfig {
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
authState: AuthState;
|
||||
apiConfig: ApiConfig;
|
||||
userData: any;
|
||||
userStats: any;
|
||||
isLoading: boolean;
|
||||
isDevMode: boolean;
|
||||
directOAuthToken: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
authenticate: [];
|
||||
handleDirectOAuthAuth: [];
|
||||
'update:directOAuthToken': [value: string];
|
||||
}>();
|
||||
|
||||
async function authenticate() {
|
||||
emit('authenticate');
|
||||
}
|
||||
|
||||
async function handleDirectOAuthAuth() {
|
||||
emit('handleDirectOAuthAuth');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- All styles now handled by Tailwind CSS -->
|
||||
206
src/views/Projects.vue
Normal file
206
src/views/Projects.vue
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<div class="min-h-72">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center h-64">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-primary"></div>
|
||||
<p class="text-text-secondary">Loading projects...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="flex items-center justify-center h-64">
|
||||
<div class="text-center">
|
||||
<p class="text-accent-danger mb-4">{{ error }}</p>
|
||||
<button
|
||||
@click="loadProjects"
|
||||
class="px-4 py-2 bg-accent-primary text-white rounded-lg hover:bg-accent-secondary transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projects List -->
|
||||
<div v-else-if="projects && projects.length > 0" class="space-y-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-lg font-semibold text-text-primary">Your Projects</h3>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.name"
|
||||
class="bg-bg-secondary border border-border-primary rounded-xl p-4 hover:border-accent-primary transition-colors cursor-pointer"
|
||||
@click="selectProject(project)"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-text-primary font-medium text-lg mb-1">{{ project.name }}</h4>
|
||||
<div class="flex items-center gap-4 text-sm text-text-secondary">
|
||||
<span>{{ project.total_heartbeats }} heartbeats</span>
|
||||
<span>{{ formatDuration(project.total_seconds) }}</span>
|
||||
<span v-if="project.recent_activity_seconds > 0" class="text-accent-primary">
|
||||
Active recently
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-semibold text-accent-primary">
|
||||
{{ (project.total_seconds / 3600).toFixed(1) }}h
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Languages and Editors -->
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<span
|
||||
v-for="language in project.languages.slice(0, 3)"
|
||||
:key="language"
|
||||
class="px-2 py-1 bg-bg-tertiary text-text-primary text-xs rounded-md"
|
||||
>
|
||||
{{ language }}
|
||||
</span>
|
||||
<span
|
||||
v-if="project.languages.length > 3"
|
||||
class="px-2 py-1 bg-bg-tertiary text-text-secondary text-xs rounded-md"
|
||||
>
|
||||
+{{ project.languages.length - 3 }} more
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Time Range -->
|
||||
<div class="text-xs text-text-secondary">
|
||||
<span v-if="project.first_heartbeat">
|
||||
First: {{ formatDate(project.first_heartbeat) }}
|
||||
</span>
|
||||
<span v-if="project.last_heartbeat" class="ml-4">
|
||||
Last: {{ formatDate(project.last_heartbeat) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Repo Link -->
|
||||
<div v-if="project.repo_url" class="mt-2">
|
||||
<a
|
||||
:href="project.repo_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent-primary text-sm hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
View Repository →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="flex items-center justify-center h-64">
|
||||
<div class="text-center">
|
||||
<p class="text-text-secondary mb-4">No projects found</p>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Start coding to see your projects appear here!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface Project {
|
||||
name: string;
|
||||
total_seconds: number;
|
||||
total_heartbeats: number;
|
||||
languages: string[];
|
||||
editors: string[];
|
||||
first_heartbeat: string | null;
|
||||
last_heartbeat: string | null;
|
||||
repo_url: string | null;
|
||||
recent_activity_seconds: number;
|
||||
recent_activity_formatted: string;
|
||||
}
|
||||
|
||||
interface ProjectsResponse {
|
||||
projects: Project[];
|
||||
total_count: number;
|
||||
time_range: {
|
||||
since: string;
|
||||
until: string;
|
||||
};
|
||||
}
|
||||
|
||||
const projects = ref<Project[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
currentTheme: string;
|
||||
toggleTheme: () => void;
|
||||
apiConfig: {
|
||||
base_url: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
// Load projects data
|
||||
async function loadProjects() {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await invoke("get_projects", {
|
||||
apiConfig: props.apiConfig
|
||||
}) as ProjectsResponse;
|
||||
projects.value = response.projects || [];
|
||||
} catch (err) {
|
||||
console.error("Failed to load projects:", err);
|
||||
error.value = err instanceof Error ? err.message : "Failed to load projects";
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Select a project (for future detailed view)
|
||||
function selectProject(project: Project) {
|
||||
console.log("Selected project:", project.name);
|
||||
// TODO: Implement project details view
|
||||
}
|
||||
|
||||
// Format duration helper
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return "0m";
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Format date helper
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Load projects on mount
|
||||
onMounted(() => {
|
||||
loadProjects();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- All styles now handled by Tailwind CSS -->
|
||||
196
src/views/Settings.vue
Normal file
196
src/views/Settings.vue
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<template>
|
||||
<div class="min-h-72">
|
||||
<div class="space-y-8">
|
||||
<!-- Theme Settings -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-text-primary">Appearance</h3>
|
||||
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-text-primary mb-1">Theme</h4>
|
||||
<p class="text-sm text-text-secondary">Choose between dark and light mode</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-text-secondary">{{ currentTheme === 'dark' ? 'Dark' : 'Light' }}</span>
|
||||
<button
|
||||
@click="toggleTheme"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
|
||||
:class="currentTheme === 'dark' ? 'bg-accent-primary' : 'bg-border-primary'"
|
||||
>
|
||||
<span
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="currentTheme === 'dark' ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App Information -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-text-primary">About</h3>
|
||||
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-text-secondary">Version</span>
|
||||
<span class="text-text-primary font-medium">1.0.0</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-text-secondary">Build</span>
|
||||
<span class="text-text-primary font-medium">Development</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key -->
|
||||
<div v-if="apiKey" class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-text-primary">API Access</h3>
|
||||
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="font-medium text-text-primary mb-2">Your API Key</h4>
|
||||
<p class="text-sm text-text-secondary mb-4">Use this key to authenticate with the KubeTime API</p>
|
||||
<div class="flex gap-3 items-center">
|
||||
<input
|
||||
:type="showApiKey ? 'text' : 'password'"
|
||||
:value="apiKey"
|
||||
readonly
|
||||
class="flex-1 p-3 bg-bg-tertiary border border-border-secondary rounded-xl text-text-primary font-mono text-sm"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button @click="$emit('update:showApiKey', !showApiKey)" class="p-3 border border-border-secondary rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-bg-tertiary text-text-secondary hover:bg-bg-primary hover:text-text-primary hover:border-accent-primary">
|
||||
<svg v-if="showApiKey" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878L12 12m-2.122-2.122l1.415 1.415M12 12l2.122 2.122m-2.122-2.122L12 12m2.122 2.122l-1.415-1.415M12 12l-2.122-2.122"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="copyApiKey" class="p-3 border border-accent-info rounded-xl cursor-pointer text-sm min-w-11 transition-all duration-200 bg-accent-info text-white hover:bg-blue-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-text-primary">Preferences</h3>
|
||||
<div class="bg-bg-secondary border border-border-primary rounded-xl p-6">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-text-primary mb-1">Auto-start</h4>
|
||||
<p class="text-sm text-text-secondary">Start with system</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full bg-border-primary transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-text-primary mb-1">Discord RPC</h4>
|
||||
<p class="text-sm text-text-secondary">Show coding activity in Discord</p>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleDiscordRpc"
|
||||
:disabled="isLoading"
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="discordRpcEnabled ? 'bg-accent-primary' : 'bg-border-primary'"
|
||||
>
|
||||
<span
|
||||
v-if="isLoading"
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform animate-pulse"
|
||||
:class="discordRpcEnabled ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||
:class="discordRpcEnabled ? 'translate-x-6' : 'translate-x-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-text-primary mb-1">Notifications</h4>
|
||||
<p class="text-sm text-text-secondary">Show desktop notifications</p>
|
||||
</div>
|
||||
<button
|
||||
class="relative inline-flex h-6 w-11 items-center rounded-full bg-accent-primary transition-colors focus:outline-none focus:ring-2 focus:ring-accent-primary focus:ring-offset-2"
|
||||
>
|
||||
<span class="inline-block h-4 w-4 transform rounded-full bg-white translate-x-6 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Theme } from '../composables/useTheme'
|
||||
|
||||
defineProps<{
|
||||
currentTheme: Theme
|
||||
toggleTheme: () => void
|
||||
apiKey: string | null
|
||||
showApiKey: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
copyApiKey: []
|
||||
'update:showApiKey': [value: boolean]
|
||||
}>()
|
||||
|
||||
const discordRpcEnabled = ref(false);
|
||||
const isLoading = ref(false);
|
||||
|
||||
async function loadDiscordRpcState() {
|
||||
try {
|
||||
discordRpcEnabled.value = await invoke("get_discord_rpc_enabled");
|
||||
} catch (error) {
|
||||
console.error("Failed to load Discord RPC state:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDiscordRpc() {
|
||||
if (isLoading.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const newState = !discordRpcEnabled.value;
|
||||
await invoke("set_discord_rpc_enabled", { enabled: newState });
|
||||
discordRpcEnabled.value = newState;
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle Discord RPC:", error);
|
||||
// Revert the UI state on error
|
||||
discordRpcEnabled.value = !discordRpcEnabled.value;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function copyApiKey() {
|
||||
emit('copyApiKey')
|
||||
}
|
||||
|
||||
// Load Discord RPC state on mount
|
||||
onMounted(() => {
|
||||
loadDiscordRpcState();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- All styles now handled by Tailwind CSS -->
|
||||
23
src/views/Statistics.vue
Normal file
23
src/views/Statistics.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-text-primary">Statistics</h1>
|
||||
<div class="text-sm text-text-secondary">Your coding insights and trends</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Dashboard -->
|
||||
<StatisticsDashboard :apiConfig="apiConfig" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import StatisticsDashboard from '../components/StatisticsDashboard.vue';
|
||||
|
||||
interface ApiConfig {
|
||||
base_url: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
apiConfig: ApiConfig;
|
||||
}>();
|
||||
</script>
|
||||
7
src/vite-env.d.ts
vendored
Normal file
7
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
99
tailwind.config.js
Normal file
99
tailwind.config.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Dark theme colors
|
||||
'dark': {
|
||||
'sidebar-bg': '#2a1f21',
|
||||
'sidebar-text': '#f5e6e8',
|
||||
'sidebar-unread-text': '#ffffff',
|
||||
'sidebar-text-hover-bg': '#3d2b2e',
|
||||
'sidebar-text-active-border': '#ff7a8a',
|
||||
'sidebar-text-active-color': '#ffffff',
|
||||
'sidebar-header-bg': '#1f1617',
|
||||
'sidebar-header-text': '#f5e6e8',
|
||||
'sidebar-team-bar-bg': '#1f1617',
|
||||
'online-indicator': '#33d6a6',
|
||||
'away-indicator': '#f1c40f',
|
||||
'dnd-indicator': '#ec3750',
|
||||
'mention-bg': '#d63c56',
|
||||
'mention-color': '#ffffff',
|
||||
'center-channel-bg': '#3d2b2e',
|
||||
'center-channel-color': '#f5e6e8',
|
||||
'new-message-separator': '#ff7a8a',
|
||||
'link-color': '#ff9aaa',
|
||||
'button-bg': '#c8394f',
|
||||
'button-color': '#ffffff',
|
||||
'error-text': '#ff6b7d',
|
||||
'mention-highlight-bg': '#4a2d31',
|
||||
'mention-highlight-link': '#ff9aaa',
|
||||
},
|
||||
// Light theme colors
|
||||
'light': {
|
||||
'sidebar-bg': '#fdf7f8',
|
||||
'sidebar-text': '#5d3a3f',
|
||||
'sidebar-unread-text': '#2c1a1d',
|
||||
'sidebar-text-hover-bg': '#f8e8ea',
|
||||
'sidebar-text-active-border': '#c8394f',
|
||||
'sidebar-text-active-color': '#8b2635',
|
||||
'sidebar-header-bg': '#ffffff',
|
||||
'sidebar-header-text': '#5d3a3f',
|
||||
'sidebar-team-bar-bg': '#ffffff',
|
||||
'online-indicator': '#33d6a6',
|
||||
'away-indicator': '#ff8c37',
|
||||
'dnd-indicator': '#ec3750',
|
||||
'mention-bg': '#c8394f',
|
||||
'mention-color': '#ffffff',
|
||||
'center-channel-bg': '#ffffff',
|
||||
'center-channel-color': '#5d3a3f',
|
||||
'new-message-separator': '#ff7a8a',
|
||||
'link-color': '#a12d3e',
|
||||
'button-bg': '#c8394f',
|
||||
'button-color': '#ffffff',
|
||||
'error-text': '#d63c56',
|
||||
'mention-highlight-bg': '#fce4e6',
|
||||
'mention-highlight-link': '#a12d3e',
|
||||
},
|
||||
// Semantic color mappings
|
||||
'bg-primary': 'var(--bg-primary)',
|
||||
'bg-secondary': 'var(--bg-secondary)',
|
||||
'bg-tertiary': 'var(--bg-tertiary)',
|
||||
'bg-card': 'var(--bg-card)',
|
||||
'bg-card-secondary': 'var(--bg-card-secondary)',
|
||||
'bg-card-tertiary': 'var(--bg-card-tertiary)',
|
||||
'bg-sidebar': 'var(--bg-sidebar)',
|
||||
'text-primary': 'var(--text-primary)',
|
||||
'text-secondary': 'var(--text-secondary)',
|
||||
'text-muted': 'var(--text-muted)',
|
||||
'accent-primary': 'var(--accent-primary)',
|
||||
'accent-secondary': 'var(--accent-secondary)',
|
||||
'accent-danger': 'var(--accent-danger)',
|
||||
'accent-warning': 'var(--accent-warning)',
|
||||
'accent-info': 'var(--accent-info)',
|
||||
'border-primary': 'var(--border-primary)',
|
||||
'border-secondary': 'var(--border-secondary)',
|
||||
},
|
||||
fontFamily: {
|
||||
'sans': ['"Outfit"', 'sans-serif'],
|
||||
'outfit': ['"Outfit"', 'sans-serif'],
|
||||
},
|
||||
borderRadius: {
|
||||
'xl': '12px',
|
||||
'2xl': '16px',
|
||||
'3xl': '20px',
|
||||
},
|
||||
boxShadow: {
|
||||
'primary': '0 4px 6px rgba(0, 0, 0, 0.2)',
|
||||
'secondary': '0 10px 25px rgba(0, 0, 0, 0.3)',
|
||||
'card': '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
'card-hover': '0 4px 16px rgba(0, 0, 0, 0.15)',
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
33
vite.config.ts
Normal file
33
vite.config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
Loading…
Add table
Reference in a new issue