fix: dashboard now in as well as settings

This commit is contained in:
2026-04-09 08:11:31 +02:00
parent c6d26957dc
commit 35a934adbf
9 changed files with 1221 additions and 81 deletions

View File

@@ -8,7 +8,7 @@ use bds_core::db::Database;
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
use bds_core::engine;
use bds_core::i18n::{detect_os_locale, UiLocale};
use bds_core::model::{Media, Post, Project, PublishingPreferences, Script, SshMode, Template};
use bds_core::model::{Media, Post, PostStatus, Project, PublishingPreferences, Script, SshMode, Template};
use crate::i18n::{t, tw};
use crate::platform::menu::{self, MenuAction, MenuRegistry};
@@ -27,8 +27,8 @@ use crate::views::{
template_editor::{TemplateEditorState, TemplateEditorMsg},
script_editor::{ScriptEditorState, ScriptEditorMsg},
tags_view::{self, TagsMsg, TagsSection, TagsViewState},
settings_view::{SettingsViewState, SettingsMsg},
dashboard::DashboardState,
settings_view::{default_category_rows, SettingsCategoryRow, SettingsViewState, SettingsMsg},
dashboard::{DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth},
};
// ───────────────────────────────────────────────────────────
@@ -302,9 +302,50 @@ fn save_editor_settings_state_impl(
.map_err(|e| e.to_string())
}
fn month_abbreviation(month: u32) -> String {
match month {
1 => "Jan",
2 => "Feb",
3 => "Mar",
4 => "Apr",
5 => "May",
6 => "Jun",
7 => "Jul",
8 => "Aug",
9 => "Sep",
10 => "Oct",
11 => "Nov",
12 => "Dec",
_ => "?",
}
.to_string()
}
fn format_timestamp(timestamp_ms: i64) -> String {
let secs = timestamp_ms / 1000;
let (year, month, day) = bds_core::util::timestamp::year_month_day_from_unix_ms(timestamp_ms);
let hour = ((secs % 86_400) / 3_600) as u32;
let minute = ((secs % 3_600) / 60) as u32;
format!("{year}-{month:02}-{day:02} {hour:02}:{minute:02}")
}
fn format_bytes(size: i64) -> String {
let size = size.max(0) as f64;
if size < 1024.0 {
return format!("{} B", size as i64);
}
if size < 1024.0 * 1024.0 {
return format!("{:.1} KB", size / 1024.0);
}
if size < 1024.0 * 1024.0 * 1024.0 {
return format!("{:.1} MB", size / (1024.0 * 1024.0));
}
format!("{:.1} GB", size / (1024.0 * 1024.0 * 1024.0))
}
#[cfg(test)]
mod tests {
use super::{persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, POST_AUTO_SAVE_DELAY_MS};
use super::{persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS};
use crate::views::media_editor::{MediaEditorMsg, MediaEditorState};
use crate::views::post_editor::PostEditorState;
use crate::views::script_editor::ScriptEditorState;
@@ -352,8 +393,18 @@ mod tests {
insert_project(db.conn(), &project).unwrap();
let tempdir = TempDir::new().unwrap();
std::fs::create_dir_all(tempdir.path().join("meta")).unwrap();
std::fs::write(tempdir.path().join("meta/project.json"), "{}\n").unwrap();
std::fs::write(
tempdir.path().join("meta/project.json"),
r#"{"name":"Test Project","maxPostsPerPage":50,"blogLanguages":["en"],"semanticSimilarityEnabled":false}"#,
)
.unwrap();
std::fs::write(tempdir.path().join("meta/publishing.json"), "{}\n").unwrap();
std::fs::write(
tempdir.path().join("meta/categories.json"),
r#"["article","aside","page","picture"]"#,
)
.unwrap();
std::fs::write(tempdir.path().join("meta/category-meta.json"), "{}\n").unwrap();
(db, project, tempdir)
}
@@ -696,6 +747,117 @@ mod tests {
assert!(linked.is_empty());
assert!(app.media_editors.get(&imported.id).unwrap().linked_posts.is_empty());
}
#[test]
fn refresh_counts_populates_dashboard_state() {
let (db, project, tmp) = setup();
let first = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"First",
Some("Body"),
vec!["rust".to_string()],
vec!["article".to_string()],
None,
Some("en"),
None,
)
.unwrap();
let second = post::create_post(
db.conn(),
tmp.path(),
&project.id,
"Second",
Some("Body"),
vec!["lua".to_string()],
vec!["aside".to_string()],
None,
Some("en"),
None,
)
.unwrap();
let mut second_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &second.id).unwrap();
second_post.status = PostStatus::Published;
bds_core::db::queries::post::update_post(db.conn(), &second_post).unwrap();
bds_core::engine::tag::discover_tags(db.conn(), tmp.path(), &project.id).unwrap();
let source = tmp.path().join("tiny.png");
std::fs::write(&source, tiny_png_bytes()).unwrap();
media::import_media(
db.conn(),
tmp.path(),
&project.id,
&source,
"tiny.png",
Some("Tiny"),
Some("Alt"),
None,
None,
Some("en"),
vec!["cover".to_string()],
)
.unwrap();
let mut app = make_app(db, project, &tmp);
let _ = app.refresh_counts();
let dash = app.dashboard_state.expect("dashboard state should be set");
assert_eq!(dash.stats.total_posts, 2);
assert_eq!(dash.stats.published_count, 1);
assert_eq!(dash.stats.media_count, 1);
assert_eq!(dash.stats.tag_count, 2);
assert_eq!(dash.recent_posts.len(), 2);
assert!(!dash.category_cloud.is_empty());
assert!(!dash.timeline.is_empty());
assert_eq!(dash.recent_posts[0].title, "Second");
assert_eq!(first.title, "First");
}
#[test]
fn save_project_persists_languages_and_blogmark_category() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
let mut state = app.hydrate_settings_state();
state.project_name = "Localized Project".to_string();
state.main_language = "de".to_string();
state.blog_languages = vec!["de".to_string(), "en".to_string()];
state.blogmark_category = "article".to_string();
state.semantic_similarity_enabled = true;
app.settings_state = Some(state);
let _ = app.handle_settings_msg(SettingsMsg::SaveProject);
let meta = bds_core::engine::meta::read_project_json(tmp.path()).unwrap();
assert_eq!(meta.name, "Localized Project");
assert_eq!(meta.main_language.as_deref(), Some("de"));
assert_eq!(meta.blog_languages, vec!["de".to_string(), "en".to_string()]);
assert_eq!(meta.blogmark_category.as_deref(), Some("article"));
assert!(meta.semantic_similarity_enabled);
}
#[test]
fn add_category_and_reset_defaults_updates_metadata_files() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
app.settings_state = Some(app.hydrate_settings_state());
let _ = app.handle_settings_msg(SettingsMsg::AddCategoryNameChanged("news".to_string()));
let _ = app.handle_settings_msg(SettingsMsg::AddCategory);
let categories = bds_core::engine::meta::read_categories_json(tmp.path()).unwrap();
assert!(categories.iter().any(|category| category == "news"));
let _ = app.handle_settings_msg(SettingsMsg::ResetCategoriesToDefaults);
let categories = bds_core::engine::meta::read_categories_json(tmp.path()).unwrap();
assert_eq!(categories, vec![
"article".to_string(),
"aside".to_string(),
"page".to_string(),
"picture".to_string(),
]);
}
}
// ───────────────────────────────────────────────────────────
@@ -2539,6 +2701,8 @@ impl BdsApp {
}
}
}
self.dashboard_state = Some(self.hydrate_dashboard_state());
}
// Refresh sidebar data with current filters (async — off main thread)
@@ -2548,6 +2712,119 @@ impl BdsApp {
Task::batch([t1, t2])
}
fn hydrate_dashboard_state(&self) -> DashboardState {
let Some(project) = &self.active_project else {
return DashboardState::new(String::new());
};
let Some(db) = &self.db else {
return DashboardState::new(project.name.clone());
};
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
.unwrap_or_default();
let media = bds_core::db::queries::media::list_media_by_project(db.conn(), &project.id)
.unwrap_or_default();
let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
.unwrap_or_default();
let mut draft_count = 0usize;
let mut published_count = 0usize;
let mut archived_count = 0usize;
let mut monthly_counts: std::collections::BTreeMap<(i32, u32), usize> = std::collections::BTreeMap::new();
let mut category_counts: HashMap<String, usize> = HashMap::new();
let mut tag_counts: HashMap<String, usize> = HashMap::new();
for post in &posts {
match post.status {
PostStatus::Draft => draft_count += 1,
PostStatus::Published => published_count += 1,
PostStatus::Archived => archived_count += 1,
}
let (year, month, _) = bds_core::util::timestamp::year_month_day_from_unix_ms(post.updated_at);
let year = year.parse::<i32>().unwrap_or(0);
let month = month.parse::<u32>().unwrap_or(0);
*monthly_counts.entry((year, month)).or_insert(0) += 1;
for category in &post.categories {
*category_counts.entry(category.clone()).or_insert(0) += 1;
}
for tag in &post.tags {
*tag_counts.entry(tag.to_lowercase()).or_insert(0) += 1;
}
}
let image_count = media.iter().filter(|item| item.mime_type.starts_with("image/")).count();
let total_media_size = media.iter().map(|item| item.size).sum::<i64>();
let mut timeline = monthly_counts
.into_iter()
.rev()
.take(12)
.map(|((year, month), count)| DashboardTimelineMonth {
label: month_abbreviation(month),
year,
count,
})
.collect::<Vec<_>>();
timeline.reverse();
let mut tag_cloud = tags
.into_iter()
.map(|tag| DashboardTag {
count: tag_counts.get(&tag.name.to_lowercase()).copied().unwrap_or(0),
name: tag.name,
color: tag.color,
})
.collect::<Vec<_>>();
tag_cloud.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase()));
let tag_overflow_count = tag_cloud.len().saturating_sub(40);
tag_cloud.truncate(40);
let mut category_cloud = category_counts
.into_iter()
.map(|(name, count)| DashboardCategory { name, count })
.collect::<Vec<_>>();
category_cloud.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase()));
let mut sorted_posts = posts;
sorted_posts.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
let mut recent_posts = sorted_posts
.into_iter()
.map(|post| DashboardRecentPost {
post_id: post.id,
title: if post.title.trim().is_empty() { "Untitled".to_string() } else { post.title },
status: match post.status {
PostStatus::Published => "published".to_string(),
_ => "draft".to_string(),
},
date: format_timestamp(post.updated_at),
})
.collect::<Vec<_>>();
recent_posts.truncate(5);
DashboardState {
title: t(self.ui_locale, "dashboard.overview"),
subtitle: project.name.clone(),
stats: DashboardStats {
total_posts: draft_count + published_count + archived_count,
published_count,
draft_count,
archived_count,
media_count: media.len(),
image_count,
total_media_size: format_bytes(total_media_size),
tag_count: tag_counts.len(),
category_count: category_cloud.len(),
},
timeline,
tag_cloud,
tag_overflow_count,
category_cloud,
recent_posts,
}
}
/// Number of items to load per sidebar page.
/// Matches the TypeScript app's limit of 500 for initial load.
const SIDEBAR_PAGE_SIZE: i64 = 500;
@@ -3710,9 +3987,43 @@ impl BdsApp {
if let Some(data_dir) = &self.data_dir {
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
state.public_url = meta.public_url.unwrap_or_default();
state.main_language = meta.main_language.unwrap_or_else(|| self.content_language.clone());
state.default_author = meta.default_author.unwrap_or_default();
state.max_posts_per_page = meta.max_posts_per_page.to_string();
state.blogmark_category = meta.blogmark_category.unwrap_or_default();
state.blog_languages = if meta.blog_languages.is_empty() {
vec![state.main_language.clone()]
} else {
meta.blog_languages
};
if !state.blog_languages.iter().any(|language| language == &state.main_language) {
state.blog_languages.push(state.main_language.clone());
}
state.semantic_similarity_enabled = meta.semantic_similarity_enabled;
}
let categories = engine::meta::read_categories_json(data_dir).unwrap_or_else(|_| {
default_category_rows().into_iter().map(|row| row.name).collect()
});
let category_meta = engine::meta::read_category_meta_json(data_dir).unwrap_or_default();
state.categories = categories
.into_iter()
.map(|name| {
let meta = category_meta.get(&name);
SettingsCategoryRow {
title: name.clone(),
render_in_lists: meta.map(|value| value.render_in_lists).unwrap_or(true),
show_title: meta.map(|value| value.show_title).unwrap_or(true),
post_template_slug: meta
.and_then(|value| value.post_template_slug.clone())
.unwrap_or_default(),
list_template_slug: meta
.and_then(|value| value.list_template_slug.clone())
.unwrap_or_default(),
is_protected: ["article", "aside", "page", "picture"].contains(&name.as_str()),
name,
}
})
.collect();
if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) {
state.ssh_host = pub_prefs.ssh_host.unwrap_or_default();
state.ssh_username = pub_prefs.ssh_user.unwrap_or_default();
@@ -3724,6 +4035,13 @@ impl BdsApp {
}
}
if let Some(db) = &self.db {
if let Some(project) = &self.active_project {
state.template_options = bds_core::db::queries::template::list_templates_by_project(db.conn(), &project.id)
.unwrap_or_default()
.into_iter()
.map(|template| template.slug)
.collect();
}
if let Ok(setting) = bds_core::db::queries::setting::get_setting_by_key(db.conn(), "editor.default_mode") {
state.default_mode = setting.value;
}
@@ -3948,8 +4266,28 @@ impl BdsApp {
}
}
SettingsMsg::PublicUrlChanged(s) => { state.public_url = s; }
SettingsMsg::MainLanguageChanged(s) => {
state.main_language = s.clone();
if !state.blog_languages.iter().any(|language| language == &s) {
state.blog_languages.push(s);
}
}
SettingsMsg::ToggleBlogLanguage(language) => {
if language == state.main_language {
if !state.blog_languages.iter().any(|item| item == &language) {
state.blog_languages.push(language);
}
} else if let Some(index) = state.blog_languages.iter().position(|item| item == &language) {
state.blog_languages.remove(index);
} else {
state.blog_languages.push(language);
}
state.blog_languages.sort();
state.blog_languages.dedup();
}
SettingsMsg::DefaultAuthorChanged(s) => { state.default_author = s; }
SettingsMsg::MaxPostsPerPageChanged(s) => { state.max_posts_per_page = s; }
SettingsMsg::BlogmarkCategoryChanged(s) => { state.blogmark_category = s; }
SettingsMsg::SaveProject => {
if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, self.active_project.as_mut()) {
let max_posts = match state.max_posts_per_page.trim().parse::<i32>() {
@@ -3977,8 +4315,12 @@ impl BdsApp {
if value.trim().is_empty() { None } else { Some(value) }
};
meta.public_url = if state.public_url.trim().is_empty() { None } else { Some(state.public_url.clone()) };
meta.main_language = if state.main_language.trim().is_empty() { None } else { Some(state.main_language.clone()) };
meta.default_author = if state.default_author.trim().is_empty() { None } else { Some(state.default_author.clone()) };
meta.max_posts_per_page = max_posts;
meta.blogmark_category = if state.blogmark_category.trim().is_empty() { None } else { Some(state.blogmark_category.clone()) };
meta.blog_languages = state.blog_languages.clone();
meta.semantic_similarity_enabled = state.semantic_similarity_enabled;
if let Err(e) = meta.validate() {
self.notify(ToastLevel::Error, &format!("Save failed: {e}"));
return Task::none();
@@ -3994,6 +4336,9 @@ impl BdsApp {
if let Some(listing) = self.projects.iter_mut().find(|p| p.id == project.id) {
*listing = project.clone();
}
self.content_language = state.main_language.clone();
self.blog_languages = state.blog_languages.clone();
self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
(Err(e), _) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
@@ -4013,6 +4358,124 @@ impl BdsApp {
}
}
}
SettingsMsg::AddCategoryNameChanged(value) => { state.new_category_name = value; }
SettingsMsg::AddCategory => {
let category_name = state.new_category_name.trim();
if category_name.is_empty() {
self.notify(ToastLevel::Error, "Category name required");
return Task::none();
}
if state.categories.iter().any(|row| row.name.eq_ignore_ascii_case(category_name)) {
self.notify(ToastLevel::Error, "Category already exists");
return Task::none();
}
if let Some(data_dir) = &self.data_dir {
match engine::meta::add_category(data_dir, category_name) {
Ok(()) => {
self.settings_state = Some(self.hydrate_settings_state());
if let Some(state) = self.settings_state.as_mut() {
state.new_category_name.clear();
}
self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
}
}
}
SettingsMsg::CategoryTitleChanged(name, value) => {
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
row.title = value;
}
}
SettingsMsg::CategoryRenderInListsChanged(name, value) => {
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
row.render_in_lists = value;
}
}
SettingsMsg::CategoryShowTitleChanged(name, value) => {
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
row.show_title = value;
}
}
SettingsMsg::CategoryPostTemplateChanged(name, value) => {
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
row.post_template_slug = value;
}
}
SettingsMsg::CategoryListTemplateChanged(name, value) => {
if let Some(row) = state.categories.iter_mut().find(|row| row.name == name) {
row.list_template_slug = value;
}
}
SettingsMsg::SaveCategory(name) => {
if let Some(data_dir) = &self.data_dir {
if let Some(row) = state.categories.iter().find(|row| row.name == name) {
let mut category_meta = engine::meta::read_category_meta_json(data_dir).unwrap_or_default();
category_meta.insert(
row.name.clone(),
bds_core::model::metadata::CategorySettings {
render_in_lists: row.render_in_lists,
show_title: row.show_title,
post_template_slug: (!row.post_template_slug.is_empty()).then(|| row.post_template_slug.clone()),
list_template_slug: (!row.list_template_slug.is_empty()).then(|| row.list_template_slug.clone()),
},
);
match engine::meta::write_category_meta_json(data_dir, &category_meta) {
Ok(()) => {
self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
}
}
}
}
SettingsMsg::RemoveCategory(name) => {
if let Some(data_dir) = &self.data_dir {
match engine::meta::remove_category(data_dir, &name) {
Ok(()) => {
self.settings_state = Some(self.hydrate_settings_state());
self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
Err(e) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
}
}
}
SettingsMsg::ResetCategoriesToDefaults => {
if let Some(data_dir) = &self.data_dir {
let default_names = default_category_rows()
.into_iter()
.map(|row| row.name)
.collect::<Vec<_>>();
let default_meta = default_category_rows()
.into_iter()
.map(|row| {
(
row.name,
bds_core::model::metadata::CategorySettings {
render_in_lists: row.render_in_lists,
show_title: row.show_title,
post_template_slug: None,
list_template_slug: None,
},
)
})
.collect::<HashMap<_, _>>();
match (
engine::meta::write_categories_json(data_dir, &default_names),
engine::meta::write_category_meta_json(data_dir, &default_meta),
) {
(Ok(()), Ok(())) => {
self.settings_state = Some(self.hydrate_settings_state());
self.dashboard_state = Some(self.hydrate_dashboard_state());
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
(Err(e), _) | (_, Err(e)) => self.notify(ToastLevel::Error, &format!("Save failed: {e}")),
}
}
}
SettingsMsg::SshModeChanged(s) => { state.ssh_mode = s; }
SettingsMsg::SshHostChanged(s) => { state.ssh_host = s; }
SettingsMsg::SshUsernameChanged(s) => { state.ssh_username = s; }
@@ -4059,6 +4522,9 @@ impl BdsApp {
SettingsMsg::ResetSystemPrompt => {
state.system_prompt = iced::widget::text_editor::Content::new();
}
SettingsMsg::SemanticSimilarityChanged(value) => {
state.semantic_similarity_enabled = value;
}
SettingsMsg::RebuildPosts => { return Task::done(Message::RebuildDatabase); }
SettingsMsg::RebuildMedia => { return Task::done(Message::RebuildDatabase); }
SettingsMsg::RebuildScripts => { return Task::done(Message::RebuildDatabase); }

View File

@@ -1,4 +1,4 @@
use iced::widget::{column, container, row, text, Space};
use iced::widget::{button, column, container, row, scrollable, text, Space};
use iced::{Alignment, Background, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale;
@@ -6,29 +6,83 @@ use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
use crate::state::tabs::{Tab, TabType};
#[derive(Debug, Clone)]
pub struct DashboardStats {
pub total_posts: usize,
pub published_count: usize,
pub draft_count: usize,
pub archived_count: usize,
pub media_count: usize,
pub image_count: usize,
pub total_media_size: String,
pub tag_count: usize,
pub category_count: usize,
}
#[derive(Debug, Clone)]
pub struct DashboardTimelineMonth {
pub label: String,
pub year: i32,
pub count: usize,
}
#[derive(Debug, Clone)]
pub struct DashboardTag {
pub name: String,
pub count: usize,
pub color: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DashboardCategory {
pub name: String,
pub count: usize,
}
#[derive(Debug, Clone)]
pub struct DashboardRecentPost {
pub post_id: String,
pub title: String,
pub status: String,
pub date: String,
}
/// Dashboard overview state.
#[derive(Debug, Clone)]
pub struct DashboardState {
pub post_count: usize,
pub media_count: usize,
pub template_count: usize,
pub script_count: usize,
pub draft_count: usize,
pub published_count: usize,
pub project_name: String,
pub title: String,
pub subtitle: String,
pub stats: DashboardStats,
pub timeline: Vec<DashboardTimelineMonth>,
pub tag_cloud: Vec<DashboardTag>,
pub tag_overflow_count: usize,
pub category_cloud: Vec<DashboardCategory>,
pub recent_posts: Vec<DashboardRecentPost>,
}
impl DashboardState {
pub fn new(project_name: String) -> Self {
Self {
post_count: 0,
media_count: 0,
template_count: 0,
script_count: 0,
draft_count: 0,
published_count: 0,
project_name,
title: t(UiLocale::En, "dashboard.overview"),
subtitle: project_name,
stats: DashboardStats {
total_posts: 0,
published_count: 0,
draft_count: 0,
archived_count: 0,
media_count: 0,
image_count: 0,
total_media_size: "0 B".to_string(),
tag_count: 0,
category_count: 0,
},
timeline: Vec::new(),
tag_cloud: Vec::new(),
tag_overflow_count: 0,
category_cloud: Vec::new(),
recent_posts: Vec::new(),
}
}
}
@@ -38,55 +92,89 @@ pub fn view<'a>(
state: &'a DashboardState,
locale: UiLocale,
) -> Element<'a, Message> {
let header = text(t(locale, "dashboard.overview"))
.size(20)
let header = text(state.title.clone())
.size(24)
.color(Color::WHITE);
let project_label = text(state.project_name.clone())
let project_label = text(state.subtitle.clone())
.size(14)
.color(Color::from_rgb(0.6, 0.6, 0.7));
let counts_row = row![
stat_card(&t(locale, "dashboard.posts"), state.post_count),
stat_card(&t(locale, "dashboard.media"), state.media_count),
stat_card(&t(locale, "dashboard.templates"), state.template_count),
stat_card(&t(locale, "dashboard.scripts"), state.script_count),
stat_card(
&t(locale, "dashboard.posts"),
state.stats.total_posts.to_string(),
{
let mut details = vec![
format!("{} {}", state.stats.published_count, t(locale, "dashboard.published")),
format!("{} {}", state.stats.draft_count, t(locale, "dashboard.drafts")),
];
if state.stats.archived_count > 0 {
details.push(format!("{} {}", state.stats.archived_count, t(locale, "dashboard.archived")));
}
details
},
),
stat_card(
&t(locale, "dashboard.media"),
state.stats.media_count.to_string(),
vec![
format!("{} {}", state.stats.image_count, t(locale, "dashboard.images")),
state.stats.total_media_size.clone(),
],
),
stat_card(
&t(locale, "dashboard.tags"),
state.stats.tag_count.to_string(),
vec![format!("{} {}", state.stats.category_count, t(locale, "dashboard.categories"))],
),
]
.spacing(16);
let status_row = row![
stat_card(&t(locale, "dashboard.drafts"), state.draft_count),
stat_card(&t(locale, "dashboard.published"), state.published_count),
]
.spacing(16);
let timeline = timeline_chart(&state.timeline, locale);
let tags = tag_cloud(&state.tag_cloud, state.tag_overflow_count, locale);
let categories = category_cloud(&state.category_cloud, locale);
let recent = recent_posts(&state.recent_posts, locale);
container(
scrollable(container(
column![
header,
project_label,
Space::with_height(16),
inputs::section_header(&t(locale, "dashboard.entityCounts")),
inputs::section_header(&t(locale, "dashboard.statCards")),
counts_row,
Space::with_height(12),
inputs::section_header(&t(locale, "dashboard.statusCounts")),
status_row,
Space::with_height(20),
inputs::section_header(&t(locale, "dashboard.timeline")),
timeline,
Space::with_height(20),
row![tags, categories].spacing(16),
Space::with_height(20),
inputs::section_header(&t(locale, "dashboard.recentPosts")),
recent,
]
.spacing(8)
.padding(24)
.width(Length::Fill),
)
))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn stat_card<'a>(label: &str, count: usize) -> Element<'a, Message> {
fn stat_card<'a>(label: &str, value: String, details: Vec<String>) -> Element<'a, Message> {
let card_bg = Color::from_rgb(0.15, 0.16, 0.20);
container(
column![
text(count.to_string()).size(28).color(Color::WHITE),
text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
]
column(
std::iter::once(text(value).size(28).color(Color::WHITE).into())
.chain(std::iter::once(text(label.to_string()).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)).into()))
.chain(details.into_iter().map(|detail| {
text(detail)
.size(12)
.color(Color::from_rgb(0.72, 0.74, 0.80))
.into()
}))
.collect::<Vec<Element<'a, Message>>>(),
)
.spacing(4)
.align_x(Alignment::Center),
)
@@ -102,3 +190,187 @@ fn stat_card<'a>(label: &str, count: usize) -> Element<'a, Message> {
})
.into()
}
fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], locale: UiLocale) -> Element<'a, Message> {
if months.is_empty() {
return text(t(locale, "dashboard.noTimelineData"))
.size(13)
.color(Color::from_rgb(0.6, 0.62, 0.68))
.into();
}
let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1);
row(
months
.iter()
.map(|month| {
let height = 24.0 + (month.count as f32 / max_count as f32) * 96.0;
container(
column![
text(month.count.to_string()).size(12).color(Color::WHITE),
container(Space::with_height(height))
.width(Length::Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.25, 0.48, 0.80))),
border: iced::Border { radius: 6.0.into(), ..iced::Border::default() },
..container::Style::default()
}),
text(format!("{} {}", month.label, month.year)).size(11).color(Color::from_rgb(0.7, 0.72, 0.78)),
]
.spacing(6)
.align_x(Alignment::Center),
)
.width(Length::FillPortion(1))
.into()
})
.collect::<Vec<_>>(),
)
.spacing(10)
.align_y(Alignment::End)
.into()
}
fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLocale) -> Element<'a, Message> {
let items = tags.iter().fold(column![inputs::section_header(&t(locale, "dashboard.tagCloud"))].spacing(8), |column, tag| {
let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28));
column.push(
container(
row![
text(tag.name.clone()).size(scale_font(tag.count)).color(contrast_color(bg)),
text(tag.count.to_string()).size(12).color(contrast_color(bg)),
]
.spacing(8)
.align_y(Alignment::Center),
)
.padding([6, 10])
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)),
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
..container::Style::default()
}),
)
});
let with_overflow = if overflow_count > 0 {
items.push(text(format!("{} {}", t(locale, "dashboard.and"), overflow_count)).size(12).color(Color::from_rgb(0.6, 0.62, 0.68)))
} else {
items
};
container(with_overflow).width(Length::FillPortion(1)).into()
}
fn category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> {
let items = categories.iter().fold(column![inputs::section_header(&t(locale, "dashboard.categoryCloud"))].spacing(8), |column, category| {
column.push(
container(
row![
text(category.name.clone()).size(13).color(Color::WHITE),
text(category.count.to_string()).size(12).color(Color::from_rgb(0.72, 0.74, 0.80)),
]
.spacing(8),
)
.padding([6, 10])
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
..container::Style::default()
}),
)
});
container(items).width(Length::FillPortion(1)).into()
}
fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Element<'a, Message> {
if posts.is_empty() {
return text(t(locale, "dashboard.noRecentPosts"))
.size(13)
.color(Color::from_rgb(0.6, 0.62, 0.68))
.into();
}
column(
posts
.iter()
.map(|post| {
container(
row![
button(
column![
text(post.title.clone()).size(14).color(Color::WHITE),
text(post.date.clone()).size(12).color(Color::from_rgb(0.6, 0.62, 0.68)),
]
.spacing(4),
)
.on_press(Message::OpenTab(Tab {
id: post.post_id.clone(),
tab_type: TabType::Post,
title: post.title.clone(),
is_transient: true,
is_dirty: false,
}))
.width(Length::Fill),
status_badge(&post.status),
button(text(t(locale, "dashboard.pin")).size(12))
.on_press(Message::OpenTab(Tab {
id: post.post_id.clone(),
tab_type: TabType::Post,
title: post.title.clone(),
is_transient: false,
is_dirty: false,
}))
.padding([6, 10]),
]
.spacing(12)
.align_y(Alignment::Center),
)
.padding(12)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::from_rgb(0.13, 0.14, 0.18))),
border: iced::Border { radius: 8.0.into(), ..iced::Border::default() },
..container::Style::default()
})
.into()
})
.collect::<Vec<_>>(),
)
.spacing(8)
.into()
}
fn status_badge<'a>(status: &str) -> Element<'a, Message> {
let bg = if status.eq_ignore_ascii_case("published") {
Color::from_rgb(0.16, 0.42, 0.24)
} else {
Color::from_rgb(0.45, 0.33, 0.14)
};
container(text(status.to_string()).size(11).color(Color::WHITE))
.padding([4, 8])
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg)),
border: iced::Border { radius: 999.0.into(), ..iced::Border::default() },
..container::Style::default()
})
.into()
}
fn scale_font(count: usize) -> u16 {
let clamped = count.min(20) as f32;
(11.0 + (clamped / 20.0) * 11.0).round() as u16
}
fn parse_color(value: Option<&str>) -> Option<Color> {
let value = value?.trim_start_matches('#');
if value.len() != 6 {
return None;
}
let red = u8::from_str_radix(&value[0..2], 16).ok()?;
let green = u8::from_str_radix(&value[2..4], 16).ok()?;
let blue = u8::from_str_radix(&value[4..6], 16).ok()?;
Some(Color::from_rgb8(red, green, blue))
}
fn contrast_color(background: Color) -> Color {
let luma = 0.299 * background.r + 0.587 * background.g + 0.114 * background.b;
if luma > 0.55 { Color::BLACK } else { Color::WHITE }
}

