Compare commits

...

9 Commits

Author SHA1 Message Date
fa097771d4 arch: push wire types out of ClientApp, extract event_service, cleanup dead code
- ClientApp stores domain types, RepaintCommand carries DisplayHint + Vec<(String,Value)>
- adapters no longer convert Wire→Domain (eliminated duplication in esp32 + desktop)
- event_service in application layer handles LayoutChanged/WebhookDataReceived/ThemeChanged
- bootstrap event_handler reduced to 10-line dispatcher
- polling_service reuses event_service::apply_and_broadcast (deduplicated broadcast pattern)
- AppState.config_service() replaces 11 inline ConfigService::new() calls
- delete unused poll_interval_secs parameter chain
- delete unused StoragePort/ClientConfig (zero implementations)
2026-06-19 18:30:14 +02:00
7001b5e911 arch: split ConfigRepository, extract polling, consolidate conversions, decouple protocol
- Value↔JSON: From impls on domain Value behind `json` feature, delete 4 duplicate converters
- ConfigRepository split into ConfigRepository (12), UserRepository (3), WidgetStateCache (2)
- polling orchestration moved from bootstrap to application::polling_service
- WidgetRenderer in client-domain owns scroll/cache, both clients use it
- network loop consolidated into client-application::run_connection_loop
- protocol crate drops domain dep, Wire↔Domain conversions move to adapters
2026-06-19 18:12:50 +02:00
1c854d127f fix bottom scroll artifacts, slow scroll for readability 2026-06-19 13:35:46 +02:00
838e29702a fix scroll artifacts at widget edges, disable esp-mesh 2026-06-19 13:32:44 +02:00
5bcf4c4e0d strip unused esp32 deps, fix render loop power waste 2026-06-19 13:22:12 +02:00
27c1fe3f37 optimize esp32 release binary size: 1.7MB -> 1.1MB 2026-06-19 13:13:23 +02:00
b964801765 remove all modals, inline editing, live layout preview, clock preview
all Dialog/AlertDialog removed from widgets, data-sources, presets,
layout-builder pages. replaced with inline card expansion for
edit/create and inline confirm bars for delete.

data source form: live clock format preview with 1s tick, timezone
validation against Intl.supportedValuesOf.

layout preview: fetches live widget data via useWidgetPreview, renders
formatted content based on display_hint kind instead of widget names.
2026-06-19 13:08:00 +02:00
13497dd53c state recovery, polling optimizations, error rendering
widget states cached to SQLite, loaded on startup to seed DataProjection
so server restart preserves last-known data for reconnecting clients.

polling: first poll runs immediately, widget list cached per-task with
30s refresh, static text polled once inline instead of looping.

poll failures propagate WidgetError::SourceUnavailable to clients.
render engine prepends [offline] prefix in accent color, stale data
preserved below.
2026-06-19 12:56:12 +02:00
8b1dac9669 update README: wiring table, new features, data-generators in arch diagram 2026-06-19 12:37:30 +02:00
70 changed files with 2001 additions and 1555 deletions

View File

@@ -2,7 +2,6 @@
KFRAME_DATABASE_URL=sqlite:kframe.db?mode=rwc
KFRAME_TCP_ADDR=0.0.0.0:2699
KFRAME_HTTP_ADDR=0.0.0.0:3000
KFRAME_POLL_INTERVAL_SECS=5
# Auth (required)
JWT_SECRET=change-me-to-a-random-secret

6
Cargo.lock generated
View File

@@ -79,9 +79,11 @@ dependencies = [
name = "application"
version = "0.1.0"
dependencies = [
"anyhow",
"domain",
"thiserror",
"tokio",
"tracing",
]
[[package]]
@@ -526,6 +528,9 @@ dependencies = [
[[package]]
name = "domain"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "dotenvy"
@@ -1687,7 +1692,6 @@ dependencies = [
name = "protocol"
version = "0.1.0"
dependencies = [
"domain",
"postcard",
"serde",
]

View File

@@ -89,6 +89,23 @@ cd spa && bun install && bun run dev
# 5. Add a data source, create widgets, build a layout
```
### ESP32 wiring
2.4" ILI9341 SPI LCD module → ESP-WROOM-32:
| LCD Pin | ESP32 GPIO | Function |
|---------|------------|----------|
| VCC | 3V3 | Power |
| GND | GND | Ground |
| DIN | GPIO23 | SPI MOSI |
| CLK | GPIO18 | SPI SCLK |
| CS | GPIO26 | Chip select |
| DC | GPIO21 | Data/command |
| RST | GPIO22 | Reset |
| BL | 3V3 | Backlight (always on) |
Uses SPI2 (HSPI) at 26 MHz. Pin assignments are in `crates/client-esp32/src/main.rs`.
### ESP32 client
```bash

View File

@@ -1,6 +1,6 @@
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId,
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
};
use std::collections::HashMap;
use std::sync::RwLock;
@@ -177,6 +177,10 @@ impl ConfigRepository for MemoryConfigStore {
guard.remove(&id);
Ok(())
}
}
impl UserRepository for MemoryConfigStore {
type Error = MemoryConfigError;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
let guard = self
@@ -204,3 +208,18 @@ impl ConfigRepository for MemoryConfigStore {
Ok(guard.len() as u32)
}
}
impl WidgetStateCache for MemoryConfigStore {
type Error = MemoryConfigError;
async fn save_widget_states(
&self,
_states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
Ok(())
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
Ok(vec![])
}
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
sqlx.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -96,6 +96,15 @@ impl SqliteConfigStore {
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS widget_state_cache (
widget_id INTEGER PRIMARY KEY,
state_json TEXT NOT NULL
)",
)
.execute(&self.pool)
.await?;
// Add alignment columns to widgets (idempotent)
let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN h_align TEXT NOT NULL DEFAULT 'left'")
.execute(&self.pool)

View File

@@ -3,13 +3,14 @@ mod layout;
mod presets;
mod theme;
mod users;
mod widget_state_cache;
mod widgets;
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId,
User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
};
impl ConfigRepository for SqliteConfigStore {
@@ -78,6 +79,10 @@ impl ConfigRepository for SqliteConfigStore {
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
self.save_theme_impl(theme).await
}
}
impl UserRepository for SqliteConfigStore {
type Error = SqliteConfigError;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
self.get_user_by_username_impl(username).await
@@ -91,3 +96,18 @@ impl ConfigRepository for SqliteConfigStore {
self.count_users_impl().await
}
}
impl WidgetStateCache for SqliteConfigStore {
type Error = SqliteConfigError;
async fn save_widget_states(
&self,
states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
self.save_widget_states_impl(states).await
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
self.load_widget_states_impl().await
}
}

View File

@@ -0,0 +1,60 @@
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{Value, WidgetId, WidgetState};
use sqlx::Row;
use std::collections::BTreeMap;
impl SqliteConfigStore {
pub(crate) async fn save_widget_states_impl(
&self,
states: &[(WidgetId, WidgetState)],
) -> Result<(), SqliteConfigError> {
for (id, state) in states {
let json = domain_state_to_json(state)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
sqlx::query(
"INSERT OR REPLACE INTO widget_state_cache (widget_id, state_json) VALUES (?, ?)",
)
.bind(*id as i64)
.bind(&json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
}
Ok(())
}
pub(crate) async fn load_widget_states_impl(
&self,
) -> Result<Vec<(WidgetId, WidgetState)>, SqliteConfigError> {
let rows = sqlx::query("SELECT widget_id, state_json FROM widget_state_cache")
.fetch_all(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
let mut result = Vec::new();
for row in &rows {
let id: i64 = row.get("widget_id");
let json_str: String = row.get("state_json");
let state = json_to_domain_state(&json_str)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
result.push((id as WidgetId, state));
}
Ok(result)
}
}
fn domain_state_to_json(state: &WidgetState) -> Result<String, serde_json::Error> {
let data: serde_json::Map<String, serde_json::Value> = state
.data
.iter()
.map(|(k, v)| (k.clone(), v.into()))
.collect();
serde_json::to_string(&data)
}
fn json_to_domain_state(json: &str) -> Result<WidgetState, serde_json::Error> {
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(json)?;
let data: BTreeMap<String, Value> = map.into_iter().map(|(k, v)| (k, v.into())).collect();
Ok(WidgetState { data, error: None })
}

View File

@@ -2,7 +2,7 @@ use config_sqlite::SqliteConfigStore;
use domain::{
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
LayoutNode, LayoutPreset, Sizing, UserRepository, WidgetConfig, WidgetStateCache,
};
use std::time::Duration;

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
application.workspace = true
api-types.workspace = true
axum.workspace = true

View File

@@ -1,10 +1,11 @@
pub mod extractors;
mod routes;
use application::ConfigService;
use axum::Router;
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
UserRepository, WidgetStateCache, WidgetStateReader,
};
use std::sync::Arc;
use tower_http::cors::CorsLayer;
@@ -36,10 +37,23 @@ impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
}
}
impl<C, E, W, B, R, A, H> AppState<C, E, W, B, R, A, H>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{
pub fn config_service(&self) -> ConfigService<'_, C, E> {
ConfigService::new(self.config.as_ref(), self.events.as_ref())
}
}
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,
@@ -69,8 +83,9 @@ pub async fn serve<C, E, W, B, R, A, H>(
state: AppState<C, E, W, B, R, A, H>,
) -> Result<(), std::io::Error>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,

View File

@@ -2,7 +2,7 @@ use crate::AppState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
use domain::{AuthPort, PasswordHashPort, UserRepository};
use serde::{Deserialize, Serialize};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
@@ -28,7 +28,7 @@ pub async fn login<C, E, W, B, R, A, H>(
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, String)>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
A: AuthPort,
H: PasswordHashPort,
@@ -51,7 +51,7 @@ pub async fn register<C, E, W, B, R, A, H>(
Json(body): Json<LoginRequest>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
H: PasswordHashPort,
{
@@ -71,7 +71,7 @@ pub async fn auth_status<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<StatusResponse>, StatusCode>
where
C: ConfigRepository,
C: UserRepository,
C::Error: std::fmt::Debug,
{
let count = state

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::DataSourceDto;
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -65,7 +65,7 @@ where
let source = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.create_data_source(source)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -87,7 +87,7 @@ where
let source = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.update_data_source(source)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -105,7 +105,7 @@ where
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.delete_data_source(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::LayoutDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher};
@@ -39,7 +39,7 @@ where
let layout = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.update_layout(layout)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -12,13 +12,14 @@ use axum::Router;
use axum::routing::{get, post};
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
UserRepository, WidgetStateCache, WidgetStateReader,
};
pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreatePresetDto, PresetDto};
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -65,7 +65,7 @@ where
let preset = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.save_preset(preset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -83,7 +83,7 @@ where
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.delete_preset(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -101,7 +101,7 @@ where
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.load_preset(id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -1,7 +1,7 @@
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};
@@ -38,7 +38,7 @@ where
E::Error: std::fmt::Debug,
{
let theme = body.into_domain();
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.update_theme(theme)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -31,7 +31,7 @@ where
));
}
let data = json_to_domain_value(body);
let data: domain::Value = body.into();
state
.events
@@ -41,22 +41,3 @@ where
Ok(StatusCode::OK)
}
fn json_to_domain_value(json: serde_json::Value) -> domain::Value {
match json {
serde_json::Value::Null => domain::Value::Null,
serde_json::Value::Bool(b) => domain::Value::Bool(b),
serde_json::Value::Number(n) => domain::Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => domain::Value::String(s),
serde_json::Value::Array(arr) => {
domain::Value::Array(arr.into_iter().map(json_to_domain_value).collect())
}
serde_json::Value::Object(obj) => {
let map = obj
.into_iter()
.map(|(k, v)| (k, json_to_domain_value(v)))
.collect();
domain::Value::Object(map)
}
}
}

View File

@@ -1,7 +1,7 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreateWidgetDto, WidgetDto};
use application::ConfigService;
use axum::{
extract::{Path, State},
http::StatusCode,
@@ -65,7 +65,7 @@ where
let widget = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.create_widget(widget)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -87,7 +87,7 @@ where
let widget = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.update_widget(widget)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -105,7 +105,7 @@ where
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
let svc = state.config_service();
svc.delete_widget(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -126,32 +126,10 @@ where
{
match state.widget_states.get_widget_state(id).await {
Some(ws) => {
let map: serde_json::Map<String, serde_json::Value> = ws
.data
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
let map: serde_json::Map<String, serde_json::Value> =
ws.data.iter().map(|(k, v)| (k.clone(), v.into())).collect();
Ok(Json(serde_json::Value::Object(map)))
}
None => Err(StatusCode::NOT_FOUND),
}
}
fn domain_value_to_json(v: &domain::Value) -> serde_json::Value {
match v {
domain::Value::Null => serde_json::Value::Null,
domain::Value::Bool(b) => serde_json::Value::Bool(*b),
domain::Value::Number(n) => serde_json::json!(n),
domain::Value::String(s) => serde_json::Value::String(s.clone()),
domain::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect())
}
domain::Value::Object(obj) => {
let map = obj
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
}
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
domain = { workspace = true, features = ["json"] }
reqwest.workspace = true
serde_json.workspace = true
thiserror.workspace = true

View File

@@ -28,21 +28,6 @@ impl HttpJsonAdapter {
}
}
fn json_to_value(json: serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Value::String(s),
serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
serde_json::Value::Object(map) => Value::Object(
map.into_iter()
.map(|(k, v)| (k, json_to_value(v)))
.collect(),
),
}
}
impl DataSourcePort for HttpJsonAdapter {
type Error = HttpJsonError;
@@ -70,6 +55,6 @@ impl DataSourcePort for HttpJsonAdapter {
let resp = req.send().await.map_err(HttpJsonError::Request)?;
let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?;
Ok(json_to_value(json))
Ok(json.into())
}
}

