Compare commits

...

12 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
a6152c9a9a update README.md to include clock and static text as data sources, and add widget alignment and connection indicator features 2026-06-19 12:35:10 +02:00
455d5da901 webhook through event system, extract data-generators adapter
webhook route now emits WebhookDataReceived event instead of directly
mutating DataProjection and broadcasting. event_handler applies data
and pushes incremental DataUpdate.

clock/static_text generators extracted to data-generators crate behind
DataSourcePort. chrono removed from bootstrap. polling adapters grouped
into Adapters struct.
2026-06-19 12:33:42 +02:00
437056cfc4 clean up 2026-06-19 11:32:49 +02:00
77 changed files with 2183 additions and 8087 deletions

View File

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

19
Cargo.lock generated
View File

@@ -79,9 +79,11 @@ dependencies = [
name = "application" name = "application"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"domain", "domain",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -226,9 +228,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"application", "application",
"chrono",
"chrono-tz",
"config-sqlite", "config-sqlite",
"data-generators",
"domain", "domain",
"dotenvy", "dotenvy",
"http-api", "http-api",
@@ -467,6 +468,16 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "data-generators"
version = "0.1.0"
dependencies = [
"chrono",
"chrono-tz",
"domain",
"thiserror",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -517,6 +528,9 @@ dependencies = [
[[package]] [[package]]
name = "domain" name = "domain"
version = "0.1.0" version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
@@ -1678,7 +1692,6 @@ dependencies = [
name = "protocol" name = "protocol"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"domain",
"postcard", "postcard",
"serde", "serde",
] ]

View File

@@ -16,6 +16,7 @@ members = [
"crates/adapters/media", "crates/adapters/media",
"crates/adapters/auth", "crates/adapters/auth",
"crates/adapters/secret-store", "crates/adapters/secret-store",
"crates/adapters/data-generators",
"crates/api-types", "crates/api-types",
"crates/bootstrap", "crates/bootstrap",
"crates/client-desktop", "crates/client-desktop",
@@ -57,5 +58,4 @@ postcard = { version = "1.1", default-features = false, features = ["alloc"] }
tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] } tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] }
tower = "0.5" tower = "0.5"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
chrono = "0.4" data-generators = { path = "crates/adapters/data-generators" }
chrono-tz = "0.10"

View File

@@ -25,6 +25,7 @@ Hexagonal / ports-and-adapters with full CQRS. Domain logic has zero framework d
│ tcp-server binary protocol broadcast │ │ tcp-server binary protocol broadcast │
│ http-json external API polling │ │ http-json external API polling │
│ media, rss source-specific adapters │ │ media, rss source-specific adapters │
│ data-generators clock, static text │
│ auth argon2 + JWT │ │ auth argon2 + JWT │
├─────────────────── Shared ───────────────────┤ ├─────────────────── Shared ───────────────────┤
│ protocol/ wire types, postcard serde │ │ protocol/ wire types, postcard serde │
@@ -53,10 +54,12 @@ See `docs/adr/` for architectural decision records and `CONTEXT.md` for the doma
## Features ## Features
- **Data sources**: HTTP/JSON, weather, media (Subsonic/Navidrome), RSS, webhooks - **Data sources**: HTTP/JSON, weather, media (Subsonic/Navidrome), RSS, webhooks, clock, static text
- **Layout engine**: flexbox-like containers (row/column, fixed/flex sizing, gap, padding, justify-content, align-items) - **Layout engine**: flexbox-like containers (row/column, fixed/flex sizing, gap, padding, justify-content, align-items)
- **Theming**: 5 configurable colors (primary, secondary, accent, text, background), live push to clients - **Theming**: 5 configurable colors (primary, secondary, accent, text, background), live push to clients
- **Rich text**: inline color markup (`{primary}text{/}`, `{#FF0000}hex{/}`) - **Rich text**: inline color markup (`{primary}text{/}`, `{#FF0000}hex{/}`)
- **Widget alignment**: per-widget horizontal/vertical text alignment (left/center/right, top/middle/bottom), reflected in layout preview
- **Connection indicator**: green/red dot on ESP32 display showing server connectivity
- **Overflow scroll**: bounce animation when content exceeds widget bounds, speed auto-derived from overflow - **Overflow scroll**: bounce animation when content exceeds widget bounds, speed auto-derived from overflow
- **Captive portal**: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning - **Captive portal**: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning
- **Auth**: argon2 password hashing, JWT tokens, protected API routes - **Auth**: argon2 password hashing, JWT tokens, protected API routes
@@ -86,6 +89,23 @@ cd spa && bun install && bun run dev
# 5. Add a data source, create widgets, build a layout # 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 ### ESP32 client
```bash ```bash

View File