View File

@@ -6,7 +6,7 @@ use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
use crate::i18n::{t, tw};
/// Collapsible section identifiers per editor_settings.allium.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -21,6 +21,40 @@ pub enum SettingsSection {
MCP,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SettingsCategoryRow {
pub name: String,
pub title: String,
pub render_in_lists: bool,
pub show_title: bool,
pub post_template_slug: String,
pub list_template_slug: String,
pub is_protected: bool,
}
impl std::fmt::Display for SettingsCategoryRow {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.name)
}
}
const PROTECTED_CATEGORIES: [&str; 4] = ["article", "aside", "page", "picture"];
pub fn default_category_rows() -> Vec<SettingsCategoryRow> {
PROTECTED_CATEGORIES
.iter()
.map(|name| SettingsCategoryRow {
name: (*name).to_string(),
title: (*name).to_string(),
render_in_lists: true,
show_title: true,
post_template_slug: String::new(),
list_template_slug: String::new(),
is_protected: true,
})
.collect()
}
impl SettingsSection {
pub fn all() -> &'static [SettingsSection] {
&[
@@ -59,13 +93,21 @@ pub struct SettingsViewState {
pub project_description: text_editor::Content,
pub data_path: String,
pub public_url: String,
pub main_language: String,
pub blog_languages: Vec<String>,
pub available_languages: Vec<String>,
pub default_author: String,
pub max_posts_per_page: String,
pub blogmark_category: String,
// Editor
pub default_mode: String,
pub diff_view_style: String,
pub wrap_long_lines: bool,
pub hide_unchanged_regions: bool,
// Content
pub categories: Vec<SettingsCategoryRow>,
pub new_category_name: String,
pub template_options: Vec<String>,
// Publishing
pub ssh_mode: String,
pub ssh_host: String,
@@ -74,6 +116,8 @@ pub struct SettingsViewState {
// AI
pub offline_mode: bool,
pub system_prompt: text_editor::Content,
// Technology
pub semantic_similarity_enabled: bool,
}
impl std::fmt::Debug for SettingsViewState {
@@ -94,18 +138,26 @@ impl Clone for SettingsViewState {
project_description: text_editor::Content::with_text(&self.project_description.text()),
data_path: self.data_path.clone(),
public_url: self.public_url.clone(),
main_language: self.main_language.clone(),
blog_languages: self.blog_languages.clone(),
available_languages: self.available_languages.clone(),
default_author: self.default_author.clone(),
max_posts_per_page: self.max_posts_per_page.clone(),
blogmark_category: self.blogmark_category.clone(),
default_mode: self.default_mode.clone(),
diff_view_style: self.diff_view_style.clone(),
wrap_long_lines: self.wrap_long_lines,
hide_unchanged_regions: self.hide_unchanged_regions,
categories: self.categories.clone(),
new_category_name: self.new_category_name.clone(),
template_options: self.template_options.clone(),
ssh_mode: self.ssh_mode.clone(),
ssh_host: self.ssh_host.clone(),
ssh_username: self.ssh_username.clone(),
ssh_remote_path: self.ssh_remote_path.clone(),
offline_mode: self.offline_mode,
system_prompt: text_editor::Content::with_text(&self.system_prompt.text()),
semantic_similarity_enabled: self.semantic_similarity_enabled,
}
}
}
@@ -120,18 +172,26 @@ impl Default for SettingsViewState {
project_description: text_editor::Content::new(),
data_path: String::new(),
public_url: String::new(),
main_language: "en".to_string(),
blog_languages: vec!["en".to_string()],
available_languages: vec!["en".to_string(), "de".to_string(), "fr".to_string(), "it".to_string(), "es".to_string()],
default_author: String::new(),
max_posts_per_page: "50".to_string(),
blogmark_category: String::new(),
default_mode: "markdown".to_string(),
diff_view_style: "inline".to_string(),
wrap_long_lines: true,
hide_unchanged_regions: false,
categories: default_category_rows(),
new_category_name: String::new(),
template_options: Vec::new(),
ssh_mode: "rsync".to_string(),
ssh_host: String::new(),
ssh_username: String::new(),
ssh_remote_path: String::new(),
offline_mode: false,
system_prompt: text_editor::Content::new(),
semantic_similarity_enabled: false,
}
}
}
@@ -171,8 +231,11 @@ pub enum SettingsMsg {
BrowseDataPath,
ResetDataPath,
PublicUrlChanged(String),
MainLanguageChanged(String),
ToggleBlogLanguage(String),
DefaultAuthorChanged(String),
MaxPostsPerPageChanged(String),
BlogmarkCategoryChanged(String),
SaveProject,
// Editor
DefaultModeChanged(String),
@@ -180,6 +243,17 @@ pub enum SettingsMsg {
WrapLongLinesChanged(bool),
HideUnchangedRegionsChanged(bool),
SaveEditor,
// Content
AddCategoryNameChanged(String),
AddCategory,
CategoryTitleChanged(String, String),
CategoryRenderInListsChanged(String, bool),
CategoryShowTitleChanged(String, bool),
CategoryPostTemplateChanged(String, String),
CategoryListTemplateChanged(String, String),
SaveCategory(String),
RemoveCategory(String),
ResetCategoriesToDefaults,
// Publishing
SshModeChanged(String),
SshHostChanged(String),
@@ -192,6 +266,8 @@ pub enum SettingsMsg {
SystemPromptAction(text_editor::Action),
SaveSystemPrompt,
ResetSystemPrompt,
// Technology
SemanticSimilarityChanged(bool),
// Data maintenance
RebuildPosts,
RebuildMedia,
@@ -215,7 +291,7 @@ pub fn view<'a>(
let query_lower = state.search_query.to_lowercase();
let mut sections = column![].spacing(12).width(Length::Fill);
let mut section_items = Vec::new();
for section in state.ordered_sections() {
let label = t(locale, section.i18n_key());
if !query_lower.is_empty() && !label.to_lowercase().contains(&query_lower) {
@@ -223,9 +299,24 @@ pub fn view<'a>(
}
let collapsed = state.collapsed.contains(&section);
let section_el = render_section(state, &section, &label, collapsed, locale);
sections = sections.push(section_el);
section_items.push(section_el);
}
let sections = if section_items.is_empty() {
column![
text(t(locale, "common.noResults"))
.size(14)
.color(Color::from_rgb(0.7, 0.72, 0.78)),
button(text(t(locale, "common.clear")).size(13))
.on_press(Message::Settings(SettingsMsg::SearchChanged(String::new())))
.padding([6, 12]),
]
.spacing(8)
.width(Length::Fill)
} else {
column(section_items).spacing(12).width(Length::Fill)
};
let body = scrollable(
column![search, sections]
.spacing(16)
@@ -301,9 +392,9 @@ fn render_section<'a>(
let content: Element<'a, Message> = match section {
SettingsSection::Project => section_project(state, locale),
SettingsSection::Editor => section_editor(state, locale),
SettingsSection::Content => section_content(locale),
SettingsSection::Content => section_content(state, locale),
SettingsSection::AI => section_ai(state, locale),
SettingsSection::Technology => section_technology(locale),
SettingsSection::Technology => section_technology(state, locale),
SettingsSection::Publishing => section_publishing(state, locale),
SettingsSection::Data => section_data(locale),
SettingsSection::MCP => section_mcp(locale),
@@ -352,6 +443,31 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
&state.public_url,
|s| Message::Settings(SettingsMsg::PublicUrlChanged(s)),
);
let language_options = state.available_languages.clone();
let main_language = inputs::labeled_select(
&t(locale, "settings.mainLanguage"),
&language_options,
Some(&state.main_language),
|s| Message::Settings(SettingsMsg::MainLanguageChanged(s)),
);
let blog_languages = column(
state
.available_languages
.iter()
.map(|language| {
let label = if *language == state.main_language {
format!("{} ({})", language, t(locale, "settings.mainLanguageRequired"))
} else {
language.clone()
};
inputs::labeled_checkbox(&label, state.blog_languages.iter().any(|item| item == language), {
let language = language.clone();
move |_| Message::Settings(SettingsMsg::ToggleBlogLanguage(language.clone()))
})
})
.collect::<Vec<_>>(),
)
.spacing(6);
let author = inputs::labeled_input(
&t(locale, "settings.defaultAuthor"),
"",
@@ -364,18 +480,60 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen
&state.max_posts_per_page,
|s| Message::Settings(SettingsMsg::MaxPostsPerPageChanged(s)),
);
let blogmark_category = inputs::labeled_select(
&t(locale, "settings.blogmarkCategory"),
&state.categories,
state.categories.iter().find(|row| row.name == state.blogmark_category),
|row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)),
);
let save = button(text(t(locale, "common.save")).size(13))
.on_press(Message::Settings(SettingsMsg::SaveProject))
.style(inputs::primary_button)
.padding([6, 16]);
column![name, desc, data_path, url, author, max_posts, save]
column![
name,
desc,
data_path,
url,
main_language,
column![
text(t(locale, "settings.blogLanguages"))
.size(12)
.color(inputs::LABEL_COLOR)
.shaping(Shaping::Advanced),
blog_languages,
]
.spacing(4),
author,
max_posts,
blogmark_category,
save,
]
.spacing(8)
.padding([0, 16])
.into()
}
fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let mode_options = vec![
"wysiwyg".to_string(),
"markdown".to_string(),
"preview".to_string(),
];
let diff_options = vec!["inline".to_string(), "side-by-side".to_string()];
let mode = inputs::labeled_select(
&t(locale, "settings.defaultMode"),
&mode_options,
Some(&state.default_mode),
|s| Message::Settings(SettingsMsg::DefaultModeChanged(s)),
);
let diff = inputs::labeled_select(
&t(locale, "settings.diffViewStyle"),
&diff_options,
Some(&state.diff_view_style),
|s| Message::Settings(SettingsMsg::DiffViewStyleChanged(s)),
);
let wrap = inputs::labeled_checkbox(
&t(locale, "settings.wrapLongLines"),
state.wrap_long_lines,
@@ -391,17 +549,112 @@ fn section_editor<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element
.style(inputs::primary_button)
.padding([6, 16]);
column![wrap, hide, save]
column![mode, diff, wrap, hide, save]
.spacing(8)
.padding([0, 16])
.into()
}
fn section_content<'a>(locale: UiLocale) -> Element<'a, Message> {
// Categories table placeholder — full implementation in future iteration
text(t(locale, "settings.contentPlaceholder"))
.size(13)
.color(Color::from_rgb(0.5, 0.5, 0.5))
fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
let template_options = std::iter::once(String::new())
.chain(state.template_options.iter().cloned())
.collect::<Vec<_>>();
let category_rows = state.categories.iter().fold(column![].spacing(8), |column, category| {
let title = inputs::labeled_input(
&tw(locale, "settings.categoryTitle", &[("category", &category.name)]),
"",
&category.title,
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryTitleChanged(name.clone(), value))
},
);
let post_template = inputs::labeled_select(
&t(locale, "settings.categoryPostTemplate"),
&template_options,
Some(&category.post_template_slug),
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryPostTemplateChanged(name.clone(), value))
},
);
let list_template = inputs::labeled_select(
&t(locale, "settings.categoryListTemplate"),
&template_options,
Some(&category.list_template_slug),
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryListTemplateChanged(name.clone(), value))
},
);
let toggles = column![
inputs::labeled_checkbox(
&t(locale, "settings.categoryRenderInLists"),
category.render_in_lists,
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryRenderInListsChanged(name.clone(), value))
},
),
inputs::labeled_checkbox(
&t(locale, "settings.categoryShowTitles"),
category.show_title,
{
let name = category.name.clone();
move |value| Message::Settings(SettingsMsg::CategoryShowTitleChanged(name.clone(), value))
},
),
]
.spacing(6);
let actions = row![
button(text(t(locale, "common.save")).size(13))
.on_press(Message::Settings(SettingsMsg::SaveCategory(category.name.clone())))
.style(inputs::primary_button)
.padding([6, 12]),
button(text(t(locale, "common.remove")).size(13))
.on_press_maybe((!category.is_protected).then(|| Message::Settings(SettingsMsg::RemoveCategory(category.name.clone()))))
.style(inputs::danger_button)
.padding([6, 12]),
]
.spacing(8);
column.push(
container(
column![
text(category.name.clone()).size(15).color(Color::WHITE),
title,
toggles,
row![post_template, list_template].spacing(12),
actions,
]
.spacing(8),
)
.padding(12),
)
});
let add_row = row![
inputs::labeled_input(
&t(locale, "settings.addCategory"),
"news",
&state.new_category_name,
|value| Message::Settings(SettingsMsg::AddCategoryNameChanged(value)),
),
button(text(t(locale, "common.add")).size(13))
.on_press(Message::Settings(SettingsMsg::AddCategory))
.style(inputs::primary_button)
.padding([6, 12]),
button(text(t(locale, "settings.resetCategories")).size(13))
.on_press(Message::Settings(SettingsMsg::ResetCategoriesToDefaults))
.padding([6, 12]),
]
.spacing(8)
.align_y(Alignment::End);
column![category_rows, add_row]
.spacing(12)
.padding([0, 16])
.into()
}
@@ -436,11 +689,20 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a,
.into()
}
fn section_technology<'a>(locale: UiLocale) -> Element<'a, Message> {
text(t(locale, "settings.technologyPlaceholder"))
.size(13)
.color(Color::from_rgb(0.5, 0.5, 0.5))
.into()
fn section_technology<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {
column![
text(t(locale, "settings.luaRuntimeOnly"))
.size(13)
.color(Color::from_rgb(0.7, 0.72, 0.78)),
inputs::labeled_checkbox(
&t(locale, "settings.semanticSimilarityEnabled"),
state.semantic_similarity_enabled,
|value| Message::Settings(SettingsMsg::SemanticSimilarityChanged(value)),
),
]
.spacing(8)
.padding([0, 16])
.into()
}
fn section_publishing<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> {

View File

@@ -551,7 +551,7 @@ mod tests {
Some(&dashboard),
);
match route {
ContentRoute::Dashboard(state) => assert_eq!(state.project_name, "Test Project"),
ContentRoute::Dashboard(state) => assert_eq!(state.subtitle, "Test Project"),
_ => panic!("expected dashboard route"),
}
}

View File

@@ -278,19 +278,38 @@
"tags.selectTag": "Tag zum Bearbeiten auswählen",
"template.forceDeleteTitle": "Vorlage zwangsweise löschen",
"template.forceDeleteMessage": "Diese Vorlage wird von {posts} Beiträgen und {tags} Tags verwendet. Trotzdem löschen?",
"common.add": "Hinzufügen",
"common.remove": "Entfernen",
"common.clear": "Leeren",
"common.noResults": "Keine Ergebnisse",
"settings.projectName": "Projektname",
"settings.projectDescription": "Beschreibung",
"settings.dataPath": "Datenordner",
"settings.browse": "Durchsuchen...",
"settings.reset": "Zurücksetzen",
"settings.publicUrl": "Öffentliche URL",
"settings.mainLanguage": "Hauptsprache",
"settings.mainLanguageRequired": "erforderlich",
"settings.blogLanguages": "Blogsprachen",
"settings.defaultAuthor": "Standardautor",
"settings.maxPostsPerPage": "Beiträge pro Seite",
"settings.blogmarkCategory": "Blogmark-Kategorie",
"settings.defaultMode": "Standard-Editormodus",
"settings.diffViewStyle": "Diff-Ansicht",
"settings.categoryTitle": "Titel für {category}",
"settings.categoryPostTemplate": "Beitragsvorlage",
"settings.categoryListTemplate": "Listenvorlage",
"settings.categoryRenderInLists": "In Listen rendern",
"settings.categoryShowTitles": "Titel anzeigen",
"settings.addCategory": "Kategorie hinzufügen",
"settings.resetCategories": "Auf Standard zurücksetzen",
"settings.wrapLongLines": "Lange Zeilen umbrechen",
"settings.hideUnchangedRegions": "Unveränderte Bereiche ausblenden",
"settings.offlineMode": "Flugmodus",
"settings.systemPrompt": "System-Prompt",
"settings.resetToDefault": "Auf Standard zurücksetzen",
"settings.luaRuntimeOnly": "Lua ist die einzige Skriptlaufzeit in der Rust-App.",
"settings.semanticSimilarityEnabled": "Semantische Ähnlichkeit",
"settings.sshHost": "SSH-Host",
"settings.sshUsername": "SSH-Benutzername",
"settings.sshRemotePath": "Remote-Pfad",
@@ -308,10 +327,19 @@
"dashboard.overview": "Übersicht",
"dashboard.posts": "Beiträge",
"dashboard.media": "Medien",
"dashboard.templates": "Vorlagen",
"dashboard.scripts": "Skripte",
"dashboard.tags": "Tags",
"dashboard.categories": "Kategorien",
"dashboard.images": "Bilder",
"dashboard.drafts": "Entwürfe",
"dashboard.published": "Veröffentlicht",
"dashboard.entityCounts": "Anzahl Entitäten",
"dashboard.statusCounts": "Statusübersicht"
"dashboard.archived": "Archiviert",
"dashboard.statCards": "Überblick",
"dashboard.timeline": "Zeitachse",
"dashboard.noTimelineData": "Noch keine Zeitachsendaten",
"dashboard.tagCloud": "Tag-Wolke",
"dashboard.categoryCloud": "Kategorie-Wolke",
"dashboard.recentPosts": "Letzte Beiträge",
"dashboard.noRecentPosts": "Keine aktuellen Beiträge",
"dashboard.and": "und",
"dashboard.pin": "Anheften"
}