View File

@@ -1,6 +1,7 @@
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
use crate::error::TcpServerError;
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireTheme, encode};
use tokio::sync::broadcast;
pub struct TcpBroadcaster {
@@ -31,13 +32,13 @@ impl BroadcastPort for TcpBroadcaster {
layout: &Layout,
widgets: &[(WidgetId, DisplayHint, WidgetState)],
) -> Result<(), Self::Error> {
let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_layout = layout_to_wire(&layout.root);
let wire_widgets: Vec<WidgetDescriptor> = widgets
.iter()
.map(|(id, hint, state)| WidgetDescriptor {
id: *id,
display_hint: hint.into(),
state: state.into(),
display_hint: display_hint_to_wire(hint),
state: widget_state_to_wire(state),
})
.collect();
@@ -58,8 +59,8 @@ impl BroadcastPort for TcpBroadcaster {
.iter()
.map(|(id, hint, state)| WidgetDescriptor {
id: *id,
display_hint: hint.into(),
state: state.into(),
display_hint: display_hint_to_wire(hint),
state: widget_state_to_wire(state),
})
.collect();

View File

@@ -0,0 +1,103 @@
use domain::value_objects::{
AlignItems, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, LayoutNode,
Sizing, VAlign, Value, WidgetError, WidgetState,
};
use protocol::{
WireAlignItems, WireContainerNode, WireDirection, WireDisplayHint, WireDisplayHintKind,
WireHAlign, WireJustifyContent, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing,
WireVAlign, WireValue, WireWidgetError, WireWidgetState,
};
pub fn value_to_wire(v: &Value) -> WireValue {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
Value::Object(map) => WireValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), value_to_wire(v)))
.collect(),
),
}
}
pub fn widget_error_to_wire(e: &WidgetError) -> WireWidgetError {
match e {
WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable,
WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed,
}
}
pub fn widget_state_to_wire(s: &WidgetState) -> WireWidgetState {
WireWidgetState {
data: s
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: value_to_wire(v),
})
.collect(),
error: s.error.as_ref().map(widget_error_to_wire),
}
}
pub fn display_hint_to_wire(h: &DisplayHint) -> WireDisplayHint {
WireDisplayHint {
kind: match h.kind {
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
},
h_align: match h.h_align {
HAlign::Left => WireHAlign::Left,
HAlign::Center => WireHAlign::Center,
HAlign::Right => WireHAlign::Right,
},
v_align: match h.v_align {
VAlign::Top => WireVAlign::Top,
VAlign::Middle => WireVAlign::Middle,
VAlign::Bottom => WireVAlign::Bottom,
},
}
}
pub fn layout_to_wire(n: &LayoutNode) -> WireLayoutNode {
match n {
LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id),
LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode {
direction: match c.direction {
Direction::Row => WireDirection::Row,
Direction::Column => WireDirection::Column,
},
gap: c.gap,
padding: c.padding,
justify_content: match c.justify_content {
JustifyContent::Start => WireJustifyContent::Start,
JustifyContent::Center => WireJustifyContent::Center,
JustifyContent::End => WireJustifyContent::End,
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
},
align_items: match c.align_items {
AlignItems::Start => WireAlignItems::Start,
AlignItems::Center => WireAlignItems::Center,
AlignItems::End => WireAlignItems::End,
AlignItems::Stretch => WireAlignItems::Stretch,
},
children: c
.children
.iter()
.map(|ch| WireLayoutChild {
sizing: match ch.sizing {
Sizing::Fixed(px) => WireSizing::Fixed(px),
Sizing::Flex(w) => WireSizing::Flex(w),
},
node: layout_to_wire(&ch.node),
})
.collect(),
}),
}
}

View File

@@ -1,5 +1,6 @@
mod broadcaster;
mod client_tracker;
mod conversions;
mod error;
mod event_bus;
mod server;

View File

@@ -1,8 +1,9 @@
use crate::broadcaster::domain_theme_to_wire;
use crate::client_tracker::ClientTracker;
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
use crate::error::TcpServerError;
use domain::{ConfigRepository, WidgetStateReader};
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
use protocol::{ServerMessage, WidgetDescriptor, encode};
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
@@ -87,14 +88,14 @@ where
}
};
let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_layout = layout_to_wire(&layout.root);
let mut wire_widgets = Vec::new();
for w in &widgets {
if let Some(s) = widget_states.get_widget_state(w.id).await {
wire_widgets.push(WidgetDescriptor {
id: w.id,
display_hint: (&w.display_hint).into(),
state: (&s).into(),
display_hint: display_hint_to_wire(&w.display_hint),
state: widget_state_to_wire(&s),
});
}
}

View File

@@ -7,6 +7,8 @@ edition = "2024"
domain.workspace = true
thiserror.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -1,4 +1,4 @@
use domain::{AuthPort, ConfigRepository, PasswordHashPort, User};
use domain::{AuthPort, PasswordHashPort, User, UserRepository};
pub enum AuthError<E> {
InvalidCredentials,
@@ -26,7 +26,7 @@ pub async fn login<C, A, H>(
password: &str,
) -> Result<String, AuthError<C::Error>>
where
C: ConfigRepository,
C: UserRepository,
A: AuthPort,
H: PasswordHashPort,
{
@@ -55,7 +55,7 @@ pub async fn register<C, H>(
password: &str,
) -> Result<(), AuthError<C::Error>>
where
C: ConfigRepository,
C: UserRepository,
H: PasswordHashPort,
{
let count = config.count_users().await.map_err(AuthError::Repository)?;

View File

@@ -19,6 +19,13 @@ impl DataProjection {
Self::default()
}
pub async fn seed(&self, states: Vec<(WidgetId, WidgetState)>) {
let mut current = self.current.lock().await;
for (id, state) in states {
current.insert(id, state);
}
}
pub async fn get_state(&self, widget_id: WidgetId) -> Option<WidgetState> {
self.current.lock().await.get(&widget_id).cloned()
}

View File

@@ -0,0 +1,123 @@
use crate::DataProjection;
use domain::{
BroadcastPort, ConfigRepository, DomainEvent, Layout, Value, WidgetConfig, WidgetState,
};
use std::sync::Arc;
use tracing::{error, info, warn};
pub async fn handle_event<C, B>(
event: DomainEvent,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
match event {
DomainEvent::LayoutChanged { layout } => {
handle_layout_changed(&layout, config, broadcaster, projection).await;
}
DomainEvent::WebhookDataReceived { source_id, data } => {
handle_webhook_data(source_id, &data, config, broadcaster, projection).await;
}
DomainEvent::ThemeChanged { theme } => {
if let Err(e) = broadcaster.push_theme_update(&theme).await {
error!(error = %e, "failed to push theme update");
}
info!("theme changed, pushed update to clients");
}
_ => {}
}
}
async fn handle_layout_changed<C, B>(
layout: &Layout,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for screen update");
return;
}
};
let mut widget_states = Vec::new();
for w in &widgets {
if let Some(s) = projection.get_state(w.id).await {
widget_states.push((w.id, w.display_hint.clone(), s));
}
}
if let Err(e) = broadcaster.push_screen_update(layout, &widget_states).await {
error!(error = %e, "failed to push screen update");
}
info!("layout changed, pushed screen update to clients");
}
async fn handle_webhook_data<C, B>(
source_id: u16,
data: &Value,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for webhook");
return;
}
};
let changed = apply_and_broadcast(source_id, data, &widgets, broadcaster, projection).await;
if !changed.is_empty() {
info!(source_id, count = changed.len(), "webhook data pushed");
}
}
pub async fn apply_and_broadcast<B>(
source_id: u16,
data: &Value,
widgets: &[WidgetConfig],
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) -> Vec<(u16, WidgetState)>
where
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let changed: Vec<(u16, WidgetState)> =
projection.apply_poll_result(source_id, data, widgets).await;
if !changed.is_empty() {
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, state)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, state.clone()))
})
.collect();
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
warn!(error = %e, "failed to push update");
}
}
changed
}

View File

@@ -1,6 +1,8 @@
pub mod auth_service;
mod config_service;
mod data_projection;
pub mod event_service;
pub mod polling_service;
pub use config_service::ConfigService;
pub use data_projection::DataProjection;

View File

@@ -0,0 +1,254 @@
use crate::DataProjection;
use domain::{
BroadcastPort, ConfigRepository, DataSource, Value, WidgetConfig, WidgetError, WidgetState,
WidgetStateCache,
};
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
pub async fn run<C, B, P, F>(
config: Arc<C>,
broadcaster: Arc<B>,
projection: Arc<DataProjection>,
poller: Arc<P>,
) where
C: ConfigRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Display + Send,
<C as WidgetStateCache>::Error: std::fmt::Display + Send,
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Display + Send,
P: Fn(&DataSource) -> F + Send + Sync + 'static,
F: Future<Output = Result<Value, anyhow::Error>> + Send,
{
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
let mut static_done: HashSet<u16> = HashSet::new();
info!("polling manager started");
loop {
let sources = match config.list_data_sources().await {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "failed to list data sources");
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
continue;
}
};
let current_ids: Vec<u16> = sources.iter().map(|s| s.id).collect();
running.retain(|id, handle| {
if !current_ids.contains(id) {
info!(source_id = id, "stopping poll for removed source");
handle.abort();
false
} else {
true
}
});
static_done.retain(|id| current_ids.contains(id));
for source in &sources {
if source.source_type == domain::DataSourceType::Webhook {
continue;
}
if source.source_type == domain::DataSourceType::StaticText {
if static_done.contains(&source.id) {
continue;
}
poll_and_broadcast(&*poller, source, &config, &broadcaster, &projection).await;
static_done.insert(source.id);
continue;
}
if running.contains_key(&source.id) {
continue;
}
let source_id = source.id;
let source = source.clone();
let config = config.clone();
let broadcaster = broadcaster.clone();
let projection = projection.clone();
let poller = poller.clone();
info!(
source_id = source.id,
name = %source.name,
interval_secs = source.poll_interval.as_secs(),
"starting poll task"
);
let handle = tokio::spawn(async move {
poll_loop(source, config, broadcaster, projection, poller).await;
});
running.insert(source_id, handle);
}
if running.is_empty() && static_done.is_empty() {
debug!("no pollable sources, waiting");
}
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
}
}
async fn poll_and_broadcast<C, B, P, F>(
poller: &P,
source: &DataSource,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository + WidgetStateCache,
<C as ConfigRepository>::Error: std::fmt::Display,
<C as WidgetStateCache>::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
P: Fn(&DataSource) -> F,
F: Future<Output = Result<Value, anyhow::Error>>,
{
let result = match poller(source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
return;
}
};
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch widgets");
return;
}
};
let changed = crate::event_service::apply_and_broadcast(
source.id,
&result,
&widgets,
broadcaster,
projection,
)
.await;
if !changed.is_empty() {
if let Err(e) = config.save_widget_states(&changed).await {
warn!(error = %e, "failed to cache widget states");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
}
async fn poll_loop<C, B, P, F>(
source: DataSource,
config: Arc<C>,
broadcaster: Arc<B>,
projection: Arc<DataProjection>,
poller: Arc<P>,
) where
C: ConfigRepository + WidgetStateCache,
<C as ConfigRepository>::Error: std::fmt::Display,
<C as WidgetStateCache>::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
P: Fn(&DataSource) -> F,
F: Future<Output = Result<Value, anyhow::Error>>,
{
let interval = source.poll_interval;
let mut widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch initial widget list");
vec![]
}
};
let mut last_refresh = tokio::time::Instant::now();
loop {
let result = match poller(&source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
broadcast_errors(&source, &widgets, &broadcaster, &projection).await;
tokio::time::sleep(interval).await;
continue;
}
};
if last_refresh.elapsed() >= SOURCE_REFRESH_INTERVAL {
if let Ok(w) = config.list_widgets().await {
widgets = w;
}
last_refresh = tokio::time::Instant::now();
}
let changed = crate::event_service::apply_and_broadcast(
source.id,
&result,
&widgets,
&broadcaster,
&projection,
)
.await;
if !changed.is_empty() {
if let Err(e) = config.save_widget_states(&changed).await {
warn!(error = %e, "failed to cache widget states");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
tokio::time::sleep(interval).await;
}
}
async fn broadcast_errors<B>(
source: &DataSource,
widgets: &[WidgetConfig],
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let affected: Vec<_> = widgets
.iter()
.filter(|w| w.data_source_id == source.id)
.collect();
if affected.is_empty() {
return;
}
let mut error_states = Vec::new();
for w in &affected {
let mut state = projection
.get_state(w.id)
.await
.unwrap_or_else(|| WidgetState {
data: std::collections::BTreeMap::new(),
error: None,
});
state.error = Some(WidgetError::SourceUnavailable);
error_states.push((w.id, state));
}
projection.seed(error_states.clone()).await;
let with_hints: Vec<_> = error_states
.iter()
.filter_map(|(id, state)| {
let hint = affected.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, state.clone()))
})
.collect();
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
warn!(error = %e, "failed to push error update");
}
}

View File

