mirror of
https://github.com/System-End/hackatime-desktop.git
synced 2026-04-19 20:55:13 +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