View File

@@ -278,19 +278,38 @@
"tags.selectTag": "Select a tag to edit",
"template.forceDeleteTitle": "Force Delete Template",
"template.forceDeleteMessage": "This template is used by {posts} posts and {tags} tags. Force delete?",
"common.add": "Add",
"common.remove": "Remove",
"common.clear": "Clear",
"common.noResults": "No results",
"settings.projectName": "Project Name",
"settings.projectDescription": "Description",
"settings.dataPath": "Data Folder",
"settings.browse": "Browse...",
"settings.reset": "Reset",
"settings.publicUrl": "Public URL",
"settings.mainLanguage": "Main Language",
"settings.mainLanguageRequired": "required",
"settings.blogLanguages": "Blog Languages",
"settings.defaultAuthor": "Default Author",
"settings.maxPostsPerPage": "Posts per Page",
"settings.blogmarkCategory": "Blogmark Category",
"settings.defaultMode": "Default Editor Mode",
"settings.diffViewStyle": "Diff View Style",
"settings.categoryTitle": "Title for {category}",
"settings.categoryPostTemplate": "Post Template",
"settings.categoryListTemplate": "List Template",
"settings.categoryRenderInLists": "Render in Lists",
"settings.categoryShowTitles": "Show Titles",
"settings.addCategory": "Add Category",
"settings.resetCategories": "Reset to Defaults",
"settings.wrapLongLines": "Wrap Long Lines",
"settings.hideUnchangedRegions": "Hide Unchanged Regions",
"settings.offlineMode": "Airplane Mode",
"settings.systemPrompt": "System Prompt",
"settings.resetToDefault": "Reset to Default",
"settings.luaRuntimeOnly": "Lua is the only scripting runtime in the Rust app.",
"settings.semanticSimilarityEnabled": "Semantic Similarity",
"settings.sshHost": "SSH Host",
"settings.sshUsername": "SSH Username",
"settings.sshRemotePath": "Remote Path",
@@ -308,10 +327,19 @@
"dashboard.overview": "Overview",
"dashboard.posts": "Posts",
"dashboard.media": "Media",
"dashboard.templates": "Templates",
"dashboard.scripts": "Scripts",
"dashboard.tags": "Tags",
"dashboard.categories": "Categories",
"dashboard.images": "Images",
"dashboard.drafts": "Drafts",
"dashboard.published": "Published",
"dashboard.entityCounts": "Entity Counts",
"dashboard.statusCounts": "Status Breakdown"
"dashboard.archived": "Archived",
"dashboard.statCards": "Snapshot",
"dashboard.timeline": "Timeline",
"dashboard.noTimelineData": "No timeline data yet",
"dashboard.tagCloud": "Tag Cloud",
"dashboard.categoryCloud": "Category Cloud",
"dashboard.recentPosts": "Recent Posts",
"dashboard.noRecentPosts": "No recent posts",
"dashboard.and": "and",
"dashboard.pin": "Pin"
}

