add SPA config UI, wire media/rss adapters, event-driven layout push

- React SPA: dashboard, data sources CRUD, widgets CRUD, layout builder,
  presets. TanStack Router + Query, shadcn/ui, Vite proxy to :3000
- wire media + rss adapters into polling loop, remove xtb source type
- media adapter: read username/password from headers, proper subsonic auth
- event handler: subscribe to LayoutChanged, push screen update to clients
- fix clippy warnings across workspace (Default impls, collapsible ifs,
  redundant closures, is_none_or, unused imports)
This commit is contained in:
2026-06-19 00:12:42 +02:00
parent 21c08911df
commit 26ebfad3a2
175 changed files with 12338 additions and 801 deletions

View File

@@ -1,6 +1,6 @@
pub mod error;
mod serialization;
mod repository;
mod serialization;
use sqlx::SqlitePool;
@@ -27,8 +27,10 @@ impl SqliteConfigStore {
data_source_id INTEGER NOT NULL,
mappings TEXT NOT NULL,
max_data_size INTEGER NOT NULL
)"
).execute(&self.pool).await?;
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS data_sources (
@@ -37,23 +39,29 @@ impl SqliteConfigStore {
source_type TEXT NOT NULL,
poll_interval_secs INTEGER NOT NULL,
config TEXT NOT NULL
)"
).execute(&self.pool).await?;
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS layout (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL
)"
).execute(&self.pool).await?;
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS presets (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
layout_data TEXT NOT NULL
)"
).execute(&self.pool).await?;
)",
)
.execute(&self.pool)
.await?;
Ok(())
}

View File

