fix: dashboard now in as well as settings
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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(§ion);
|
||||
let section_el = render_section(state, §ion, &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> {
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user