Menu
Commit f21a8121
f21a8121a42248001d0b1e88d62efe693179ea69 by Ólafur B. J.
- CHANGELOG.md +5 -0
- Cargo.lock +4 -4
- Cargo.toml +1 -1
- VERSION +1 -1
- crates/crm-api/src/handlers/crud.rs +33 -19
- crates/crm-api/src/handlers/meta.rs +48 -25
- crates/crm-api/src/handlers/search.rs +22 -6
- crates/crm-api/src/handlers/static_files.rs +8 -3
- crates/crm-api/src/handlers/timeline.rs +21 -8
- crates/crm-api/src/router_setup.rs +166 -37
- crates/crm-api/tests/api_test.rs +7 -4
- crates/crm-cli/src/cmd_seed.rs +226 -124
- crates/crm-cli/src/cmd_serve.rs +1 -2
- crates/crm-cli/src/main.rs +9 -2
- crates/crm-core/src/entities/company.rs +23 -6
- crates/crm-core/src/entities/mod.rs +6 -6
- crates/crm-core/src/entities/note.rs +26 -6
- crates/crm-core/src/entities/opportunity.rs +19 -5
- crates/crm-core/src/entities/person.rs +17 -3
- crates/crm-core/src/entities/task.rs +35 -11
- crates/crm-core/src/entities/timeline.rs +4 -1
- crates/crm-core/src/metadata/mod.rs +357 -38
- crates/crm-core/src/types.rs +13 -4
- crates/crm-core/tests/integration.rs +12 -5
- crates/crm-mcp/src/server.rs +5 -2
- crates/crm-mcp/src/tools.rs +206 -106
- deploy.sh +7 -1
- site/backend/page.json +1 -1
- site/frontend/companies.html +4 -0
- site/frontend/company-detail.html +4 -0
- site/frontend/dashboard.html +4 -0
- site/frontend/index.html +3 -0
- site/frontend/notes.html +4 -0
- site/frontend/opportunities.html +4 -0
- site/frontend/opportunity-detail.html +4 -0
- site/frontend/people.html +4 -0
- site/frontend/person-detail.html +4 -0
- site/frontend/settings.html +4 -0
- site/frontend/styles.css +77 -44
- site/frontend/tasks.html +4 -0
- site/frontend/theme.js +96 -0
- site/frontend/themes.css +19 -0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75459d8..5b91bfb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,7 @@
## [Unreleased]
+## [0.1.1] - 2026-06-26
+
### Added
- Project scaffold based on AkurAI-Framework
@@ -12,3 +14,6 @@
- CLI binary with serve/seed commands
- Integration tests: 7 passing
- Open-source release: MIT, public repo
+- AkurAI-Framework theme system: vendor themes.css (17 themes), theme.js ESM switcher, flash-guard, theme-picker across all pages
+- All CSS color tokens aliased to framework semantic tokens (--bg, --panel, --accent, --fg, --muted, --ok, --warn, --danger, --info, --border-2, etc.)
+- Deployed to https://akurai-crm.olibuijr.com (EC2, nginx, systemd, Let's Encrypt)
diff --git a/Cargo.lock b/Cargo.lock
index f293011..327f55a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12,7 +12,7 @@ dependencies = [
[[package]]
name = "akurai-crm"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"akurai-collections",
"akurai-http",
@@ -48,7 +48,7 @@ version = "0.8.0"
[[package]]
name = "crm-api"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"akurai-collections",
"akurai-http",
@@ -60,7 +60,7 @@ dependencies = [
[[package]]
name = "crm-core"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"akurai-json",
"akurai-schema",
@@ -68,7 +68,7 @@ dependencies = [
[[package]]
name = "crm-mcp"
-version = "0.1.0"
+version = "0.1.1"
dependencies = [
"akurai-http",
"akurai-json",
diff --git a/Cargo.toml b/Cargo.toml
index 9cf9f90..0201879 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
-version = "0.1.0"
+version = "0.1.1"
edition = "2021"
license = "MIT"
repository = "https://github.com/olibuijr/akurai-crm"
diff --git a/VERSION b/VERSION
index 6e8bf73..17e51c3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.0
+0.1.1
diff --git a/crates/crm-api/src/handlers/crud.rs b/crates/crm-api/src/handlers/crud.rs
index 6eb02d7..508da7f 100644
--- a/crates/crm-api/src/handlers/crud.rs
+++ b/crates/crm-api/src/handlers/crud.rs
@@ -1,5 +1,7 @@
use crate::handlers::timeline::record_timeline;
-use crate::router_setup::{bad_request, internal_error, json_response, not_found, upper_bound, CrmState};
+use crate::router_setup::{
+ bad_request, internal_error, json_response, not_found, upper_bound, CrmState,
+};
use akurai_http::{Request, Response};
use akurai_json::Value;
use std::sync::{Arc, Mutex};
@@ -34,7 +36,10 @@ fn build_key(coll_name: &str, id: u64) -> Vec<u8> {
key
}
-pub fn list_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn list_route(
+ state: Arc<Mutex<CrmState>>,
+ entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |_req: &Request| {
let state = match state.lock() {
Ok(s) => s,
@@ -52,7 +57,8 @@ pub fn list_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn
if let Ok(entries) = db.range(&prefix, &end) {
for (_key_bytes, val_bytes) in entries {
- if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or("")) {
+ if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
+ {
records.push(json);
}
}
@@ -62,7 +68,10 @@ pub fn list_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn
})
}
-pub fn get_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn get_route(
+ state: Arc<Mutex<CrmState>>,
+ entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |req: &Request| {
let id = match extract_id(&req.path) {
Some(id) => id,
@@ -80,19 +89,20 @@ pub fn get_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn F
let key = build_key(coll_name, id);
match db.get(&key) {
- Ok(Some(val)) => {
- match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
- Ok(json) => json_response(json),
- Err(_) => internal_error("corrupted data"),
- }
- }
+ Ok(Some(val)) => match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
+ Ok(json) => json_response(json),
+ Err(_) => internal_error("corrupted data"),
+ },
Ok(None) => not_found(&format!("{entity}/{id}")),
Err(e) => internal_error(&format!("storage: {e}")),
}
})
}
-pub fn create_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn create_route(
+ state: Arc<Mutex<CrmState>>,
+ entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |req: &Request| {
let body = match parse_body(req) {
Ok(v) => v,
@@ -156,7 +166,10 @@ pub fn create_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dy
})
}
-pub fn update_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn update_route(
+ state: Arc<Mutex<CrmState>>,
+ entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |req: &Request| {
let id = match extract_id(&req.path) {
Some(id) => id,
@@ -179,12 +192,10 @@ pub fn update_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dy
let key = build_key(coll_name, id);
let existing = match db.get(&key) {
- Ok(Some(val)) => {
- match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
- Ok(v) => v,
- Err(_) => return internal_error("corrupted data"),
- }
- }
+ Ok(Some(val)) => match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
+ Ok(v) => v,
+ Err(_) => return internal_error("corrupted data"),
+ },
Ok(None) => return not_found(&format!("{entity}/{id}")),
Err(e) => return internal_error(&format!("storage: {e}")),
};
@@ -241,7 +252,10 @@ pub fn update_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dy
})
}
-pub fn delete_route(state: Arc<Mutex<CrmState>>, entity: &'static str) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn delete_route(
+ state: Arc<Mutex<CrmState>>,
+ entity: &'static str,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |req: &Request| {
let id = match extract_id(&req.path) {
Some(id) => id,
diff --git a/crates/crm-api/src/handlers/meta.rs b/crates/crm-api/src/handlers/meta.rs
index 69b4551..7010eef 100644
--- a/crates/crm-api/src/handlers/meta.rs
+++ b/crates/crm-api/src/handlers/meta.rs
@@ -9,32 +9,55 @@ pub fn meta_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Respon
Ok(s) => s,
Err(_) => return internal_error("lock poisoned"),
};
- let objects: Vec<Value> = state.objects.iter().map(|obj| {
- let fields: Vec<Value> = obj.fields.iter().map(|f| {
- Value::Object(vec![
- ("id".into(), Value::Int(f.id as i64)),
- ("name".into(), Value::Str(f.name.clone())),
- ("label".into(), Value::Str(f.label.clone())),
- ("type".into(), Value::Str(format!("{:?}", f.field_type).to_lowercase())),
- ("required".into(), Value::Bool(f.is_required)),
- ("unique".into(), Value::Bool(f.is_unique)),
- ("description".into(), Value::Str(f.description.clone().unwrap_or_default())),
- ])
- }).collect();
+ let objects: Vec<Value> = state
+ .objects
+ .iter()
+ .map(|obj| {
+ let fields: Vec<Value> = obj
+ .fields
+ .iter()
+ .map(|f| {
+ Value::Object(vec![
+ ("id".into(), Value::Int(f.id as i64)),
+ ("name".into(), Value::Str(f.name.clone())),
+ ("label".into(), Value::Str(f.label.clone())),
+ (
+ "type".into(),
+ Value::Str(format!("{:?}", f.field_type).to_lowercase()),
+ ),
+ ("required".into(), Value::Bool(f.is_required)),
+ ("unique".into(), Value::Bool(f.is_unique)),
+ (
+ "description".into(),
+ Value::Str(f.description.clone().unwrap_or_default()),
+ ),
+ ])
+ })
+ .collect();
- Value::Object(vec![
- ("id".into(), Value::Int(obj.id as i64)),
- ("nameSingular".into(), Value::Str(obj.name_singular.clone())),
- ("namePlural".into(), Value::Str(obj.name_plural.clone())),
- ("labelSingular".into(), Value::Str(obj.label_singular.clone())),
- ("labelPlural".into(), Value::Str(obj.label_plural.clone())),
- ("description".into(), Value::Str(obj.description.clone().unwrap_or_default())),
- ("icon".into(), Value::Str(obj.icon.clone().unwrap_or_default())),
- ("isCustom".into(), Value::Bool(obj.is_custom)),
- ("isSearchable".into(), Value::Bool(obj.is_searchable)),
- ("fields".into(), Value::Array(fields)),
- ])
- }).collect();
+ Value::Object(vec![
+ ("id".into(), Value::Int(obj.id as i64)),
+ ("nameSingular".into(), Value::Str(obj.name_singular.clone())),
+ ("namePlural".into(), Value::Str(obj.name_plural.clone())),
+ (
+ "labelSingular".into(),
+ Value::Str(obj.label_singular.clone()),
+ ),
+ ("labelPlural".into(), Value::Str(obj.label_plural.clone())),
+ (
+ "description".into(),
+ Value::Str(obj.description.clone().unwrap_or_default()),
+ ),
+ (
+ "icon".into(),
+ Value::Str(obj.icon.clone().unwrap_or_default()),
+ ),
+ ("isCustom".into(), Value::Bool(obj.is_custom)),
+ ("isSearchable".into(), Value::Bool(obj.is_searchable)),
+ ("fields".into(), Value::Array(fields)),
+ ])
+ })
+ .collect();
json_response(Value::Object(vec![
("version".into(), Value::Str("0.1.0".into())),
diff --git a/crates/crm-api/src/handlers/search.rs b/crates/crm-api/src/handlers/search.rs
index 7e68fe5..3eb958c 100644
--- a/crates/crm-api/src/handlers/search.rs
+++ b/crates/crm-api/src/handlers/search.rs
@@ -23,16 +23,21 @@ fn url_decode(s: &str) -> String {
fn json_values_contain(val: &akurai_json::Value, term: &str) -> bool {
match val {
akurai_json::Value::Str(s) => s.to_lowercase().contains(term),
- akurai_json::Value::Object(pairs) => pairs.iter().any(|(_, v)| json_values_contain(v, term)),
+ akurai_json::Value::Object(pairs) => {
+ pairs.iter().any(|(_, v)| json_values_contain(v, term))
+ }
akurai_json::Value::Array(items) => items.iter().any(|v| json_values_contain(v, term)),
_ => false,
}
}
-pub fn search_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn search_route(
+ state: Arc<Mutex<CrmState>>,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |req: &Request| {
let query = req.query.as_deref().unwrap_or("");
- let search_term = query.split('&')
+ let search_term = query
+ .split('&')
.find_map(|pair| {
let mut parts = pair.splitn(2, '=');
if parts.next()? == "q" {
@@ -60,16 +65,27 @@ pub fn search_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Resp
};
let mut results = Vec::new();
- let entity_prefixes = ["people:", "companies:", "opportunities:", "tasks:", "notes:"];
+ let entity_prefixes = [
+ "people:",
+ "companies:",
+ "opportunities:",
+ "tasks:",
+ "notes:",
+ ];
for prefix_str in &entity_prefixes {
let prefix = prefix_str.as_bytes();
let end = upper_bound(prefix);
if let Ok(entries) = db.range(prefix, &end) {
for (_key_bytes, val_bytes) in entries {
- if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or("")) {
+ if let Ok(json) =
+ akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
+ {
if json_values_contain(&json, &search_term) {
let entry = vec![
- ("entity".into(), Value::Str(prefix_str.trim_end_matches(':').into())),
+ (
+ "entity".into(),
+ Value::Str(prefix_str.trim_end_matches(':').into()),
+ ),
("record".into(), json),
];
results.push(Value::Object(entry));
diff --git a/crates/crm-api/src/handlers/static_files.rs b/crates/crm-api/src/handlers/static_files.rs
index 7ebc5db..c29615b 100644
--- a/crates/crm-api/src/handlers/static_files.rs
+++ b/crates/crm-api/src/handlers/static_files.rs
@@ -2,7 +2,9 @@ use crate::router_setup::{internal_error, not_found, CrmState};
use akurai_http::{Request, Response};
use std::sync::{Arc, Mutex};
-pub fn static_file_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn static_file_route(
+ state: Arc<Mutex<CrmState>>,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |req: &Request| {
let path = &req.path;
let state = match state.lock() {
@@ -19,8 +21,11 @@ pub fn static_file_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) ->
};
let canonical = std::fs::canonicalize(&file_path).ok();
- let frontend_canonical = std::fs::canonicalize(frontend).unwrap_or_else(|_| std::path::PathBuf::from("/nonexistent"));
- let is_safe = canonical.as_ref().is_some_and(|c| c.starts_with(&frontend_canonical));
+ let frontend_canonical = std::fs::canonicalize(frontend)
+ .unwrap_or_else(|_| std::path::PathBuf::from("/nonexistent"));
+ let is_safe = canonical
+ .as_ref()
+ .is_some_and(|c| c.starts_with(&frontend_canonical));
if !is_safe {
return not_found("invalid path");
diff --git a/crates/crm-api/src/handlers/timeline.rs b/crates/crm-api/src/handlers/timeline.rs
index 7bd58e1..ea5bdd0 100644
--- a/crates/crm-api/src/handlers/timeline.rs
+++ b/crates/crm-api/src/handlers/timeline.rs
@@ -3,7 +3,9 @@ use akurai_http::{Request, Response};
use akurai_json::Value;
use std::sync::{Arc, Mutex};
-pub fn timeline_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
+pub fn timeline_route(
+ state: Arc<Mutex<CrmState>>,
+) -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(move |req: &Request| {
let query = req.query.as_deref().unwrap_or("");
let mut entity_type = String::new();
@@ -30,15 +32,18 @@ pub fn timeline_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Re
let mut parts = pair.splitn(2, '=');
match parts.next() {
Some("entityType") => entity_type = url_decode(parts.next().unwrap_or("")),
- Some("entityId") => entity_id = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0),
+ Some("entityId") => {
+ entity_id = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0)
+ }
_ => {}
}
}
if entity_type.is_empty() || entity_id == 0 {
- return json_response(Value::Object(vec![
- ("timeline".into(), Value::Array(vec![])),
- ]));
+ return json_response(Value::Object(vec![(
+ "timeline".into(),
+ Value::Array(vec![]),
+ )]));
}
let state = match state.lock() {
@@ -55,7 +60,8 @@ pub fn timeline_route(state: Arc<Mutex<CrmState>>) -> Box<dyn Fn(&Request) -> Re
let end = upper_bound(&prefix);
if let Ok(entries) = db.range(&prefix, &end) {
for (_key_bytes, val_bytes) in entries {
- if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or("")) {
+ if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or(""))
+ {
activities.push(json);
}
}
@@ -79,12 +85,19 @@ pub fn record_timeline(
actor_id: Option<u64>,
) -> Result<(), String> {
use std::time::{SystemTime, UNIX_EPOCH};
- let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
let rand_suffix = RandomState::new().build_hasher().finish() % 10000;
- let key = format!("timeline:{}:{}:{}_{}", entity_type, entity_id, now, rand_suffix).into_bytes();
+ let key = format!(
+ "timeline:{}:{}:{}_{}",
+ entity_type, entity_id, now, rand_suffix
+ )
+ .into_bytes();
let record = Value::Object(vec![
("action".into(), Value::Str(action.into())),
diff --git a/crates/crm-api/src/router_setup.rs b/crates/crm-api/src/router_setup.rs
index 4d75f67..3bfdf93 100644
--- a/crates/crm-api/src/router_setup.rs
+++ b/crates/crm-api/src/router_setup.rs
@@ -32,12 +32,23 @@ impl CrmState {
pub fn now() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
- SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64
}
}
-pub fn r(state: &'static str, p: &'static str, h: Box<dyn Fn(&Request) -> Response + Send + Sync>) -> Route {
- Route { method: state, pattern: p, handler: h }
+pub fn r(
+ state: &'static str,
+ p: &'static str,
+ h: Box<dyn Fn(&Request) -> Response + Send + Sync>,
+) -> Route {
+ Route {
+ method: state,
+ pattern: p,
+ handler: h,
+ }
}
/// Build the route table for the CRM API
@@ -46,34 +57,146 @@ pub fn build_router(state: Arc<Mutex<CrmState>>) -> Vec<Route> {
r("OPTIONS", "/*", options_handler()),
r("GET", "/", handlers::static_file_route(Arc::clone(&state))),
r("GET", "/*", handlers::static_file_route(Arc::clone(&state))),
- r("GET", "/api/_meta", handlers::meta_route(Arc::clone(&state))),
- r("GET", "/api/people", handlers::list_route(Arc::clone(&state), "people")),
- r("GET", "/api/people/:id", handlers::get_route(Arc::clone(&state), "people")),
- r("POST", "/api/people", handlers::create_route(Arc::clone(&state), "people")),
- r("PUT", "/api/people/:id", handlers::update_route(Arc::clone(&state), "people")),
- r("DELETE", "/api/people/:id", handlers::delete_route(Arc::clone(&state), "people")),
- r("GET", "/api/companies", handlers::list_route(Arc::clone(&state), "companies")),
- r("GET", "/api/companies/:id", handlers::get_route(Arc::clone(&state), "companies")),
- r("POST", "/api/companies", handlers::create_route(Arc::clone(&state), "companies")),
- r("PUT", "/api/companies/:id", handlers::update_route(Arc::clone(&state), "companies")),
- r("DELETE", "/api/companies/:id", handlers::delete_route(Arc::clone(&state), "companies")),
- r("GET", "/api/opportunities", handlers::list_route(Arc::clone(&state), "opportunities")),
- r("GET", "/api/opportunities/:id", handlers::get_route(Arc::clone(&state), "opportunities")),
- r("POST", "/api/opportunities", handlers::create_route(Arc::clone(&state), "opportunities")),
- r("PUT", "/api/opportunities/:id", handlers::update_route(Arc::clone(&state), "opportunities")),
- r("DELETE", "/api/opportunities/:id", handlers::delete_route(Arc::clone(&state), "opportunities")),
- r("GET", "/api/tasks", handlers::list_route(Arc::clone(&state), "tasks")),
- r("GET", "/api/tasks/:id", handlers::get_route(Arc::clone(&state), "tasks")),
- r("POST", "/api/tasks", handlers::create_route(Arc::clone(&state), "tasks")),
- r("PUT", "/api/tasks/:id", handlers::update_route(Arc::clone(&state), "tasks")),
- r("DELETE", "/api/tasks/:id", handlers::delete_route(Arc::clone(&state), "tasks")),
- r("GET", "/api/notes", handlers::list_route(Arc::clone(&state), "notes")),
- r("GET", "/api/notes/:id", handlers::get_route(Arc::clone(&state), "notes")),
- r("POST", "/api/notes", handlers::create_route(Arc::clone(&state), "notes")),
- r("PUT", "/api/notes/:id", handlers::update_route(Arc::clone(&state), "notes")),
- r("DELETE", "/api/notes/:id", handlers::delete_route(Arc::clone(&state), "notes")),
- r("GET", "/api/search", handlers::search_route(Arc::clone(&state))),
- r("GET", "/api/timeline", handlers::timeline_route(Arc::clone(&state))),
+ r(
+ "GET",
+ "/api/_meta",
+ handlers::meta_route(Arc::clone(&state)),
+ ),
+ r(
+ "GET",
+ "/api/people",
+ handlers::list_route(Arc::clone(&state), "people"),
+ ),
+ r(
+ "GET",
+ "/api/people/:id",
+ handlers::get_route(Arc::clone(&state), "people"),
+ ),
+ r(
+ "POST",
+ "/api/people",
+ handlers::create_route(Arc::clone(&state), "people"),
+ ),
+ r(
+ "PUT",
+ "/api/people/:id",
+ handlers::update_route(Arc::clone(&state), "people"),
+ ),
+ r(
+ "DELETE",
+ "/api/people/:id",
+ handlers::delete_route(Arc::clone(&state), "people"),
+ ),
+ r(
+ "GET",
+ "/api/companies",
+ handlers::list_route(Arc::clone(&state), "companies"),
+ ),
+ r(
+ "GET",
+ "/api/companies/:id",
+ handlers::get_route(Arc::clone(&state), "companies"),
+ ),
+ r(
+ "POST",
+ "/api/companies",
+ handlers::create_route(Arc::clone(&state), "companies"),
+ ),
+ r(
+ "PUT",
+ "/api/companies/:id",
+ handlers::update_route(Arc::clone(&state), "companies"),
+ ),
+ r(
+ "DELETE",
+ "/api/companies/:id",
+ handlers::delete_route(Arc::clone(&state), "companies"),
+ ),
+ r(
+ "GET",
+ "/api/opportunities",
+ handlers::list_route(Arc::clone(&state), "opportunities"),
+ ),
+ r(
+ "GET",
+ "/api/opportunities/:id",
+ handlers::get_route(Arc::clone(&state), "opportunities"),
+ ),
+ r(
+ "POST",
+ "/api/opportunities",
+ handlers::create_route(Arc::clone(&state), "opportunities"),
+ ),
+ r(
+ "PUT",
+ "/api/opportunities/:id",
+ handlers::update_route(Arc::clone(&state), "opportunities"),
+ ),
+ r(
+ "DELETE",
+ "/api/opportunities/:id",
+ handlers::delete_route(Arc::clone(&state), "opportunities"),
+ ),
+ r(
+ "GET",
+ "/api/tasks",
+ handlers::list_route(Arc::clone(&state), "tasks"),
+ ),
+ r(
+ "GET",
+ "/api/tasks/:id",
+ handlers::get_route(Arc::clone(&state), "tasks"),
+ ),
+ r(
+ "POST",
+ "/api/tasks",
+ handlers::create_route(Arc::clone(&state), "tasks"),
+ ),
+ r(
+ "PUT",
+ "/api/tasks/:id",
+ handlers::update_route(Arc::clone(&state), "tasks"),
+ ),
+ r(
+ "DELETE",
+ "/api/tasks/:id",
+ handlers::delete_route(Arc::clone(&state), "tasks"),
+ ),
+ r(
+ "GET",
+ "/api/notes",
+ handlers::list_route(Arc::clone(&state), "notes"),
+ ),
+ r(
+ "GET",
+ "/api/notes/:id",
+ handlers::get_route(Arc::clone(&state), "notes"),
+ ),
+ r(
+ "POST",
+ "/api/notes",
+ handlers::create_route(Arc::clone(&state), "notes"),
+ ),
+ r(
+ "PUT",
+ "/api/notes/:id",
+ handlers::update_route(Arc::clone(&state), "notes"),
+ ),
+ r(
+ "DELETE",
+ "/api/notes/:id",
+ handlers::delete_route(Arc::clone(&state), "notes"),
+ ),
+ r(
+ "GET",
+ "/api/search",
+ handlers::search_route(Arc::clone(&state)),
+ ),
+ r(
+ "GET",
+ "/api/timeline",
+ handlers::timeline_route(Arc::clone(&state)),
+ ),
]
}
@@ -94,9 +217,8 @@ pub fn json_response(data: akurai_json::Value) -> Response {
}
pub fn error_response(status: u16, msg: &str) -> Response {
- let data = akurai_json::Value::Object(vec![
- ("error".into(), akurai_json::Value::Str(msg.into())),
- ]);
+ let data =
+ akurai_json::Value::Object(vec![("error".into(), akurai_json::Value::Str(msg.into()))]);
let mut resp = json_response(data);
resp.status = status;
resp.reason = match status {
@@ -104,7 +226,8 @@ pub fn error_response(status: u16, msg: &str) -> Response {
404 => "Not Found",
500 => "Internal Server Error",
_ => "Error",
- }.into();
+ }
+ .into();
resp
}
@@ -112,8 +235,14 @@ pub fn options_handler() -> Box<dyn Fn(&Request) -> Response + Send + Sync> {
Box::new(|_req: &Request| {
Response::new(204)
.with_header("Access-Control-Allow-Origin", "*")
- .with_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
- .with_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ .with_header(
+ "Access-Control-Allow-Methods",
+ "GET, POST, PUT, DELETE, OPTIONS",
+ )
+ .with_header(
+ "Access-Control-Allow-Headers",
+ "Content-Type, Authorization",
+ )
.with_header("Access-Control-Max-Age", "86400")
})
}
diff --git a/crates/crm-api/tests/api_test.rs b/crates/crm-api/tests/api_test.rs
index 6aa89fd..b68bdf6 100644
--- a/crates/crm-api/tests/api_test.rs
+++ b/crates/crm-api/tests/api_test.rs
@@ -1,8 +1,7 @@
-use crm_api::CrmState;
use akurai_json::Value;
+use crm_api::CrmState;
use std::path::PathBuf;
-
#[test]
fn test_create_and_list_people() {
let db_path = "_test_people.db";
@@ -12,7 +11,9 @@ fn test_create_and_list_people() {
let mut db = state.db.lock().unwrap();
let now = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
let record = Value::Object(vec![
("id".into(), Value::Int(1)),
@@ -33,7 +34,9 @@ fn test_create_and_list_people() {
let mut found = false;
if let Ok(entries) = db.range(b"people:", b"people:\xff") {
for (key, _) in entries {
- if key == b"people:1" { found = true; }
+ if key == b"people:1" {
+ found = true;
+ }
}
}
assert!(found, "should find the person in range scan");
diff --git a/crates/crm-cli/src/cmd_seed.rs b/crates/crm-cli/src/cmd_seed.rs
index a3c0786..4af41f6 100644
--- a/crates/crm-cli/src/cmd_seed.rs
+++ b/crates/crm-cli/src/cmd_seed.rs
@@ -1,5 +1,5 @@
-use crm_api::CrmState;
use akurai_json::Value;
+use crm_api::CrmState;
pub fn run(db_path: &str) -> Result<(), String> {
println!("🌱 Seeding CRM database: {db_path}");
@@ -10,7 +10,10 @@ pub fn run(db_path: &str) -> Result<(), String> {
let mut insert = |entity: &str, id: u64, fields: Vec<(&str, Value)>| -> Result<(), String> {
use std::time::{SystemTime, UNIX_EPOCH};
- let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
let mut obj = Vec::new();
obj.push(("id".into(), Value::Int(id as i64)));
obj.push(("createdAt".into(), Value::Int(now)));
@@ -26,140 +29,239 @@ pub fn run(db_path: &str) -> Result<(), String> {
};
// People
- insert("people", 1, vec![
- ("firstName", Value::Str("Anna".into())),
- ("lastName", Value::Str("Jónsdóttir".into())),
- ("email", Value::Str("anna@example.is".into())),
- ("jobTitle", Value::Str("CEO".into())),
- ("companyId", Value::Int(1)),
- ])?;
- insert("people", 2, vec![
- ("firstName", Value::Str("Björn".into())),
- ("lastName", Value::Str("Guðmundsson".into())),
- ("email", Value::Str("bjorn@example.is".into())),
- ("jobTitle", Value::Str("CTO".into())),
- ("companyId", Value::Int(1)),
- ])?;
- insert("people", 3, vec![
- ("firstName", Value::Str("Dóra".into())),
- ("lastName", Value::Str("Sigurðardóttir".into())),
- ("email", Value::Str("dora@acme.is".into())),
- ("jobTitle", Value::Str("VP Engineering".into())),
- ("companyId", Value::Int(2)),
- ])?;
- insert("people", 4, vec![
- ("firstName", Value::Str("Einar".into())),
- ("lastName", Value::Str("Pálsson".into())),
- ("email", Value::Str("einar@startup.is".into())),
- ("jobTitle", Value::Str("Founder".into())),
- ])?;
- insert("people", 5, vec![
- ("firstName", Value::Str("Fríða".into())),
- ("lastName", Value::Str("Jónsdóttir".into())),
- ("email", Value::Str("frida@nordic.is".into())),
- ("jobTitle", Value::Str("Sales Director".into())),
- ("companyId", Value::Int(3)),
- ])?;
+ insert(
+ "people",
+ 1,
+ vec![
+ ("firstName", Value::Str("Anna".into())),
+ ("lastName", Value::Str("Jónsdóttir".into())),
+ ("email", Value::Str("anna@example.is".into())),
+ ("jobTitle", Value::Str("CEO".into())),
+ ("companyId", Value::Int(1)),
+ ],
+ )?;
+ insert(
+ "people",
+ 2,
+ vec![
+ ("firstName", Value::Str("Björn".into())),
+ ("lastName", Value::Str("Guðmundsson".into())),
+ ("email", Value::Str("bjorn@example.is".into())),
+ ("jobTitle", Value::Str("CTO".into())),
+ ("companyId", Value::Int(1)),
+ ],
+ )?;
+ insert(
+ "people",
+ 3,
+ vec![
+ ("firstName", Value::Str("Dóra".into())),
+ ("lastName", Value::Str("Sigurðardóttir".into())),
+ ("email", Value::Str("dora@acme.is".into())),
+ ("jobTitle", Value::Str("VP Engineering".into())),
+ ("companyId", Value::Int(2)),
+ ],
+ )?;
+ insert(
+ "people",
+ 4,
+ vec![
+ ("firstName", Value::Str("Einar".into())),
+ ("lastName", Value::Str("Pálsson".into())),
+ ("email", Value::Str("einar@startup.is".into())),
+ ("jobTitle", Value::Str("Founder".into())),
+ ],
+ )?;
+ insert(
+ "people",
+ 5,
+ vec![
+ ("firstName", Value::Str("Fríða".into())),
+ ("lastName", Value::Str("Jónsdóttir".into())),
+ ("email", Value::Str("frida@nordic.is".into())),
+ ("jobTitle", Value::Str("Sales Director".into())),
+ ("companyId", Value::Int(3)),
+ ],
+ )?;
println!(" ✓ 5 people created");
// Companies
- insert("companies", 1, vec![
- ("name", Value::Str("AkurAI".into())),
- ("domainName", Value::Str("akurai.is".into())),
- ("annualRevenue", Value::Int(50_000_000)),
- ("employeeCount", Value::Int(25)),
- ("websiteUrl", Value::Str("https://akurai.is".into())),
- ])?;
- insert("companies", 2, vec![
- ("name", Value::Str("Acme Corp".into())),
- ("domainName", Value::Str("acme.is".into())),
- ("annualRevenue", Value::Int(1_200_000_000)),
- ("employeeCount", Value::Int(150)),
- ])?;
- insert("companies", 3, vec![
- ("name", Value::Str("Nordic Technologies".into())),
- ("domainName", Value::Str("nordic.is".into())),
- ("annualRevenue", Value::Int(850_000_000)),
- ("employeeCount", Value::Int(75)),
- ])?;
- insert("companies", 4, vec![
- ("name", Value::Str("Startup Iceland".into())),
- ("domainName", Value::Str("startup.is".into())),
- ("annualRevenue", Value::Int(50_000_000)),
- ("employeeCount", Value::Int(5)),
- ])?;
+ insert(
+ "companies",
+ 1,
+ vec![
+ ("name", Value::Str("AkurAI".into())),
+ ("domainName", Value::Str("akurai.is".into())),
+ ("annualRevenue", Value::Int(50_000_000)),
+ ("employeeCount", Value::Int(25)),
+ ("websiteUrl", Value::Str("https://akurai.is".into())),
+ ],
+ )?;
+ insert(
+ "companies",
+ 2,
+ vec![
+ ("name", Value::Str("Acme Corp".into())),
+ ("domainName", Value::Str("acme.is".into())),
+ ("annualRevenue", Value::Int(1_200_000_000)),
+ ("employeeCount", Value::Int(150)),
+ ],
+ )?;
+ insert(
+ "companies",
+ 3,
+ vec![
+ ("name", Value::Str("Nordic Technologies".into())),
+ ("domainName", Value::Str("nordic.is".into())),
+ ("annualRevenue", Value::Int(850_000_000)),
+ ("employeeCount", Value::Int(75)),
+ ],
+ )?;
+ insert(
+ "companies",
+ 4,
+ vec![
+ ("name", Value::Str("Startup Iceland".into())),
+ ("domainName", Value::Str("startup.is".into())),
+ ("annualRevenue", Value::Int(50_000_000)),
+ ("employeeCount", Value::Int(5)),
+ ],
+ )?;
println!(" ✓ 4 companies created");
// Opportunities
- insert("opportunities", 1, vec![
- ("name", Value::Str("AkurAI Platform Deal".into())),
- ("amount", Value::Int(25_000_000)),
- ("stage", Value::Str("negotiation".into())),
- ("probability", Value::Int(70)),
- ("personId", Value::Int(1)),
- ("companyId", Value::Int(1)),
- ])?;
- insert("opportunities", 2, vec![
- ("name", Value::Str("Acme Enterprise License".into())),
- ("amount", Value::Int(50_000_000)),
- ("stage", Value::Str("proposal".into())),
- ("probability", Value::Int(50)),
- ("personId", Value::Int(2)),
- ("companyId", Value::Int(2)),
- ])?;
- insert("opportunities", 3, vec![
- ("name", Value::Str("Nordic Consulting".into())),
- ("amount", Value::Int(8_500_000)),
- ("stage", Value::Str("meeting".into())),
- ("probability", Value::Int(30)),
- ("personId", Value::Int(5)),
- ("companyId", Value::Int(3)),
- ])?;
- insert("opportunities", 4, vec![
- ("name", Value::Str("Startup Iceland Partnership".into())),
- ("amount", Value::Int(2_500_000)),
- ("stage", Value::Str("new".into())),
- ("probability", Value::Int(10)),
- ("personId", Value::Int(4)),
- ("companyId", Value::Int(4)),
- ])?;
- insert("opportunities", 5, vec![
- ("name", Value::Str("Lost Deal - Legacy Migrate".into())),
- ("amount", Value::Int(10_000_000)),
- ("stage", Value::Str("lost".into())),
- ("probability", Value::Int(0)),
- ])?;
+ insert(
+ "opportunities",
+ 1,
+ vec![
+ ("name", Value::Str("AkurAI Platform Deal".into())),
+ ("amount", Value::Int(25_000_000)),
+ ("stage", Value::Str("negotiation".into())),
+ ("probability", Value::Int(70)),
+ ("personId", Value::Int(1)),
+ ("companyId", Value::Int(1)),
+ ],
+ )?;
+ insert(
+ "opportunities",
+ 2,
+ vec![
+ ("name", Value::Str("Acme Enterprise License".into())),
+ ("amount", Value::Int(50_000_000)),
+ ("stage", Value::Str("proposal".into())),
+ ("probability", Value::Int(50)),
+ ("personId", Value::Int(2)),
+ ("companyId", Value::Int(2)),
+ ],
+ )?;
+ insert(
+ "opportunities",
+ 3,
+ vec![
+ ("name", Value::Str("Nordic Consulting".into())),
+ ("amount", Value::Int(8_500_000)),
+ ("stage", Value::Str("meeting".into())),
+ ("probability", Value::Int(30)),
+ ("personId", Value::Int(5)),
+ ("companyId", Value::Int(3)),
+ ],
+ )?;
+ insert(
+ "opportunities",
+ 4,
+ vec![
+ ("name", Value::Str("Startup Iceland Partnership".into())),
+ ("amount", Value::Int(2_500_000)),
+ ("stage", Value::Str("new".into())),
+ ("probability", Value::Int(10)),
+ ("personId", Value::Int(4)),
+ ("companyId", Value::Int(4)),
+ ],
+ )?;
+ insert(
+ "opportunities",
+ 5,
+ vec![
+ ("name", Value::Str("Lost Deal - Legacy Migrate".into())),
+ ("amount", Value::Int(10_000_000)),
+ ("stage", Value::Str("lost".into())),
+ ("probability", Value::Int(0)),
+ ],
+ )?;
println!(" ✓ 5 opportunities created");
// Tasks
- insert("tasks", 1, vec![
- ("title", Value::Str("Follow up with Anna about AkurAI deal".into())),
- ("status", Value::Str("in_progress".into())),
- ("dueAt", Value::Int(
- (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64) + 86400 * 3
- )),
- ])?;
- insert("tasks", 2, vec![
- ("title", Value::Str("Prepare proposal for Acme Corp".into())),
- ("status", Value::Str("todo".into())),
- ("dueAt", Value::Int(
- (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64) + 86400 * 7
- )),
- ])?;
- insert("tasks", 3, vec![
- ("title", Value::Str("Send NDA to Nordic Technologies".into())),
- ("status", Value::Str("done".into())),
- ])?;
- insert("tasks", 4, vec![
- ("title", Value::Str("Review Q3 pipeline forecast".into())),
- ("status", Value::Str("todo".into())),
- ("dueAt", Value::Int(
- (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64) + 86400 * 14
- )),
- ])?;
+ insert(
+ "tasks",
+ 1,
+ vec![
+ (
+ "title",
+ Value::Str("Follow up with Anna about AkurAI deal".into()),
+ ),
+ ("status", Value::Str("in_progress".into())),
+ (
+ "dueAt",
+ Value::Int(
+ (std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64)
+ + 86400 * 3,
+ ),
+ ),
+ ],
+ )?;
+ insert(
+ "tasks",
+ 2,
+ vec![
+ ("title", Value::Str("Prepare proposal for Acme Corp".into())),
+ ("status", Value::Str("todo".into())),
+ (
+ "dueAt",
+ Value::Int(
+ (std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64)
+ + 86400 * 7,
+ ),
+ ),
+ ],
+ )?;
+ insert(
+ "tasks",
+ 3,
+ vec![
+ (
+ "title",
+ Value::Str("Send NDA to Nordic Technologies".into()),
+ ),
+ ("status", Value::Str("done".into())),
+ ],
+ )?;
+ insert(
+ "tasks",
+ 4,
+ vec![
+ ("title", Value::Str("Review Q3 pipeline forecast".into())),
+ ("status", Value::Str("todo".into())),
+ (
+ "dueAt",
+ Value::Int(
+ (std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64)
+ + 86400 * 14,
+ ),
+ ),
+ ],
+ )?;
println!(" ✓ 4 tasks created");
diff --git a/crates/crm-cli/src/cmd_serve.rs b/crates/crm-cli/src/cmd_serve.rs
index 74c864e..772684f 100644
--- a/crates/crm-cli/src/cmd_serve.rs
+++ b/crates/crm-cli/src/cmd_serve.rs
@@ -12,8 +12,7 @@ pub struct Config {
}
pub fn run(cfg: Config) -> io::Result<()> {
- let state = CrmState::new(&cfg.db, cfg.dir.clone())
- .map_err(io::Error::other)?;
+ let state = CrmState::new(&cfg.db, cfg.dir.clone()).map_err(io::Error::other)?;
let state = Arc::new(Mutex::new(state));
let routes = build_router(state);
diff --git a/crates/crm-cli/src/main.rs b/crates/crm-cli/src/main.rs
index 8803252..02cb93d 100644
--- a/crates/crm-cli/src/main.rs
+++ b/crates/crm-cli/src/main.rs
@@ -50,7 +50,9 @@ fn parse_serve(args: &[String]) -> Result<cmd_serve::Config, String> {
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
- "--host" => { host = next(args, &mut i, "--host")?; }
+ "--host" => {
+ host = next(args, &mut i, "--host")?;
+ }
"--port" | "-p" => {
port = next(args, &mut i, "--port")?
.parse()
@@ -66,7 +68,12 @@ fn parse_serve(args: &[String]) -> Result<cmd_serve::Config, String> {
}
i += 1;
}
- Ok(cmd_serve::Config { host, port, dir, db })
+ Ok(cmd_serve::Config {
+ host,
+ port,
+ dir,
+ db,
+ })
}
fn next(args: &[String], i: &mut usize, flag: &str) -> Result<String, String> {
diff --git a/crates/crm-core/src/entities/company.rs b/crates/crm-core/src/entities/company.rs
index 356cfc0..6a6407b 100644
--- a/crates/crm-core/src/entities/company.rs
+++ b/crates/crm-core/src/entities/company.rs
@@ -25,7 +25,10 @@ impl Company {
fields.push(("domainName".into(), Value::Str(d.clone())));
}
if let Some(ref a) = self.address {
- fields.push(("address".into(), Value::Str(format!("{}, {}, {} {}", a.street, a.city, a.state, a.zip))));
+ fields.push((
+ "address".into(),
+ Value::Str(format!("{}, {}, {} {}", a.street, a.city, a.state, a.zip)),
+ ));
}
if let Some(r) = self.annual_revenue {
fields.push(("annualRevenue".into(), Value::Int(r)));
@@ -48,12 +51,24 @@ impl Company {
use akurai_json::Value;
let pairs = match val {
Value::Object(p) => p,
- _ => return Err(crate::CoreError::InvalidEntity("expected object for Company".into())),
+ _ => {
+ return Err(crate::CoreError::InvalidEntity(
+ "expected object for Company".into(),
+ ))
+ }
};
let mut c = Company {
- id, name: String::new(), domain_name: None, address: None,
- annual_revenue: None, employee_count: None, linkedin_url: None,
- website_url: None, created_at: 0, updated_at: 0, created_by: None,
+ id,
+ name: String::new(),
+ domain_name: None,
+ address: None,
+ annual_revenue: None,
+ employee_count: None,
+ linkedin_url: None,
+ website_url: None,
+ created_at: 0,
+ updated_at: 0,
+ created_by: None,
};
for (k, v) in pairs {
match k.as_str() {
@@ -69,7 +84,9 @@ impl Company {
}
}
if c.name.is_empty() {
- return Err(crate::CoreError::Validation("company name is required".into()));
+ return Err(crate::CoreError::Validation(
+ "company name is required".into(),
+ ));
}
Ok(c)
}
diff --git a/crates/crm-core/src/entities/mod.rs b/crates/crm-core/src/entities/mod.rs
index 6d4ae47..a011d71 100644
--- a/crates/crm-core/src/entities/mod.rs
+++ b/crates/crm-core/src/entities/mod.rs
@@ -1,13 +1,13 @@
-mod person;
mod company;
-mod opportunity;
-mod task;
mod note;
+mod opportunity;
+mod person;
+mod task;
mod timeline;
-pub use person::Person;
pub use company::Company;
-pub use opportunity::Opportunity;
-pub use task::Task;
pub use note::Note;
+pub use opportunity::Opportunity;
+pub use person::Person;
+pub use task::Task;
pub use timeline::TimelineActivity;
diff --git a/crates/crm-core/src/entities/note.rs b/crates/crm-core/src/entities/note.rs
index b5704be..5091dc3 100644
--- a/crates/crm-core/src/entities/note.rs
+++ b/crates/crm-core/src/entities/note.rs
@@ -34,11 +34,22 @@ impl Note {
use akurai_json::Value;
let pairs = match val {
Value::Object(p) => p,
- _ => return Err(crate::CoreError::InvalidEntity("expected object for Note".into())),
+ _ => {
+ return Err(crate::CoreError::InvalidEntity(
+ "expected object for Note".into(),
+ ))
+ }
};
let mut n = Note {
- id, title: None, body: String::new(), person_ids: vec![], company_ids: vec![],
- opportunity_ids: vec![], created_at: 0, updated_at: 0, created_by: None,
+ id,
+ title: None,
+ body: String::new(),
+ person_ids: vec![],
+ company_ids: vec![],
+ opportunity_ids: vec![],
+ created_at: 0,
+ updated_at: 0,
+ created_by: None,
};
for (k, v) in pairs {
match k.as_str() {
@@ -49,17 +60,26 @@ impl Note {
_ => {
if k == "personIds" {
if let Value::Array(arr) = v {
- n.person_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+ n.person_ids = arr
+ .iter()
+ .filter_map(|x| x.as_i64().map(|n| n as u64))
+ .collect();
}
}
if k == "companyIds" {
if let Value::Array(arr) = v {
- n.company_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+ n.company_ids = arr
+ .iter()
+ .filter_map(|x| x.as_i64().map(|n| n as u64))
+ .collect();
}
}
if k == "opportunityIds" {
if let Value::Array(arr) = v {
- n.opportunity_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+ n.opportunity_ids = arr
+ .iter()
+ .filter_map(|x| x.as_i64().map(|n| n as u64))
+ .collect();
}
}
}
diff --git a/crates/crm-core/src/entities/opportunity.rs b/crates/crm-core/src/entities/opportunity.rs
index c9b594c..e2f96b4 100644
--- a/crates/crm-core/src/entities/opportunity.rs
+++ b/crates/crm-core/src/entities/opportunity.rs
@@ -46,12 +46,24 @@ impl Opportunity {
use akurai_json::Value;
let pairs = match val {
Value::Object(p) => p,
- _ => return Err(crate::CoreError::InvalidEntity("expected object for Opportunity".into())),
+ _ => {
+ return Err(crate::CoreError::InvalidEntity(
+ "expected object for Opportunity".into(),
+ ))
+ }
};
let mut o = Opportunity {
- id, name: String::new(), amount: None, stage: PipelineStage::New,
- close_date: None, probability: None, person_id: None, company_id: None,
- created_at: 0, updated_at: 0, created_by: None,
+ id,
+ name: String::new(),
+ amount: None,
+ stage: PipelineStage::New,
+ close_date: None,
+ probability: None,
+ person_id: None,
+ company_id: None,
+ created_at: 0,
+ updated_at: 0,
+ created_by: None,
};
for (k, v) in pairs {
match k.as_str() {
@@ -79,7 +91,9 @@ impl Opportunity {
}
}
if o.name.is_empty() {
- return Err(crate::CoreError::Validation("opportunity name is required".into()));
+ return Err(crate::CoreError::Validation(
+ "opportunity name is required".into(),
+ ));
}
Ok(o)
}
diff --git a/crates/crm-core/src/entities/person.rs b/crates/crm-core/src/entities/person.rs
index 08e1559..66a6332 100644
--- a/crates/crm-core/src/entities/person.rs
+++ b/crates/crm-core/src/entities/person.rs
@@ -52,7 +52,11 @@ impl Person {
use akurai_json::Value;
let pairs = match val {
Value::Object(p) => p,
- _ => return Err(crate::CoreError::InvalidEntity("expected object for Person".into())),
+ _ => {
+ return Err(crate::CoreError::InvalidEntity(
+ "expected object for Person".into(),
+ ))
+ }
};
let mut p = Person {
id,
@@ -71,8 +75,18 @@ impl Person {
match k.as_str() {
"firstName" => p.name.first = v.as_str().unwrap_or("").to_string(),
"lastName" => p.name.last = v.as_str().unwrap_or("").to_string(),
- "email" => p.email = Some(Email { address: v.as_str().unwrap_or("").to_string(), label: "work".into() }),
- "phone" => p.phone = Some(Phone { number: v.as_str().unwrap_or("").to_string(), label: "mobile".into() }),
+ "email" => {
+ p.email = Some(Email {
+ address: v.as_str().unwrap_or("").to_string(),
+ label: "work".into(),
+ })
+ }
+ "phone" => {
+ p.phone = Some(Phone {
+ number: v.as_str().unwrap_or("").to_string(),
+ label: "mobile".into(),
+ })
+ }
"jobTitle" => p.job_title = Some(v.as_str().unwrap_or("").to_string()),
"companyId" => p.company_id = v.as_i64().map(|n| n as u64),
"linkedinUrl" => p.linkedin_url = Some(v.as_str().unwrap_or("").to_string()),
diff --git a/crates/crm-core/src/entities/task.rs b/crates/crm-core/src/entities/task.rs
index 99baed7..674e4f9 100644
--- a/crates/crm-core/src/entities/task.rs
+++ b/crates/crm-core/src/entities/task.rs
@@ -7,9 +7,9 @@ pub struct Task {
pub description: Option<String>, // rich text / markdown
pub status: TaskStatus,
pub due_at: Option<Timestamp>,
- pub assignee_id: Option<RecordId>, // person or user
- pub person_ids: Vec<RecordId>, // linked people
- pub company_ids: Vec<RecordId>, // linked companies
+ pub assignee_id: Option<RecordId>, // person or user
+ pub person_ids: Vec<RecordId>, // linked people
+ pub company_ids: Vec<RecordId>, // linked companies
pub opportunity_ids: Vec<RecordId>, // linked opps
pub created_at: Timestamp,
pub updated_at: Timestamp,
@@ -44,12 +44,25 @@ impl Task {
use akurai_json::Value;
let pairs = match val {
Value::Object(p) => p,
- _ => return Err(crate::CoreError::InvalidEntity("expected object for Task".into())),
+ _ => {
+ return Err(crate::CoreError::InvalidEntity(
+ "expected object for Task".into(),
+ ))
+ }
};
let mut t = Task {
- id, title: String::new(), description: None, status: TaskStatus::Todo,
- due_at: None, assignee_id: None, person_ids: vec![], company_ids: vec![],
- opportunity_ids: vec![], created_at: 0, updated_at: 0, created_by: None,
+ id,
+ title: String::new(),
+ description: None,
+ status: TaskStatus::Todo,
+ due_at: None,
+ assignee_id: None,
+ person_ids: vec![],
+ company_ids: vec![],
+ opportunity_ids: vec![],
+ created_at: 0,
+ updated_at: 0,
+ created_by: None,
};
for (k, v) in pairs {
match k.as_str() {
@@ -71,24 +84,35 @@ impl Task {
_ => {
if k == "personIds" {
if let Value::Array(arr) = v {
- t.person_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+ t.person_ids = arr
+ .iter()
+ .filter_map(|x| x.as_i64().map(|n| n as u64))
+ .collect();
}
}
if k == "companyIds" {
if let Value::Array(arr) = v {
- t.company_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+ t.company_ids = arr
+ .iter()
+ .filter_map(|x| x.as_i64().map(|n| n as u64))
+ .collect();
}
}
if k == "opportunityIds" {
if let Value::Array(arr) = v {
- t.opportunity_ids = arr.iter().filter_map(|x| x.as_i64().map(|n| n as u64)).collect();
+ t.opportunity_ids = arr
+ .iter()
+ .filter_map(|x| x.as_i64().map(|n| n as u64))
+ .collect();
}
}
}
}
}
if t.title.is_empty() {
- return Err(crate::CoreError::Validation("task title is required".into()));
+ return Err(crate::CoreError::Validation(
+ "task title is required".into(),
+ ));
}
Ok(t)
}
diff --git a/crates/crm-core/src/entities/timeline.rs b/crates/crm-core/src/entities/timeline.rs
index 76784bc..3e786dd 100644
--- a/crates/crm-core/src/entities/timeline.rs
+++ b/crates/crm-core/src/entities/timeline.rs
@@ -17,7 +17,10 @@ impl TimelineActivity {
use akurai_json::Value;
let mut fields = vec![
("id".into(), Value::Int(self.id as i64)),
- ("entityType".into(), Value::Str(self.entity_type.singular().into())),
+ (
+ "entityType".into(),
+ Value::Str(self.entity_type.singular().into()),
+ ),
("entityId".into(), Value::Int(self.entity_id as i64)),
("action".into(), Value::Str(self.action.clone())),
];
diff --git a/crates/crm-core/src/metadata/mod.rs b/crates/crm-core/src/metadata/mod.rs
index 127690b..afd41c2 100644
--- a/crates/crm-core/src/metadata/mod.rs
+++ b/crates/crm-core/src/metadata/mod.rs
@@ -83,12 +83,78 @@ impl ObjectMetadata {
description: Some("A contact person".into()),
icon: Some("user".into()),
fields: vec![
- FieldMetadata { id: 1, name: "name".into(), label: "Name".into(), field_type: FieldType::FullName, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 2, name: "email".into(), label: "Email".into(), field_type: FieldType::Email, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 3, name: "phone".into(), label: "Phone".into(), field_type: FieldType::Phone, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 4, name: "jobTitle".into(), label: "Job Title".into(), field_type: FieldType::Text, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 5, name: "companyId".into(), label: "Company".into(), field_type: FieldType::Relation, description: Some("Related company".into()), is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 6, name: "linkedinUrl".into(), label: "LinkedIn".into(), field_type: FieldType::Link, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+ FieldMetadata {
+ id: 1,
+ name: "name".into(),
+ label: "Name".into(),
+ field_type: FieldType::FullName,
+ description: None,
+ is_required: true,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 2,
+ name: "email".into(),
+ label: "Email".into(),
+ field_type: FieldType::Email,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 3,
+ name: "phone".into(),
+ label: "Phone".into(),
+ field_type: FieldType::Phone,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 4,
+ name: "jobTitle".into(),
+ label: "Job Title".into(),
+ field_type: FieldType::Text,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 5,
+ name: "companyId".into(),
+ label: "Company".into(),
+ field_type: FieldType::Relation,
+ description: Some("Related company".into()),
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 6,
+ name: "linkedinUrl".into(),
+ label: "LinkedIn".into(),
+ field_type: FieldType::Link,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
],
is_custom: false,
is_searchable: true,
@@ -105,12 +171,78 @@ impl ObjectMetadata {
description: Some("An organization or business".into()),
icon: Some("building".into()),
fields: vec![
- FieldMetadata { id: 7, name: "name".into(), label: "Name".into(), field_type: FieldType::Text, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 8, name: "domainName".into(), label: "Domain".into(), field_type: FieldType::Text, description: None, is_required: false, is_unique: true, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 9, name: "annualRevenue".into(), label: "Annual Revenue".into(), field_type: FieldType::Currency, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 10, name: "employeeCount".into(), label: "Employees".into(), field_type: FieldType::Number, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 11, name: "websiteUrl".into(), label: "Website".into(), field_type: FieldType::Link, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 12, name: "linkedinUrl".into(), label: "LinkedIn".into(), field_type: FieldType::Link, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+ FieldMetadata {
+ id: 7,
+ name: "name".into(),
+ label: "Name".into(),
+ field_type: FieldType::Text,
+ description: None,
+ is_required: true,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 8,
+ name: "domainName".into(),
+ label: "Domain".into(),
+ field_type: FieldType::Text,
+ description: None,
+ is_required: false,
+ is_unique: true,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 9,
+ name: "annualRevenue".into(),
+ label: "Annual Revenue".into(),
+ field_type: FieldType::Currency,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 10,
+ name: "employeeCount".into(),
+ label: "Employees".into(),
+ field_type: FieldType::Number,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 11,
+ name: "websiteUrl".into(),
+ label: "Website".into(),
+ field_type: FieldType::Link,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 12,
+ name: "linkedinUrl".into(),
+ label: "LinkedIn".into(),
+ field_type: FieldType::Link,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
],
is_custom: false,
is_searchable: true,
@@ -127,21 +259,126 @@ impl ObjectMetadata {
description: Some("A sales deal or pipeline entry".into()),
icon: Some("trending-up".into()),
fields: vec![
- FieldMetadata { id: 13, name: "name".into(), label: "Name".into(), field_type: FieldType::Text, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 14, name: "amount".into(), label: "Amount".into(), field_type: FieldType::Currency, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 15, name: "stage".into(), label: "Stage".into(), field_type: FieldType::Select, description: Some("Pipeline stage".into()), is_required: true, is_unique: false, is_custom: false, default_value: Some(Value::Str("new".into())), options: vec![
- FieldOption { label: "New".into(), value: "new".into(), color: Some("#94a3b8".into()) },
- FieldOption { label: "Screening".into(), value: "screening".into(), color: Some("#60a5fa".into()) },
- FieldOption { label: "Meeting".into(), value: "meeting".into(), color: Some("#818cf8".into()) },
- FieldOption { label: "Proposal".into(), value: "proposal".into(), color: Some("#a78bfa".into()) },
- FieldOption { label: "Negotiation".into(), value: "negotiation".into(), color: Some("#f59e0b".into()) },
- FieldOption { label: "Won".into(), value: "won".into(), color: Some("#22c55e".into()) },
- FieldOption { label: "Lost".into(), value: "lost".into(), color: Some("#ef4444".into()) },
- ]},
- FieldMetadata { id: 16, name: "closeDate".into(), label: "Close Date".into(), field_type: FieldType::Date, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 17, name: "probability".into(), label: "Probability".into(), field_type: FieldType::Number, description: Some("Win probability (%)".into()), is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 18, name: "personId".into(), label: "Contact".into(), field_type: FieldType::Relation, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 19, name: "companyId".into(), label: "Company".into(), field_type: FieldType::Relation, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+ FieldMetadata {
+ id: 13,
+ name: "name".into(),
+ label: "Name".into(),
+ field_type: FieldType::Text,
+ description: None,
+ is_required: true,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 14,
+ name: "amount".into(),
+ label: "Amount".into(),
+ field_type: FieldType::Currency,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 15,
+ name: "stage".into(),
+ label: "Stage".into(),
+ field_type: FieldType::Select,
+ description: Some("Pipeline stage".into()),
+ is_required: true,
+ is_unique: false,
+ is_custom: false,
+ default_value: Some(Value::Str("new".into())),
+ options: vec![
+ FieldOption {
+ label: "New".into(),
+ value: "new".into(),
+ color: Some("#94a3b8".into()),
+ },
+ FieldOption {
+ label: "Screening".into(),
+ value: "screening".into(),
+ color: Some("#60a5fa".into()),
+ },
+ FieldOption {
+ label: "Meeting".into(),
+ value: "meeting".into(),
+ color: Some("#818cf8".into()),
+ },
+ FieldOption {
+ label: "Proposal".into(),
+ value: "proposal".into(),
+ color: Some("#a78bfa".into()),
+ },
+ FieldOption {
+ label: "Negotiation".into(),
+ value: "negotiation".into(),
+ color: Some("#f59e0b".into()),
+ },
+ FieldOption {
+ label: "Won".into(),
+ value: "won".into(),
+ color: Some("#22c55e".into()),
+ },
+ FieldOption {
+ label: "Lost".into(),
+ value: "lost".into(),
+ color: Some("#ef4444".into()),
+ },
+ ],
+ },
+ FieldMetadata {
+ id: 16,
+ name: "closeDate".into(),
+ label: "Close Date".into(),
+ field_type: FieldType::Date,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 17,
+ name: "probability".into(),
+ label: "Probability".into(),
+ field_type: FieldType::Number,
+ description: Some("Win probability (%)".into()),
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 18,
+ name: "personId".into(),
+ label: "Contact".into(),
+ field_type: FieldType::Relation,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 19,
+ name: "companyId".into(),
+ label: "Company".into(),
+ field_type: FieldType::Relation,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
],
is_custom: false,
is_searchable: true,
@@ -158,15 +395,75 @@ impl ObjectMetadata {
description: Some("A to-do item".into()),
icon: Some("check-square".into()),
fields: vec![
- FieldMetadata { id: 20, name: "title".into(), label: "Title".into(), field_type: FieldType::Text, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 21, name: "description".into(), label: "Description".into(), field_type: FieldType::RichText, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 22, name: "status".into(), label: "Status".into(), field_type: FieldType::Select, description: None, is_required: true, is_unique: false, is_custom: false, default_value: Some(Value::Str("todo".into())), options: vec![
- FieldOption { label: "Todo".into(), value: "todo".into(), color: Some("#94a3b8".into()) },
- FieldOption { label: "In Progress".into(), value: "in_progress".into(), color: Some("#60a5fa".into()) },
- FieldOption { label: "Done".into(), value: "done".into(), color: Some("#22c55e".into()) },
- FieldOption { label: "Cancelled".into(), value: "cancelled".into(), color: Some("#ef4444".into()) },
- ]},
- FieldMetadata { id: 23, name: "dueAt".into(), label: "Due Date".into(), field_type: FieldType::Date, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+ FieldMetadata {
+ id: 20,
+ name: "title".into(),
+ label: "Title".into(),
+ field_type: FieldType::Text,
+ description: None,
+ is_required: true,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 21,
+ name: "description".into(),
+ label: "Description".into(),
+ field_type: FieldType::RichText,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 22,
+ name: "status".into(),
+ label: "Status".into(),
+ field_type: FieldType::Select,
+ description: None,
+ is_required: true,
+ is_unique: false,
+ is_custom: false,
+ default_value: Some(Value::Str("todo".into())),
+ options: vec![
+ FieldOption {
+ label: "Todo".into(),
+ value: "todo".into(),
+ color: Some("#94a3b8".into()),
+ },
+ FieldOption {
+ label: "In Progress".into(),
+ value: "in_progress".into(),
+ color: Some("#60a5fa".into()),
+ },
+ FieldOption {
+ label: "Done".into(),
+ value: "done".into(),
+ color: Some("#22c55e".into()),
+ },
+ FieldOption {
+ label: "Cancelled".into(),
+ value: "cancelled".into(),
+ color: Some("#ef4444".into()),
+ },
+ ],
+ },
+ FieldMetadata {
+ id: 23,
+ name: "dueAt".into(),
+ label: "Due Date".into(),
+ field_type: FieldType::Date,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
],
is_custom: false,
is_searchable: true,
@@ -183,8 +480,30 @@ impl ObjectMetadata {
description: Some("A rich-text note".into()),
icon: Some("file-text".into()),
fields: vec![
- FieldMetadata { id: 24, name: "title".into(), label: "Title".into(), field_type: FieldType::Text, description: None, is_required: false, is_unique: false, is_custom: false, default_value: None, options: vec![] },
- FieldMetadata { id: 25, name: "body".into(), label: "Body".into(), field_type: FieldType::RichText, description: None, is_required: true, is_unique: false, is_custom: false, default_value: None, options: vec![] },
+ FieldMetadata {
+ id: 24,
+ name: "title".into(),
+ label: "Title".into(),
+ field_type: FieldType::Text,
+ description: None,
+ is_required: false,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
+ FieldMetadata {
+ id: 25,
+ name: "body".into(),
+ label: "Body".into(),
+ field_type: FieldType::RichText,
+ description: None,
+ is_required: true,
+ is_unique: false,
+ is_custom: false,
+ default_value: None,
+ options: vec![],
+ },
],
is_custom: false,
is_searchable: true,
diff --git a/crates/crm-core/src/types.rs b/crates/crm-core/src/types.rs
index 32dd511..43b8d88 100644
--- a/crates/crm-core/src/types.rs
+++ b/crates/crm-core/src/types.rs
@@ -1,5 +1,3 @@
-
-
/// Unique record identifier (auto-increment u64, stored as big-endian in B+tree)
pub type RecordId = u64;
@@ -18,7 +16,10 @@ pub struct FullName {
impl FullName {
pub fn new(first: &str, last: &str) -> Self {
- Self { first: first.into(), last: last.into() }
+ Self {
+ first: first.into(),
+ last: last.into(),
+ }
}
pub fn display(&self) -> String {
format!("{} {}", self.first, self.last)
@@ -70,7 +71,15 @@ pub enum PipelineStage {
impl PipelineStage {
pub fn all() -> &'static [Self] {
- &[Self::New, Self::Screening, Self::Meeting, Self::Proposal, Self::Negotiation, Self::Won, Self::Lost]
+ &[
+ Self::New,
+ Self::Screening,
+ Self::Meeting,
+ Self::Proposal,
+ Self::Negotiation,
+ Self::Won,
+ Self::Lost,
+ ]
}
pub fn label(&self) -> &str {
match self {
diff --git a/crates/crm-core/tests/integration.rs b/crates/crm-core/tests/integration.rs
index 8959713..08274cf 100644
--- a/crates/crm-core/tests/integration.rs
+++ b/crates/crm-core/tests/integration.rs
@@ -5,8 +5,14 @@ fn test_person_roundtrip() {
let person = Person {
id: 1,
name: FullName::new("Anna", "Jónsdóttir"),
- email: Some(Email { address: "anna@test.is".into(), label: "work".into() }),
- phone: Some(Phone { number: "+354 123 4567".into(), label: "mobile".into() }),
+ email: Some(Email {
+ address: "anna@test.is".into(),
+ label: "work".into(),
+ }),
+ phone: Some(Phone {
+ number: "+354 123 4567".into(),
+ label: "mobile".into(),
+ }),
job_title: Some("CEO".into()),
company_id: Some(1),
linkedin_url: None,
@@ -26,9 +32,10 @@ fn test_person_roundtrip() {
#[test]
fn test_company_validation() {
- let result = Company::from_json(1, &akurai_json::Value::Object(vec![
- ("name".into(), akurai_json::Value::Str("".into())),
- ]));
+ let result = Company::from_json(
+ 1,
+ &akurai_json::Value::Object(vec![("name".into(), akurai_json::Value::Str("".into()))]),
+ );
assert!(result.is_err());
}
diff --git a/crates/crm-mcp/src/server.rs b/crates/crm-mcp/src/server.rs
index d9adbcd..e6dd125 100644
--- a/crates/crm-mcp/src/server.rs
+++ b/crates/crm-mcp/src/server.rs
@@ -16,7 +16,8 @@ pub struct McpServer {
impl McpServer {
pub fn new(config: McpConfig) -> Result<Self, String> {
- let btree = BTree::open(&config.db_path).map_err(|e| format!("failed to open MCP database: {e}"))?;
+ let btree = BTree::open(&config.db_path)
+ .map_err(|e| format!("failed to open MCP database: {e}"))?;
let tools = ToolRegistry::new(Arc::new(Mutex::new(btree)));
Ok(Self {
config,
@@ -53,6 +54,8 @@ impl McpServer {
let server = akurai_http::Server::bind(&addr)
.map_err(|e| format!("failed to bind MCP server: {e}"))?;
- server.run(handler).map_err(|e| format!("server error: {e}"))
+ server
+ .run(handler)
+ .map_err(|e| format!("server error: {e}"))
}
}
diff --git a/crates/crm-mcp/src/tools.rs b/crates/crm-mcp/src/tools.rs
index 23c91d6..e4f4a23 100644
--- a/crates/crm-mcp/src/tools.rs
+++ b/crates/crm-mcp/src/tools.rs
@@ -14,44 +14,87 @@ impl ToolRegistry {
/// List all available tools with their schemas
pub fn list_tools(&self) -> Vec<Value> {
vec![
- self.tool_def("list_people", "List all CRM contacts", vec![
- ("limit", "integer", "Maximum number of results", false),
- ]),
- self.tool_def("get_person", "Get a person by ID", vec![
- ("id", "integer", "Person record ID", true),
- ]),
- self.tool_def("create_person", "Create a new person", vec![
- ("firstName", "string", "First name", true),
- ("lastName", "string", "Last name", true),
- ("email", "string", "Email address", false),
- ("jobTitle", "string", "Job title", false),
- ("companyId", "integer", "Company ID", false),
- ]),
- self.tool_def("list_companies", "List all companies", vec![
- ("limit", "integer", "Maximum number of results", false),
- ]),
- self.tool_def("get_company", "Get a company by ID", vec![
- ("id", "integer", "Company record ID", true),
- ]),
- self.tool_def("list_opportunities", "List all sales opportunities", vec![
- ("stage", "string", "Filter by stage (new/screening/meeting/proposal/negotiation/won/lost)", false),
- ("limit", "integer", "Maximum number of results", false),
- ]),
- self.tool_def("get_opportunity", "Get an opportunity by ID", vec![
- ("id", "integer", "Opportunity record ID", true),
- ]),
- self.tool_def("get_pipeline", "Get pipeline summary with counts per stage", vec![]),
- self.tool_def("search_crm", "Search across all CRM entities", vec![
- ("query", "string", "Search term", true),
- ]),
- self.tool_def("list_tasks", "List tasks", vec![
- ("status", "string", "Filter by status (todo/in_progress/done/cancelled)", false),
- ("limit", "integer", "Maximum number of results", false),
- ]),
+ self.tool_def(
+ "list_people",
+ "List all CRM contacts",
+ vec![("limit", "integer", "Maximum number of results", false)],
+ ),
+ self.tool_def(
+ "get_person",
+ "Get a person by ID",
+ vec![("id", "integer", "Person record ID", true)],
+ ),
+ self.tool_def(
+ "create_person",
+ "Create a new person",
+ vec![
+ ("firstName", "string", "First name", true),
+ ("lastName", "string", "Last name", true),
+ ("email", "string", "Email address", false),
+ ("jobTitle", "string", "Job title", false),
+ ("companyId", "integer", "Company ID", false),
+ ],
+ ),
+ self.tool_def(
+ "list_companies",
+ "List all companies",
+ vec![("limit", "integer", "Maximum number of results", false)],
+ ),
+ self.tool_def(
+ "get_company",
+ "Get a company by ID",
+ vec![("id", "integer", "Company record ID", true)],
+ ),
+ self.tool_def(
+ "list_opportunities",
+ "List all sales opportunities",
+ vec![
+ (
+ "stage",
+ "string",
+ "Filter by stage (new/screening/meeting/proposal/negotiation/won/lost)",
+ false,
+ ),
+ ("limit", "integer", "Maximum number of results", false),
+ ],
+ ),
+ self.tool_def(
+ "get_opportunity",
+ "Get an opportunity by ID",
+ vec![("id", "integer", "Opportunity record ID", true)],
+ ),
+ self.tool_def(
+ "get_pipeline",
+ "Get pipeline summary with counts per stage",
+ vec![],
+ ),
+ self.tool_def(
+ "search_crm",
+ "Search across all CRM entities",
+ vec![("query", "string", "Search term", true)],
+ ),
+ self.tool_def(
+ "list_tasks",
+ "List tasks",
+ vec![
+ (
+ "status",
+ "string",
+ "Filter by status (todo/in_progress/done/cancelled)",
+ false,
+ ),
+ ("limit", "integer", "Maximum number of results", false),
+ ],
+ ),
]
}
- fn tool_def(&self, name: &str, description: &str, params: Vec<(&str, &str, &str, bool)>) -> Value {
+ fn tool_def(
+ &self,
+ name: &str,
+ description: &str,
+ params: Vec<(&str, &str, &str, bool)>,
+ ) -> Value {
let mut properties = Vec::new();
let mut required = Vec::new();
for (pname, ptype, pdesc, is_required) in params {
@@ -69,11 +112,14 @@ impl ToolRegistry {
Value::Object(vec![
("name".into(), Value::Str(name.into())),
("description".into(), Value::Str(description.into())),
- ("inputSchema".into(), Value::Object(vec![
- ("type".into(), Value::Str("object".into())),
- ("properties".into(), Value::Object(properties)),
- ("required".into(), Value::Array(required)),
- ])),
+ (
+ "inputSchema".into(),
+ Value::Object(vec![
+ ("type".into(), Value::Str("object".into())),
+ ("properties".into(), Value::Object(properties)),
+ ("required".into(), Value::Array(required)),
+ ]),
+ ),
])
}
@@ -107,20 +153,23 @@ impl ToolRegistry {
match method {
"tools/list" => {
let tools_list = self.list_tools();
- self.json_rpc_result(id, Value::Object(vec![
- ("tools".into(), Value::Array(tools_list)),
- ]))
+ self.json_rpc_result(
+ id,
+ Value::Object(vec![("tools".into(), Value::Array(tools_list))]),
+ )
}
"tools/call" => {
let params = match req.get("params") {
Some(Value::Object(pairs)) => pairs.clone(),
_ => vec![],
};
- let tool_name = params.iter()
+ let tool_name = params
+ .iter()
.find(|(k, _)| k == "name")
.and_then(|(_, v)| v.as_str())
.unwrap_or("");
- let arguments: Vec<(String, Value)> = params.iter()
+ let arguments: Vec<(String, Value)> = params
+ .iter()
.find(|(k, _)| k == "arguments")
.and_then(|(_, v)| match v {
Value::Object(pairs) => Some(pairs.clone()),
@@ -129,14 +178,16 @@ impl ToolRegistry {
.unwrap_or_default();
match self.execute(tool_name, &arguments) {
- Ok(result) => self.json_rpc_result(id, Value::Object(vec![
- ("content".into(), Value::Array(vec![
- Value::Object(vec![
+ Ok(result) => self.json_rpc_result(
+ id,
+ Value::Object(vec![(
+ "content".into(),
+ Value::Array(vec![Value::Object(vec![
("type".into(), Value::Str("json".into())),
("text".into(), Value::Str(result.to_json())),
- ]),
- ])),
- ])),
+ ])]),
+ )]),
+ ),
Err(e) => self.json_rpc_error(id, -32603, &format!("Tool error: {e}")),
}
}
@@ -145,34 +196,38 @@ impl ToolRegistry {
Some(Value::Object(pairs)) => pairs.clone(),
_ => vec![],
};
- let tool_name = params.iter()
+ let tool_name = params
+ .iter()
.find(|(k, _)| k == "name")
.and_then(|(_, v)| v.as_str())
.unwrap_or("");
let tools_list = self.list_tools();
- let tool = tools_list.into_iter().find(|t| {
- t.get("name").and_then(|v| v.as_str()) == Some(tool_name)
- });
+ let tool = tools_list
+ .into_iter()
+ .find(|t| t.get("name").and_then(|v| v.as_str()) == Some(tool_name));
match tool {
Some(t) => self.json_rpc_result(id, Value::Object(vec![("tool".into(), t)])),
None => self.json_rpc_error(id, -32602, &format!("Unknown tool: {tool_name}")),
}
}
- "initialize" => {
- self.json_rpc_result(id, Value::Object(vec![
+ "initialize" => self.json_rpc_result(
+ id,
+ Value::Object(vec![
("protocolVersion".into(), Value::Str("2025-03-26".into())),
- ("capabilities".into(), Value::Object(vec![
- ("tools".into(), Value::Object(vec![])),
- ])),
- ("serverInfo".into(), Value::Object(vec![
- ("name".into(), Value::Str("akurai-crm-mcp".into())),
- ("version".into(), Value::Str("0.1.0".into())),
- ])),
- ]))
- }
- "notifications/initialized" => {
- self.json_rpc_result(id, Value::Null)
- }
+ (
+ "capabilities".into(),
+ Value::Object(vec![("tools".into(), Value::Object(vec![]))]),
+ ),
+ (
+ "serverInfo".into(),
+ Value::Object(vec![
+ ("name".into(), Value::Str("akurai-crm-mcp".into())),
+ ("version".into(), Value::Str("0.1.0".into())),
+ ]),
+ ),
+ ]),
+ ),
+ "notifications/initialized" => self.json_rpc_result(id, Value::Null),
_ => self.json_rpc_error(id, -32601, &format!("Method not found: {method}")),
}
}
@@ -190,10 +245,13 @@ impl ToolRegistry {
fn json_rpc_error(&self, id: Option<Value>, code: i64, message: &str) -> String {
let mut resp = Vec::new();
resp.push(("jsonrpc".into(), Value::Str("2.0".into())));
- resp.push(("error".into(), Value::Object(vec![
- ("code".into(), Value::Int(code)),
- ("message".into(), Value::Str(message.into())),
- ])));
+ resp.push((
+ "error".into(),
+ Value::Object(vec![
+ ("code".into(), Value::Int(code)),
+ ("message".into(), Value::Str(message.into())),
+ ]),
+ ));
if let Some(id_val) = id {
resp.push(("id".into(), id_val));
}
@@ -228,7 +286,9 @@ impl ToolRegistry {
match db.get(&key).map_err(|e| format!("storage: {e}"))? {
Some(bytes) => {
let s = String::from_utf8_lossy(&bytes).to_string();
- akurai_json::parse(&s).map(Some).map_err(|e| format!("parse: {e}"))
+ akurai_json::parse(&s)
+ .map(Some)
+ .map_err(|e| format!("parse: {e}"))
}
None => Ok(None),
}
@@ -245,7 +305,9 @@ impl ToolRegistry {
}
fn get_person(&self, args: &[(String, Value)]) -> Result<Value, String> {
- let id = Self::get_arg(args, "id").and_then(|v| v.as_i64()).ok_or("missing id")?;
+ let id = Self::get_arg(args, "id")
+ .and_then(|v| v.as_i64())
+ .ok_or("missing id")?;
self.get_record("people", id as u64)
.map(|opt| opt.unwrap_or(Value::Null))
}
@@ -253,10 +315,15 @@ impl ToolRegistry {
fn create_person(&self, args: &[(String, Value)]) -> Result<Value, String> {
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let now = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64;
let counter_key = "people:_counter";
- let next_id = match db.get(counter_key.as_bytes()).map_err(|e| format!("counter read: {e}"))? {
+ let next_id = match db
+ .get(counter_key.as_bytes())
+ .map_err(|e| format!("counter read: {e}"))?
+ {
Some(bytes) => {
let s = String::from_utf8_lossy(&bytes).to_string();
match s.parse::<u64>() {
@@ -270,8 +337,12 @@ impl ToolRegistry {
.map_err(|e| format!("counter write: {e}"))?;
db.commit().map_err(|e| format!("commit: {e}"))?;
- let first = Self::get_arg(args, "firstName").and_then(|v| v.as_str()).unwrap_or("");
- let last = Self::get_arg(args, "lastName").and_then(|v| v.as_str()).unwrap_or("");
+ let first = Self::get_arg(args, "firstName")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+ let last = Self::get_arg(args, "lastName")
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
let mut fields = vec![
("id".into(), Value::Int(next_id as i64)),
@@ -313,7 +384,9 @@ impl ToolRegistry {
}
fn get_company(&self, args: &[(String, Value)]) -> Result<Value, String> {
- let id = Self::get_arg(args, "id").and_then(|v| v.as_i64()).ok_or("missing id")?;
+ let id = Self::get_arg(args, "id")
+ .and_then(|v| v.as_i64())
+ .ok_or("missing id")?;
self.get_record("companies", id as u64)
.map(|opt| opt.unwrap_or(Value::Null))
}
@@ -321,9 +394,7 @@ impl ToolRegistry {
fn list_opportunities(&self, args: &[(String, Value)]) -> Result<Value, String> {
let mut opps = self.scan_collection("opportunities")?;
if let Some(stage) = Self::get_arg(args, "stage").and_then(|v| v.as_str()) {
- opps.retain(|o| {
- o.get("stage").and_then(|v| v.as_str()) == Some(stage)
- });
+ opps.retain(|o| o.get("stage").and_then(|v| v.as_str()) == Some(stage));
}
if let Some(limit_val) = Self::get_arg(args, "limit") {
if let Some(limit) = limit_val.as_i64() {
@@ -334,29 +405,48 @@ impl ToolRegistry {
}
fn get_opportunity(&self, args: &[(String, Value)]) -> Result<Value, String> {
- let id = Self::get_arg(args, "id").and_then(|v| v.as_i64()).ok_or("missing id")?;
+ let id = Self::get_arg(args, "id")
+ .and_then(|v| v.as_i64())
+ .ok_or("missing id")?;
self.get_record("opportunities", id as u64)
.map(|opt| opt.unwrap_or(Value::Null))
}
fn get_pipeline(&self) -> Result<Value, String> {
let opps = self.scan_collection("opportunities")?;
- let stages = ["new", "screening", "meeting", "proposal", "negotiation", "won", "lost"];
- let stage_counts: Vec<Value> = stages.iter().map(|&stage| {
- let count = opps.iter().filter(|o| {
- o.get("stage").and_then(|v| v.as_str()) == Some(stage)
- }).count();
- let total_amount: i64 = opps.iter().filter_map(|o| {
- if o.get("stage").and_then(|v| v.as_str()) == Some(stage) {
- o.get("amount").and_then(|v| v.as_i64())
- } else { None }
- }).sum();
- Value::Object(vec![
- ("stage".into(), Value::Str(stage.into())),
- ("count".into(), Value::Int(count as i64)),
- ("totalAmount".into(), Value::Int(total_amount)),
- ])
- }).collect();
+ let stages = [
+ "new",
+ "screening",
+ "meeting",
+ "proposal",
+ "negotiation",
+ "won",
+ "lost",
+ ];
+ let stage_counts: Vec<Value> = stages
+ .iter()
+ .map(|&stage| {
+ let count = opps
+ .iter()
+ .filter(|o| o.get("stage").and_then(|v| v.as_str()) == Some(stage))
+ .count();
+ let total_amount: i64 = opps
+ .iter()
+ .filter_map(|o| {
+ if o.get("stage").and_then(|v| v.as_str()) == Some(stage) {
+ o.get("amount").and_then(|v| v.as_i64())
+ } else {
+ None
+ }
+ })
+ .sum();
+ Value::Object(vec![
+ ("stage".into(), Value::Str(stage.into())),
+ ("count".into(), Value::Int(count as i64)),
+ ("totalAmount".into(), Value::Int(total_amount)),
+ ])
+ })
+ .collect();
Ok(Value::Object(vec![
("pipeline".into(), Value::Array(stage_counts)),
@@ -365,14 +455,23 @@ impl ToolRegistry {
}
fn search_crm(&self, args: &[(String, Value)]) -> Result<Value, String> {
- let query = Self::get_arg(args, "query").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
+ let query = Self::get_arg(args, "query")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_lowercase();
if query.is_empty() {
return Ok(Value::Array(vec![]));
}
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let mut results = Vec::new();
- let prefixes = ["people:", "companies:", "opportunities:", "tasks:", "notes:"];
+ let prefixes = [
+ "people:",
+ "companies:",
+ "opportunities:",
+ "tasks:",
+ "notes:",
+ ];
for prefix_str in &prefixes {
let prefix = prefix_str.as_bytes();
@@ -383,7 +482,10 @@ impl ToolRegistry {
if val.to_lowercase().contains(&query) {
if let Ok(json) = akurai_json::parse(&val) {
results.push(Value::Object(vec![
- ("entity".into(), Value::Str(prefix_str.trim_end_matches(':').into())),
+ (
+ "entity".into(),
+ Value::Str(prefix_str.trim_end_matches(':').into()),
+ ),
("record".into(), json),
]));
}
@@ -398,9 +500,7 @@ impl ToolRegistry {
fn list_tasks(&self, args: &[(String, Value)]) -> Result<Value, String> {
let mut tasks = self.scan_collection("tasks")?;
if let Some(status) = Self::get_arg(args, "status").and_then(|v| v.as_str()) {
- tasks.retain(|t| {
- t.get("status").and_then(|v| v.as_str()) == Some(status)
- });
+ tasks.retain(|t| t.get("status").and_then(|v| v.as_str()) == Some(status));
}
if let Some(limit_val) = Self::get_arg(args, "limit") {
if let Some(limit) = limit_val.as_i64() {
diff --git a/deploy.sh b/deploy.sh
index c5f362b..e4765b2 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -50,7 +50,13 @@ if [ -f CHANGELOG.md ]; then
' CHANGELOG.md > CHANGELOG.md.tmp && mv CHANGELOG.md.tmp CHANGELOG.md
fi
-# ── 4. Commit, tag, push ───────────────────────────────────────────────────
+# ── 4. Static musl build ────────────────────────────────────────────────────
+say "Build: static musl binary"
+rustup target add x86_64-unknown-linux-musl >/dev/null 2>&1 || true
+cargo build --release --target x86_64-unknown-linux-musl -p akurai-crm \
+ || die "musl build failed"
+
+# ── 5. Commit, tag, push ───────────────────────────────────────────────────
say "Git: commit + tag v$NEW"
git add -A
git commit -q -m "release: v$NEW"
diff --git a/site/backend/page.json b/site/backend/page.json
index 2377f63..4899eee 100644
--- a/site/backend/page.json
+++ b/site/backend/page.json
@@ -3,7 +3,7 @@
"title": "AkurAI-CRM",
"description": "Pure Rust CRM built on AkurAI-Framework",
"url": "https://akurai-crm.olibuijr.com",
- "version": "v0.1.0"
+ "version": "v0.1.1"
},
"brand": {
"name": "AkurAI-CRM",
diff --git a/site/frontend/companies.html b/site/frontend/companies.html
index dce460d..467b406 100644
--- a/site/frontend/companies.html
+++ b/site/frontend/companies.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Companies — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -62,6 +65,7 @@
<input type="text" placeholder="Search companies..." id="search-companies">
</div>
<div class="topbar-actions">
+ <span class="theme-picker" data-theme-picker></span>
<button class="icon-btn" id="notif-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</button>
diff --git a/site/frontend/company-detail.html b/site/frontend/company-detail.html
index e2660c5..82744a6 100644
--- a/site/frontend/company-detail.html
+++ b/site/frontend/company-detail.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NordicTech — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -62,6 +65,7 @@
</div>
<div class="topbar-spacer"></div>
<div class="topbar-actions">
+ <span class="theme-picker" data-theme-picker></span>
<button class="icon-btn" id="notif-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</button>
diff --git a/site/frontend/dashboard.html b/site/frontend/dashboard.html
index 8a3f46c..289ac75 100644
--- a/site/frontend/dashboard.html
+++ b/site/frontend/dashboard.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -63,6 +66,7 @@
</div>
<div class="topbar-spacer"></div>
<div class="topbar-actions">
+ <span class="theme-picker" data-theme-picker></span>
<button class="icon-btn" id="notif-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="dot"></span>
diff --git a/site/frontend/index.html b/site/frontend/index.html
index 60d9b3d..c21b13d 100644
--- a/site/frontend/index.html
+++ b/site/frontend/index.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="login-page">
diff --git a/site/frontend/notes.html b/site/frontend/notes.html
index 0a96ad6..01b450c 100644
--- a/site/frontend/notes.html
+++ b/site/frontend/notes.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Notes — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -62,6 +65,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Note
</button>
+ <span class="theme-picker" data-theme-picker></span>
</div>
</header>
diff --git a/site/frontend/opportunities.html b/site/frontend/opportunities.html
index 6353a9a..f46f504 100644
--- a/site/frontend/opportunities.html
+++ b/site/frontend/opportunities.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Opportunities — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -72,6 +75,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Deal
</button>
+ <span class="theme-picker" data-theme-picker></span>
</div>
</header>
diff --git a/site/frontend/opportunity-detail.html b/site/frontend/opportunity-detail.html
index 7f9e53b..2d229f4 100644
--- a/site/frontend/opportunity-detail.html
+++ b/site/frontend/opportunity-detail.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NordicTech Platform Deal — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -62,6 +65,7 @@
</div>
<div class="topbar-spacer"></div>
<div class="topbar-actions">
+ <span class="theme-picker" data-theme-picker></span>
<button class="icon-btn" id="notif-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</button>
diff --git a/site/frontend/people.html b/site/frontend/people.html
index 70eaaeb..f9885e9 100644
--- a/site/frontend/people.html
+++ b/site/frontend/people.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>People — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -62,6 +65,7 @@
<input type="text" placeholder="Search people..." id="search-people">
</div>
<div class="topbar-actions">
+ <span class="theme-picker" data-theme-picker></span>
<button class="icon-btn" id="notif-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</button>
diff --git a/site/frontend/person-detail.html b/site/frontend/person-detail.html
index 14868aa..354978f 100644
--- a/site/frontend/person-detail.html
+++ b/site/frontend/person-detail.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jóna Jónsdóttir — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -62,6 +65,7 @@
</div>
<div class="topbar-spacer"></div>
<div class="topbar-actions">
+ <span class="theme-picker" data-theme-picker></span>
<button class="icon-btn" id="notif-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</button>
diff --git a/site/frontend/settings.html b/site/frontend/settings.html
index 42b3647..9da6a08 100644
--- a/site/frontend/settings.html
+++ b/site/frontend/settings.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -58,6 +61,7 @@
<div class="topbar-title">Settings</div>
<div class="topbar-spacer"></div>
<div class="topbar-actions">
+ <span class="theme-picker" data-theme-picker></span>
<button class="icon-btn" id="notif-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</button>
diff --git a/site/frontend/styles.css b/site/frontend/styles.css
index 1c95b23..27f3adc 100644
--- a/site/frontend/styles.css
+++ b/site/frontend/styles.css
@@ -3,37 +3,44 @@
============================================================ */
/* ----- Design Tokens ----- */
+/* Color tokens aliased to AkurAI-Framework semantic tokens (themes.css).
+ Non-color tokens (spacing, type, layout) are independent and unchanged. */
:root {
- --color-sidebar-bg: #0f172a;
- --color-sidebar-hover: #1e293b;
- --color-sidebar-active: #312e81;
- --color-sidebar-text: #94a3b8;
- --color-sidebar-text-active: #f1f5f9;
- --color-sidebar-brand: #818cf8;
+ /* Sidebar — uses deepest bg layer so it recedes behind content */
+ --color-sidebar-bg: var(--bg);
+ --color-sidebar-hover: var(--panel);
+ --color-sidebar-active: var(--panel-2);
+ --color-sidebar-text: var(--muted);
+ --color-sidebar-text-active: var(--fg);
+ --color-sidebar-brand: var(--accent);
- --color-bg: #f8fafc;
- --color-surface: #ffffff;
- --color-surface-hover: #f1f5f9;
- --color-border: #e2e8f0;
- --color-border-light: #f1f5f9;
+ /* Page / surface */
+ --color-bg: var(--bg-2);
+ --color-surface: var(--panel);
+ --color-surface-hover: var(--panel-2);
+ --color-border: var(--border-2);
+ --color-border-light: var(--border);
- --color-primary: #6366f1;
- --color-primary-hover: #4f46e5;
- --color-primary-light: #eef2ff;
- --color-primary-subtle: rgba(99, 102, 241, 0.08);
+ /* Accent / primary */
+ --color-primary: var(--accent);
+ --color-primary-hover: var(--accent-2);
+ --color-primary-light: color-mix(in srgb, var(--accent) 14%, var(--panel));
+ --color-primary-subtle: color-mix(in srgb, var(--accent) 8%, transparent);
- --color-success: #22c55e;
- --color-success-light: #dcfce7;
- --color-warning: #f59e0b;
- --color-warning-light: #fef3c7;
- --color-danger: #ef4444;
- --color-danger-light: #fee2e2;
- --color-info: #3b82f6;
- --color-info-light: #dbeafe;
+ /* Status */
+ --color-success: var(--ok);
+ --color-success-light: color-mix(in srgb, var(--ok) 14%, var(--panel));
+ --color-warning: var(--warn);
+ --color-warning-light: color-mix(in srgb, var(--warn) 14%, var(--panel));
+ --color-danger: var(--danger);
+ --color-danger-light: color-mix(in srgb, var(--danger) 14%, var(--panel));
+ --color-info: var(--info);
+ --color-info-light: color-mix(in srgb, var(--info) 14%, var(--panel));
- --color-text: #0f172a;
- --color-text-secondary: #64748b;
- --color-text-tertiary: #94a3b8;
+ /* Text */
+ --color-text: var(--fg);
+ --color-text-secondary: var(--muted);
+ --color-text-tertiary: var(--muted);
--color-text-inverse: #ffffff;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
@@ -217,15 +224,15 @@ table .cell-muted { color: var(--color-text-secondary); font-size: var(--text-xs
/* ----- Badges / Tags ----- */
.badge { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: var(--radius-full); font-size: var(--text-xs); font-weight: var(--weight-medium); line-height: 1.5; white-space: nowrap; }
-.badge-slate { background: #f1f5f9; color: #475569; }
-.badge-blue { background: var(--color-info-light); color: #1d4ed8; }
-.badge-indigo { background: var(--color-primary-light); color: var(--color-primary); }
-.badge-violet { background: #ede9fe; color: #7c3aed; }
-.badge-amber { background: var(--color-warning-light); color: #b45309; }
-.badge-green { background: var(--color-success-light); color: #16a34a; }
-.badge-red { background: var(--color-danger-light); color: #dc2626; }
-.badge-gray { background: #f1f5f9; color: #64748b; }
-.badge-new { background: #f1f5f9; color: #475569; }
+.badge-slate { background: var(--panel-2); color: var(--muted); }
+.badge-blue { background: var(--color-info-light); color: var(--info); }
+.badge-indigo { background: var(--color-primary-light); color: var(--accent); }
+.badge-violet { background: color-mix(in srgb, var(--accent-2) 14%, var(--panel)); color: var(--accent-2); }
+.badge-amber { background: var(--color-warning-light); color: var(--warn); }
+.badge-green { background: var(--color-success-light); color: var(--ok); }
+.badge-red { background: var(--color-danger-light); color: var(--danger); }
+.badge-gray { background: var(--panel-2); color: var(--muted); }
+.badge-new { background: var(--panel-2); color: var(--muted); }
/* ----- Kanban Board ----- */
.kanban-board { display: grid; grid-template-columns: repeat(7, 1fr); gap: var(--space-4); min-height: 500px; overflow-x: auto; padding-bottom: var(--space-4); }
@@ -243,13 +250,13 @@ table .cell-muted { color: var(--color-text-secondary); font-size: var(--text-xs
.kanban-dropzone { min-height: 60px; border: 2px dashed var(--color-border); border-radius: var(--radius-md); margin: var(--space-2); display: flex; align-items: center; justify-content: center; font-size: var(--text-xs); color: var(--color-text-tertiary); }
/* Column stage colors */
-.kanban-column.new .kanban-card { border-left-color: #94a3b8; }
-.kanban-column.screening .kanban-card { border-left-color: #60a5fa; }
-.kanban-column.meeting .kanban-card { border-left-color: #818cf8; }
-.kanban-column.proposal .kanban-card { border-left-color: #a78bfa; }
-.kanban-column.negotiation .kanban-card { border-left-color: #f59e0b; }
-.kanban-column.won .kanban-card { border-left-color: #22c55e; }
-.kanban-column.lost .kanban-card { border-left-color: #ef4444; }
+.kanban-column.new .kanban-card { border-left-color: var(--muted); }
+.kanban-column.screening .kanban-card { border-left-color: var(--info); }
+.kanban-column.meeting .kanban-card { border-left-color: var(--accent); }
+.kanban-column.proposal .kanban-card { border-left-color: var(--accent-2); }
+.kanban-column.negotiation .kanban-card { border-left-color: var(--warn); }
+.kanban-column.won .kanban-card { border-left-color: var(--ok); }
+.kanban-column.lost .kanban-card { border-left-color: var(--danger); }
/* ----- Pipeline visualization ----- */
.pipeline-chart { display: flex; align-items: flex-end; gap: var(--space-3); height: 200px; padding: var(--space-4) 0; }
@@ -421,8 +428,8 @@ table .cell-muted { color: var(--color-text-secondary); font-size: var(--text-xs
@keyframes slideUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
/* ----- Login Page ----- */
-.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%); position: relative; overflow: hidden; }
-.login-page::before { content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: radial-gradient(circle at 30% 50%, rgba(99,102,241,0.08) 0%, transparent 50%), radial-gradient(circle at 70% 50%, rgba(99,102,241,0.06) 0%, transparent 50%); pointer-events: none; }
+.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); position: relative; overflow: hidden; }
+.login-page::before { content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: radial-gradient(circle at 30% 50%, color-mix(in srgb, var(--accent) 8%, transparent) 0%, transparent 50%), radial-gradient(circle at 70% 50%, color-mix(in srgb, var(--accent) 6%, transparent) 0%, transparent 50%); pointer-events: none; }
.login-card { position: relative; background: var(--color-surface); border-radius: var(--radius-xl); padding: var(--space-10); width: 100%; max-width: 420px; box-shadow: var(--shadow-xl); }
.login-brand { text-align: center; margin-bottom: var(--space-8); }
.login-brand .brand-icon { width: 48px; height: 48px; background: var(--color-primary); border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; margin: 0 auto var(--space-4); color: #fff; font-size: var(--text-2xl); font-weight: var(--weight-bold); }
@@ -473,3 +480,29 @@ table .cell-muted { color: var(--color-text-secondary); font-size: var(--text-xs
.stats-grid { grid-template-columns: 1fr; }
.kanban-board { grid-template-columns: 1fr; }
}
+
+/* ----- Theme Picker — token-driven, re-themes with everything else ----- */
+.theme-picker { display: inline-flex; align-items: center; }
+.theme-select {
+ appearance: none;
+ -webkit-appearance: none;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--fg);
+ background: var(--panel);
+ border: 1px solid var(--border-2);
+ border-radius: 8px;
+ padding: 6px 28px 6px 11px;
+ cursor: pointer;
+ line-height: 1.2;
+ background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
+ background-position: calc(100% - 15px) center, calc(100% - 10px) center;
+ background-size: 5px 5px, 5px 5px;
+ background-repeat: no-repeat;
+ transition: border-color .15s ease, background-color .15s ease;
+}
+.theme-select:hover { border-color: var(--fg); }
+.theme-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.theme-select optgroup { color: var(--fg); background: var(--panel); font-weight: 700; }
+.theme-select option { color: var(--fg); background: var(--panel); font-weight: 500; }
diff --git a/site/frontend/tasks.html b/site/frontend/tasks.html
index 8db3074..cbb6338 100644
--- a/site/frontend/tasks.html
+++ b/site/frontend/tasks.html
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tasks — AkurAI-CRM</title>
<link rel="stylesheet" href="styles.css">
+ <link rel="stylesheet" href="themes.css">
+ <script>(function(){try{var t=localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t);}catch(e){}})()</script>
+ <script type="module" src="theme.js"></script>
</head>
<body>
<div class="app-shell">
@@ -62,6 +65,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Task
</button>
+ <span class="theme-picker" data-theme-picker></span>
</div>
</header>
diff --git a/site/frontend/theme.js b/site/frontend/theme.js
new file mode 100644
index 0000000..5f37166
--- /dev/null
+++ b/site/frontend/theme.js
@@ -0,0 +1,96 @@
+// AkurAI theme switcher — native ESM, zero deps.
+//
+// Themes are generated by the framework (`/themes.css`, registry `/themes.json`)
+// as `:root[data-theme="…"]` token blocks. This module only chooses the active
+// `data-theme` on <html>, persists it, and builds a small picker. To avoid a
+// flash of the wrong theme, set the attribute as early as possible — the inline
+// snippet in <head> (see `theme-init.js` contents below) runs before paint; this
+// module then renders the UI.
+
+const STORAGE_KEY = "akurai-theme";
+// Default per polarity when the user has never chosen — honors the OS setting.
+const DEFAULT_DARK = "akurai";
+const DEFAULT_LIGHT = "akurai-light";
+
+export function prefersLight() {
+ return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
+}
+
+export function defaultTheme() {
+ return prefersLight() ? DEFAULT_LIGHT : DEFAULT_DARK;
+}
+
+export function currentTheme() {
+ return localStorage.getItem(STORAGE_KEY) || document.documentElement.getAttribute("data-theme") || defaultTheme();
+}
+
+export function applyTheme(slug) {
+ document.documentElement.setAttribute("data-theme", slug);
+ try {
+ localStorage.setItem(STORAGE_KEY, slug);
+ } catch {
+ /* storage may be unavailable (private mode); theme still applies for the session */
+ }
+}
+
+// Fetch the registry and build a grouped picker. `mount` is the element the
+// <select> (and label) are appended to. Returns the registry array.
+export async function mountPicker(mount) {
+ if (!mount) return [];
+ let themes = [];
+ try {
+ const res = await fetch("/themes.json", { headers: { accept: "application/json" } });
+ themes = await res.json();
+ } catch {
+ return [];
+ }
+ if (!Array.isArray(themes) || themes.length === 0) return [];
+
+ const select = document.createElement("select");
+ select.className = "theme-select";
+ select.setAttribute("aria-label", "Color theme");
+
+ // Group by family so light/dark siblings sit together.
+ const families = [];
+ const byFamily = new Map();
+ for (const t of themes) {
+ if (!byFamily.has(t.family)) {
+ byFamily.set(t.family, []);
+ families.push(t.family);
+ }
+ byFamily.get(t.family).push(t);
+ }
+ for (const family of families) {
+ const group = document.createElement("optgroup");
+ group.label = family;
+ for (const t of byFamily.get(family)) {
+ const opt = document.createElement("option");
+ opt.value = t.slug;
+ // Label already carries the name; append a polarity hint when ambiguous.
+ opt.textContent = t.label;
+ group.appendChild(opt);
+ }
+ select.appendChild(group);
+ }
+
+ const active = currentTheme();
+ select.value = active;
+ applyTheme(active);
+
+ select.addEventListener("change", () => applyTheme(select.value));
+ mount.appendChild(select);
+ return themes;
+}
+
+// Auto-mount: if a `[data-theme-picker]` element exists, fill it on load.
+if (typeof document !== "undefined") {
+ const boot = () => {
+ const slot = document.querySelector("[data-theme-picker]");
+ if (slot) mountPicker(slot);
+ };
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", boot);
+ } else {
+ boot();
+ }
+}
diff --git a/site/frontend/themes.css b/site/frontend/themes.css
new file mode 100644
index 0000000..efbf7f4
--- /dev/null
+++ b/site/frontend/themes.css
@@ -0,0 +1,19 @@
+/* AkurAI-Framework themes — generated by akurai-css::theme. Do not edit by hand. */
+:root{--bg:#0a0c11;--bg-2:#0f131b;--panel:#141925;--panel-2:#1a2030;--border:#1a2030;--border-2:#8a93a8;--fg:#e7ebf3;--muted:#8a93a8;--dim:#1a2030;--accent:#5b8cff;--accent-2:#8a6bff;--ok:#3ddc84;--warn:#f5b945;--info:#58b2ff;--danger:#ff6b6b;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="akurai"]{--bg:#0a0c11;--bg-2:#0f131b;--panel:#141925;--panel-2:#1a2030;--border:#1a2030;--border-2:#8a93a8;--fg:#e7ebf3;--muted:#8a93a8;--dim:#1a2030;--accent:#5b8cff;--accent-2:#8a6bff;--ok:#3ddc84;--warn:#f5b945;--info:#58b2ff;--danger:#ff6b6b;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="akurai-light"]{--bg:#f7f8fb;--bg-2:#eef1f6;--panel:#e2e7f0;--panel-2:#cdd5e2;--border:#cdd5e2;--border-2:#5b657d;--fg:#1d2430;--muted:#5b657d;--dim:#cdd5e2;--accent:#2e56b8;--accent-2:#6d4bd8;--ok:#1f9d57;--warn:#b7791f;--info:#2570d4;--danger:#d92d20;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="claude-code"]{--bg:#1f1e1b;--bg-2:#262320;--panel:#2f2b27;--panel-2:#3a352f;--border:#3a352f;--border-2:#8a8279;--fg:#e6e0d6;--muted:#8a8279;--dim:#3a352f;--accent:#cc785c;--accent-2:#a87b9e;--ok:#8a9a5b;--warn:#d9a05b;--info:#5b9aa0;--danger:#bf4d43;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="claude-code-light"]{--bg:#f5f1e8;--bg-2:#ece7da;--panel:#e0dac9;--panel-2:#cfc8b5;--border:#cfc8b5;--border-2:#6b6457;--fg:#3d3a34;--muted:#6b6457;--dim:#cfc8b5;--accent:#c15f3c;--accent-2:#8a5a7e;--ok:#5f7a3a;--warn:#b07a2e;--info:#3f7a80;--danger:#bf4d43;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="nord"]{--bg:#2e3440;--bg-2:#3b4252;--panel:#434c5e;--panel-2:#4c566a;--border:#4c566a;--border-2:#d8dee9;--fg:#e5e9f0;--muted:#d8dee9;--dim:#4c566a;--accent:#81a1c1;--accent-2:#b48ead;--ok:#a3be8c;--warn:#ebcb8b;--info:#88c0d0;--danger:#bf616a;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="nord-light"]{--bg:#eceff4;--bg-2:#e5e9f0;--panel:#d8dee9;--panel-2:#aebacf;--border:#aebacf;--border-2:#4c566a;--fg:#2e3440;--muted:#4c566a;--dim:#aebacf;--accent:#5e81ac;--accent-2:#8a5a7e;--ok:#6e8b4f;--warn:#a9863a;--info:#2d7d8a;--danger:#b1444e;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="catppuccin-mocha"]{--bg:#1e1e2e;--bg-2:#181825;--panel:#313244;--panel-2:#45475a;--border:#45475a;--border-2:#585b70;--fg:#cdd6f4;--muted:#585b70;--dim:#45475a;--accent:#89b4fa;--accent-2:#cba6f7;--ok:#a6e3a1;--warn:#f9e2af;--info:#94e2d5;--danger:#f38ba8;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="catppuccin-latte"]{--bg:#eff1f5;--bg-2:#e6e9ef;--panel:#ccd0da;--panel-2:#bcc0cc;--border:#bcc0cc;--border-2:#acb0be;--fg:#4c4f69;--muted:#acb0be;--dim:#bcc0cc;--accent:#1e66f5;--accent-2:#8839ef;--ok:#40a02b;--warn:#df8e1d;--info:#179299;--danger:#d20f39;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="solarized-dark"]{--bg:#002b36;--bg-2:#073642;--panel:#586e75;--panel-2:#657b83;--border:#657b83;--border-2:#839496;--fg:#93a1a1;--muted:#839496;--dim:#657b83;--accent:#268bd2;--accent-2:#6c71c4;--ok:#859900;--warn:#b58900;--info:#2aa198;--danger:#dc322f;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="solarized-light"]{--bg:#fdf6e3;--bg-2:#eee8d5;--panel:#93a1a1;--panel-2:#839496;--border:#839496;--border-2:#657b83;--fg:#586e75;--muted:#657b83;--dim:#839496;--accent:#268bd2;--accent-2:#6c71c4;--ok:#859900;--warn:#b58900;--info:#2aa198;--danger:#dc322f;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="gruvbox-dark"]{--bg:#282828;--bg-2:#3c3836;--panel:#504945;--panel-2:#665c54;--border:#665c54;--border-2:#bdae93;--fg:#d5c4a1;--muted:#bdae93;--dim:#665c54;--accent:#83a598;--accent-2:#d3869b;--ok:#b8bb26;--warn:#fabd2f;--info:#8ec07c;--danger:#fb4934;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="gruvbox-light"]{--bg:#fbf1c7;--bg-2:#ebdbb2;--panel:#d5c4a1;--panel-2:#bdae93;--border:#bdae93;--border-2:#665c54;--fg:#504945;--muted:#665c54;--dim:#bdae93;--accent:#076678;--accent-2:#8f3f71;--ok:#79740e;--warn:#b57614;--info:#427b58;--danger:#9d0006;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="tokyo-night"]{--bg:#1a1b26;--bg-2:#16161e;--panel:#2f3549;--panel-2:#444b6a;--border:#444b6a;--border-2:#787c99;--fg:#a9b1d6;--muted:#787c99;--dim:#444b6a;--accent:#2ac3de;--accent-2:#bb9af7;--ok:#9ece6a;--warn:#0db9d7;--info:#b4f9f8;--danger:#c0caf5;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="tokyo-night-light"]{--bg:#d5d6db;--bg-2:#cbccd1;--panel:#dfe0e5;--panel-2:#9699a3;--border:#9699a3;--border-2:#4c505e;--fg:#343b59;--muted:#4c505e;--dim:#9699a3;--accent:#34548a;--accent-2:#5a4a78;--ok:#485e30;--warn:#166775;--info:#3e6968;--danger:#343b58;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="rose-pine"]{--bg:#191724;--bg-2:#1f1d2e;--panel:#26233a;--panel-2:#6e6a86;--border:#6e6a86;--border-2:#908caa;--fg:#e0def4;--muted:#908caa;--dim:#6e6a86;--accent:#c4a7e7;--accent-2:#f6c177;--ok:#31748f;--warn:#ebbcba;--info:#9ccfd8;--danger:#eb6f92;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}
+:root[data-theme="rose-pine-dawn"]{--bg:#faf4ed;--bg-2:#fffaf3;--panel:#f2e9de;--panel-2:#9893a5;--border:#9893a5;--border-2:#797593;--fg:#575279;--muted:#797593;--dim:#9893a5;--accent:#907aa9;--accent-2:#ea9d34;--ok:#286983;--warn:#d7827e;--info:#56949f;--danger:#b4637a;--shadow:0 18px 45px -24px rgba(20,22,30,.16);color-scheme:light;}
+:root[data-theme="dracula"]{--bg:#282a36;--bg-2:#21222c;--panel:#44475a;--panel-2:#6272a4;--border:#6272a4;--border-2:#9ea8c7;--fg:#f8f8f2;--muted:#9ea8c7;--dim:#6272a4;--accent:#bd93f9;--accent-2:#ff79c6;--ok:#50fa7b;--warn:#f1fa8c;--info:#8be9fd;--danger:#ff5555;--shadow:0 24px 60px -28px rgba(0,0,0,.55);color-scheme:dark;}