@@ -1,6 +1,6 @@
use domain::{ use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId, User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::RwLock; use std::sync::RwLock;
@@ -177,6 +177,10 @@ impl ConfigRepository for MemoryConfigStore {
guard.remove(&id); guard.remove(&id);
Ok(()) Ok(())
} }
}
impl UserRepository for MemoryConfigStore {
type Error = MemoryConfigError;
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> { async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
let guard = self let guard = self
@@ -204,3 +208,18 @@ impl ConfigRepository for MemoryConfigStore {
Ok(guard.len() as u32) 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" edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain = { workspace = true, features = ["json"] }
sqlx.workspace = true sqlx.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true

View File

@@ -96,6 +96,15 @@ impl SqliteConfigStore {
.execute(&self.pool) .execute(&self.pool)
.await?; .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) // Add alignment columns to widgets (idempotent)
let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN h_align TEXT NOT NULL DEFAULT 'left'") let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN h_align TEXT NOT NULL DEFAULT 'left'")
.execute(&self.pool) .execute(&self.pool)

View File

@@ -3,13 +3,14 @@ mod layout;
mod presets; mod presets;
mod theme; mod theme;
mod users; mod users;
mod widget_state_cache;
mod widgets; mod widgets;
use crate::SqliteConfigStore; use crate::SqliteConfigStore;
use crate::error::SqliteConfigError; use crate::error::SqliteConfigError;
use domain::{ use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId, User, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
}; };
impl ConfigRepository for SqliteConfigStore { impl ConfigRepository for SqliteConfigStore {
@@ -78,6 +79,10 @@ impl ConfigRepository for SqliteConfigStore {
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> { async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
self.save_theme_impl(theme).await 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> { async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
self.get_user_by_username_impl(username).await self.get_user_by_username_impl(username).await
@@ -91,3 +96,18 @@ impl ConfigRepository for SqliteConfigStore {
self.count_users_impl().await 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::{ use domain::{
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild, Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
LayoutNode, LayoutPreset, Sizing, WidgetConfig, LayoutNode, LayoutPreset, Sizing, UserRepository, WidgetConfig, WidgetStateCache,
}; };
use std::time::Duration; use std::time::Duration;

View File

@@ -0,0 +1,10 @@
[package]
name = "data-generators"
version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
chrono = "0.4"
chrono-tz = "0.10"
thiserror.workspace = true

View File

@@ -0,0 +1,59 @@
use chrono::Utc;
use chrono_tz::Tz;
use domain::{DataSource, DataSourceConfig, DataSourcePort, Value};
use std::collections::BTreeMap;
#[derive(Default)]
pub struct ClockGenerator;
impl ClockGenerator {
pub fn new() -> Self {
Self
}
}
#[derive(Debug, thiserror::Error)]
pub enum GeneratorError {
#[error("wrong config type for generator")]
WrongConfig,
}
impl DataSourcePort for ClockGenerator {
type Error = GeneratorError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let (fmt, tz_name) = match &source.config {
DataSourceConfig::Clock { format, timezone } => (format.as_str(), timezone.as_str()),
_ => ("%H:%M:%S", "UTC"),
};
let tz: Tz = tz_name.parse().unwrap_or(chrono_tz::UTC);
let now = Utc::now().with_timezone(&tz);
let formatted = now.format(fmt).to_string();
let mut map = BTreeMap::new();
map.insert("time".into(), Value::String(formatted));
Ok(Value::Object(map))
}
}
#[derive(Default)]
pub struct StaticTextGenerator;
impl StaticTextGenerator {
pub fn new() -> Self {
Self
}
}
impl DataSourcePort for StaticTextGenerator {
type Error = GeneratorError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let text = match &source.config {
DataSourceConfig::StaticText { text } => text.clone(),
_ => String::new(),
};
let mut map = BTreeMap::new();
map.insert("text".into(), Value::String(text));
Ok(Value::Object(map))
}
}

View File

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

View File

@@ -1,10 +1,11 @@
pub mod extractors; pub mod extractors;
mod routes; mod routes;
use application::ConfigService;
use axum::Router; use axum::Router;
use domain::{ use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort, AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader, UserRepository, WidgetStateCache, WidgetStateReader,
}; };
use std::sync::Arc; use std::sync::Arc;
use tower_http::cors::CorsLayer; 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 pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
where where
C: ConfigRepository + Send + Sync + 'static, C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send, <C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static, E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send, E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static, 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>, state: AppState<C, E, W, B, R, A, H>,
) -> Result<(), std::io::Error> ) -> Result<(), std::io::Error>
where where
C: ConfigRepository + Send + Sync + 'static, C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send, <C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static, E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send, E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static, W: WidgetStateReader + Send + Sync + 'static,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
use crate::AppState; use crate::AppState;
use crate::extractors::AuthUser; use crate::extractors::AuthUser;
use api_types::ThemeDto; use api_types::ThemeDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json}; use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher}; use domain::{ConfigRepository, EventPublisher};
@@ -38,7 +38,7 @@ where
E::Error: std::fmt::Debug, E::Error: std::fmt::Debug,
{ {
let theme = body.into_domain(); 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) svc.update_theme(theme)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;

View File

@@ -2,7 +2,7 @@ use crate::AppState;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::Json; use axum::response::Json;
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader}; use domain::{ConfigRepository, DomainEvent, EventPublisher};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>; type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
@@ -16,9 +16,6 @@ where
C::Error: std::fmt::Debug, C::Error: std::fmt::Debug,
E: EventPublisher, E: EventPublisher,
E::Error: std::fmt::Debug, E::Error: std::fmt::Debug,
W: WidgetStateReader,
B: BroadcastPort,
B::Error: std::fmt::Debug,
{ {
let source = state let source = state
.config .config
@@ -34,55 +31,13 @@ where
)); ));
} }
let raw = json_to_domain_value(body); let data: domain::Value = body.into();
let widgets = state
.config state
.list_widgets() .events
.publish(DomainEvent::WebhookDataReceived { source_id, data })
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
let layout = state
.config
.get_layout()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
let changed = state
.widget_states
.apply_raw_data(source_id, &raw, &widgets)
.await;
if !changed.is_empty()
&& let Some(l) = &layout
{
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, s)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, s.clone()))
})
.collect();
let _ = state.broadcaster.push_screen_update(l, &with_hints).await;
}
Ok(StatusCode::OK) 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::AppState;
use crate::extractors::AuthUser; use crate::extractors::AuthUser;
use api_types::{CreateWidgetDto, WidgetDto}; use api_types::{CreateWidgetDto, WidgetDto};
use application::ConfigService;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
@@ -65,7 +65,7 @@ where
let widget = body let widget = body
.into_domain() .into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?; .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) svc.create_widget(widget)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -87,7 +87,7 @@ where
let widget = body let widget = body
.into_domain() .into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?; .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) svc.update_widget(widget)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
@@ -105,7 +105,7 @@ where
E: EventPublisher, E: EventPublisher,
E::Error: std::fmt::Debug, 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) svc.delete_widget(id)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
@@ -126,32 +126,10 @@ where
{ {
match state.widget_states.get_widget_state(id).await { match state.widget_states.get_widget_state(id).await {
Some(ws) => { Some(ws) => {
let map: serde_json::Map<String, serde_json::Value> = ws let map: serde_json::Map<String, serde_json::Value> =
.data ws.data.iter().map(|(k, v)| (k.clone(), v.into())).collect();
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
Ok(Json(serde_json::Value::Object(map))) Ok(Json(serde_json::Value::Object(map)))
} }
None => Err(StatusCode::NOT_FOUND), 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" edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain = { workspace = true, features = ["json"] }
reqwest.workspace = true reqwest.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.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 { impl DataSourcePort for HttpJsonAdapter {
type Error = HttpJsonError; type Error = HttpJsonError;
@@ -70,6 +55,6 @@ impl DataSourcePort for HttpJsonAdapter {
let resp = req.send().await.map_err(HttpJsonError::Request)?; let resp = req.send().await.map_err(HttpJsonError::Request)?;
let json: serde_json::Value = resp.json().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 crate::error::TcpServerError;
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState}; 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; use tokio::sync::broadcast;
pub struct TcpBroadcaster { pub struct TcpBroadcaster {
@@ -31,13 +32,13 @@ impl BroadcastPort for TcpBroadcaster {
layout: &Layout, layout: &Layout,
widgets: &[(WidgetId, DisplayHint, WidgetState)], widgets: &[(WidgetId, DisplayHint, WidgetState)],
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
let wire_layout: WireLayoutNode = (&layout.root).into(); let wire_layout = layout_to_wire(&layout.root);
let wire_widgets: Vec<WidgetDescriptor> = widgets let wire_widgets: Vec<WidgetDescriptor> = widgets
.iter() .iter()
.map(|(id, hint, state)| WidgetDescriptor { .map(|(id, hint, state)| WidgetDescriptor {
id: *id, id: *id,
display_hint: hint.into(), display_hint: display_hint_to_wire(hint),
state: state.into(), state: widget_state_to_wire(state),
}) })
.collect(); .collect();
@@ -58,8 +59,8 @@ impl BroadcastPort for TcpBroadcaster {
.iter() .iter()
.map(|(id, hint, state)| WidgetDescriptor { .map(|(id, hint, state)| WidgetDescriptor {
id: *id, id: *id,
display_hint: hint.into(), display_hint: display_hint_to_wire(hint),
state: state.into(), state: widget_state_to_wire(state),
}) })
.collect(); .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 broadcaster;
mod client_tracker; mod client_tracker;
mod conversions;
mod error; mod error;
mod event_bus; mod event_bus;
mod server; mod server;

View File

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

View File

@@ -7,6 +7,8 @@ edition = "2024"
domain.workspace = true domain.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } 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> { pub enum AuthError<E> {
InvalidCredentials, InvalidCredentials,
@@ -26,7 +26,7 @@ pub async fn login<C, A, H>(
password: &str, password: &str,
) -> Result<String, AuthError<C::Error>> ) -> Result<String, AuthError<C::Error>>
where where
C: ConfigRepository, C: UserRepository,
A: AuthPort, A: AuthPort,
H: PasswordHashPort, H: PasswordHashPort,
{ {
@@ -55,7 +55,7 @@ pub async fn register<C, H>(
password: &str, password: &str,
) -> Result<(), AuthError<C::Error>> ) -> Result<(), AuthError<C::Error>>
where where
C: ConfigRepository, C: UserRepository,
H: PasswordHashPort, H: PasswordHashPort,
{ {
let count = config.count_users().await.map_err(AuthError::Repository)?; let count = config.count_users().await.map_err(AuthError::Repository)?;

View File

@@ -19,6 +19,13 @@ impl DataProjection {
Self::default() 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> { pub async fn get_state(&self, widget_id: WidgetId) -> Option<WidgetState> {
self.current.lock().await.get(&widget_id).cloned() 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; pub mod auth_service;
mod config_service; mod config_service;
mod data_projection; mod data_projection;
pub mod event_service;
pub mod polling_service;
pub use config_service::ConfigService; pub use config_service::ConfigService;
pub use data_projection::DataProjection; 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::{ use domain::{
ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset, 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::collections::HashMap;
use std::sync::Mutex; use std::sync::Mutex;
@@ -123,6 +124,10 @@ impl ConfigRepository for InMemoryConfigRepository {
self.presets.lock().unwrap().remove(&id); self.presets.lock().unwrap().remove(&id);
Ok(()) Ok(())
} }
}
impl UserRepository for InMemoryConfigRepository {
type Error = Never;
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> { async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
Ok(None) 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 { pub struct InMemoryEventPublisher {
events: Mutex<Vec<DomainEvent>>, events: Mutex<Vec<DomainEvent>>,
} }

View File

@@ -19,5 +19,4 @@ anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
dotenvy.workspace = true dotenvy.workspace = true
chrono.workspace = true data-generators.workspace = true
chrono-tz.workspace = true

View File

@@ -4,7 +4,6 @@ pub struct ServerConfig {
pub database_url: String, pub database_url: String,
pub tcp_addr: String, pub tcp_addr: String,
pub http_addr: String, pub http_addr: String,
pub poll_interval_secs: u64,
pub spa_dir: Option<String>, pub spa_dir: Option<String>,
} }
@@ -15,10 +14,6 @@ impl ServerConfig {
.unwrap_or_else(|_| "sqlite:kframe.db?mode=rwc".into()), .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()), 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()), 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(), spa_dir: env::var("KFRAME_SPA_DIR").ok(),
} }
} }

View File

@@ -1,9 +1,8 @@
use application::DataProjection; use application::DataProjection;
use config_sqlite::SqliteConfigStore; use config_sqlite::SqliteConfigStore;
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
use std::sync::Arc; use std::sync::Arc;
use tcp_server::{TcpBroadcaster, TcpEventBus}; use tcp_server::{TcpBroadcaster, TcpEventBus};
use tracing::{error, info, warn}; use tracing::{error, warn};
pub async fn run( pub async fn run(
event_bus: Arc<TcpEventBus>, event_bus: Arc<TcpEventBus>,
@@ -15,38 +14,10 @@ pub async fn run(
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(DomainEvent::LayoutChanged { layout }) => { Ok(event) => {
let widgets = match config.list_widgets().await { application::event_service::handle_event(event, &config, &broadcaster, &projection)
Ok(w) => w, .await;
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::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)) => { Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "event handler lagged, missed events"); warn!(skipped = n, "event handler lagged, missed events");
} }

View File

@@ -5,12 +5,13 @@ mod polling;
use anyhow::Result; use anyhow::Result;
use application::DataProjection; use application::DataProjection;
use config_sqlite::SqliteConfigStore; use config_sqlite::SqliteConfigStore;
use domain::WidgetStateCache;
use http_api::AppState; use http_api::AppState;
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService}; use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
use secret_store::AesSecretStore; use secret_store::AesSecretStore;
use std::sync::Arc; use std::sync::Arc;
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server}; use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
use tracing::{error, info}; use tracing::{error, info, warn};
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@@ -40,6 +41,15 @@ async fn main() -> Result<()> {
let auth = Arc::new(JwtAuthService::new(auth_config)); let auth = Arc::new(JwtAuthService::new(auth_config));
let hasher = Arc::new(Argon2Hasher); 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_addr = cfg.tcp_addr.clone();
let tcp_bc = broadcaster.clone(); let tcp_bc = broadcaster.clone();
let tcp_tracker = tracker.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; event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await;
}); });
polling::run( polling::run(config_store, broadcaster, projection).await
config_store,
broadcaster,
projection,
cfg.poll_interval_secs,
)
.await
} }

View File

@@ -1,191 +1,77 @@
use anyhow::Result; use anyhow::Result;
use application::DataProjection; use application::DataProjection;
use chrono::Utc;
use chrono_tz::Tz;
use config_sqlite::SqliteConfigStore; use config_sqlite::SqliteConfigStore;
use domain::{ use data_generators::{ClockGenerator, StaticTextGenerator};
BroadcastPort, ConfigRepository, DataSource, DataSourceConfig, DataSourcePort, DataSourceType, use domain::{DataSource, DataSourcePort, DataSourceType, Value};
Value, WidgetState,
};
use http_json::HttpJsonAdapter; use http_json::HttpJsonAdapter;
use media_adapter::MediaAdapter; use media_adapter::MediaAdapter;
use rss_adapter::RssAdapter; use rss_adapter::RssAdapter;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tcp_server::TcpBroadcaster; 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 {
http: Arc<HttpJsonAdapter>,
media: Arc<MediaAdapter>,
rss: Arc<RssAdapter>,
clock: Arc<ClockGenerator>,
static_text: Arc<StaticTextGenerator>,
}
impl Adapters {
async fn poll(&self, source: &DataSource) -> Result<Value> {
match source.source_type {
DataSourceType::HttpJson | DataSourceType::Weather => self
.http
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Media => self
.media
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Rss => self
.rss
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Clock => self
.clock
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::StaticText => self
.static_text
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Webhook => Err(anyhow::anyhow!(
"webhook sources are push-based, not polled"
)),
}
}
}
pub async fn run( pub async fn run(
config: Arc<SqliteConfigStore>, config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>, broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>, projection: Arc<DataProjection>,
_poll_interval_secs: u64,
) -> Result<()> { ) -> Result<()> {
let http_adapter = Arc::new(HttpJsonAdapter::new()); let adapters = Adapters {
let media_adapter = Arc::new(MediaAdapter::new()); http: Arc::new(HttpJsonAdapter::new()),
let rss_adapter = Arc::new(RssAdapter::new()); media: Arc::new(MediaAdapter::new()),
rss: Arc::new(RssAdapter::new()),
clock: Arc::new(ClockGenerator::new()),
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();
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 source = source.clone();
let config = config.clone(); async move { adapters.poll(&source).await }
let broadcaster = broadcaster.clone();
let projection = projection.clone();
let http = http_adapter.clone();
let media = media_adapter.clone();
let rss = rss_adapter.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, http, media, rss).await;
}); });
running.insert(source_id, handle); application::polling_service::run(config, broadcaster, projection, poller).await;
} Ok(())
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>,
http_adapter: Arc<HttpJsonAdapter>,
media_adapter: Arc<MediaAdapter>,
rss_adapter: Arc<RssAdapter>,
) {
let interval = source.poll_interval;
loop {
tokio::time::sleep(interval).await;
let result = match poll_source(&http_adapter, &media_adapter, &rss_adapter, &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");
}
}
}
async fn poll_source(
http_adapter: &HttpJsonAdapter,
media_adapter: &MediaAdapter,
rss_adapter: &RssAdapter,
source: &DataSource,
) -> Result<Value> {
match source.source_type {
DataSourceType::HttpJson | DataSourceType::Weather => http_adapter
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Media => media_adapter
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Rss => rss_adapter
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Clock => Ok(generate_clock(&source.config)),
DataSourceType::StaticText => Ok(generate_static_text(&source.config)),
DataSourceType::Webhook => Err(anyhow::anyhow!(
"webhook sources are push-based, not polled"
)),
}
}
fn generate_clock(config: &DataSourceConfig) -> Value {
let (fmt, tz_name) = match config {
DataSourceConfig::Clock { format, timezone } => (format.as_str(), timezone.as_str()),
_ => ("%H:%M:%S", "UTC"),
};
let tz: Tz = tz_name.parse().unwrap_or(chrono_tz::UTC);
let now = Utc::now().with_timezone(&tz);
let formatted = now.format(fmt).to_string();
let mut map = BTreeMap::new();
map.insert("time".into(), Value::String(formatted));
Value::Object(map)
}
fn generate_static_text(config: &DataSourceConfig) -> Value {
let text = match config {
DataSourceConfig::StaticText { text } => text.clone(),
_ => String::new(),
};
let mut map = BTreeMap::new();
map.insert("text".into(), Value::String(text));
Value::Object(map)
} }

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 client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
use domain::LayoutNode; use domain::{DisplayHint, Value, WidgetError, WidgetState};
use protocol::{ use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode};
ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState,
};
use std::collections::HashMap; use std::collections::HashMap;
pub struct ClientApp { pub struct ClientApp {
screen: BoundingBox, screen: BoundingBox,
render_tree: Option<RenderTree>, render_tree: Option<RenderTree>,
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>, widget_states: HashMap<u16, (DisplayHint, WidgetState)>,
theme: ThemeConfig, theme: ThemeConfig,
theme_changed: bool, theme_changed: bool,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone)]
pub struct RepaintCommand { pub struct RepaintCommand {
pub widget_id: u16, pub widget_id: u16,
pub bounds: BoundingBox, pub bounds: BoundingBox,
pub display_hint: WireDisplayHint, pub display_hint: DisplayHint,
pub state: WireWidgetState, pub data: Vec<(String, Value)>,
pub error: Option<WidgetError>,
} }
impl ClientApp { impl ClientApp {
@@ -73,13 +73,14 @@ impl ClientApp {
wire_layout: WireLayoutNode, wire_layout: WireLayoutNode,
widgets: Vec<WidgetDescriptor>, widgets: Vec<WidgetDescriptor>,
) -> Vec<RepaintCommand> { ) -> Vec<RepaintCommand> {
let layout: LayoutNode = wire_layout.into(); let layout = wire_to_layout(wire_layout);
let new_tree = LayoutEngine::compute(&layout, self.screen); let new_tree = LayoutEngine::compute(&layout, self.screen);
self.widget_states.clear(); self.widget_states.clear();
for w in &widgets { for w in widgets {
self.widget_states let hint = wire_to_display_hint(w.display_hint);
.insert(w.id, (w.display_hint.clone(), w.state.clone())); 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); let repaints = self.build_repaints_for_all(&new_tree);
@@ -96,21 +97,19 @@ impl ClientApp {
let mut repaints = Vec::new(); let mut repaints = Vec::new();
for w in widgets { for w in widgets {
let hint = wire_to_display_hint(w.display_hint);
let state = wire_to_widget_state(w.state);
let changed = self let changed = self
.widget_states .widget_states
.get(&w.id) .get(&w.id)
.is_none_or(|(_, prev_state)| *prev_state != w.state); .is_none_or(|(_, prev)| *prev != state);
if changed { if changed {
if let Some(bounds) = tree.get_widget_bounds(w.id) { if let Some(bounds) = tree.get_widget_bounds(w.id) {
repaints.push(RepaintCommand { repaints.push(Self::make_repaint(w.id, *bounds, &hint, &state));
widget_id: w.id,
bounds: *bounds,
display_hint: w.display_hint.clone(),
state: w.state.clone(),
});
} }
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 { for (id, (hint, state)) in &self.widget_states {
if let Some(bounds) = tree.get_widget_bounds(*id) { if let Some(bounds) = tree.get_widget_bounds(*id) {
repaints.push(RepaintCommand { repaints.push(Self::make_repaint(*id, *bounds, hint, state));
widget_id: *id,
bounds: *bounds,
display_hint: hint.clone(),
state: state.clone(),
});
} }
} }
repaints.sort_by_key(|r| r.widget_id); repaints.sort_by_key(|r| r.widget_id);
repaints 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 { 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 client_app;
mod connection_loop;
pub mod conversions;
pub use client_app::{ClientApp, RepaintCommand}; 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 client_domain::BoundingBox;
use protocol::{ use protocol::{
ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection, ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection,
@@ -84,8 +84,8 @@ fn data_update_only_repaints_changed_widgets() {
assert_eq!(repaints.len(), 1); assert_eq!(repaints.len(), 1);
assert_eq!(repaints[0].widget_id, 1); assert_eq!(repaints[0].widget_id, 1);
assert_eq!( assert_eq!(
repaints[0].state.data[0].value, repaints[0].data[0],
WireValue::String("6.1°C".into()) ("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_application::{ClientApp, RepaintCommand, run_connection_loop};
use client_domain::NetworkPort; use client_domain::{
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig}; BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
WidgetRenderer,
};
use display_terminal::TerminalDisplay; use display_terminal::TerminalDisplay;
use domain::DisplayHint; use protocol::ServerMessage;
use protocol::decode_server_message;
use std::sync::mpsc; use std::sync::mpsc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::{Duration, Instant};
use tcp_client::StdTcpClient; 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() { fn main() {
let screen = BoundingBox::screen(240, 320); let screen = BoundingBox::screen(240, 320);
let mut app = ClientApp::new(screen); let mut app = ClientApp::new(screen);
@@ -18,52 +29,33 @@ fn main() {
large: (10, 20), large: (10, 20),
}; };
let mut engine = RenderEngine::new(metrics, ThemeConfig::default()); let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
let mut renderer = WidgetRenderer::new();
println!("=== K-Frame Desktop Client ==="); println!("=== K-Frame Desktop Client ===");
println!("Screen: {}x{}", screen.width, screen.height); println!("Screen: {}x{}", screen.width, screen.height);
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel::<ServerMessage>();
thread::spawn(move || { thread::spawn(move || {
let server_addr = "127.0.0.1:2699";
let mut net = StdTcpClient::new(); let mut net = StdTcpClient::new();
let tx_clone = tx.clone();
loop { run_connection_loop(
if !net.is_connected() { &mut net,
println!("[NET] Connecting to {server_addr}..."); "127.0.0.1:2699",
match net.connect(server_addr) { Duration::from_millis(50),
Ok(()) => println!("[NET] Connected!"), Duration::from_secs(2),
Err(e) => { move |msg| {
println!("[NET] Connection failed: {e}, retrying in 2s..."); let _ = tx_clone.send(msg);
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) => { |_connected| {},
thread::sleep(Duration::from_millis(50)); );
}
Err(e) => {
println!("[NET] Receive error: {e}, reconnecting...");
let _ = net.disconnect();
thread::sleep(Duration::from_secs(2));
}
}
}
}); });
println!("[RENDER] Render loop started"); println!("[RENDER] Render loop started");
let mut last_tick = Instant::now();
loop { loop {
match rx.recv_timeout(Duration::from_millis(100)) { match rx.recv_timeout(Duration::from_millis(50)) {
Ok(msg) => { Ok(msg) => {
let repaints = app.handle_message(msg); let repaints = app.handle_message(msg);
@@ -73,20 +65,13 @@ fn main() {
if !repaints.is_empty() { if !repaints.is_empty() {
println!("\n--- Repaint ({} widgets) ---", repaints.len()); println!("\n--- Repaint ({} widgets) ---", repaints.len());
let requests: Vec<_> = repaints.iter().map(to_request).collect();
let bg = engine.theme().background; let bg = engine.theme().background;
for cmd in &repaints { let updates = renderer.apply_repaints(&engine, requests);
display.fill_rect(cmd.bounds, bg).unwrap(); for update in &updates {
display.fill_rect(update.bounds, bg).unwrap();
let hint: DisplayHint = cmd.display_hint.clone().into(); for dc in &update.commands {
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 {
display display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font) .draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap(); .unwrap();
@@ -98,5 +83,23 @@ fn main() {
Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break, 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 scroll;
mod text_layout; mod text_layout;
mod theme; mod theme;
mod widget_renderer;
pub use alignment::align_offset; pub use alignment::align_offset;
pub use bounding_box::BoundingBox; pub use bounding_box::BoundingBox;
@@ -18,9 +19,10 @@ pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
pub use font::{FontMetrics, FontSize}; pub use font::{FontMetrics, FontSize};
pub use layout_engine::LayoutEngine; pub use layout_engine::LayoutEngine;
pub use markup::{TextSpan, parse_markup}; 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_engine::{DrawCommand, RenderEngine};
pub use render_tree::RenderTree; pub use render_tree::RenderTree;
pub use scroll::ScrollState; pub use scroll::ScrollState;
pub use text_layout::wrap_lines; pub use text_layout::wrap_lines;
pub use theme::ThemeConfig; pub use theme::ThemeConfig;
pub use widget_renderer::{RenderUpdate, RepaintRequest, WidgetRenderer};

View File

@@ -1,7 +1,5 @@
mod display; mod display;
mod network; mod network;
mod storage;
pub use display::DisplayPort; pub use display::DisplayPort;
pub use network::NetworkPort; 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, BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset,
markup::parse_markup, text_layout::wrap_lines, 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)] #[derive(Debug, Clone, PartialEq)]
pub struct DrawCommand { pub struct DrawCommand {
@@ -92,26 +92,38 @@ impl RenderEngine {
data: &[(String, Value)], data: &[(String, Value)],
bounds: BoundingBox, bounds: BoundingBox,
scroll_offset: u16, scroll_offset: u16,
error: Option<&WidgetError>,
) -> Vec<DrawCommand> { ) -> 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); let mut cmds = self.render_text(&text, bounds, hint.h_align, hint.v_align);
if scroll_offset > 0 { if scroll_offset > 0 {
for cmd in &mut cmds { for cmd in &mut cmds {
cmd.y = cmd.y.saturating_sub(scroll_offset); 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 cmds
} }
pub fn content_height(&self, hint: &DisplayHint, data: &[(String, Value)], width: u16) -> u16 { pub fn content_height(
let text = self.format_widget(hint, data); &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) let plain: String = parse_markup(&text, &self.theme)
.iter() .iter()
.map(|s| s.text.as_str()) .map(|s| s.text.as_str())

View File

@@ -1,7 +1,7 @@
use std::time::Duration; use std::time::Duration;
const PAUSE_DURATION: Duration = Duration::from_secs(2); const PAUSE_DURATION: Duration = Duration::from_secs(3);
const SCROLL_SPEED_PX_PER_SEC: f32 = 30.0; const SCROLL_SPEED_PX_PER_SEC: f32 = 15.0;
#[derive(Debug)] #[derive(Debug)]
pub struct ScrollState { 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", "client-domain",
"domain", "domain",
"embedded-graphics", "embedded-graphics",
"embedded-hal-bus",
"embedded-text",
"embuild", "embuild",
"esp-idf-hal", "esp-idf-hal",
"esp-idf-svc", "esp-idf-svc",
"esp-idf-sys", "esp-idf-sys",
"log", "log",
"mipidsi", "mipidsi",
"postcard",
"protocol", "protocol",
"serde",
] ]
[[package]] [[package]]
@@ -489,16 +485,6 @@ dependencies = [
"embedded-hal 1.0.0", "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]] [[package]]
name = "embedded-hal-nb" name = "embedded-hal-nb"
version = "1.0.0" version = "1.0.0"
@@ -561,17 +547,6 @@ dependencies = [
"strum 0.27.2", "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]] [[package]]
name = "embuild" name = "embuild"
version = "0.33.1" version = "0.33.1"
@@ -1126,12 +1101,6 @@ dependencies = [
"syn 2.0.118", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"

View File

@@ -15,16 +15,18 @@ esp-idf-sys = "0.37"
mipidsi = "0.10" mipidsi = "0.10"
embedded-graphics = "0.8" 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" log = "0.4"
[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"
codegen-units = 1
[profile.dev]
panic = "abort"
[build-dependencies] [build-dependencies]
embuild = "0.33" 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_TIMEOUT_S=30
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=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) // 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 // Reordered to start from top for cleaner fan
const OUTER_HEX_SVG: [(f32, f32); 6] = [ const OUTER_HEX_SVG: [(f32, f32); 6] = [

View File

@@ -1,56 +1,26 @@
use std::sync::mpsc; use std::sync::mpsc;
use std::thread; use std::thread;
use client_domain::NetworkPort; use client_application::run_connection_loop;
use protocol::decode_server_message;
use super::RenderEvent; use super::RenderEvent;
use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY}; use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY};
use crate::adapters::network::Esp32Network; use crate::adapters::network::Esp32Network;
use log::*;
pub fn spawn(server_addr: String, tx: mpsc::Sender<RenderEvent>) { pub fn spawn(server_addr: String, tx: mpsc::Sender<RenderEvent>) {
thread::Builder::new() thread::Builder::new()
.stack_size(NET_THREAD_STACK_SIZE) .stack_size(NET_THREAD_STACK_SIZE)
.name("net".into()) .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"); .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::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::collections::HashMap;
use client_application::RepaintCommand;
use client_domain::{ use client_domain::{
BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, ScrollState, ThemeConfig, BoundingBox, Color, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
WidgetRenderer,
}; };
use client_application::{ClientApp, RepaintCommand}; use client_application::ClientApp;
use domain::{DisplayHint, Value};
use protocol::ServerMessage; use protocol::ServerMessage;
use super::RenderEvent; use super::RenderEvent;
use crate::config::RENDER_POLL_INTERVAL; 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_CONNECTED: Color = Color(0, 200, 0);
const COLOR_DISCONNECTED: Color = Color(200, 0, 0); const COLOR_DISCONNECTED: Color = Color(200, 0, 0);
struct WidgetCache { fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
hint: DisplayHint, RepaintRequest {
data: Vec<(String, Value)>, widget_id: cmd.widget_id,
bounds: BoundingBox, bounds: cmd.bounds,
scroll: ScrollState, display_hint: cmd.display_hint.clone(),
data: cmd.data.clone(),
error: cmd.error.clone(),
}
} }
pub fn run( pub fn run(
@@ -36,7 +40,7 @@ pub fn run(
}; };
let mut engine = RenderEngine::new(metrics, ThemeConfig::default()); let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
let mut app = ClientApp::new(screen); 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 first_update = true;
let mut last_tick = Instant::now(); let mut last_tick = Instant::now();
let mut connected = false; let mut connected = false;
@@ -46,7 +50,12 @@ pub fn run(
display.flush().unwrap(); display.flush().unwrap();
loop { 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) { match rx.recv_timeout(timeout) {
Ok(RenderEvent::ConnectionStatus(status)) => { Ok(RenderEvent::ConnectionStatus(status)) => {
if status != connected { if status != connected {
@@ -69,14 +78,19 @@ pub fn run(
display.fill_rect(screen, bg).unwrap(); display.fill_rect(screen, bg).unwrap();
first_update = false; first_update = false;
} }
for cmd in &repaints {
let cache = update_cache(&engine, cmd); let requests: Vec<_> = repaints.iter().map(to_request).collect();
display.fill_rect(cache.bounds, bg).unwrap(); let updates = renderer.apply_repaints(&engine, requests);
draw_widget(&engine, &mut display, &cache); for update in &updates {
widgets.insert(cmd.widget_id, cache); 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); draw_indicator(&mut display, screen, connected);
display.flush().unwrap(); display.flush().unwrap();
} }
@@ -92,16 +106,17 @@ pub fn run(
let elapsed = now.duration_since(last_tick); let elapsed = now.duration_since(last_tick);
last_tick = now; last_tick = now;
let mut needs_flush = false; let scroll_updates = renderer.tick_scroll(&engine, elapsed);
for cache in widgets.values_mut() { if !scroll_updates.is_empty() {
if cache.scroll.tick(elapsed) {
let bg = engine.theme().background; let bg = engine.theme().background;
display.fill_rect(cache.bounds, bg).unwrap(); for update in &scroll_updates {
draw_widget(&engine, &mut display, cache); display.fill_rect(update.bounds, bg).unwrap();
needs_flush = true; 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); draw_indicator(&mut display, screen, connected);
display.flush().unwrap(); display.flush().unwrap();
} }
@@ -109,43 +124,14 @@ pub fn run(
} }
fn draw_indicator(display: &mut Esp32DisplayAdapter, screen: BoundingBox, connected: bool) { 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 x = screen.x + screen.width - INDICATOR_DIAMETER - INDICATOR_MARGIN;
let y = screen.y + screen.height - INDICATOR_DIAMETER - INDICATOR_MARGIN; let y = screen.y + screen.height - INDICATOR_DIAMETER - INDICATOR_MARGIN;
display.fill_circle(x, y, INDICATOR_DIAMETER, color).unwrap(); 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();
}
} }

View File

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

View File

@@ -1,17 +1,43 @@
use crate::entities::{DataSourceId, LayoutPresetId, WidgetId}; use crate::entities::{DataSourceId, LayoutPresetId, WidgetId};
use crate::value_objects::{Layout, ThemeConfig}; use crate::value_objects::{Layout, ThemeConfig, Value};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum DomainEvent { pub enum DomainEvent {
WidgetCreated { id: WidgetId }, WidgetCreated {
WidgetUpdated { id: WidgetId }, id: WidgetId,
WidgetDeleted { id: WidgetId }, },
DataSourceAdded { id: DataSourceId }, WidgetUpdated {
DataSourceUpdated { id: DataSourceId }, id: WidgetId,
DataSourceRemoved { id: DataSourceId }, },
LayoutChanged { layout: Layout }, WidgetDeleted {
ThemeChanged { theme: ThemeConfig }, id: WidgetId,
LayoutPresetSaved { id: LayoutPresetId }, },
LayoutPresetLoaded { id: LayoutPresetId }, DataSourceAdded {
LayoutPresetDeleted { id: LayoutPresetId }, id: DataSourceId,
},
DataSourceUpdated {
id: DataSourceId,
},
DataSourceRemoved {
id: DataSourceId,
},
LayoutChanged {
layout: Layout,
},
ThemeChanged {
theme: ThemeConfig,
},
LayoutPresetSaved {
id: LayoutPresetId,
},
LayoutPresetLoaded {
id: LayoutPresetId,
},
LayoutPresetDeleted {
id: LayoutPresetId,
},
WebhookDataReceived {
source_id: DataSourceId,
data: Value,
},
} }

View File

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

View File

@@ -1,5 +1,5 @@
use crate::entities::{ use crate::entities::{
DataSource, DataSourceId, LayoutPreset, LayoutPresetId, User, WidgetConfig, WidgetId, DataSource, DataSourceId, LayoutPreset, LayoutPresetId, WidgetConfig, WidgetId,
}; };
use crate::value_objects::{Layout, ThemeConfig}; use crate::value_objects::{Layout, ThemeConfig};
use std::future::Future; use std::future::Future;
@@ -56,11 +56,4 @@ pub trait ConfigRepository {
&self, &self,
theme: &ThemeConfig, theme: &ThemeConfig,
) -> impl Future<Output = Result<(), Self::Error>> + Send; ) -> 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 data_source_port;
mod event; mod event;
mod secret_store; mod secret_store;
mod user_repository;
mod widget_state_cache;
mod widget_state_reader; mod widget_state_reader;
pub use auth::{AuthPort, PasswordHashPort}; pub use auth::{AuthPort, PasswordHashPort};
@@ -14,4 +16,6 @@ pub use config_repository::ConfigRepository;
pub use data_source_port::DataSourcePort; pub use data_source_port::DataSourcePort;
pub use event::EventPublisher; pub use event::EventPublisher;
pub use secret_store::SecretStore; pub use secret_store::SecretStore;
pub use user_repository::UserRepository;
pub use widget_state_cache::WidgetStateCache;
pub use widget_state_reader::WidgetStateReader; 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; 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)] #[derive(Debug, Clone, PartialEq)]
pub enum Value { pub enum Value {
Null, Null,

View File

@@ -4,7 +4,6 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
domain.workspace = true
serde.workspace = true serde.workspace = true
postcard.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 serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -15,60 +11,12 @@ pub enum WireValue {
Object(BTreeMap<String, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireWidgetError { pub enum WireWidgetError {
SourceUnavailable, SourceUnavailable,
ExtractionFailed, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireKeyValue { pub struct WireKeyValue {
pub key: String, pub key: String,
@@ -81,35 +29,6 @@ pub struct WireWidgetState {
pub error: Option<WireWidgetError>, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireDisplayHintKind { pub enum WireDisplayHintKind {
IconValue, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireSizing { pub enum WireSizing {
Fixed(u16), Fixed(u16),
Flex(u8), 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireDirection { pub enum WireDirection {
Row, Row,
Column, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireJustifyContent { pub enum WireJustifyContent {
Start, Start,
@@ -285,30 +88,6 @@ pub enum WireJustifyContent {
SpaceEvenly, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum WireAlignItems { pub enum WireAlignItems {
Start, Start,
@@ -317,28 +96,6 @@ pub enum WireAlignItems {
Stretch, 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)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WireContainerNode { pub struct WireContainerNode {
pub direction: WireDirection, pub direction: WireDirection,
@@ -360,49 +117,3 @@ pub enum WireLayoutNode {
Container(WireContainerNode), Container(WireContainerNode),
Leaf(u16), 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +0,0 @@
from PIL import Image
INPUT = "/home/gabriel/Downloads/Logo.png"
OUTPUT = "/home/gabriel/Downloads/logo_splash.h"
DISPLAY_W, DISPLAY_H = 240, 320
BG_COLOR = (30, 30, 30) # dark background, change to (0,0,0) for pure black
img = Image.open(INPUT).convert("RGB")
# Scale logo to fit within display, preserving aspect ratio
img.thumbnail((DISPLAY_W, DISPLAY_H), Image.LANCZOS)
# Center on display canvas
canvas = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BG_COLOR)
x = (DISPLAY_W - img.width) // 2
y = (DISPLAY_H - img.height) // 2
canvas.paste(img, (x, y))
# Convert to RGB565
pixels = []
for r, g, b in canvas.getdata():
rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
# Swap bytes for ILI9341 (big-endian over SPI)
pixels.append(((rgb565 & 0xFF) << 8) | (rgb565 >> 8))
# Write header file
with open(OUTPUT, "w") as f:
f.write("#pragma once\n")
f.write("#include <pgmspace.h>\n\n")
f.write(f"// {DISPLAY_W}x{DISPLAY_H} RGB565, big-endian\n")
f.write(f"const uint16_t SPLASH_WIDTH = {DISPLAY_W};\n")
f.write(f"const uint16_t SPLASH_HEIGHT = {DISPLAY_H};\n\n")
f.write("const uint16_t splash_logo[] PROGMEM = {\n")
for i, p in enumerate(pixels):
if i % 12 == 0:
f.write(" ")
f.write(f"0x{p:04X},")
if i % 12 == 11:
f.write("\n")
f.write("\n};\n")
print(f"Done — {len(pixels)} pixels, {len(pixels)*2/1024:.1f} KB")
print(f"Output: {OUTPUT}")

View File

@@ -1,6 +1,7 @@
import { useMemo, useRef } from "react" import { useMemo, useRef } from "react"
import type { LayoutNode, ThemeConfig, Widget } from "@/api/types" import type { LayoutNode, ThemeConfig, Widget } from "@/api/types"
import { computeLayout } from "@/lib/layout-engine" import { computeLayout } from "@/lib/layout-engine"
import { useWidgetPreview } from "@/api/widgets"
interface LayoutPreviewProps { interface LayoutPreviewProps {
layout: LayoutNode layout: LayoutNode
@@ -19,6 +20,91 @@ function collectWidgetIds(node: LayoutNode): number[] {
return (node.children ?? []).flatMap((c) => collectWidgetIds(c.node)) 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({ export function LayoutPreview({
layout, layout,
screenWidth, screenWidth,
@@ -54,11 +140,6 @@ export function LayoutPreview({
const box = bounds.get(wid) const box = bounds.get(wid)
if (!box) return null if (!box) return null
const w = widgets.find((w) => w.id === wid) 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 ( return (
<div <div
key={wid} key={wid}
@@ -70,35 +151,11 @@ export function LayoutPreview({
height: box.height * scale, height: box.height * scale,
border: `1px solid ${colorToCSS(theme.secondary)}`, border: `1px solid ${colorToCSS(theme.secondary)}`,
boxSizing: "border-box", boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: flexAlign,
justifyContent: flexJustify,
overflow: "hidden", overflow: "hidden",
padding: 2 * scale, padding: 2 * scale,
}} }}
> >
<span <WidgetCell wid={wid} widget={w} scale={scale} theme={theme} />
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>
)}
</div> </div>
) )
})} })}

View File

@@ -1,4 +1,4 @@
import { useState } from "react" import { useState, useEffect } from "react"
import { import {
useDataSources, useDataSources,
useCreateDataSource, useCreateDataSource,
@@ -14,13 +14,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { import {
@@ -30,28 +23,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } 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 { 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" import { toast } from "sonner"
const SOURCE_TYPES: SourceType[] = [ const SOURCE_TYPES: SourceType[] = [
"weather", "weather", "media", "rss", "http_json", "webhook", "clock", "static_text",
"media",
"rss",
"http_json",
"webhook",
"clock",
"static_text",
] ]
const EXTERNAL_TYPES: SourceType[] = ["weather", "media", "rss", "http_json", "webhook"] 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: [] }, 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() { export function DataSourcesPage() {
const { data: sources = [], isLoading } = useDataSources() const { data: sources = [], isLoading } = useDataSources()
const create = useCreateDataSource() const create = useCreateDataSource()
const update = useUpdateDataSource() const update = useUpdateDataSource()
const del = useDeleteDataSource() const del = useDeleteDataSource()
const [editing, setEditing] = useState<DataSource | null>(null) const [editingId, setEditingId] = useState<number | null>(null)
const [deleting, setDeleting] = 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() { function openNew() {
const nextId = const nextId = sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1
sources.length > 0 ? Math.max(...sources.map((s) => s.id)) + 1 : 1 setNewSource({ ...EMPTY, id: nextId })
setEditing({ ...EMPTY, id: nextId }) setEditingId(null)
} }
function openEdit(ds: DataSource) { function startEdit(ds: DataSource) {
setEditing({ ...ds }) setEditingId(ds.id)
setEditingData({ ...ds })
setNewSource(null)
} }
async function save() { function cancelEdit() {
if (!editing) return setEditingId(null)
const isNew = !sources.some((s) => s.id === editing.id) setEditingData(null)
setNewSource(null)
}
async function saveExisting() {
if (!editingData) return
try { try {
if (isNew) { await update.mutateAsync(editingData)
await create.mutateAsync(editing)
toast.success("Data source created")
} else {
await update.mutateAsync(editing)
toast.success("Data source updated") toast.success("Data source updated")
} setEditingId(null)
setEditing(null) setEditingData(null)
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
} }
async function confirmDelete() { async function saveNew() {
if (deleting == null) return if (!newSource) return
try { 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") toast.success("Data source deleted")
} catch (e) { } catch (e) {
toast.error(String(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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">Data Sources</h1>
Data Sources <p className="text-muted-foreground text-sm">Configure data feeds</p>
</h1>
<p className="text-muted-foreground text-sm">
Configure external data feeds
</p>
</div> </div>
<Button onClick={openNew}> <Button onClick={openNew} disabled={!!newSource}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Source Add Source
</Button> </Button>
</div> </div>
{sources.length === 0 ? ( <div className="grid gap-3">
<Card> {newSource && (
<CardContent className="py-12 text-center"> <Card className="border-primary">
<p className="text-muted-foreground"> <CardHeader className="py-3">
No data sources configured yet. <CardTitle className="text-base">New Data Source</CardTitle>
</p> </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> </CardContent>
</Card> </Card>
) : ( )}
<div className="grid gap-3">
{sources.map((ds) => ( {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}> <Card key={ds.id}>
<CardHeader className="flex flex-row items-center justify-between py-3"> <CardHeader className="flex flex-row items-center justify-between py-3">
<div className="space-y-1"> <div className="space-y-1">
@@ -155,91 +207,43 @@ export function DataSourcesPage() {
<Badge variant="secondary">{ds.source_type}</Badge> <Badge variant="secondary">{ds.source_type}</Badge>
{ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>} {ds.poll_interval_secs > 0 && <span>every {ds.poll_interval_secs}s</span>}
{ds.config.type === "external" && ds.config.url && ( {ds.config.type === "external" && ds.config.url && (
<span className="text-muted-foreground max-w-xs truncate text-xs"> <span className="text-muted-foreground max-w-xs truncate text-xs">{ds.config.url}</span>
{ds.config.url}
</span>
)} )}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<Button <Button variant="ghost" size="icon" onClick={() => isEditing ? cancelEdit() : startEdit(ds)}>
variant="ghost" {isEditing ? <ChevronUp className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
size="icon"
onClick={() => openEdit(ds)}
>
<Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" onClick={() => setConfirmingDelete(isDeleting ? null : ds.id)}>
variant="ghost"
size="icon"
onClick={() => setDeleting(ds.id)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
</Card>
))} {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> </div>
</CardContent>
)} )}
{/* Edit / Create Dialog */} {isEditing && editingData && (
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}> <CardContent className="space-y-4 pt-0">
<DialogContent> <DataSourceForm value={editingData} onChange={setEditingData} />
<DialogHeader> <div className="flex gap-2 justify-end">
<DialogTitle> <Button variant="outline" size="sm" onClick={cancelEdit}>Cancel</Button>
{editing && sources.some((s) => s.id === editing.id) <Button size="sm" onClick={saveExisting} disabled={!isValidSave(editingData)}>Save</Button>
? "Edit Data Source" </div>
: "New Data Source"} </CardContent>
</DialogTitle>
</DialogHeader>
{editing && (
<DataSourceForm value={editing} onChange={setEditing} />
)} )}
<DialogFooter> </Card>
<Button variant="outline" onClick={() => setEditing(null)}> )
Cancel })}
</Button> </div>
<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({ function HeaderRow({
headerKey, headerKey, headerValue, onChangeKey, onChangeValue, onRemove,
headerValue,
onChangeKey,
onChangeValue,
onRemove,
}: { }: {
headerKey: string headerKey: string
headerValue: string headerValue: string
@@ -268,12 +268,7 @@ function HeaderRow({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input value={headerKey} onChange={(e) => onChangeKey(e.target.value)} placeholder="key" className="flex-1" />
value={headerKey}
onChange={(e) => onChangeKey(e.target.value)}
placeholder="key"
className="flex-1"
/>
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
type={sensitive && !visible ? "password" : "text"} type={sensitive && !visible ? "password" : "text"}
@@ -283,17 +278,8 @@ function HeaderRow({
className={sensitive ? "pr-9" : ""} className={sensitive ? "pr-9" : ""}
/> />
{sensitive && ( {sensitive && (
<Button <Button variant="ghost" size="icon" className="absolute top-0 right-0 h-full w-9" onClick={() => setVisible((v) => !v)}>
variant="ghost" {visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
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> </Button>
)} )}
</div> </div>
@@ -304,13 +290,28 @@ function HeaderRow({
) )
} }
function DataSourceForm({ function ClockPreview({ format, timezone }: { format: string; timezone: string }) {
value, const [preview, setPreview] = useState("")
onChange,
}: { useEffect(() => {
value: DataSource setPreview(formatClockPreview(format, timezone))
onChange: (ds: DataSource) => void 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]) => const set = <K extends keyof DataSource>(k: K, v: DataSource[K]) =>
onChange({ ...value, [k]: v }) onChange({ ...value, [k]: v })
@@ -326,29 +327,18 @@ function DataSourceForm({
const isStaticText = value.config.type === "static_text" const isStaticText = value.config.type === "static_text"
return ( return (
<div className="grid gap-4 py-2"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Name</Label> <Label>Name</Label>
<Input <Input value={value.name} onChange={(e) => set("name", e.target.value)} placeholder="e.g. weather" />
value={value.name}
onChange={(e) => set("name", e.target.value)}
placeholder="e.g. weather"
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Source Type</Label> <Label>Source Type</Label>
<Select <Select value={value.source_type} onValueChange={(v) => onSourceTypeChange(v as SourceType)}>
value={value.source_type} <SelectTrigger><SelectValue /></SelectTrigger>
onValueChange={(v) => onSourceTypeChange(v as SourceType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
{SOURCE_TYPES.map((t) => ( {SOURCE_TYPES.map((t) => (
<SelectItem key={t} value={t}> <SelectItem key={t} value={t}>{t}</SelectItem>
{t}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -358,20 +348,11 @@ function DataSourceForm({
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>URL</Label> <Label>URL</Label>
<Input <Input value={value.config.url ?? ""} onChange={(e) => setConfig({ url: e.target.value || null })} placeholder="https://..." />
value={value.config.url ?? ""}
onChange={(e) => setConfig({ url: e.target.value || null })}
placeholder="https://..."
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>API Key</Label> <Label>API Key</Label>
<Input <Input type="password" value={value.config.api_key ?? ""} onChange={(e) => setConfig({ api_key: e.target.value || null })} placeholder="Optional" />
type="password"
value={value.config.api_key ?? ""}
onChange={(e) => setConfig({ api_key: e.target.value || null })}
placeholder="Optional"
/>
</div> </div>
</> </>
)} )}
@@ -380,55 +361,33 @@ function DataSourceForm({
<> <>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Format</Label> <Label>Format</Label>
<Input <Input value={value.config.format} onChange={(e) => setConfig({ format: e.target.value })} placeholder="%H:%M:%S" />
value={value.config.format}
onChange={(e) => setConfig({ format: e.target.value })}
placeholder="%H:%M:%S"
/>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Timezone</Label> <Label>Timezone</Label>
<Input <Input value={value.config.timezone} onChange={(e) => setConfig({ timezone: e.target.value })} placeholder="Europe/Warsaw" />
value={value.config.timezone}
onChange={(e) => setConfig({ timezone: e.target.value })}
placeholder="Europe/Warsaw"
/>
</div> </div>
<ClockPreview format={value.config.format} timezone={value.config.timezone} />
</> </>
)} )}
{isStaticText && ( {isStaticText && (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Text</Label> <Label>Text</Label>
<Input <Input value={value.config.text} onChange={(e) => setConfig({ text: e.target.value })} placeholder="Hello world" />
value={value.config.text}
onChange={(e) => setConfig({ text: e.target.value })}
placeholder="Hello world"
/>
</div> </div>
)} )}
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Poll Interval (seconds)</Label> <Label>Poll Interval (seconds)</Label>
<Input <Input type="number" value={value.poll_interval_secs} onChange={(e) => set("poll_interval_secs", Number(e.target.value))} min={1} />
type="number"
value={value.poll_interval_secs}
onChange={(e) => set("poll_interval_secs", Number(e.target.value))}
min={1}
/>
</div> </div>
{isExternal && ( {isExternal && (
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Headers</Label> <Label>Headers</Label>
<Button <Button variant="outline" size="sm" onClick={() => setConfig({ headers: [...value.config.headers, ["", ""]] })}>
variant="outline"
size="sm"
onClick={() =>
setConfig({ headers: [...value.config.headers, ["", ""]] })
}
>
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Add Add
</Button> </Button>
@@ -448,9 +407,7 @@ function DataSourceForm({
next[i] = [k, newVal] next[i] = [k, newVal]
setConfig({ headers: next }) setConfig({ headers: next })
}} }}
onRemove={() => onRemove={() => setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })}
setConfig({ headers: value.config.headers.filter((_, idx) => idx !== i) })
}
/> />
))} ))}
</div> </div>

View File

@@ -18,16 +18,6 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -287,7 +277,15 @@ export function LayoutBuilderPage() {
onAddContainer={(path, dir) => onAddContainer={(path, dir) =>
addChild(path, makeContainerChild(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)} onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
isRoot={selected.length === 0} isRoot={selected.length === 0}
widgets={widgets} widgets={widgets}
@@ -296,7 +294,15 @@ export function LayoutBuilderPage() {
<LeafProps <LeafProps
path={selected} path={selected}
widgetId={selectedNode?.widget_id ?? 0} 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)} onUpdateSizing={(sizing) => updateSizing(selected, sizing)}
widgets={widgets} widgets={widgets}
sizing={ sizing={
@@ -317,39 +323,6 @@ export function LayoutBuilderPage() {
</Card> </Card>
</div> </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 && ( {showPreview && root && theme && (
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -493,7 +466,10 @@ function ContainerProps({
onUpdateProp, onUpdateProp,
onAddWidget, onAddWidget,
onAddContainer, onAddContainer,
onRemove, pendingDelete,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onUpdateSizing, onUpdateSizing,
isRoot, isRoot,
widgets, widgets,
@@ -503,7 +479,10 @@ function ContainerProps({
onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void onUpdateProp: (path: Path, prop: "gap" | "padding" | "direction" | "justify_content" | "align_items", value: number | string) => void
onAddWidget: (path: Path, widgetId: number) => void onAddWidget: (path: Path, widgetId: number) => void
onAddContainer: (path: Path, direction: Direction) => void onAddContainer: (path: Path, direction: Direction) => void
onRemove: () => void pendingDelete: Path | null
onRequestDelete: () => void
onConfirmDelete: () => void
onCancelDelete: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
isRoot: boolean isRoot: boolean
widgets: { id: number; name: string }[] widgets: { id: number; name: string }[]
@@ -622,14 +601,37 @@ function ContainerProps({
</Select> </Select>
)} )}
</div> </div>
{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 <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={onRemove} 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" /> <Trash2 className="mr-1 h-3 w-3" />
{isRoot ? "Clear Layout" : "Remove"} {isRoot ? "Clear Layout" : "Remove"}
</Button> </Button>
)}
</div> </div>
) )
} }
@@ -637,14 +639,20 @@ function ContainerProps({
function LeafProps({ function LeafProps({
path, path,
widgetId, widgetId,
onRemove, pendingDelete,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onUpdateSizing, onUpdateSizing,
widgets, widgets,
sizing, sizing,
}: { }: {
path: Path path: Path
widgetId: number widgetId: number
onRemove: () => void pendingDelete: Path | null
onRequestDelete: () => void
onConfirmDelete: () => void
onCancelDelete: () => void
onUpdateSizing: (sizing: LayoutChild["sizing"]) => void onUpdateSizing: (sizing: LayoutChild["sizing"]) => void
widgets: { id: number; name: string }[] widgets: { id: number; name: string }[]
sizing?: LayoutChild["sizing"] sizing?: LayoutChild["sizing"]
@@ -657,10 +665,31 @@ function LeafProps({
<p className="text-sm">{w?.name ?? `#${widgetId}`}</p> <p className="text-sm">{w?.name ?? `#${widgetId}`}</p>
</div> </div>
{sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />} {sizing && <SizingEditor path={path} onUpdate={onUpdateSizing} sizing={sizing} />}
<Button variant="destructive" size="sm" onClick={onRemove}> {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" /> <Trash2 className="mr-1 h-3 w-3" />
Remove Remove
</Button> </Button>
)}
</div> </div>
) )
} }

View File

@@ -15,26 +15,8 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card" } 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 { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Save, Upload, Trash2, ChevronUp } from "lucide-react"
import { Save, Upload, Trash2 } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
export function PresetsPage() { export function PresetsPage() {
@@ -45,22 +27,22 @@ export function PresetsPage() {
const loadPreset = useLoadPreset() const loadPreset = useLoadPreset()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveName, setSaveName] = useState("") const [presetName, setPresetName] = useState("")
const [deleting, setDeleting] = useState<number | null>(null) const [confirmingDelete, setConfirmingDelete] = useState<number | null>(null)
async function saveAsPreset() { async function saveAsPreset() {
if (!layout || !saveName) return if (!layout || !presetName) return
const nextId = const nextId =
presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1 presets.length > 0 ? Math.max(...presets.map((p) => p.id)) + 1 : 1
try { try {
await createPreset.mutateAsync({ await createPreset.mutateAsync({
id: nextId, id: nextId,
name: saveName, name: presetName,
layout, layout,
}) })
toast.success("Preset saved") toast.success("Preset saved")
setSaving(false) setSaving(false)
setSaveName("") setPresetName("")
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
@@ -75,15 +57,14 @@ export function PresetsPage() {
} }
} }
async function confirmDelete() { async function confirmDelete(id: number) {
if (deleting == null) return
try { try {
await deletePreset.mutateAsync(deleting) await deletePreset.mutateAsync(id)
toast.success("Preset deleted") toast.success("Preset deleted")
} catch (e) { } catch (e) {
toast.error(String(e)) toast.error(String(e))
} }
setDeleting(null) setConfirmingDelete(null)
} }
function nodeCount(node: Preset["layout"]["root"]): number { function nodeCount(node: Preset["layout"]["root"]): number {
@@ -104,12 +85,50 @@ export function PresetsPage() {
Save and restore layout configurations Save and restore layout configurations
</p> </p>
</div> </div>
<Button onClick={() => setSaving(true)} disabled={!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" /> <Save className="mr-2 h-4 w-4" />
Save Current Layout )}
{saving ? "Close" : "Save Current Layout"}
</Button> </Button>
</div> </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 ? ( {presets.length === 0 ? (
<Card> <Card>
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
@@ -128,6 +147,28 @@ export function PresetsPage() {
</CardDescription> </CardDescription>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
{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)}> <Button variant="outline" size="sm" onClick={() => load(p.id)}>
<Upload className="mr-1 h-3 w-3" /> <Upload className="mr-1 h-3 w-3" />
Load Load
@@ -135,62 +176,18 @@ export function PresetsPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setDeleting(p.id)} onClick={() => setConfirmingDelete(p.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</>
)}
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
))} ))}
</div> </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> </div>
) )
} }

View File

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