@@ -1,6 +1,7 @@
use domain::{
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
LayoutPresetId, ThemeConfig, User, WidgetConfig, WidgetId,
LayoutPresetId, ThemeConfig, User, UserRepository, WidgetConfig, WidgetId, WidgetState,
WidgetStateCache,
};
use std::collections::HashMap;
use std::sync::Mutex;
@@ -123,6 +124,10 @@ impl ConfigRepository for InMemoryConfigRepository {
self.presets.lock().unwrap().remove(&id);
Ok(())
}
}
impl UserRepository for InMemoryConfigRepository {
type Error = Never;
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
Ok(None)
@@ -137,6 +142,21 @@ impl ConfigRepository for InMemoryConfigRepository {
}
}
impl WidgetStateCache for InMemoryConfigRepository {
type Error = Never;
async fn save_widget_states(
&self,
_states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
Ok(())
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
Ok(vec![])
}
}
pub struct InMemoryEventPublisher {
events: Mutex<Vec<DomainEvent>>,
}

View File

@@ -4,7 +4,6 @@ pub struct ServerConfig {
pub database_url: String,
pub tcp_addr: String,
pub http_addr: String,
pub poll_interval_secs: u64,
pub spa_dir: Option<String>,
}
@@ -15,10 +14,6 @@ impl ServerConfig {
.unwrap_or_else(|_| "sqlite:kframe.db?mode=rwc".into()),
tcp_addr: env::var("KFRAME_TCP_ADDR").unwrap_or_else(|_| "0.0.0.0:2699".into()),
http_addr: env::var("KFRAME_HTTP_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()),
poll_interval_secs: env::var("KFRAME_POLL_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
spa_dir: env::var("KFRAME_SPA_DIR").ok(),
}
}

View File

@@ -1,9 +1,8 @@
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
use std::sync::Arc;
use tcp_server::{TcpBroadcaster, TcpEventBus};
use tracing::{error, info, warn};
use tracing::{error, warn};
pub async fn run(
event_bus: Arc<TcpEventBus>,
@@ -15,69 +14,10 @@ pub async fn run(
loop {
match rx.recv().await {
Ok(DomainEvent::LayoutChanged { layout }) => {
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for screen update");
continue;
}
};
let mut widget_states = Vec::new();
for w in &widgets {
if let Some(s) = projection.get_state(w.id).await {
widget_states.push((w.id, w.display_hint.clone(), s));
}
}
if let Err(e) = broadcaster
.push_screen_update(&layout, &widget_states)
.await
{
error!(error = %e, "failed to push screen update");
}
info!("layout changed, pushed screen update to clients");
}
Ok(DomainEvent::WebhookDataReceived { source_id, data }) => {
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for webhook");
continue;
}
};
let changed = projection
.apply_poll_result(source_id, &data, &widgets)
Ok(event) => {
application::event_service::handle_event(event, &config, &broadcaster, &projection)
.await;
if !changed.is_empty() {
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, state)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, state.clone()))
})
.collect();
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
error!(error = %e, "failed to push webhook data update");
}
info!(
source_id,
count = changed.len(),
"webhook data received, pushed update"
);
}
}
Ok(DomainEvent::ThemeChanged { theme }) => {
if let Err(e) = broadcaster.push_theme_update(&theme).await {
error!(error = %e, "failed to push theme update");
}
info!("theme changed, pushed update to clients");
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "event handler lagged, missed events");
}

View File

@@ -5,12 +5,13 @@ mod polling;
use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use domain::WidgetStateCache;
use http_api::AppState;
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
use secret_store::AesSecretStore;
use std::sync::Arc;
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
use tracing::{error, info};
use tracing::{error, info, warn};
#[tokio::main]
async fn main() -> Result<()> {
@@ -40,6 +41,15 @@ async fn main() -> Result<()> {
let auth = Arc::new(JwtAuthService::new(auth_config));
let hasher = Arc::new(Argon2Hasher);
match config_store.load_widget_states().await {
Ok(states) if !states.is_empty() => {
info!(count = states.len(), "loaded cached widget states");
projection.seed(states).await;
}
Ok(_) => {}
Err(e) => warn!(error = %e, "failed to load cached widget states"),
}
let tcp_addr = cfg.tcp_addr.clone();
let tcp_bc = broadcaster.clone();
let tcp_tracker = tracker.clone();
@@ -80,11 +90,5 @@ async fn main() -> Result<()> {
event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await;
});
polling::run(
config_store,
broadcaster,
projection,
cfg.poll_interval_secs,
)
.await
polling::run(config_store, broadcaster, projection).await
}

View File

@@ -2,20 +2,12 @@ use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use data_generators::{ClockGenerator, StaticTextGenerator};
use domain::{
BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value, WidgetState,
};
use domain::{DataSource, DataSourcePort, DataSourceType, Value};
use http_json::HttpJsonAdapter;
use media_adapter::MediaAdapter;
use rss_adapter::RssAdapter;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tcp_server::TcpBroadcaster;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Clone)]
struct Adapters {
@@ -65,7 +57,6 @@ pub async fn run(
config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>,
_poll_interval_secs: u64,
) -> Result<()> {
let adapters = Adapters {
http: Arc::new(HttpJsonAdapter::new()),
@@ -75,109 +66,12 @@ pub async fn run(
static_text: Arc::new(StaticTextGenerator::new()),
};
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
let poller = Arc::new(move |source: &DataSource| {
let adapters = adapters.clone();
let source = source.clone();
async move { adapters.poll(&source).await }
});
info!("polling manager started");
loop {
let sources = config
.list_data_sources()
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let current_ids: Vec<u16> = sources.iter().map(|s| s.id).collect();
running.retain(|id, handle| {
if !current_ids.contains(id) {
info!(source_id = id, "stopping poll for removed source");
handle.abort();
false
} else {
true
}
});
for source in &sources {
if source.source_type == DataSourceType::Webhook {
continue;
}
if running.contains_key(&source.id) {
continue;
}
let source_id = source.id;
let source = source.clone();
let config = config.clone();
let broadcaster = broadcaster.clone();
let projection = projection.clone();
let adapters = adapters.clone();
info!(
source_id = source.id,
name = %source.name,
interval_secs = source.poll_interval.as_secs(),
"starting poll task"
);
let handle = tokio::spawn(async move {
poll_loop(source, config, broadcaster, projection, adapters).await;
});
running.insert(source_id, handle);
}
if running.is_empty() {
debug!("no pollable sources, waiting");
}
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
}
}
async fn poll_loop(
source: DataSource,
config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>,
adapters: Adapters,
) {
let interval = source.poll_interval;
loop {
tokio::time::sleep(interval).await;
let result = match adapters.poll(&source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
continue;
}
};
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch widgets");
continue;
}
};
let changed: Vec<(u16, WidgetState)> = projection
.apply_poll_result(source.id, &result, &widgets)
.await;
if !changed.is_empty() {
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, state)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, state.clone()))
})
.collect();
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
warn!(error = %e, "failed to push update");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
}
application::polling_service::run(config, broadcaster, projection, poller).await;
Ok(())
}

View File