View File

@@ -278,19 +278,38 @@
"tags.selectTag": "Seleccione una etiqueta para editar",
"template.forceDeleteTitle": "Forzar eliminación de plantilla",
"template.forceDeleteMessage": "Esta plantilla se usa en {posts} publicaciones y {tags} etiquetas. ¿Forzar eliminación?",
"common.add": "Agregar",
"common.remove": "Eliminar",
"common.clear": "Limpiar",
"common.noResults": "Sin resultados",
"settings.projectName": "Nombre del proyecto",
"settings.projectDescription": "Descripción",
"settings.dataPath": "Carpeta de datos",
"settings.browse": "Explorar...",
"settings.reset": "Restablecer",
"settings.publicUrl": "URL pública",
"settings.mainLanguage": "Idioma principal",
"settings.mainLanguageRequired": "obligatorio",
"settings.blogLanguages": "Idiomas del blog",
"settings.defaultAuthor": "Autor predeterminado",
"settings.maxPostsPerPage": "Artículos por página",
"settings.blogmarkCategory": "Categoría de Blogmark",
"settings.defaultMode": "Modo de editor predeterminado",
"settings.diffViewStyle": "Estilo de diff",
"settings.categoryTitle": "Título para {category}",
"settings.categoryPostTemplate": "Plantilla de artículo",
"settings.categoryListTemplate": "Plantilla de lista",
"settings.categoryRenderInLists": "Mostrar en listas",
"settings.categoryShowTitles": "Mostrar títulos",
"settings.addCategory": "Agregar categoría",
"settings.resetCategories": "Restablecer categorías predeterminadas",
"settings.wrapLongLines": "Ajuste de línea automático",
"settings.hideUnchangedRegions": "Ocultar regiones sin cambios",
"settings.offlineMode": "Modo avión",
"settings.systemPrompt": "Prompt del sistema",
"settings.resetToDefault": "Restaurar valores predeterminados",
"settings.luaRuntimeOnly": "Lua es el único tiempo de ejecución de scripts en la app Rust.",
"settings.semanticSimilarityEnabled": "Similitud semántica",
"settings.sshHost": "Host SSH",
"settings.sshUsername": "Nombre de usuario SSH",
"settings.sshRemotePath": "Ruta remota",
@@ -308,10 +327,19 @@
"dashboard.overview": "Resumen",
"dashboard.posts": "Artículos",
"dashboard.media": "Medios",
"dashboard.templates": "Plantillas",
"dashboard.scripts": "Scripts",
"dashboard.tags": "Etiquetas",
"dashboard.categories": "Categorías",
"dashboard.images": "Imágenes",
"dashboard.drafts": "Borradores",
"dashboard.published": "Publicados",
"dashboard.entityCounts": "Recuento de entidades",
"dashboard.statusCounts": "Desglose por estado"
"dashboard.archived": "Archivados",
"dashboard.statCards": "Resumen",
"dashboard.timeline": "Cronología",
"dashboard.noTimelineData": "Todavía no hay datos de cronología",
"dashboard.tagCloud": "Nube de etiquetas",
"dashboard.categoryCloud": "Nube de categorías",
"dashboard.recentPosts": "Publicaciones recientes",
"dashboard.noRecentPosts": "No hay publicaciones recientes",
"dashboard.and": "y",
"dashboard.pin": "Fijar"
}

