theme config, layout preview, container alignment
Server: ThemeConfig entity + CRUD (GET/PUT /theme), SQLite persistence, ThemeUpdate broadcast to ESP32 on save and initial connect. Client: render engine uses theme colors, full-screen redraw on theme change. SPA: theme page with color pickers + presets, layout preview with TS port of layout engine, justify/align controls on containers. DisplayHint refactored to struct (kind + h_align + v_align).
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
use domain::{
|
||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
|
||||
WidgetConfig, WidgetId,
|
||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
||||
User, WidgetConfig, WidgetId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
@@ -15,6 +15,7 @@ pub struct MemoryConfigStore {
|
||||
widgets: RwLock<HashMap<WidgetId, WidgetConfig>>,
|
||||
data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
|
||||
layout: RwLock<Option<Layout>>,
|
||||
theme: RwLock<Option<ThemeConfig>>,
|
||||
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
|
||||
users: RwLock<Vec<User>>,
|
||||
}
|
||||
@@ -25,6 +26,7 @@ impl Default for MemoryConfigStore {
|
||||
widgets: RwLock::new(HashMap::new()),
|
||||
data_sources: RwLock::new(HashMap::new()),
|
||||
layout: RwLock::new(None),
|
||||
theme: RwLock::new(None),
|
||||
presets: RwLock::new(HashMap::new()),
|
||||
users: RwLock::new(Vec::new()),
|
||||
}
|
||||
@@ -125,6 +127,23 @@ impl ConfigRepository for MemoryConfigStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_theme(&self) -> Result<Option<ThemeConfig>, Self::Error> {
|
||||
let guard = self
|
||||
.theme
|
||||
.read()
|
||||
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
Ok(guard.clone())
|
||||
}
|
||||
|
||||
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
|
||||
let mut guard = self
|
||||
.theme
|
||||
.write()
|
||||
.map_err(|_| MemoryConfigError::LockPoisoned)?;
|
||||
*guard = Some(theme.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
|
||||
let guard = self
|
||||
.presets
|
||||
|
||||
@@ -87,6 +87,15 @@ impl SqliteConfigStore {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS theme (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
data TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
mod data_sources;
|
||||
mod layout;
|
||||
mod presets;
|
||||
mod theme;
|
||||
mod users;
|
||||
mod widgets;
|
||||
|
||||
use crate::SqliteConfigStore;
|
||||
use crate::error::SqliteConfigError;
|
||||
use domain::{
|
||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, User,
|
||||
WidgetConfig, WidgetId,
|
||||
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
|
||||
User, WidgetConfig, WidgetId,
|
||||
};
|
||||
|
||||
impl ConfigRepository for SqliteConfigStore {
|
||||
@@ -70,6 +71,14 @@ impl ConfigRepository for SqliteConfigStore {
|
||||
self.delete_preset_impl(id).await
|
||||
}
|
||||
|
||||
async fn get_theme(&self) -> Result<Option<ThemeConfig>, Self::Error> {
|
||||
self.get_theme_impl().await
|
||||
}
|
||||
|
||||
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
|
||||
self.save_theme_impl(theme).await
|
||||
}
|
||||
|
||||
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
|
||||
self.get_user_by_username_impl(username).await
|
||||
}
|
||||
|
||||
37
crates/adapters/config-sqlite/src/repository/theme.rs
Normal file
37
crates/adapters/config-sqlite/src/repository/theme.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::SqliteConfigStore;
|
||||
use crate::error::SqliteConfigError;
|
||||
use crate::serialization::theme as ser;
|
||||
use domain::ThemeConfig;
|
||||
use sqlx::Row;
|
||||
|
||||
impl SqliteConfigStore {
|
||||
pub(crate) async fn get_theme_impl(&self) -> Result<Option<ThemeConfig>, SqliteConfigError> {
|
||||
let row = sqlx::query("SELECT data FROM theme WHERE id = 1")
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
match row {
|
||||
None => Ok(None),
|
||||
Some(row) => {
|
||||
let json: String = row.get("data");
|
||||
Ok(Some(ser::theme_from_json(&json)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn save_theme_impl(
|
||||
&self,
|
||||
theme: &ThemeConfig,
|
||||
) -> Result<(), SqliteConfigError> {
|
||||
let json = ser::theme_to_json(theme)?;
|
||||
|
||||
sqlx::query("INSERT OR REPLACE INTO theme (id, data) VALUES (1, ?)")
|
||||
.bind(&json)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(SqliteConfigError::Sql)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::error::SqliteConfigError;
|
||||
use domain::{AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode, Sizing};
|
||||
use domain::{
|
||||
AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode, Sizing,
|
||||
};
|
||||
|
||||
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
|
||||
let v = node_to_json(&layout.root);
|
||||
@@ -37,6 +39,19 @@ fn node_to_json(node: &LayoutNode) -> serde_json::Value {
|
||||
"direction": match c.direction { Direction::Row => "row", Direction::Column => "column" },
|
||||
"gap": c.gap,
|
||||
"padding": c.padding,
|
||||
"justify_content": match c.justify_content {
|
||||
JustifyContent::Start => "start",
|
||||
JustifyContent::Center => "center",
|
||||
JustifyContent::End => "end",
|
||||
JustifyContent::SpaceBetween => "space_between",
|
||||
JustifyContent::SpaceEvenly => "space_evenly",
|
||||
},
|
||||
"align_items": match c.align_items {
|
||||
AlignItems::Start => "start",
|
||||
AlignItems::Center => "center",
|
||||
AlignItems::End => "end",
|
||||
AlignItems::Stretch => "stretch",
|
||||
},
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
@@ -93,12 +108,26 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let justify_content = match v["justify_content"].as_str() {
|
||||
Some("center") => JustifyContent::Center,
|
||||
Some("end") => JustifyContent::End,
|
||||
Some("space_between") => JustifyContent::SpaceBetween,
|
||||
Some("space_evenly") => JustifyContent::SpaceEvenly,
|
||||
_ => JustifyContent::Start,
|
||||
};
|
||||
let align_items = match v["align_items"].as_str() {
|
||||
Some("center") => AlignItems::Center,
|
||||
Some("end") => AlignItems::End,
|
||||
Some("stretch") => AlignItems::Stretch,
|
||||
_ => AlignItems::Start,
|
||||
};
|
||||
|
||||
Ok(LayoutNode::Container(ContainerNode {
|
||||
direction,
|
||||
gap,
|
||||
padding,
|
||||
justify_content: JustifyContent::Start,
|
||||
align_items: AlignItems::Stretch,
|
||||
justify_content,
|
||||
align_items,
|
||||
children,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod data_source;
|
||||
pub mod layout;
|
||||
pub mod preset;
|
||||
pub mod theme;
|
||||
pub mod widget;
|
||||
|
||||
40
crates/adapters/config-sqlite/src/serialization/theme.rs
Normal file
40
crates/adapters/config-sqlite/src/serialization/theme.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use crate::error::SqliteConfigError;
|
||||
use domain::{ThemeColor, ThemeConfig};
|
||||
|
||||
pub fn theme_to_json(theme: &ThemeConfig) -> Result<String, SqliteConfigError> {
|
||||
let v = serde_json::json!({
|
||||
"primary": color_to_json(&theme.primary),
|
||||
"secondary": color_to_json(&theme.secondary),
|
||||
"accent": color_to_json(&theme.accent),
|
||||
"text": color_to_json(&theme.text),
|
||||
"background": color_to_json(&theme.background),
|
||||
});
|
||||
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn theme_from_json(json: &str) -> Result<ThemeConfig, SqliteConfigError> {
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
|
||||
let err = |msg: &str| SqliteConfigError::Serialization(msg.into());
|
||||
|
||||
Ok(ThemeConfig {
|
||||
primary: color_from_json(&v["primary"]).map_err(|_| err("invalid primary"))?,
|
||||
secondary: color_from_json(&v["secondary"]).map_err(|_| err("invalid secondary"))?,
|
||||
accent: color_from_json(&v["accent"]).map_err(|_| err("invalid accent"))?,
|
||||
text: color_from_json(&v["text"]).map_err(|_| err("invalid text"))?,
|
||||
background: color_from_json(&v["background"]).map_err(|_| err("invalid background"))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn color_to_json(c: &ThemeColor) -> serde_json::Value {
|
||||
serde_json::json!({ "r": c.r, "g": c.g, "b": c.b })
|
||||
}
|
||||
|
||||
fn color_from_json(v: &serde_json::Value) -> Result<ThemeColor, SqliteConfigError> {
|
||||
let err = |msg: &str| SqliteConfigError::Serialization(msg.into());
|
||||
Ok(ThemeColor {
|
||||
r: v["r"].as_u64().ok_or_else(|| err("missing r"))? as u8,
|
||||
g: v["g"].as_u64().ok_or_else(|| err("missing g"))? as u8,
|
||||
b: v["b"].as_u64().ok_or_else(|| err("missing b"))? as u8,
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use config_sqlite::SqliteConfigStore;
|
||||
use domain::{
|
||||
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
|
||||
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild, LayoutNode,
|
||||
LayoutPreset, Sizing, WidgetConfig,
|
||||
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
|
||||
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ mod clients;
|
||||
mod data_sources;
|
||||
mod layout;
|
||||
mod presets;
|
||||
mod theme;
|
||||
mod webhook;
|
||||
mod widgets;
|
||||
|
||||
@@ -70,6 +71,11 @@ where
|
||||
get(layout::get_layout::<C, E, W, B, R, A, H>)
|
||||
.put(layout::update_layout::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/theme",
|
||||
get(theme::get_theme::<C, E, W, B, R, A, H>)
|
||||
.put(theme::update_theme::<C, E, W, B, R, A, H>),
|
||||
)
|
||||
.route(
|
||||
"/presets",
|
||||
get(presets::list_presets::<C, E, W, B, R, A, H>)
|
||||
|
||||
46
crates/adapters/http-api/src/routes/theme.rs
Normal file
46
crates/adapters/http-api/src/routes/theme.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use crate::AppState;
|
||||
use crate::extractors::AuthUser;
|
||||
use api_types::ThemeDto;
|
||||
use application::ConfigService;
|
||||
use axum::{extract::State, http::StatusCode, response::Json};
|
||||
use domain::{ConfigRepository, EventPublisher};
|
||||
|
||||
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
|
||||
|
||||
pub async fn get_theme<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
) -> Result<Json<ThemeDto>, StatusCode>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
E: EventPublisher,
|
||||
E::Error: std::fmt::Debug,
|
||||
{
|
||||
let theme = state
|
||||
.config
|
||||
.get_theme()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.unwrap_or_default();
|
||||
Ok(Json(ThemeDto::from(&theme)))
|
||||
}
|
||||
|
||||
pub async fn update_theme<C, E, W, B, R, A, H>(
|
||||
_auth: AuthUser,
|
||||
State(state): S<C, E, W, B, R, A, H>,
|
||||
Json(body): Json<ThemeDto>,
|
||||
) -> Result<StatusCode, (StatusCode, String)>
|
||||
where
|
||||
C: ConfigRepository,
|
||||
C::Error: std::fmt::Debug,
|
||||
E: EventPublisher,
|
||||
E::Error: std::fmt::Debug,
|
||||
{
|
||||
let theme = body.into_domain();
|
||||
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
|
||||
svc.update_theme(theme)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::error::TcpServerError;
|
||||
use domain::{BroadcastPort, DisplayHint, Layout, WidgetId, WidgetState};
|
||||
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
|
||||
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
|
||||
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub struct TcpBroadcaster {
|
||||
@@ -70,4 +70,26 @@ impl BroadcastPort for TcpBroadcaster {
|
||||
let frame = encode(&msg).map_err(TcpServerError::Encode)?;
|
||||
self.send_frame(frame)
|
||||
}
|
||||
|
||||
async fn push_theme_update(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
|
||||
let wire_theme = domain_theme_to_wire(theme);
|
||||
let msg = ServerMessage::ThemeUpdate { theme: wire_theme };
|
||||
let frame = encode(&msg).map_err(TcpServerError::Encode)?;
|
||||
self.send_frame(frame)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn domain_theme_to_wire(t: &ThemeConfig) -> WireTheme {
|
||||
let c = |c: &domain::ThemeColor| WireColor {
|
||||
r: c.r,
|
||||
g: c.g,
|
||||
b: c.b,
|
||||
};
|
||||
WireTheme {
|
||||
primary: c(&t.primary),
|
||||
secondary: c(&t.secondary),
|
||||
accent: c(&t.accent),
|
||||
text: c(&t.text),
|
||||
background: c(&t.background),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::broadcaster::domain_theme_to_wire;
|
||||
use crate::client_tracker::ClientTracker;
|
||||
use crate::error::TcpServerError;
|
||||
use domain::{ConfigRepository, WidgetStateReader};
|
||||
@@ -103,11 +104,24 @@ where
|
||||
widgets: wire_widgets,
|
||||
};
|
||||
|
||||
match encode(&msg) {
|
||||
Ok(frame) => Some(frame),
|
||||
let mut combined = match encode(&msg) {
|
||||
Ok(frame) => frame,
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to encode initial screen update");
|
||||
None
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(Some(theme)) = config.get_theme().await {
|
||||
let wire_theme = domain_theme_to_wire(&theme);
|
||||
let theme_msg = ServerMessage::ThemeUpdate { theme: wire_theme };
|
||||
match encode(&theme_msg) {
|
||||
Ok(frame) => combined.extend_from_slice(&frame),
|
||||
Err(e) => {
|
||||
error!(error = %e, "failed to encode initial theme update");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(combined)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user