@@ -1,24 +1,24 @@
use crate::conversions::{wire_to_display_hint, wire_to_layout, wire_to_widget_state};
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
use domain::LayoutNode;
use protocol::{
ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState,
};
use domain::{DisplayHint, Value, WidgetError, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode};
use std::collections::HashMap;
pub struct ClientApp {
screen: BoundingBox,
render_tree: Option<RenderTree>,
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>,
widget_states: HashMap<u16, (DisplayHint, WidgetState)>,
theme: ThemeConfig,
theme_changed: bool,
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone)]
pub struct RepaintCommand {
pub widget_id: u16,
pub bounds: BoundingBox,
pub display_hint: WireDisplayHint,
pub state: WireWidgetState,
pub display_hint: DisplayHint,
pub data: Vec<(String, Value)>,
pub error: Option<WidgetError>,
}
impl ClientApp {
@@ -73,13 +73,14 @@ impl ClientApp {
wire_layout: WireLayoutNode,
widgets: Vec<WidgetDescriptor>,
) -> Vec<RepaintCommand> {
let layout: LayoutNode = wire_layout.into();
let layout = wire_to_layout(wire_layout);
let new_tree = LayoutEngine::compute(&layout, self.screen);
self.widget_states.clear();
for w in &widgets {
self.widget_states
.insert(w.id, (w.display_hint.clone(), w.state.clone()));
for w in widgets {
let hint = wire_to_display_hint(w.display_hint);
let state = wire_to_widget_state(w.state);
self.widget_states.insert(w.id, (hint, state));
}
let repaints = self.build_repaints_for_all(&new_tree);
@@ -96,21 +97,19 @@ impl ClientApp {
let mut repaints = Vec::new();
for w in widgets {
let hint = wire_to_display_hint(w.display_hint);
let state = wire_to_widget_state(w.state);
let changed = self
.widget_states
.get(&w.id)
.is_none_or(|(_, prev_state)| *prev_state != w.state);
.is_none_or(|(_, prev)| *prev != state);
if changed {
if let Some(bounds) = tree.get_widget_bounds(w.id) {
repaints.push(RepaintCommand {
widget_id: w.id,
bounds: *bounds,
display_hint: w.display_hint.clone(),
state: w.state.clone(),
});
repaints.push(Self::make_repaint(w.id, *bounds, &hint, &state));
}
self.widget_states.insert(w.id, (w.display_hint, w.state));
self.widget_states.insert(w.id, (hint, state));
}
}
@@ -122,18 +121,32 @@ impl ClientApp {
for (id, (hint, state)) in &self.widget_states {
if let Some(bounds) = tree.get_widget_bounds(*id) {
repaints.push(RepaintCommand {
widget_id: *id,
bounds: *bounds,
display_hint: hint.clone(),
state: state.clone(),
});
repaints.push(Self::make_repaint(*id, *bounds, hint, state));
}
}
repaints.sort_by_key(|r| r.widget_id);
repaints
}
fn make_repaint(
id: u16,
bounds: BoundingBox,
hint: &DisplayHint,
state: &WidgetState,
) -> RepaintCommand {
RepaintCommand {
widget_id: id,
bounds,
display_hint: hint.clone(),
data: state
.data
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
error: state.error.clone(),
}
}
}
fn wire_color(c: WireColor) -> Color {

View File

@@ -0,0 +1,42 @@
use client_domain::NetworkPort;
use protocol::{ServerMessage, decode_server_message};
use std::thread;
use std::time::Duration;
pub fn run_connection_loop<N: NetworkPort>(
net: &mut N,
server_addr: &str,
poll_interval: Duration,
reconnect_delay: Duration,
mut on_message: impl FnMut(ServerMessage),
mut on_connection_change: impl FnMut(bool),
) {
loop {
if !net.is_connected() {
match net.connect(server_addr) {
Ok(()) => on_connection_change(true),
Err(_) => {
on_connection_change(false);
thread::sleep(reconnect_delay);
continue;
}
}
}
match net.receive() {
Ok(Some(payload)) => {
if let Ok(msg) = decode_server_message(&payload) {
on_message(msg);
}
}
Ok(None) => {
thread::sleep(poll_interval);
}
Err(_) => {
let _ = net.disconnect();
on_connection_change(false);
thread::sleep(reconnect_delay);
}
}
}
}

View File

@@ -0,0 +1,100 @@
use domain::value_objects::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState,
};
use protocol::{
WireAlignItems, WireDirection, WireDisplayHint, WireDisplayHintKind, WireHAlign,
WireJustifyContent, WireLayoutNode, WireSizing, WireVAlign, WireValue, WireWidgetError,
WireWidgetState,
};
pub fn wire_to_value(w: WireValue) -> Value {
match w {
WireValue::Null => Value::Null,
WireValue::Bool(b) => Value::Bool(b),
WireValue::Number(n) => Value::Number(n),
WireValue::String(s) => Value::String(s),
WireValue::Array(arr) => Value::Array(arr.into_iter().map(wire_to_value).collect()),
WireValue::Object(map) => Value::Object(
map.into_iter()
.map(|(k, v)| (k, wire_to_value(v)))
.collect(),
),
}
}
pub fn wire_to_widget_error(w: WireWidgetError) -> WidgetError {
match w {
WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable,
WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed,
}
}
pub fn wire_to_widget_state(w: WireWidgetState) -> WidgetState {
WidgetState {
data: w
.data
.into_iter()
.map(|kv| (kv.key, wire_to_value(kv.value)))
.collect(),
error: w.error.map(wire_to_widget_error),
}
}
pub fn wire_to_display_hint(w: WireDisplayHint) -> DisplayHint {
DisplayHint {
kind: match w.kind {
WireDisplayHintKind::IconValue => DisplayHintKind::IconValue,
WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock,
WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue,
},
h_align: match w.h_align {
WireHAlign::Left => HAlign::Left,
WireHAlign::Center => HAlign::Center,
WireHAlign::Right => HAlign::Right,
},
v_align: match w.v_align {
WireVAlign::Top => VAlign::Top,
WireVAlign::Middle => VAlign::Middle,
WireVAlign::Bottom => VAlign::Bottom,
},
}
}
pub fn wire_to_layout(w: WireLayoutNode) -> LayoutNode {
match w {
WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id),
WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode {
direction: match c.direction {
WireDirection::Row => Direction::Row,
WireDirection::Column => Direction::Column,
},
gap: c.gap,
padding: c.padding,
justify_content: match c.justify_content {
WireJustifyContent::Start => JustifyContent::Start,
WireJustifyContent::Center => JustifyContent::Center,
WireJustifyContent::End => JustifyContent::End,
WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween,
WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly,
},
align_items: match c.align_items {
WireAlignItems::Start => AlignItems::Start,
WireAlignItems::Center => AlignItems::Center,
WireAlignItems::End => AlignItems::End,
WireAlignItems::Stretch => AlignItems::Stretch,
},
children: c
.children
.into_iter()
.map(|ch| LayoutChild {
sizing: match ch.sizing {
WireSizing::Fixed(px) => Sizing::Fixed(px),
WireSizing::Flex(weight) => Sizing::Flex(weight),
},
node: wire_to_layout(ch.node),
})
.collect(),
}),
}
}

View File

@@ -1,3 +1,6 @@
mod client_app;
mod connection_loop;
pub mod conversions;
pub use client_app::{ClientApp, RepaintCommand};
pub use connection_loop::run_connection_loop;

View File

@@ -1,4 +1,4 @@
use client_application::{ClientApp, RepaintCommand};
use client_application::ClientApp;
use client_domain::BoundingBox;
use protocol::{
ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection,
@@ -84,8 +84,8 @@ fn data_update_only_repaints_changed_widgets() {
assert_eq!(repaints.len(), 1);
assert_eq!(repaints[0].widget_id, 1);
assert_eq!(
repaints[0].state.data[0].value,
WireValue::String("6.1°C".into())
repaints[0].data[0],
("temperature".into(), domain::Value::String("6.1°C".into()))
);
}

View File

@@ -0,0 +1,151 @@
use client_application::conversions::{
wire_to_display_hint, wire_to_layout, wire_to_value, wire_to_widget_state,
};
use domain::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent,
LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState,
};
use protocol::{
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
};
use std::collections::BTreeMap;
fn value_to_wire(v: &Value) -> WireValue {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
Value::Object(map) => WireValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), value_to_wire(v)))
.collect(),
),
}
}
#[test]
fn value_converts_to_wire_and_back() {
let original = Value::Object(BTreeMap::from([(
"items".into(),
Value::Array(vec![
Value::String("hello".into()),
Value::Number(42.0),
Value::Bool(true),
Value::Null,
]),
)]));
let wire = value_to_wire(&original);
let roundtripped = wire_to_value(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn widget_state_with_error_converts_to_wire_and_back() {
let original = WidgetState {
data: BTreeMap::from([("temp".into(), Value::Number(5.4))]),
error: Some(WidgetError::SourceUnavailable),
};
let wire = WireWidgetState {
data: original
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: value_to_wire(v),
})
.collect(),
error: Some(WireWidgetError::SourceUnavailable),
};
let roundtripped = wire_to_widget_state(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn layout_tree_converts_to_wire_and_back() {
let original = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 4,
padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Fixed(100),
node: LayoutNode::Container(ContainerNode {
direction: Direction::Column,
gap: 2,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(2),
}],
}),
},
],
});
let wire = WireLayoutNode::Container(WireContainerNode {
direction: WireDirection::Row,
gap: 4,
padding: 2,
justify_content: protocol::WireJustifyContent::Start,
align_items: protocol::WireAlignItems::Stretch,
children: vec![
WireLayoutChild {
sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(1),
},
WireLayoutChild {
sizing: WireSizing::Fixed(100),
node: WireLayoutNode::Container(WireContainerNode {
direction: WireDirection::Column,
gap: 2,
padding: 0,
justify_content: protocol::WireJustifyContent::Start,
align_items: protocol::WireAlignItems::Stretch,
children: vec![WireLayoutChild {
sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(2),
}],
}),
},
],
});
let roundtripped = wire_to_layout(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn display_hint_converts_to_wire_and_back() {
for (hint, wire_kind) in [
(
DisplayHintKind::IconValue,
protocol::WireDisplayHintKind::IconValue,
),
(
DisplayHintKind::TextBlock,
protocol::WireDisplayHintKind::TextBlock,
),
(
DisplayHintKind::KeyValue,
protocol::WireDisplayHintKind::KeyValue,
),
] {
let original = DisplayHint::new(hint);
let wire = WireDisplayHint::new(wire_kind);
let roundtripped = wire_to_display_hint(wire);
assert_eq!(original, roundtripped);
}
}

View File

@@ -1,14 +1,25 @@
use client_application::ClientApp;
use client_domain::NetworkPort;
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
use client_application::{ClientApp, RepaintCommand, run_connection_loop};
use client_domain::{
BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
WidgetRenderer,
};
use display_terminal::TerminalDisplay;
use domain::DisplayHint;
use protocol::decode_server_message;
use protocol::ServerMessage;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
use tcp_client::StdTcpClient;
fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
RepaintRequest {
widget_id: cmd.widget_id,
bounds: cmd.bounds,
display_hint: cmd.display_hint.clone(),
data: cmd.data.clone(),
error: cmd.error.clone(),
}
}
fn main() {
let screen = BoundingBox::screen(240, 320);
let mut app = ClientApp::new(screen);
@@ -18,52 +29,33 @@ fn main() {
large: (10, 20),
};
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
let mut renderer = WidgetRenderer::new();
println!("=== K-Frame Desktop Client ===");
println!("Screen: {}x{}", screen.width, screen.height);
let (tx, rx) = mpsc::channel();
let (tx, rx) = mpsc::channel::<ServerMessage>();
thread::spawn(move || {
let server_addr = "127.0.0.1:2699";
let mut net = StdTcpClient::new();
loop {
if !net.is_connected() {
println!("[NET] Connecting to {server_addr}...");
match net.connect(server_addr) {
Ok(()) => println!("[NET] Connected!"),
Err(e) => {
println!("[NET] Connection failed: {e}, retrying in 2s...");
thread::sleep(Duration::from_secs(2));
continue;
}
}
}
match net.receive() {
Ok(Some(payload)) => match decode_server_message(&payload) {
Ok(msg) => {
let _ = tx.send(msg);
}
Err(e) => println!("[NET] Decode error: {e}"),
},
Ok(None) => {
thread::sleep(Duration::from_millis(50));
}
Err(e) => {
println!("[NET] Receive error: {e}, reconnecting...");
let _ = net.disconnect();
thread::sleep(Duration::from_secs(2));
}
}
}
let tx_clone = tx.clone();
run_connection_loop(
&mut net,
"127.0.0.1:2699",
Duration::from_millis(50),
Duration::from_secs(2),
move |msg| {
let _ = tx_clone.send(msg);
},
|_connected| {},
);
});
println!("[RENDER] Render loop started");
let mut last_tick = Instant::now();
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(msg) => {
let repaints = app.handle_message(msg);
@@ -73,20 +65,13 @@ fn main() {
if !repaints.is_empty() {
println!("\n--- Repaint ({} widgets) ---", repaints.len());
let requests: Vec<_> = repaints.iter().map(to_request).collect();
let bg = engine.theme().background;
for cmd in &repaints {
display.fill_rect(cmd.bounds, bg).unwrap();
let hint: DisplayHint = cmd.display_hint.clone().into();
let data: Vec<(String, domain::Value)> = cmd
.state
.data
.iter()
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
.collect();
let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0);
for dc in &draw_cmds {
let updates = renderer.apply_repaints(&engine, requests);
for update in &updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
@@ -98,5 +83,23 @@ fn main() {
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
let now = Instant::now();
let elapsed = now.duration_since(last_tick);
last_tick = now;
let scroll_updates = renderer.tick_scroll(&engine, elapsed);
if !scroll_updates.is_empty() {
let bg = engine.theme().background;
for update in &scroll_updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
}
}
display.flush().unwrap();
}
}
}

View File

@@ -10,6 +10,7 @@ mod render_tree;
mod scroll;
mod text_layout;
mod theme;
mod widget_renderer;
pub use alignment::align_offset;
pub use bounding_box::BoundingBox;
@@ -18,9 +19,10 @@ pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
pub use font::{FontMetrics, FontSize};
pub use layout_engine::LayoutEngine;
pub use markup::{TextSpan, parse_markup};
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
pub use ports::{DisplayPort, NetworkPort};
pub use render_engine::{DrawCommand, RenderEngine};
pub use render_tree::RenderTree;
pub use scroll::ScrollState;
pub use text_layout::wrap_lines;
pub use theme::ThemeConfig;
pub use widget_renderer::{RenderUpdate, RepaintRequest, WidgetRenderer};

View File

@@ -1,7 +1,5 @@
mod display;
mod network;
mod storage;
pub use display::DisplayPort;
pub use network::NetworkPort;
pub use storage::{ClientConfig, StoragePort};

View File

@@ -1,11 +0,0 @@
pub struct ClientConfig {
pub wifi_ssid: String,
pub wifi_password: String,
pub server_addr: String,
}
pub trait StoragePort {
type Error;
fn load_config(&self) -> Result<ClientConfig, Self::Error>;
}

View File

@@ -2,7 +2,7 @@ use crate::{
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset,
markup::parse_markup, text_layout::wrap_lines,
};
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value};
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value, WidgetError};
#[derive(Debug, Clone, PartialEq)]
pub struct DrawCommand {
@@ -92,26 +92,38 @@ impl RenderEngine {
data: &[(String, Value)],
bounds: BoundingBox,
scroll_offset: u16,
error: Option<&WidgetError>,
) -> Vec<DrawCommand> {
let text = self.format_widget(hint, data);
let mut text = self.format_widget(hint, data);
if error.is_some() {
text = format!("{{accent}}[offline]{{/}}\n{text}");
}
let mut cmds = self.render_text(&text, bounds, hint.h_align, hint.v_align);
if scroll_offset > 0 {
for cmd in &mut cmds {
cmd.y = cmd.y.saturating_sub(scroll_offset);
}
// Drop commands that scrolled above bounds
cmds.retain(|cmd| {
cmd.y + self.metrics.char_height(cmd.font) > bounds.y
&& cmd.y < bounds.y + bounds.height
});
}
cmds.retain(|cmd| {
let line_h = self.metrics.char_height(cmd.font);
cmd.y >= bounds.y && cmd.y + line_h <= bounds.y + bounds.height
});
cmds
}
pub fn content_height(&self, hint: &DisplayHint, data: &[(String, Value)], width: u16) -> u16 {
let text = self.format_widget(hint, data);
pub fn content_height(
&self,
hint: &DisplayHint,
data: &[(String, Value)],
width: u16,
error: Option<&WidgetError>,
) -> u16 {
let mut text = self.format_widget(hint, data);
if error.is_some() {
text = format!("{{accent}}[offline]{{/}}\n{text}");
}
let plain: String = parse_markup(&text, &self.theme)
.iter()
.map(|s| s.text.as_str())

View File

@@ -1,7 +1,7 @@
use std::time::Duration;
const PAUSE_DURATION: Duration = Duration::from_secs(2);
const SCROLL_SPEED_PX_PER_SEC: f32 = 30.0;
const PAUSE_DURATION: Duration = Duration::from_secs(3);
const SCROLL_SPEED_PX_PER_SEC: f32 = 15.0;
#[derive(Debug)]
pub struct ScrollState {

View File

@@ -0,0 +1,113 @@
use crate::{BoundingBox, DrawCommand, RenderEngine, ScrollState};
use domain::{DisplayHint, Value, WidgetError};
use std::collections::HashMap;
use std::time::Duration;
pub struct RenderUpdate {
pub bounds: BoundingBox,
pub commands: Vec<DrawCommand>,
}
struct WidgetCache {
hint: DisplayHint,
data: Vec<(String, Value)>,
error: Option<WidgetError>,
bounds: BoundingBox,
scroll: ScrollState,
}
pub struct RepaintRequest {
pub widget_id: u16,
pub bounds: BoundingBox,
pub display_hint: DisplayHint,
pub data: Vec<(String, Value)>,
pub error: Option<WidgetError>,
}
pub struct WidgetRenderer {
widgets: HashMap<u16, WidgetCache>,
}
impl Default for WidgetRenderer {
fn default() -> Self {
Self::new()
}
}
impl WidgetRenderer {
pub fn new() -> Self {
Self {
widgets: HashMap::new(),
}
}
pub fn apply_repaints(
&mut self,
engine: &RenderEngine,
repaints: Vec<RepaintRequest>,
) -> Vec<RenderUpdate> {
let mut updates = Vec::new();
for req in repaints {
let content_h = engine.content_height(
&req.display_hint,
&req.data,
req.bounds.width,
req.error.as_ref(),
);
let scroll = ScrollState::new(req.bounds.height, content_h);
let cmds = engine.render_widget(
&req.display_hint,
&req.data,
req.bounds,
scroll.offset(),
req.error.as_ref(),
);
updates.push(RenderUpdate {
bounds: req.bounds,
commands: cmds,
});
self.widgets.insert(
req.widget_id,
WidgetCache {
hint: req.display_hint,
data: req.data,
error: req.error,
bounds: req.bounds,
scroll,
},
);
}
updates
}
pub fn tick_scroll(&mut self, engine: &RenderEngine, elapsed: Duration) -> Vec<RenderUpdate> {
let mut updates = Vec::new();
for cache in self.widgets.values_mut() {
if cache.scroll.tick(elapsed) {
let cmds = engine.render_widget(
&cache.hint,
&cache.data,
cache.bounds,
cache.scroll.offset(),
cache.error.as_ref(),
);
updates.push(RenderUpdate {
bounds: cache.bounds,
commands: cmds,
});
}
}
updates
}
pub fn has_active_scrollers(&self) -> bool {
self.widgets.values().any(|c| c.scroll.is_active())
}
pub fn clear(&mut self) {
self.widgets.clear();
}
}

View File

@@ -238,17 +238,13 @@ dependencies = [
"client-domain",
"domain",
"embedded-graphics",
"embedded-hal-bus",
"embedded-text",
"embuild",
"esp-idf-hal",
"esp-idf-svc",
"esp-idf-sys",
"log",
"mipidsi",
"postcard",
"protocol",
"serde",
]
[[package]]
@@ -489,16 +485,6 @@ dependencies = [
"embedded-hal 1.0.0",
]
[[package]]
name = "embedded-hal-bus"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "513e0b3a8fb7d3013a8ae17a834283f170deaf7d0eeab0a7c1a36ad4dd356d22"
dependencies = [
"critical-section",
"embedded-hal 1.0.0",
]
[[package]]
name = "embedded-hal-nb"
version = "1.0.0"
@@ -561,17 +547,6 @@ dependencies = [
"strum 0.27.2",
]
[[package]]
name = "embedded-text"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cf5c72c52db2f7dbe4a9c1ed81cd21301e8d66311b194fa41c04fb4f71843ba"
dependencies = [
"az",
"embedded-graphics",
"object-chain",
]
[[package]]
name = "embuild"
version = "0.33.1"
@@ -1126,12 +1101,6 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "object-chain"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41af26158b0f5530f7b79955006c2727cd23d0d8e7c3109dc316db0a919784dd"
[[package]]
name = "once_cell"
version = "1.21.4"

View File

@@ -15,16 +15,18 @@ esp-idf-sys = "0.37"
mipidsi = "0.10"
embedded-graphics = "0.8"
embedded-text = "0.7"
embedded-hal-bus = "0.3"
serde = { version = "1.0", default-features = false, features = [
"derive",
"alloc",
] }
postcard = { version = "1.1", default-features = false, features = ["alloc"] }
log = "0.4"
[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"
codegen-units = 1
[profile.dev]
panic = "abort"
[build-dependencies]
embuild = "0.33"

View File

@@ -19,3 +19,13 @@ CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
# Disable Bluetooth (unused)
CONFIG_BT_ENABLED=n
# Disable ESP-MESH (unused)
CONFIG_ESP_WIFI_MESH_SUPPORT=n
# Reduce log verbosity
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_LOG_MAXIMUM_LEVEL_INFO=y

View File

@@ -41,12 +41,6 @@ fn fill_triangles<D: DrawTarget<Color = Rgb565>>(
}
}
// Outer hexagon vertices (150x150 viewBox)
const OUTER_HEX: [(f32, f32); 6] = [
(75.0, 0.0), (150.0, 37.5), (150.0, 112.5),
(75.0, 150.0), (0.0, 112.5), (0.0, 37.5),
];
// Converted from SVG: (150,75)(112.5,150)(37.5,150)(0,75)(37.5,0)(112.5,0)
// Reordered to start from top for cleaner fan
const OUTER_HEX_SVG: [(f32, f32); 6] = [

View File

@@ -1,56 +1,26 @@
use std::sync::mpsc;
use std::thread;
use client_domain::NetworkPort;
use protocol::decode_server_message;
use client_application::run_connection_loop;
use super::RenderEvent;
use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY};
use crate::adapters::network::Esp32Network;
use log::*;
pub fn spawn(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
thread::Builder::new()
.stack_size(NET_THREAD_STACK_SIZE)
.name("net".into())
.spawn(move || run(server_addr, tx))
.spawn(move || {
let mut net = Esp32Network::new();
let tx_msg = tx.clone();
let tx_status = tx.clone();
run_connection_loop(
&mut net,
&server_addr,
NET_POLL_INTERVAL,
NET_RECONNECT_DELAY,
move |msg| { let _ = tx_msg.send(RenderEvent::Server(msg)); },
move |connected| { let _ = tx_status.send(RenderEvent::ConnectionStatus(connected)); },
);
})
.expect("failed to spawn network thread");
}
fn run(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
let mut net = Esp32Network::new();
loop {
if !net.is_connected() {
info!("Connecting to server {server_addr}...");
match net.connect(&server_addr) {
Ok(()) => {
info!("Server connected");
let _ = tx.send(RenderEvent::ConnectionStatus(true));
}
Err(e) => {
error!("Connection failed: {e}, retrying...");
let _ = tx.send(RenderEvent::ConnectionStatus(false));
thread::sleep(NET_RECONNECT_DELAY);
continue;
}
}
}
match net.receive() {
Ok(Some(payload)) => {
match decode_server_message(&payload) {
Ok(msg) => { let _ = tx.send(RenderEvent::Server(msg)); }
Err(e) => error!("Decode error: {e}"),
}
}
Ok(None) => {
thread::sleep(NET_POLL_INTERVAL);
}
Err(e) => {
error!("Receive error: {e}, reconnecting...");
let _ = net.disconnect();
let _ = tx.send(RenderEvent::ConnectionStatus(false));
thread::sleep(NET_RECONNECT_DELAY);
}
}
}
}

View File

@@ -1,11 +1,12 @@
use std::sync::mpsc;
use std::time::{Duration, Instant};
use std::collections::HashMap;
use client_application::RepaintCommand;
use client_domain::{
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig,
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
WidgetRenderer,
};
use client_application::{ClientApp, RepaintCommand};
use domain::{DisplayHint, Value};
use client_application::ClientApp;
use protocol::ServerMessage;
use super::RenderEvent;
use crate::config::RENDER_POLL_INTERVAL;
@@ -18,11 +19,14 @@ const INDICATOR_MARGIN: u16 = 4;
const COLOR_CONNECTED: Color = Color(0, 200, 0);
const COLOR_DISCONNECTED: Color = Color(200, 0, 0);
struct WidgetCache {
hint: DisplayHint,
data: Vec<(String, Value)>,
bounds: BoundingBox,
scroll: ScrollState,
fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
RepaintRequest {
widget_id: cmd.widget_id,
bounds: cmd.bounds,
display_hint: cmd.display_hint.clone(),
data: cmd.data.clone(),
error: cmd.error.clone(),
}
}
pub fn run(
@@ -36,7 +40,7 @@ pub fn run(
};
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
let mut app = ClientApp::new(screen);
let mut widgets: HashMap<u16, WidgetCache> = HashMap::new();
let mut renderer = WidgetRenderer::new();
let mut first_update = true;
let mut last_tick = Instant::now();
let mut connected = false;
@@ -46,7 +50,12 @@ pub fn run(
display.flush().unwrap();
loop {
let timeout = RENDER_POLL_INTERVAL.min(SCROLL_TICK);
let has_scrollers = renderer.has_active_scrollers();
let timeout = if has_scrollers {
SCROLL_TICK
} else {
RENDER_POLL_INTERVAL
};
match rx.recv_timeout(timeout) {
Ok(RenderEvent::ConnectionStatus(status)) => {
if status != connected {
@@ -69,14 +78,19 @@ pub fn run(
display.fill_rect(screen, bg).unwrap();
first_update = false;
}
for cmd in &repaints {
let cache = update_cache(&engine, cmd);
display.fill_rect(cache.bounds, bg).unwrap();
draw_widget(&engine, &mut display, &cache);
widgets.insert(cmd.widget_id, cache);
let requests: Vec<_> = repaints.iter().map(to_request).collect();
let updates = renderer.apply_repaints(&engine, requests);
for update in &updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
}
}
if !repaints.is_empty() {
if !updates.is_empty() {
draw_indicator(&mut display, screen, connected);
display.flush().unwrap();
}
@@ -92,16 +106,17 @@ pub fn run(
let elapsed = now.duration_since(last_tick);
last_tick = now;
let mut needs_flush = false;
for cache in widgets.values_mut() {
if cache.scroll.tick(elapsed) {
let bg = engine.theme().background;
display.fill_rect(cache.bounds, bg).unwrap();
draw_widget(&engine, &mut display, cache);
needs_flush = true;
let scroll_updates = renderer.tick_scroll(&engine, elapsed);
if !scroll_updates.is_empty() {
let bg = engine.theme().background;
for update in &scroll_updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
}
}
}
if needs_flush {
draw_indicator(&mut display, screen, connected);
display.flush().unwrap();
}
@@ -109,43 +124,14 @@ pub fn run(
}
fn draw_indicator(display: &mut Esp32DisplayAdapter, screen: BoundingBox, connected: bool) {
let color = if connected { COLOR_CONNECTED } else { COLOR_DISCONNECTED };
let color = if connected {
COLOR_CONNECTED
} else {
COLOR_DISCONNECTED
};
let x = screen.x + screen.width - INDICATOR_DIAMETER - INDICATOR_MARGIN;
let y = screen.y + screen.height - INDICATOR_DIAMETER - INDICATOR_MARGIN;
display.fill_circle(x, y, INDICATOR_DIAMETER, color).unwrap();
}
fn update_cache(engine: &RenderEngine, cmd: &RepaintCommand) -> WidgetCache {
let hint: DisplayHint = cmd.display_hint.clone().into();
let data: Vec<(String, Value)> = cmd.state.data
.iter()
.map(|kv| (kv.key.clone(), kv.value.clone().into()))
.collect();
let content_h = engine.content_height(&hint, &data, cmd.bounds.width);
let scroll = ScrollState::new(cmd.bounds.height, content_h);
WidgetCache {
hint,
data,
bounds: cmd.bounds,
scroll,
}
}
fn draw_widget(
engine: &RenderEngine,
display: &mut Esp32DisplayAdapter,
cache: &WidgetCache,
) {
let draw_cmds = engine.render_widget(
&cache.hint,
&cache.data,
cache.bounds,
cache.scroll.offset(),
);
for dc in &draw_cmds {
display.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font).unwrap();
}
display
.fill_circle(x, y, INDICATOR_DIAMETER, color)
.unwrap();
}

View File

@@ -3,6 +3,10 @@ name = "domain"
version = "0.1.0"
edition = "2024"
[features]
json = ["serde_json"]
[dependencies]
serde_json = { workspace = true, optional = true }
[dev-dependencies]

View File

@@ -12,7 +12,8 @@ pub use entities::{
pub use events::DomainEvent;
pub use ports::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, ConnectedClient, DataSourcePort,
EventPublisher, PasswordHashPort, SecretStore, WidgetStateReader,
EventPublisher, PasswordHashPort, SecretStore, UserRepository, WidgetStateCache,
WidgetStateReader,
};
pub use value_objects::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,

View File

@@ -1,5 +1,5 @@
use crate::entities::{
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId,
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
};
use crate::value_objects::{Layout, ThemeConfig};
use std::future::Future;
@@ -56,11 +56,4 @@ pub trait ConfigRepository {
&self,
theme: &ThemeConfig,
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn get_user_by_username(
&self,
username: &str,
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
}

View File

@@ -5,6 +5,8 @@ mod config_repository;
mod data_source_port;
mod event;
mod secret_store;
mod user_repository;
mod widget_state_cache;
mod widget_state_reader;
pub use auth::{AuthPort, PasswordHashPort};
@@ -14,4 +16,6 @@ pub use config_repository::ConfigRepository;
pub use data_source_port::DataSourcePort;
pub use event::EventPublisher;
pub use secret_store::SecretStore;
pub use user_repository::UserRepository;
pub use widget_state_cache::WidgetStateCache;
pub use widget_state_reader::WidgetStateReader;

View File

@@ -0,0 +1,13 @@
use crate::entities::User;
use std::future::Future;
pub trait UserRepository {
type Error;
fn get_user_by_username(
&self,
username: &str,
) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;
fn save_user(&self, user: &User) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn count_users(&self) -> impl Future<Output = Result<u32, Self::Error>> + Send;
}

View File

@@ -0,0 +1,15 @@
use crate::entities::WidgetId;
use crate::value_objects::WidgetState;
use std::future::Future;
pub trait WidgetStateCache {
type Error;
fn save_widget_states(
&self,
states: &[(WidgetId, WidgetState)],
) -> impl Future<Output = Result<(), Self::Error>> + Send;
fn load_widget_states(
&self,
) -> impl Future<Output = Result<Vec<(WidgetId, WidgetState)>, Self::Error>> + Send;
}

View File

@@ -1,5 +1,39 @@
use std::collections::BTreeMap;
#[cfg(feature = "json")]
impl From<serde_json::Value> for Value {
fn from(json: serde_json::Value) -> Self {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Value::String(s),
serde_json::Value::Array(arr) => {
Value::Array(arr.into_iter().map(Into::into).collect())
}
serde_json::Value::Object(map) => {
Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect())
}
}
}
}
#[cfg(feature = "json")]
impl From<&Value> for serde_json::Value {
fn from(v: &Value) -> Self {
match v {
Value::Null => serde_json::Value::Null,
Value::Bool(b) => serde_json::Value::Bool(*b),
Value::Number(n) => serde_json::json!(*n),
Value::String(s) => serde_json::Value::String(s.clone()),
Value::Array(arr) => serde_json::Value::Array(arr.iter().map(Into::into).collect()),
Value::Object(map) => {
serde_json::Value::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect())
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
Null,

View File

@@ -4,7 +4,6 @@ version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
serde.workspace = true
postcard.workspace = true

View File

@@ -1,7 +1,3 @@
use domain::value_objects::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -15,60 +11,12 @@ pub enum WireValue {
Object(BTreeMap<String, WireValue>),
}
impl From<&Value> for WireValue {
fn from(v: &Value) -> Self {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(Into::into).collect()),
Value::Object(map) => {
WireValue::Object(map.iter().map(|(k, v)| (k.clone(), v.into())).collect())
}
}
}
}
impl From<WireValue> for Value {
fn from(w: WireValue) -> Self {
match w {
WireValue::Null => Value::Null,
WireValue::Bool(b) => Value::Bool(b),
WireValue::Number(n) => Value::Number(n),
WireValue::String(s) => Value::String(s),
WireValue::Array(arr) => Value::Array(arr.into_iter().map(Into::into).collect()),
WireValue::Object(map) => {
Value::Object(map.into_iter().map(|(k, v)| (k, v.into())).collect())
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireWidgetError {
SourceUnavailable,
ExtractionFailed,
}
impl From<&WidgetError> for WireWidgetError {
fn from(e: &WidgetError) -> Self {
match e {
WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable,
WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed,
}
}
}
impl From<WireWidgetError> for WidgetError {
fn from(w: WireWidgetError) -> Self {
match w {
WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable,
WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireKeyValue {
pub key: String,
@@ -81,35 +29,6 @@ pub struct WireWidgetState {
pub error: Option<WireWidgetError>,
}
impl From<&WidgetState> for WireWidgetState {
fn from(s: &WidgetState) -> Self {
WireWidgetState {
data: s
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: v.into(),
})
.collect(),
error: s.error.as_ref().map(Into::into),
}
}
}
impl From<WireWidgetState> for WidgetState {
fn from(w: WireWidgetState) -> Self {
WidgetState {
data: w
.data
.into_iter()
.map(|kv| (kv.key, kv.value.into()))
.collect(),
error: w.error.map(Into::into),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireDisplayHintKind {
IconValue,
@@ -148,134 +67,18 @@ impl WireDisplayHint {
}
}
impl From<&DisplayHintKind> for WireDisplayHintKind {
fn from(k: &DisplayHintKind) -> Self {
match k {
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
}
}
}
impl From<WireDisplayHintKind> for DisplayHintKind {
fn from(w: WireDisplayHintKind) -> Self {
match w {
WireDisplayHintKind::IconValue => DisplayHintKind::IconValue,
WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock,
WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue,
}
}
}
impl From<&HAlign> for WireHAlign {
fn from(h: &HAlign) -> Self {
match h {
HAlign::Left => WireHAlign::Left,
HAlign::Center => WireHAlign::Center,
HAlign::Right => WireHAlign::Right,
}
}
}
impl From<WireHAlign> for HAlign {
fn from(w: WireHAlign) -> Self {
match w {
WireHAlign::Left => HAlign::Left,
WireHAlign::Center => HAlign::Center,
WireHAlign::Right => HAlign::Right,
}
}
}
impl From<&VAlign> for WireVAlign {
fn from(v: &VAlign) -> Self {
match v {
VAlign::Top => WireVAlign::Top,
VAlign::Middle => WireVAlign::Middle,
VAlign::Bottom => WireVAlign::Bottom,
}
}
}
impl From<WireVAlign> for VAlign {
fn from(w: WireVAlign) -> Self {
match w {
WireVAlign::Top => VAlign::Top,
WireVAlign::Middle => VAlign::Middle,
WireVAlign::Bottom => VAlign::Bottom,
}
}
}
impl From<&DisplayHint> for WireDisplayHint {
fn from(h: &DisplayHint) -> Self {
WireDisplayHint {
kind: (&h.kind).into(),
h_align: (&h.h_align).into(),
v_align: (&h.v_align).into(),
}
}
}
impl From<WireDisplayHint> for DisplayHint {
fn from(w: WireDisplayHint) -> Self {
DisplayHint {
kind: w.kind.into(),
h_align: w.h_align.into(),
v_align: w.v_align.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireSizing {
Fixed(u16),
Flex(u8),
}
impl From<&Sizing> for WireSizing {
fn from(s: &Sizing) -> Self {
match s {
Sizing::Fixed(px) => WireSizing::Fixed(*px),
Sizing::Flex(w) => WireSizing::Flex(*w),
}
}
}
impl From<WireSizing> for Sizing {
fn from(w: WireSizing) -> Self {
match w {
WireSizing::Fixed(px) => Sizing::Fixed(px),
WireSizing::Flex(weight) => Sizing::Flex(weight),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireDirection {
Row,
Column,
}
impl From<&Direction> for WireDirection {
fn from(d: &Direction) -> Self {
match d {
Direction::Row => WireDirection::Row,
Direction::Column => WireDirection::Column,
}
}
}
impl From<WireDirection> for Direction {
fn from(w: WireDirection) -> Self {
match w {
WireDirection::Row => Direction::Row,
WireDirection::Column => Direction::Column,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireJustifyContent {
Start,
@@ -285,30 +88,6 @@ pub enum WireJustifyContent {
SpaceEvenly,
}
impl From<&JustifyContent> for WireJustifyContent {
fn from(j: &JustifyContent) -> Self {
match j {
JustifyContent::Start => WireJustifyContent::Start,
JustifyContent::Center => WireJustifyContent::Center,
JustifyContent::End => WireJustifyContent::End,
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
}
}
}
impl From<WireJustifyContent> for JustifyContent {
fn from(w: WireJustifyContent) -> Self {
match w {
WireJustifyContent::Start => JustifyContent::Start,
WireJustifyContent::Center => JustifyContent::Center,
WireJustifyContent::End => JustifyContent::End,
WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween,
WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireAlignItems {
Start,
@@ -317,28 +96,6 @@ pub enum WireAlignItems {
Stretch,
}
impl From<&AlignItems> for WireAlignItems {
fn from(a: &AlignItems) -> Self {
match a {
AlignItems::Start => WireAlignItems::Start,
AlignItems::Center => WireAlignItems::Center,
AlignItems::End => WireAlignItems::End,
AlignItems::Stretch => WireAlignItems::Stretch,
}
}
}
impl From<WireAlignItems> for AlignItems {
fn from(w: WireAlignItems) -> Self {
match w {
WireAlignItems::Start => AlignItems::Start,
WireAlignItems::Center => AlignItems::Center,
WireAlignItems::End => AlignItems::End,
WireAlignItems::Stretch => AlignItems::Stretch,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireContainerNode {
pub direction: WireDirection,
@@ -360,49 +117,3 @@ pub enum WireLayoutNode {
Container(WireContainerNode),
Leaf(u16),
}
impl From<&LayoutNode> for WireLayoutNode {
fn from(n: &LayoutNode) -> Self {
match n {
LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id),
LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode {
direction: (&c.direction).into(),
gap: c.gap,
padding: c.padding,
justify_content: (&c.justify_content).into(),
align_items: (&c.align_items).into(),
children: c
.children
.iter()
.map(|ch| WireLayoutChild {
sizing: (&ch.sizing).into(),
node: (&ch.node).into(),
})
.collect(),
}),
}
}
}
impl From<WireLayoutNode> for LayoutNode {
fn from(w: WireLayoutNode) -> Self {
match w {
WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id),
WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode {
direction: c.direction.into(),
gap: c.gap,
padding: c.padding,
justify_content: c.justify_content.into(),
align_items: c.align_items.into(),
children: c
.children
.into_iter()
.map(|ch| LayoutChild {
sizing: ch.sizing.into(),
node: ch.node.into(),
})
.collect(),
}),
}
}
}

View File

@@ -1,86 +0,0 @@
use domain::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent,
LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState,
};
use protocol::{
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
};
use std::collections::BTreeMap;
#[test]
fn value_converts_to_wire_and_back() {
let original = Value::Object(BTreeMap::from([(
"items".into(),
Value::Array(vec![
Value::String("hello".into()),
Value::Number(42.0),
Value::Bool(true),
Value::Null,
]),
)]));
let wire: WireValue = (&original).into();
let roundtripped: Value = wire.into();
assert_eq!(original, roundtripped);
}
#[test]
fn widget_state_with_error_converts_to_wire_and_back() {
let original = WidgetState {
data: BTreeMap::from([("temp".into(), Value::Number(5.4))]),
error: Some(WidgetError::SourceUnavailable),
};
let wire: WireWidgetState = (&original).into();
let roundtripped: WidgetState = wire.into();
assert_eq!(original, roundtripped);
}
#[test]
fn layout_tree_converts_to_wire_and_back() {
let original = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 4,
padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Fixed(100),
node: LayoutNode::Container(ContainerNode {
direction: Direction::Column,
gap: 2,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(2),
}],
}),
},
],
});
let wire: WireLayoutNode = (&original).into();
let roundtripped: LayoutNode = wire.into();
assert_eq!(original, roundtripped);
}
#[test]
fn display_hint_converts_to_wire_and_back() {
for hint in [
DisplayHint::new(DisplayHintKind::IconValue),
DisplayHint::new(DisplayHintKind::TextBlock),
DisplayHint::new(DisplayHintKind::KeyValue),
] {
let wire: WireDisplayHint = (&hint).into();
let roundtripped: DisplayHint = wire.into();
assert_eq!(hint, roundtripped);
}
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useRef } from "react"
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
import { computeLayout } from "@/lib/layout-engine"
import { useWidgetPreview } from "@/api/widgets"
interface LayoutPreviewProps {
layout: LayoutNode
@@ -19,6 +20,91 @@ function collectWidgetIds(node: LayoutNode): number[] {
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node))
}
function formatPreviewData(
kind: string,
data: Record<string, unknown> | undefined,
): string {
if (!data) return ""
const entries = Object.entries(data)
if (entries.length === 0) return ""
switch (kind) {
case "key_value":
return entries.map(([k, v]) => `${k}: ${v}`).join("\n")
case "text_block":
return entries.map(([, v]) => String(v ?? "")).join("\n")
case "icon_value":
return entries.map(([, v]) => String(v ?? "")).join(" ")
default:
return entries.map(([, v]) => String(v ?? "")).join("\n")
}
}
function WidgetCell({ wid, widget, scale, theme }: {
wid: number
widget: Widget | undefined
scale: number
theme: ThemeConfig
}) {
const { data } = useWidgetPreview(wid, true)
const hAlign = widget?.display_hint?.h_align ?? "left"
const vAlign = widget?.display_hint?.v_align ?? "top"
const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start"
const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start"
const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const
const kind = widget?.display_hint?.kind ?? "text_block"
const previewText = formatPreviewData(kind, data as Record<string, unknown> | undefined)
const hasData = previewText.length > 0
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: flexAlign,
justifyContent: flexJustify,
width: "100%",
height: "100%",
}}
>
{hasData ? (
<span
style={{
fontSize: 8 * scale,
color: colorToCSS(theme.text),
textAlign,
lineHeight: 1.3,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{previewText}
</span>
) : (
<>
<span
style={{
fontSize: 10 * scale,
color: colorToCSS(theme.text),
textAlign,
lineHeight: 1.2,
opacity: 0.5,
}}
>
{widget?.name ?? `#${wid}`}
</span>
{widget && (
<span style={{ fontSize: 7 * scale, color: colorToCSS(theme.accent), textAlign, opacity: 0.5 }}>
{kind}
</span>
)}
</>
)}
</div>
)
}
export function LayoutPreview({
layout,
screenWidth,
@@ -54,11 +140,6 @@ export function LayoutPreview({
const box = bounds.get(wid)
if (!box) return null
const w = widgets.find((w) => w.id === wid)
const hAlign = w?.display_hint?.h_align ?? "left"
const vAlign = w?.display_hint?.v_align ?? "top"
const flexAlign = hAlign === "center" ? "center" : hAlign === "right" ? "flex-end" : "flex-start"
const flexJustify = vAlign === "middle" ? "center" : vAlign === "bottom" ? "flex-end" : "flex-start"
const textAlign = hAlign === "center" ? "center" as const : hAlign === "right" ? "right" as const : "left" as const
return (
<div
key={wid}
@@ -70,35 +151,11 @@ export function LayoutPreview({
height: box.height * scale,
border: `1px solid ${colorToCSS(theme.secondary)}`,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: flexAlign,
justifyContent: flexJustify,
overflow: "hidden",
padding: 2 * scale,
}}
>
<span
style={{
fontSize: 10 * scale,
color: colorToCSS(theme.text),
textAlign,
lineHeight: 1.2,
}}
>
{w?.name ?? `#${wid}`}
</span>
{w && (
<span
style={{
fontSize: 8 * scale,
color: colorToCSS(theme.accent),
textAlign,
}}
>
{w.display_hint.kind}
</span>
)}
<WidgetCell wid={wid} widget={w} scale={scale} theme={theme} />
</div>
)
})}

View File

@@ -1,4 +1,4 @@
import { useState } from "react"
import { useState, useEffect } from "react"
import {
useDataSources,
useCreateDataSource,
@@ -14,13 +14,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -30,28 +23,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Plus, Pencil, Trash2, X, Eye, EyeOff } from "lucide-react"
import { Plus, Pencil, Trash2, X, Eye, EyeOff, ChevronUp } from "lucide-react"
import { toast } from "sonner"
const SOURCE_TYPES: SourceType[] = [
"weather",
"media",
"rss",
"http_json",
"webhook",
"clock",
"static_text",
"weather", "media", "rss", "http_json", "webhook", "clock", "static_text",
]
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"]
@@ -70,83 +47,158 @@ const EMPTY: DataSource = {
config: { type: "external", url: null, api_key: null, headers: [] },
}
const VALID_TIMEZONES = new Set(Intl.supportedValuesOf("timeZone"))
const STRFTIME_MAP: Record<string, (d: Date) => string> = {
"%H": (d) => String(d.getHours()).padStart(2, "0"),
"%M": (d) => String(d.getMinutes()).padStart(2, "0"),
"%S": (d) => String(d.getSeconds()).padStart(2, "0"),
"%I": (d) => String(d.getHours() % 12 || 12).padStart(2, "0"),
"%p": (d) => (d.getHours() >= 12 ? "PM" : "AM"),
"%Y": (d) => String(d.getFullYear()),
"%m": (d) => String(d.getMonth() + 1).padStart(2, "0"),
"%d": (d) => String(d.getDate()).padStart(2, "0"),
}
function formatClockPreview(fmt: string, tz: string): string {
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
}).formatToParts(new Date())
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
const h24 = Number(get("hour"))
const fakeDate = new Date(2000, 0, 1, h24, Number(get("minute")), Number(get("second")))
let result = fmt
for (const [token, fn] of Object.entries(STRFTIME_MAP)) {
if (token === "%Y") result = result.replaceAll(token, get("year"))
else if (token === "%m") result = result.replaceAll(token, get("month"))
else if (token === "%d") result = result.replaceAll(token, get("day"))
else result = result.replaceAll(token, fn(fakeDate))
}
return result
} catch {
return "invalid timezone"
}
}
function isValidSave(ds: DataSource): boolean {
if (!ds.name) return false
if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.poll_interval_secs <= 0) return false
if (EXTERNAL_TYPES.includes(ds.source_type) && ds.source_type !== "webhook" && ds.config.type === "external" && !ds.config.url) return false
return true
}
export function DataSourcesPage() {
const { data: sources = [], isLoading } = useDataSources()
const create = useCreateDataSource()
const update = useUpdateDataSource()
const del = useDeleteDataSource()
const [editing, setEditing] = useState<DataSource | null>(null)
const [deleting, setDeleting] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [editingData, setEditingData] = useState<DataSource | null>(null)
const [newSource, setNewSource] = useState<DataSource | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
function openNew() {
const nextId =
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
setEditing({ ...EMPTY, id: nextId })
const nextId = sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
setNewSource({ ...EMPTY, id: nextId })
setEditingId(null)
}
function openEdit(ds: DataSource) {
setEditing({ ...ds })
function startEdit(ds: DataSource) {
setEditingId(ds.id)
setEditingData({ ...ds })
setNewSource(null)
}
async function save() {
if (!editing) return
const isNew = !sources.some((s) => s.id === editing.id)
function cancelEdit() {
setEditingId(null)
setEditingData(null)
setNewSource(null)
}
async function saveExisting() {
if (!editingData) return
try {
if (isNew) {
await create.mutateAsync(editing)
toast.success("Data source created")
} else {
await update.mutateAsync(editing)
toast.success("Data source updated")
}
setEditing(null)
await update.mutateAsync(editingData)
toast.success("Data source updated")
setEditingId(null)
setEditingData(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete() {
if (deleting == null) return
async function saveNew() {
if (!newSource) return
try {
await del.mutateAsync(deleting)
await create.mutateAsync(newSource)
toast.success("Data source created")
setNewSource(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete(id: number) {
try {
await del.mutateAsync(id)
toast.success("Data source deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
setConfirmingDelete(null)
}
if (isLoading) return <div className="text-muted-foreground p-4">Loading</div>
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Data Sources
</h1>
<p className="text-muted-foreground text-sm">
Configure external data feeds
</p>
<h1 className="text-2xl font-semibold tracking-tight">Data Sources</h1>
<p className="text-muted-foreground text-sm">Configure data feeds</p>
</div>
<Button onClick={openNew}>
<Button onClick={openNew} disabled={!!newSource}>
<Plus className="mr-2 h-4 w-4" />
Add Source
</Button>
</div>
{sources.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">
No data sources configured yet.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{sources.map((ds) => (
<div className="grid gap-3">
{newSource && (
<Card className="border-primary">
<CardHeader className="py-3">
<CardTitle className="text-base">New Data Source</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DataSourceForm value={newSource} onChange={setNewSource} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveNew} disabled={!isValidSave(newSource)}>Save</Button>
</div>
</CardContent>
</Card>
)}
{sources.length === 0 && !newSource && (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No data sources configured yet.</p>
</CardContent>
</Card>
)}
{sources.map((ds) => {
const isEditing = editingId === ds.id
const isDeleting = confirmingDelete === ds.id
return (
<Card key={ds.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
@@ -155,91 +207,43 @@ export function DataSourcesPage() {
<Badge variant="secondary">{ds.source_type}</Badge>
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
{ds.config.type === "external" && ds.config.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs">
{ds.config.url}
</span>
<span className="text-muted-foreground max-w-xs truncate text-xs">{ds.config.url}</span>
)}
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(ds)}
>
<Pencil className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(ds)}>
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(ds.id)}
>
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : ds.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{isDeleting && (
<CardContent className="pt-0">
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
<span className="text-sm flex-1">Delete? Widgets referencing it will lose their feed.</span>
<Button size="sm" variant="destructive" onClick={() => confirmDelete(ds.id)}>Delete</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
</div>
</CardContent>
)}
{isEditing && editingData && (
<CardContent className="space-y-4 pt-0">
<DataSourceForm value={editingData} onChange={setEditingData} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveExisting} disabled={!isValidSave(editingData)}>Save</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
{/* Edit / Create Dialog */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editing && sources.some((s) => s.id === editing.id)
? "Edit Data Source"
: "New Data Source"}
</DialogTitle>
</DialogHeader>
{editing && (
<DataSourceForm value={editing} onChange={setEditing} />
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
onClick={save}
disabled={
!editing?.name ||
(EXTERNAL_TYPES.includes(editing.source_type) &&
editing.source_type !== "webhook" &&
editing.poll_interval_secs <= 0) ||
(EXTERNAL_TYPES.includes(editing.source_type) &&
editing.source_type !== "webhook" &&
editing.config.type === "external" &&
!editing.config.url)
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete data source?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this data source. Widgets referencing
it will lose their feed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
})}
</div>
</div>
)
}
@@ -251,11 +255,7 @@ function isSensitiveKey(key: string) {
}
function HeaderRow({
headerKey,
headerValue,
onChangeKey,
onChangeValue,
onRemove,
headerKey, headerValue, onChangeKey, onChangeValue, onRemove,
}: {
headerKey: string
headerValue: string
@@ -268,12 +268,7 @@ function HeaderRow({
return (
<div className="flex items-center gap-2">
<Input
value={headerKey}
onChange={(e) => onChangeKey(e.target.value)}
placeholder="key"
className="flex-1"
/>
<Input value={headerKey} onChange={(e) => onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
<div className="relative flex-1">
<Input
type={sensitive && !visible ? "password" : "text"}
@@ -283,17 +278,8 @@ function HeaderRow({
className={sensitive ? "pr-9" : ""}
/>
{sensitive && (
<Button
variant="ghost"
size="icon"
className="absolute top-0 right-0 h-full w-9"
onClick={() => setVisible((v) => !v)}
>
{visible ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
<Button variant="ghost" size="icon" className="absolute top-0 right-0 h-full w-9" onClick={() => setVisible((v) => !v)}>
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
)}
</div>
@@ -304,13 +290,28 @@ function HeaderRow({
)
}
function DataSourceForm({
value,
onChange,
}: {
value: DataSource
onChange: (ds: DataSource) => void
}) {
function ClockPreview({ format, timezone }: { format: string; timezone: string }) {
const [preview, setPreview] = useState("")
useEffect(() => {
setPreview(formatClockPreview(format, timezone))
const id = setInterval(() => setPreview(formatClockPreview(format, timezone)), 1000)
return () => clearInterval(id)
}, [format, timezone])
const validTz = VALID_TIMEZONES.has(timezone)
return (
<div className="space-y-1">
<p className="text-muted-foreground text-sm font-mono">{preview}</p>
{timezone && !validTz && (
<p className="text-destructive text-xs">Unknown timezone</p>
)}
</div>
)
}
function DataSourceForm({ value, onChange }: { value: DataSource; onChange: (ds: DataSource) => void }) {
const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
onChange({ ...value, [k]: v })
@@ -326,29 +327,18 @@ function DataSourceForm({
const isStaticText = value.config.type === "static_text"
return (
<div className="grid gap-4 py-2">
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Input
value={value.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. weather"
/>
<Input value={value.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. weather" />
</div>
<div className="grid gap-2">
<Label>Source Type</Label>
<Select
value={value.source_type}
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<Select value={value.source_type} onValueChange={(v) => onSourceTypeChange(v as SourceType)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
@@ -358,20 +348,11 @@ function DataSourceForm({
<>
<div className="grid gap-2">
<Label>URL</Label>
<Input
value={value.config.url ?? ""}
onChange={(e) => setConfig({ url: e.target.value || null })}
placeholder="https://..."
/>
<Input value={value.config.url ?? ""} onChange={(e) => setConfig({ url: e.target.value || null })} placeholder="https://..." />
</div>
<div className="grid gap-2">
<Label>API Key</Label>
<Input
type="password"
value={value.config.api_key ?? ""}
onChange={(e) => setConfig({ api_key: e.target.value || null })}
placeholder="Optional"
/>
<Input type="password" value={value.config.api_key ?? ""} onChange={(e) => setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
</div>
</>
)}
@@ -380,55 +361,33 @@ function DataSourceForm({
<>
<div className="grid gap-2">
<Label>Format</Label>
<Input
value={value.config.format}
onChange={(e) => setConfig({ format: e.target.value })}
placeholder="%H:%M:%S"
/>
<Input value={value.config.format} onChange={(e) => setConfig({ format: e.target.value })} placeholder="%H:%M:%S" />
</div>
<div className="grid gap-2">
<Label>Timezone</Label>
<Input
value={value.config.timezone}
onChange={(e) => setConfig({ timezone: e.target.value })}
placeholder="Europe/Warsaw"
/>
<Input value={value.config.timezone} onChange={(e) => setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
</div>
<ClockPreview format={value.config.format} timezone={value.config.timezone} />
</>
)}
{isStaticText && (
<div className="grid gap-2">
<Label>Text</Label>
<Input
value={value.config.text}
onChange={(e) => setConfig({ text: e.target.value })}
placeholder="Hello world"
/>
<Input value={value.config.text} onChange={(e) => setConfig({ text: e.target.value })} placeholder="Hello world" />
</div>
)}
<div className="grid gap-2">
<Label>Poll Interval (seconds)</Label>
<Input
type="number"
value={value.poll_interval_secs}
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
min={1}
/>
<Input type="number" value={value.poll_interval_secs} onChange={(e) => set("poll_interval_secs", Number(e.target.value))} min={1} />
</div>
{isExternal && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label>Headers</Label>
<Button
variant="outline"
size="sm"
onClick={() =>
setConfig({ headers: [...value.config.headers, ["", ""]] })
}
>
<Button variant="outline" size="sm" onClick={() => setConfig({ headers: [...value.config.headers, ["", ""]] })}>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
@@ -448,9 +407,7 @@ function DataSourceForm({
next[i] = [k, newVal]
setConfig({ headers: next })
}}
onRemove={() =>
setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })
}
onRemove={() => setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })}
/>
))}
</div>

View File

@@ -18,16 +18,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
@@ -287,7 +277,15 @@ export function LayoutBuilderPage() {
onAddContainer={(path, dir) =>
addChild(path, makeContainerChild(dir))
}
onRemove={() => setPendingDelete(selected)}
pendingDelete={pendingDelete}
onRequestDelete={() => setPendingDelete(selected)}
onConfirmDelete={() => {
if (pendingDelete !== null) {
removeChild(pendingDelete)
setPendingDelete(null)
}
}}
onCancelDelete={() => setPendingDelete(null)}
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
isRoot={selected.length === 0}
widgets={widgets}
@@ -296,7 +294,15 @@ export function LayoutBuilderPage() {
<LeafProps
path={selected}
widgetId={selectedNode?.widget_id ?? 0}
onRemove={() => setPendingDelete(selected)}
pendingDelete={pendingDelete}
onRequestDelete={() => setPendingDelete(selected)}
onConfirmDelete={() => {
if (pendingDelete !== null) {
removeChild(pendingDelete)
setPendingDelete(null)
}
}}
onCancelDelete={() => setPendingDelete(null)}
onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
widgets={widgets}
sizing={
@@ -317,39 +323,6 @@ export function LayoutBuilderPage() {
</Card>
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(o) => !o && setPendingDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingDelete?.length === 0
? "Clear entire layout?"
: "Remove this node?"}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.length === 0
? "This will remove the entire layout tree. You can rebuild it afterward."
: "This will remove the selected node and all its children."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (pendingDelete !== null) {
removeChild(pendingDelete)
setPendingDelete(null)
}
}}
>
{pendingDelete?.length === 0 ? "Clear" : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{showPreview && root && theme && (
<Card>
<CardHeader className="pb-3">
@@ -493,7 +466,10 @@ function ContainerProps({
onUpdateProp,
onAddWidget,
onAddContainer,
onRemove,
pendingDelete,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onUpdateSizing,
isRoot,
widgets,
@@ -503,7 +479,10 @@ function ContainerProps({
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void
onAddWidget: (path: Path, widgetId: number) => void
onAddContainer: (path: Path, direction: Direction) => void
onRemove: () => void
pendingDelete: Path | null
onRequestDelete: () => void
onConfirmDelete: () => void
onCancelDelete: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
isRoot: boolean
widgets: { id: number; name: string }[]
@@ -622,14 +601,37 @@ function ContainerProps({
</Select>
)}
</div>
<Button
variant="destructive"
size="sm"
onClick={onRemove}
>
<Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"}
</Button>
{pendingDelete !== null &&
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
<div className="grid gap-2">
<p className="text-sm text-destructive">
{isRoot
? "Clear entire layout? You can rebuild afterward."
: "Remove this node and all its children?"}
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={onConfirmDelete}
>
{isRoot ? "Clear" : "Remove"}
</Button>
<Button variant="outline" size="sm" onClick={onCancelDelete}>
Cancel
</Button>
</div>
</div>
) : (
<Button
variant="destructive"
size="sm"
onClick={onRequestDelete}
>
<Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"}
</Button>
)}
</div>
)
}
@@ -637,14 +639,20 @@ function ContainerProps({
function LeafProps({
path,
widgetId,
onRemove,
pendingDelete,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onUpdateSizing,
widgets,
sizing,
}: {
path: Path
widgetId: number
onRemove: () => void
pendingDelete: Path | null
onRequestDelete: () => void
onConfirmDelete: () => void
onCancelDelete: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
widgets: { id: number; name: string }[]
sizing?: LayoutChild["sizing"]
@@ -657,10 +665,31 @@ function LeafProps({
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
</div>
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
<Button variant="destructive" size="sm" onClick={onRemove}>
<Trash2 className="mr-1 h-3 w-3" />
Remove
</Button>
{pendingDelete !== null &&
JSON.stringify(pendingDelete) === JSON.stringify(path) ? (
<div className="grid gap-2">
<p className="text-sm text-destructive">
Remove this widget?
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={onConfirmDelete}
>
Remove
</Button>
<Button variant="outline" size="sm" onClick={onCancelDelete}>
Cancel
</Button>
</div>
</div>
) : (
<Button variant="destructive" size="sm" onClick={onRequestDelete}>
<Trash2 className="mr-1 h-3 w-3" />
Remove
</Button>
)}
</div>
)
}

View File

@@ -15,26 +15,8 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Save, Upload, Trash2 } from "lucide-react"
import { Save, Upload, Trash2, ChevronUp } from "lucide-react"
import { toast } from "sonner"
export function PresetsPage() {
@@ -45,22 +27,22 @@ export function PresetsPage() {
const loadPreset = useLoadPreset()
const [saving, setSaving] = useState(false)
const [saveName, setSaveName] = useState("")
const [deleting, setDeleting] = useState<number | null>(null)
const [presetName, setPresetName] = useState("")
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
async function saveAsPreset() {
if (!layout || !saveName) return
if (!layout || !presetName) return
const nextId =
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
try {
await createPreset.mutateAsync({
id: nextId,
name: saveName,
name: presetName,
layout,
})
toast.success("Preset saved")
setSaving(false)
setSaveName("")
setPresetName("")
} catch (e) {
toast.error(String(e))
}
@@ -75,15 +57,14 @@ export function PresetsPage() {
}
}
async function confirmDelete() {
if (deleting == null) return
async function confirmDelete(id: number) {
try {
await deletePreset.mutateAsync(deleting)
await deletePreset.mutateAsync(id)
toast.success("Preset deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
setConfirmingDelete(null)
}
function nodeCount(node: Preset["layout"]["root"]): number {
@@ -104,12 +85,50 @@ export function PresetsPage() {
Save and restore layout configurations
</p>
</div>
<Button onClick={() => setSaving(true)} disabled={!layout}>
<Save className="mr-2 h-4 w-4" />
Save Current Layout
<Button
onClick={() => setSaving((v) => !v)}
disabled={!layout}
variant={saving ? "secondary" : "default"}
>
{saving ? (
<ChevronUp className="mr-2 h-4 w-4" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{saving ? "Close" : "Save Current Layout"}
</Button>
</div>
{saving && (
<Card>
<CardHeader className="py-3">
<CardTitle className="text-base">Save Current Layout as Preset</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 pb-3">
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="e.g. dashboard"
onKeyDown={(e) => e.key === "Enter" && saveAsPreset()}
autoFocus
/>
<Button onClick={saveAsPreset} disabled={!presetName} size="sm">
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setSaving(false)
setPresetName("")
}}
>
Cancel
</Button>
</CardContent>
</Card>
)}
{presets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
@@ -128,69 +147,47 @@ export function PresetsPage() {
</CardDescription>
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
<Upload className="mr-1 h-3 w-3" />
Load
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
{confirmingDelete === p.id ? (
<>
<span className="text-sm text-muted-foreground self-center mr-1">
Delete?
</span>
<Button
variant="destructive"
size="sm"
onClick={() => confirmDelete(p.id)}
>
Delete
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmingDelete(null)}
>
Cancel
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" onClick={() => load(p.id)}>
<Upload className="mr-1 h-3 w-3" />
Load
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setConfirmingDelete(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</CardHeader>
</Card>
))}
</div>
)}
<Dialog open={saving} onOpenChange={(o) => !o && setSaving(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Save Current Layout as Preset</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label>Preset Name</Label>
<Input
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="e.g. dashboard"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaving(false)}>
Cancel
</Button>
<Button onClick={saveAsPreset} disabled={!saveName}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete preset?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this preset.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -16,13 +16,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
@@ -32,18 +25,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Plus, Pencil, Trash2, X, Eye } from "lucide-react"
import { Plus, Pencil, Trash2, X, Eye, ChevronUp } from "lucide-react"
import { toast } from "sonner"
const DISPLAY_HINT_KINDS: DisplayHintKind[] = ["icon_value", "text_block", "key_value"]
@@ -64,52 +47,72 @@ export function WidgetsPage() {
const update = useUpdateWidget()
const del = useDeleteWidget()
const [editing, setEditing] = useState<Widget | null>(null)
const [deleting, setDeleting] = useState<number | null>(null)
const [previewing, setPreviewing] = useState<number | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [editingData, setEditingData] = useState<Widget | null>(null)
const [newWidget, setNewWidget] = useState<Widget | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
const [previewingId, setPreviewingId] = useState<number | null>(null)
function openNew() {
const nextId =
widgets.length > 0 ? Math.max(...widgets.map((w) => w.id)) + 1 : 1
setEditing({
...EMPTY,
id: nextId,
data_source_id: sources[0]?.id ?? 0,
})
setNewWidget({ ...EMPTY, id: nextId, data_source_id: sources[0]?.id ?? 0 })
setEditingId(null)
}
async function save() {
if (!editing) return
const isNew = !widgets.some((w) => w.id === editing.id)
function startEdit(w: Widget) {
setEditingId(w.id)
setEditingData({ ...w })
setNewWidget(null)
}
function cancelEdit() {
setEditingId(null)
setEditingData(null)
setNewWidget(null)
}
async function saveExisting() {
if (!editingData) return
try {
if (isNew) {
await create.mutateAsync(editing)
toast.success("Widget created")
} else {
await update.mutateAsync(editing)
toast.success("Widget updated")
}
setEditing(null)
await update.mutateAsync(editingData)
toast.success("Widget updated")
setEditingId(null)
setEditingData(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete() {
if (deleting == null) return
async function saveNew() {
if (!newWidget) return
try {
await del.mutateAsync(deleting)
await create.mutateAsync(newWidget)
toast.success("Widget created")
setNewWidget(null)
} catch (e) {
toast.error(String(e))
}
}
async function confirmDelete(id: number) {
try {
await del.mutateAsync(id)
toast.success("Widget deleted")
} catch (e) {
toast.error(String(e))
}
setDeleting(null)
setConfirmingDelete(null)
}
function togglePreview(id: number) {
setPreviewingId(previewingId === id ? null : id)
}
const sourceName = (id: number) =>
sources.find((s) => s.id === id)?.name ?? `#${id}`
if (isLoading) return <div className="text-muted-foreground p-4">Loading</div>
if (isLoading) return <div className="text-muted-foreground p-4">Loading...</div>
return (
<div className="space-y-6">
@@ -120,21 +123,42 @@ export function WidgetsPage() {
Display primitives bound to data sources
</p>
</div>
<Button onClick={openNew}>
<Button onClick={openNew} disabled={!!newWidget}>
<Plus className="mr-2 h-4 w-4" />
Add Widget
</Button>
</div>
{widgets.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No widgets configured yet.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{widgets.map((w) => (
<div className="grid gap-3">
{newWidget && (
<Card className="border-primary">
<CardHeader className="py-3">
<CardTitle className="text-base">New Widget</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<WidgetForm value={newWidget} onChange={setNewWidget} sources={sources} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveNew} disabled={!newWidget.name || !newWidget.data_source_id}>Save</Button>
</div>
</CardContent>
</Card>
)}
{widgets.length === 0 && !newWidget && (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">No widgets configured yet.</p>
</CardContent>
</Card>
)}
{widgets.map((w) => {
const isEditing = editingId === w.id
const isDeleting = confirmingDelete === w.id
const isPreviewing = previewingId === w.id
return (
<Card key={w.id}>
<CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1">
@@ -142,143 +166,65 @@ export function WidgetsPage() {
<CardDescription className="flex items-center gap-2">
<Badge variant="secondary">{w.display_hint.kind}</Badge>
<span>source: {sourceName(w.data_source_id)}</span>
<span>{w.mappings.length} mapping(s)</span>
{w.mappings.length > 0 && <span>{w.mappings.length} mapping(s)</span>}
</CardDescription>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setPreviewing(w.id)}
title="Preview current data"
>
<Eye className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => togglePreview(w.id)} title="Preview data">
{isPreviewing ? <ChevronUp className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setEditing({ ...w })}
>
<Pencil className="h-4 w-4" />
<Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(w)}>
{isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(w.id)}
>
<Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : w.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
{isDeleting && (
<CardContent className="pt-0">
<div className="flex gap-2 items-center p-2 bg-destructive/10 rounded">
<span className="text-sm flex-1">Delete this widget? Layout references will become dangling.</span>
<Button size="sm" variant="destructive" onClick={() => confirmDelete(w.id)}>Delete</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirmingDelete(null)}>Cancel</Button>
</div>
</CardContent>
)}
{isPreviewing && !isEditing && (
<CardContent className="pt-0">
<WidgetPreviewInline widgetId={w.id} />
</CardContent>
)}
{isEditing && editingData && (
<CardContent className="space-y-4 pt-0">
<WidgetForm value={editingData} onChange={setEditingData} sources={sources} />
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
<Button size="sm" onClick={saveExisting} disabled={!editingData.name || !editingData.data_source_id}>Save</Button>
</div>
</CardContent>
)}
</Card>
))}
</div>
)}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{editing && widgets.some((w) => w.id === editing.id)
? "Edit Widget"
: "New Widget"}
</DialogTitle>
</DialogHeader>
{editing && (
<WidgetForm
value={editing}
onChange={setEditing}
sources={sources}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
onClick={save}
disabled={
!editing?.name ||
!editing.data_source_id
}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={deleting != null}
onOpenChange={(o) => !o && setDeleting(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete widget?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this widget. Layout references will
become dangling.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{previewing != null && (
<WidgetPreviewDialog
widgetId={previewing}
widgetName={widgets.find((w) => w.id === previewing)?.name ?? ""}
onClose={() => setPreviewing(null)}
/>
)}
)
})}
</div>
</div>
)
}
function WidgetPreviewDialog({
widgetId,
widgetName,
onClose,
}: {
widgetId: number
widgetName: string
onClose: () => void
}) {
function WidgetPreviewInline({ widgetId }: { widgetId: number }) {
const { data, isLoading, isError } = useWidgetPreview(widgetId, true)
if (isLoading) return <p className="text-muted-foreground text-sm">Loading...</p>
if (isError) return <p className="text-muted-foreground text-sm">No data yet</p>
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Preview: {widgetName}</DialogTitle>
</DialogHeader>
<div className="py-2">
{isLoading && (
<p className="text-muted-foreground text-sm">Loading</p>
)}
{isError && (
<p className="text-muted-foreground text-sm">
No data yet widget hasn't been polled
</p>
)}
{data && (
<pre className="bg-muted rounded-md p-3 text-xs">
{JSON.stringify(data, null, 2)}
</pre>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<pre className="bg-muted rounded-md p-3 text-xs">
{JSON.stringify(data, null, 2)}
</pre>
)
}
@@ -312,7 +258,7 @@ function WidgetForm({
}
return (
<div className="grid gap-4 py-2">
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Name</Label>
<Input
@@ -327,14 +273,10 @@ function WidgetForm({
value={value.display_hint.kind}
onValueChange={(v) => set("display_hint", { ...value.display_hint, kind: v as DisplayHintKind })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DISPLAY_HINT_KINDS.map((h) => (
<SelectItem key={h} value={h}>
{h}
</SelectItem>
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
@@ -346,9 +288,7 @@ function WidgetForm({
value={value.display_hint.h_align}
onValueChange={(v) => set("display_hint", { ...value.display_hint, h_align: v as HAlign })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="center">Center</SelectItem>
@@ -362,9 +302,7 @@ function WidgetForm({
value={value.display_hint.v_align}
onValueChange={(v) => set("display_hint", { ...value.display_hint, v_align: v as VAlign })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="top">Top</SelectItem>
<SelectItem value="middle">Middle</SelectItem>
@@ -379,14 +317,10 @@ function WidgetForm({
value={String(value.data_source_id)}
onValueChange={(v) => set("data_source_id", Number(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
@@ -412,26 +346,18 @@ function WidgetForm({
<div key={i} className="flex items-center gap-2">
<Input
value={m.source_path}
onChange={(e) =>
updateMapping(i, { ...m, source_path: e.target.value })
}
onChange={(e) => updateMapping(i, { ...m, source_path: e.target.value })}
placeholder="$.path.to.value"
className="flex-1"
/>
<span className="text-muted-foreground text-sm"></span>
<span className="text-muted-foreground text-sm">&rarr;</span>
<Input
value={m.target_key}
onChange={(e) =>
updateMapping(i, { ...m, target_key: e.target.value })
}
onChange={(e) => updateMapping(i, { ...m, target_key: e.target.value })}
placeholder="target_key"
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeMapping(i)}
>
<Button variant="ghost" size="icon" onClick={() => removeMapping(i)}>
<X className="h-3 w-3" />
</Button>
</div>