View File

@@ -278,19 +278,38 @@
"tags.selectTag": "Sélectionnez un tag à modifier",
"template.forceDeleteTitle": "Forcer la suppression du modèle",
"template.forceDeleteMessage": "Ce modèle est utilisé par {posts} articles et {tags} tags. Forcer la suppression ?",
"common.add": "Ajouter",
"common.remove": "Supprimer",
"common.clear": "Effacer",
"common.noResults": "Aucun résultat",
"settings.projectName": "Nom du projet",
"settings.projectDescription": "Description",
"settings.dataPath": "Dossier de données",
"settings.browse": "Parcourir...",
"settings.reset": "Réinitialiser",
"settings.publicUrl": "URL publique",
"settings.mainLanguage": "Langue principale",
"settings.mainLanguageRequired": "requise",
"settings.blogLanguages": "Langues du blog",
"settings.defaultAuthor": "Auteur par défaut",
"settings.maxPostsPerPage": "Articles par page",
"settings.blogmarkCategory": "Catégorie Blogmark",
"settings.defaultMode": "Mode d'édition par défaut",
"settings.diffViewStyle": "Style de diff",
"settings.categoryTitle": "Titre pour {category}",
"settings.categoryPostTemplate": "Modèle d'article",
"settings.categoryListTemplate": "Modèle de liste",
"settings.categoryRenderInLists": "Afficher dans les listes",
"settings.categoryShowTitles": "Afficher les titres",
"settings.addCategory": "Ajouter une catégorie",
"settings.resetCategories": "Rétablir les catégories par défaut",
"settings.wrapLongLines": "Retour à la ligne automatique",
"settings.hideUnchangedRegions": "Masquer les régions inchangées",
"settings.offlineMode": "Mode avion",
"settings.systemPrompt": "Prompt système",
"settings.resetToDefault": "Rétablir les valeurs par défaut",
"settings.luaRuntimeOnly": "Lua est l'unique moteur de script dans l'application Rust.",
"settings.semanticSimilarityEnabled": "Similarité sémantique",
"settings.sshHost": "Hôte SSH",
"settings.sshUsername": "Nom d'utilisateur SSH",
"settings.sshRemotePath": "Chemin distant",
@@ -308,10 +327,19 @@
"dashboard.overview": "Aperçu",
"dashboard.posts": "Articles",
"dashboard.media": "Médias",
"dashboard.templates": "Modèles",
"dashboard.scripts": "Scripts",
"dashboard.tags": "Tags",
"dashboard.categories": "Catégories",
"dashboard.images": "Images",
"dashboard.drafts": "Brouillons",
"dashboard.published": "Publiés",
"dashboard.entityCounts": "Nombre d'entités",
"dashboard.statusCounts": "Répartition par statut"
"dashboard.archived": "Archivés",
"dashboard.statCards": "Instantané",
"dashboard.timeline": "Chronologie",
"dashboard.noTimelineData": "Aucune donnée de chronologie",
"dashboard.tagCloud": "Nuage de tags",
"dashboard.categoryCloud": "Nuage de catégories",
"dashboard.recentPosts": "Articles récents",
"dashboard.noRecentPosts": "Aucun article récent",
"dashboard.and": "et",
"dashboard.pin": "Épingler"
}