@@ -1,10 +1,13 @@
use domain::{DataSource, DataSourceId};
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::data_source as ser;
use domain::{DataSource, DataSourceId};
impl SqliteConfigStore {
pub(crate) async fn get_data_source_impl(&self, id: DataSourceId) -> Result<Option<DataSource>, SqliteConfigError> {
pub(crate) async fn get_data_source_impl(
&self,
id: DataSourceId,
) -> Result<Option<DataSource>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
@@ -17,16 +20,21 @@ impl SqliteConfigStore {
}
}
pub(crate) async fn list_data_sources_impl(&self) -> Result<Vec<DataSource>, SqliteConfigError> {
pub(crate) async fn list_data_sources_impl(
&self,
) -> Result<Vec<DataSource>, SqliteConfigError> {
let rows = sqlx::query("SELECT * FROM data_sources")
.fetch_all(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::data_source_from_row(r)).collect()
rows.iter().map(ser::data_source_from_row).collect()
}
pub(crate) async fn save_data_source_impl(&self, source: &DataSource) -> Result<(), SqliteConfigError> {
pub(crate) async fn save_data_source_impl(
&self,
source: &DataSource,
) -> Result<(), SqliteConfigError> {
let config_json = ser::data_source_config_to_json(&source.config)?;
let type_str = ser::data_source_type_to_str(&source.source_type);
@@ -46,7 +54,10 @@ impl SqliteConfigStore {
Ok(())
}
pub(crate) async fn delete_data_source_impl(&self, id: DataSourceId) -> Result<(), SqliteConfigError> {
pub(crate) async fn delete_data_source_impl(
&self,
id: DataSourceId,
) -> Result<(), SqliteConfigError> {
sqlx::query("DELETE FROM data_sources WHERE id = ?")
.bind(id as i64)
.execute(&self.pool)

View File

@@ -1,8 +1,8 @@
use sqlx::Row;
use domain::Layout;
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::layout as ser;
use domain::Layout;
use sqlx::Row;
impl SqliteConfigStore {
pub(crate) async fn get_layout_impl(&self) -> Result<Option<Layout>, SqliteConfigError> {
@@ -23,13 +23,11 @@ impl SqliteConfigStore {
pub(crate) async fn save_layout_impl(&self, layout: &Layout) -> Result<(), SqliteConfigError> {
let json = ser::layout_to_json(layout)?;
sqlx::query(
"INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)"
)
.bind(&json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
sqlx::query("INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)")
.bind(&json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}

View File

@@ -1,16 +1,14 @@
mod widgets;
mod data_sources;
mod layout;
mod presets;
mod widgets;
use domain::{
ConfigRepository,
DataSource, DataSourceId,
Layout, LayoutPreset, LayoutPresetId,
WidgetConfig, WidgetId,
};
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, WidgetConfig,
WidgetId,
};
impl ConfigRepository for SqliteConfigStore {
type Error = SqliteConfigError;

View File

@@ -1,10 +1,13 @@
use domain::{LayoutPreset, LayoutPresetId};
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::{layout as layout_ser, preset as ser};
use domain::{LayoutPreset, LayoutPresetId};
impl SqliteConfigStore {
pub(crate) async fn get_preset_impl(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, SqliteConfigError> {
pub(crate) async fn get_preset_impl(
&self,
id: LayoutPresetId,
) -> Result<Option<LayoutPreset>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
@@ -23,26 +26,30 @@ impl SqliteConfigStore {
.await
.map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::preset_from_row(r)).collect()
rows.iter().map(ser::preset_from_row).collect()
}
pub(crate) async fn save_preset_impl(&self, preset: &LayoutPreset) -> Result<(), SqliteConfigError> {
pub(crate) async fn save_preset_impl(
&self,
preset: &LayoutPreset,
) -> Result<(), SqliteConfigError> {
let layout_json = layout_ser::layout_to_json(&preset.layout)?;
sqlx::query(
"INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)"
)
.bind(preset.id as i64)
.bind(&preset.name)
.bind(&layout_json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
sqlx::query("INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)")
.bind(preset.id as i64)
.bind(&preset.name)
.bind(&layout_json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
pub(crate) async fn delete_preset_impl(&self, id: LayoutPresetId) -> Result<(), SqliteConfigError> {
pub(crate) async fn delete_preset_impl(
&self,
id: LayoutPresetId,
) -> Result<(), SqliteConfigError> {
sqlx::query("DELETE FROM presets WHERE id = ?")
.bind(id as i64)
.execute(&self.pool)

View File

@@ -1,10 +1,13 @@
use domain::{WidgetConfig, WidgetId};
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::widget as ser;
use domain::{WidgetConfig, WidgetId};
impl SqliteConfigStore {
pub(crate) async fn get_widget_impl(&self, id: WidgetId) -> Result<Option<WidgetConfig>, SqliteConfigError> {
pub(crate) async fn get_widget_impl(
&self,
id: WidgetId,
) -> Result<Option<WidgetConfig>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
.bind(id as i64)
.fetch_optional(&self.pool)
@@ -23,10 +26,13 @@ impl SqliteConfigStore {
.await
.map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::widget_from_row(r)).collect()
rows.iter().map(ser::widget_from_row).collect()
}
pub(crate) async fn save_widget_impl(&self, config: &WidgetConfig) -> Result<(), SqliteConfigError> {
pub(crate) async fn save_widget_impl(
&self,
config: &WidgetConfig,
) -> Result<(), SqliteConfigError> {
let mappings_json = ser::mappings_to_json(&config.mappings)?;
let hint_str = ser::display_hint_to_str(&config.display_hint);

View File

@@ -1,14 +1,13 @@
use std::time::Duration;
use crate::error::SqliteConfigError;
use domain::{DataSource, DataSourceConfig, DataSourceType};
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
use domain::{DataSource, DataSourceConfig, DataSourceType};
use crate::error::SqliteConfigError;
use std::time::Duration;
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
match t {
DataSourceType::Weather => "weather",
DataSourceType::Media => "media",
DataSourceType::Xtb => "xtb",
DataSourceType::Rss => "rss",
DataSourceType::HttpJson => "http_json",
DataSourceType::Webhook => "webhook",
@@ -19,11 +18,12 @@ fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigErro
match s {
"weather" => Ok(DataSourceType::Weather),
"media" => Ok(DataSourceType::Media),
"xtb" => Ok(DataSourceType::Xtb),
"rss" => Ok(DataSourceType::Rss),
"http_json" => Ok(DataSourceType::HttpJson),
"webhook" => Ok(DataSourceType::Webhook),
_ => Err(SqliteConfigError::Serialization(format!("unknown source type: {s}"))),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown source type: {s}"
))),
}
}
@@ -37,20 +37,27 @@ pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, S
}
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> {
let v: serde_json::Value = serde_json::from_str(json)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
let v: serde_json::Value =
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
let url = v["url"].as_str().map(String::from);
let api_key = v["api_key"].as_str().map(String::from);
let headers = match v["headers"].as_array() {
Some(arr) => arr.iter().filter_map(|h| {
let pair = h.as_array()?;
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
}).collect(),
Some(arr) => arr
.iter()
.filter_map(|h| {
let pair = h.as_array()?;
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
})
.collect(),
None => vec![],
};
Ok(DataSourceConfig { url, headers, api_key })
Ok(DataSourceConfig {
url,
headers,
api_key,
})
}
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> {

View File

@@ -1,5 +1,5 @@
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
use crate::error::SqliteConfigError;
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
let v = node_to_json(&layout.root);
@@ -7,8 +7,8 @@ pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
}
pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> {
let v: serde_json::Value = serde_json::from_str(json)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
let v: serde_json::Value =
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
let root = node_from_json(&v)?;
Ok(Layout { root })
}
@@ -17,16 +17,20 @@ fn node_to_json(node: &LayoutNode) -> serde_json::Value {
match node {
LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }),
LayoutNode::Container(c) => {
let children: Vec<serde_json::Value> = c.children.iter().map(|ch| {
let sizing = match &ch.sizing {
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
};
serde_json::json!({
"sizing": sizing,
"node": node_to_json(&ch.node),
let children: Vec<serde_json::Value> = c
.children
.iter()
.map(|ch| {
let sizing = match &ch.sizing {
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
};
serde_json::json!({
"sizing": sizing,
"node": node_to_json(&ch.node),
})
})
}).collect();
.collect();
serde_json::json!({
"type": "container",
@@ -44,25 +48,44 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
match v["type"].as_str().ok_or_else(|| err("missing node type"))? {
"leaf" => {
let id = v["widget_id"].as_u64().ok_or_else(|| err("missing widget_id"))? as u16;
let id = v["widget_id"]
.as_u64()
.ok_or_else(|| err("missing widget_id"))? as u16;
Ok(LayoutNode::Leaf(id))
}
"container" => {
let direction = match v["direction"].as_str().ok_or_else(|| err("missing direction"))? {
let direction = match v["direction"]
.as_str()
.ok_or_else(|| err("missing direction"))?
{
"row" => Direction::Row,
"column" => Direction::Column,
d => return Err(err(&format!("unknown direction: {d}"))),
};
let gap = v["gap"].as_u64().unwrap_or(0) as u8;
let padding = v["padding"].as_u64().unwrap_or(0) as u8;
let children = v["children"].as_array()
let children = v["children"]
.as_array()
.ok_or_else(|| err("missing children"))?
.iter()
.map(|ch| {
let sizing_v = &ch["sizing"];
let sizing = match sizing_v["type"].as_str().ok_or_else(|| err("missing sizing type"))? {
"fixed" => Sizing::Fixed(sizing_v["value"].as_u64().ok_or_else(|| err("missing fixed value"))? as u16),
"flex" => Sizing::Flex(sizing_v["value"].as_u64().ok_or_else(|| err("missing flex value"))? as u8),
let sizing = match sizing_v["type"]
.as_str()
.ok_or_else(|| err("missing sizing type"))?
{
"fixed" => Sizing::Fixed(
sizing_v["value"]
.as_u64()
.ok_or_else(|| err("missing fixed value"))?
as u16,
),
"flex" => Sizing::Flex(
sizing_v["value"]
.as_u64()
.ok_or_else(|| err("missing flex value"))?
as u8,
),
s => return Err(err(&format!("unknown sizing: {s}"))),
};
let node = node_from_json(&ch["node"])?;
@@ -70,7 +93,12 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
})
.collect::<Result<Vec<_>, _>>()?;
Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children }))
Ok(LayoutNode::Container(ContainerNode {
direction,
gap,
padding,
children,
}))
}
t => Err(err(&format!("unknown node type: {t}"))),
}

View File

@@ -1,4 +1,4 @@
pub mod widget;
pub mod data_source;
pub mod layout;
pub mod preset;
pub mod widget;

View File

@@ -1,8 +1,8 @@
use super::layout::layout_from_json;
use crate::error::SqliteConfigError;
use domain::LayoutPreset;
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
use domain::LayoutPreset;
use crate::error::SqliteConfigError;
use super::layout::layout_from_json;
pub fn preset_from_row(row: &SqliteRow) -> Result<LayoutPreset, SqliteConfigError> {
let id: i64 = row.get("id");

View File

@@ -1,7 +1,7 @@
use crate::error::SqliteConfigError;
use domain::{DisplayHint, KeyMapping, WidgetConfig};
use sqlx::Row;
use sqlx::sqlite::SqliteRow;
use domain::{DisplayHint, KeyMapping, WidgetConfig};
use crate::error::SqliteConfigError;
pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
match hint {
@@ -16,32 +16,44 @@ fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> {
"icon_value" => Ok(DisplayHint::IconValue),
"text_block" => Ok(DisplayHint::TextBlock),
"key_value" => Ok(DisplayHint::KeyValue),
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown display hint: {s}"
))),
}
}
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
let entries: Vec<serde_json::Value> = mappings.iter().map(|m| {
serde_json::json!({
"source_path": m.source_path,
"target_key": m.target_key,
let entries: Vec<serde_json::Value> = mappings
.iter()
.map(|m| {
serde_json::json!({
"source_path": m.source_path,
"target_key": m.target_key,
})
})
}).collect();
.collect();
serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
}
fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> {
let entries: Vec<serde_json::Value> = serde_json::from_str(json)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
let entries: Vec<serde_json::Value> =
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
entries.iter().map(|v| {
Ok(KeyMapping {
source_path: v["source_path"].as_str()
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?.into(),
target_key: v["target_key"].as_str()
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?.into(),
entries
.iter()
.map(|v| {
Ok(KeyMapping {
source_path: v["source_path"]
.as_str()
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?
.into(),
target_key: v["target_key"]
.as_str()
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?
.into(),
})
})
}).collect()
.collect()
}
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {

View File

@@ -1,11 +1,9 @@
use std::time::Duration;
use domain::{
ConfigRepository, DisplayHint, KeyMapping, WidgetConfig,
DataSource, DataSourceConfig, DataSourceType,
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
LayoutPreset,
};
use config_sqlite::SqliteConfigStore;
use domain::{
ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType, Direction,
DisplayHint, KeyMapping, Layout, LayoutChild, LayoutNode, LayoutPreset, Sizing, WidgetConfig,
};
use std::time::Duration;
async fn test_store() -> SqliteConfigStore {
SqliteConfigStore::new("sqlite::memory:").await.unwrap()
@@ -18,8 +16,14 @@ fn weather_widget() -> WidgetConfig {
display_hint: DisplayHint::IconValue,
data_source_id: 10,
mappings: vec![
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() },
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() },
KeyMapping {
source_path: "$.temp".into(),
target_key: "temperature".into(),
},
KeyMapping {
source_path: "$.icon".into(),
target_key: "icon".into(),
},
],
max_data_size: 2048,
}
@@ -46,8 +50,14 @@ fn test_layout() -> Layout {
gap: 4,
padding: 2,
children: vec![
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
LayoutChild { sizing: Sizing::Fixed(80), node: LayoutNode::Leaf(2) },
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Fixed(80),
node: LayoutNode::Leaf(2),
},
],
}),
}
@@ -78,14 +88,17 @@ async fn get_nonexistent_widget_returns_none() {
async fn list_widgets_returns_all() {
let store = test_store().await;
store.save_widget(&weather_widget()).await.unwrap();
store.save_widget(&WidgetConfig {
id: 2,
name: "portfolio".into(),
display_hint: DisplayHint::KeyValue,
data_source_id: 20,
mappings: vec![],
max_data_size: 1024,
}).await.unwrap();
store
.save_widget(&WidgetConfig {
id: 2,
name: "portfolio".into(),
display_hint: DisplayHint::KeyValue,
data_source_id: 20,
mappings: vec![],
max_data_size: 1024,
})
.await
.unwrap();
let widgets = store.list_widgets().await.unwrap();
assert_eq!(widgets.len(), 2);
@@ -172,12 +185,22 @@ async fn save_and_retrieve_preset() {
#[tokio::test]
async fn list_and_delete_presets() {
let store = test_store().await;
store.save_preset(&LayoutPreset {
id: 1, name: "a".into(), layout: test_layout(),
}).await.unwrap();
store.save_preset(&LayoutPreset {
id: 2, name: "b".into(), layout: test_layout(),
}).await.unwrap();
store
.save_preset(&LayoutPreset {
id: 1,
name: "a".into(),
layout: test_layout(),
})
.await
.unwrap();
store
.save_preset(&LayoutPreset {
id: 2,
name: "b".into(),
layout: test_layout(),
})
.await
.unwrap();
assert_eq!(store.list_presets().await.unwrap().len(), 2);