View File

@@ -278,19 +278,38 @@
"tags.selectTag": "Seleziona un tag da modificare",
"template.forceDeleteTitle": "Forza eliminazione template",
"template.forceDeleteMessage": "Questo template è usato da {posts} post e {tags} tag. Forzare l'eliminazione?",
"common.add": "Aggiungi",
"common.remove": "Rimuovi",
"common.clear": "Cancella",
"common.noResults": "Nessun risultato",
"settings.projectName": "Nome progetto",
"settings.projectDescription": "Descrizione",
"settings.dataPath": "Cartella dati",
"settings.browse": "Sfoglia...",
"settings.reset": "Ripristina",
"settings.publicUrl": "URL pubblica",
"settings.mainLanguage": "Lingua principale",
"settings.mainLanguageRequired": "obbligatoria",
"settings.blogLanguages": "Lingue del blog",
"settings.defaultAuthor": "Autore predefinito",
"settings.maxPostsPerPage": "Articoli per pagina",
"settings.blogmarkCategory": "Categoria Blogmark",
"settings.defaultMode": "Modalità editor predefinita",
"settings.diffViewStyle": "Stile diff",
"settings.categoryTitle": "Titolo per {category}",
"settings.categoryPostTemplate": "Modello articolo",
"settings.categoryListTemplate": "Modello elenco",
"settings.categoryRenderInLists": "Mostra negli elenchi",
"settings.categoryShowTitles": "Mostra titoli",
"settings.addCategory": "Aggiungi categoria",
"settings.resetCategories": "Ripristina categorie predefinite",
"settings.wrapLongLines": "A capo automatico",
"settings.hideUnchangedRegions": "Nascondi regioni invariate",
"settings.offlineMode": "Modalità aereo",
"settings.systemPrompt": "Prompt di sistema",
"settings.resetToDefault": "Ripristina predefiniti",
"settings.luaRuntimeOnly": "Lua è l'unico runtime di scripting nell'app Rust.",
"settings.semanticSimilarityEnabled": "Similarità semantica",
"settings.sshHost": "Host SSH",
"settings.sshUsername": "Nome utente SSH",
"settings.sshRemotePath": "Percorso remoto",
@@ -308,10 +327,19 @@
"dashboard.overview": "Panoramica",
"dashboard.posts": "Articoli",
"dashboard.media": "Media",
"dashboard.templates": "Modelli",
"dashboard.scripts": "Script",
"dashboard.tags": "Tag",
"dashboard.categories": "Categorie",
"dashboard.images": "Immagini",
"dashboard.drafts": "Bozze",
"dashboard.published": "Pubblicati",
"dashboard.entityCounts": "Conteggio entità",
"dashboard.statusCounts": "Ripartizione per stato"
"dashboard.archived": "Archiviati",
"dashboard.statCards": "Riepilogo",
"dashboard.timeline": "Cronologia",
"dashboard.noTimelineData": "Nessun dato cronologico disponibile",
"dashboard.tagCloud": "Nuvola di tag",
"dashboard.categoryCloud": "Nuvola di categorie",
"dashboard.recentPosts": "Post recenti",
"dashboard.noRecentPosts": "Nessun post recente",
"dashboard.and": "e",
"dashboard.pin": "Fissa"
}