fix: more gaps in M3

This commit is contained in:
2026-04-09 07:48:43 +02:00
parent 9867bf9c65
commit c6d26957dc
15 changed files with 1403 additions and 202 deletions

View File

@@ -274,11 +274,21 @@ pub fn sync_tags_from_posts(
tag_q::insert_tag(conn, &tag)?;
}
}
let all_tags = tag_q::list_tags_by_project(conn, project_id)?;
Ok(all_tags)
}
/// Discover tags from posts and rewrite tags.json to match the resulting DB state.
pub fn discover_tags(
conn: &Connection,
data_dir: &Path,
project_id: &str,
) -> EngineResult<Vec<Tag>> {
let tags = sync_tags_from_posts(conn, project_id)?;
rewrite_tags_json(conn, data_dir, project_id)?;
Ok(tags)
}
/// Rewrite meta/tags.json from DB state.
pub fn rewrite_tags_json(
conn: &Connection,
@@ -520,7 +530,7 @@ mod tests {
#[test]
fn sync_tags_from_posts_creates_missing() {
let (db, dir) = setup();
let (db, _dir) = setup();
insert_post(
db.conn(),
&make_post("x1", "a", vec!["rust".into(), "web".into()]),
@@ -540,6 +550,23 @@ mod tests {
assert!(names.contains(&"web"));
}
#[test]
fn discover_tags_rewrites_tags_json() {
let (db, dir) = setup();
insert_post(
db.conn(),
&make_post("x1", "a", vec!["rust".into(), "web".into()]),
)
.unwrap();
let tags = discover_tags(db.conn(), dir.path(), "p1").unwrap();
assert_eq!(tags.len(), 2);
let entries = meta::read_tags_json(dir.path()).unwrap();
let names = entries.into_iter().map(|entry| entry.name).collect::<Vec<_>>();
assert_eq!(names, vec!["rust".to_string(), "web".to_string()]);
}
#[test]
fn import_tags_from_file_preserves_colors() {
let (db, dir) = setup();

File diff suppressed because it is too large Load Diff

View File

@@ -135,6 +135,11 @@ impl MenuRegistry {
}
}
#[cfg(test)]
pub(crate) fn empty() -> Self {
Self::new()
}
fn register(&mut self, action: MenuAction, item: &MenuItem) {
self.action_map.insert(item.id().clone(), action);
self.id_map.insert(action, item.id().clone());

View File

@@ -13,6 +13,12 @@ use crate::components::inputs;
use crate::i18n::t;
use crate::views::post_editor::TranslationFlag;
#[derive(Debug, Clone)]
pub struct LinkedPostItem {
pub post_id: String,
pub title: String,
}
/// Saved draft content for a single media translation language.
#[derive(Debug, Clone)]
pub struct MediaTranslationDraft {
@@ -49,10 +55,19 @@ pub struct MediaEditorState {
pub blog_languages: Vec<String>,
pub saved_canonical: Option<MediaTranslationDraft>,
pub translation_drafts: HashMap<String, MediaTranslationDraft>,
pub linked_posts: Vec<LinkedPostItem>,
pub post_picker_open: bool,
pub post_picker_search: String,
pub post_picker_results: Vec<LinkedPostItem>,
}
impl MediaEditorState {
pub fn from_media(media: &Media, blog_languages: &[String], translations: &[MediaTranslation]) -> Self {
pub fn from_media(
media: &Media,
blog_languages: &[String],
translations: &[MediaTranslation],
linked_posts: Vec<LinkedPostItem>,
) -> Self {
let canonical_lang = media.language.clone().unwrap_or_else(|| "en".to_string());
let mut translation_drafts = HashMap::new();
@@ -89,6 +104,10 @@ impl MediaEditorState {
blog_languages: blog_languages.to_vec(),
saved_canonical: None,
translation_drafts,
linked_posts,
post_picker_open: false,
post_picker_search: String::new(),
post_picker_results: Vec::new(),
}
}
@@ -174,6 +193,11 @@ pub enum MediaEditorMsg {
LanguageChanged(String),
TagsChanged(String),
SwitchLanguage(String),
TogglePostPicker,
PostPickerSearchChanged(String),
LinkPost(String),
OpenLinkedPost(String),
UnlinkPost(String),
Save,
Delete,
}
@@ -308,6 +332,88 @@ pub fn view<'a>(
let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill);
let meta_row3 = row![tags_input, language_input].spacing(16).width(Length::Fill);
let linked_posts_header = row![
text(t(locale, "editor.linkedPosts"))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65)),
Space::with_width(Length::Fill),
button(text(t(locale, "editor.linkToPost")).size(12))
.on_press(Message::MediaEditor(MediaEditorMsg::TogglePostPicker))
.padding([4, 10]),
]
.align_y(iced::Alignment::Center)
.spacing(8);
let post_picker: Element<'a, Message> = if state.post_picker_open {
let search = inputs::labeled_input(
&t(locale, "editor.postPickerSearch"),
&t(locale, "editor.postPickerSearchPlaceholder"),
&state.post_picker_search,
|s| Message::MediaEditor(MediaEditorMsg::PostPickerSearchChanged(s)),
);
let results: Vec<Element<'a, Message>> = if state.post_picker_results.is_empty() {
vec![
text(t(locale, "sidebar.filter.noResults"))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65))
.into(),
]
} else {
state
.post_picker_results
.iter()
.map(|post| {
button(text(post.title.clone()).size(12))
.on_press(Message::MediaEditor(MediaEditorMsg::LinkPost(post.post_id.clone())))
.padding([4, 10])
.width(Length::Fill)
.into()
})
.collect()
};
container(column![search, column(results).spacing(4)].spacing(8).padding(8))
.style(|_: &iced::Theme| iced::widget::container::Style {
background: Some(iced::Background::Color(Color::from_rgb(0.16, 0.18, 0.22))),
border: iced::Border {
color: Color::from_rgb(0.28, 0.28, 0.32),
width: 1.0,
radius: 6.0.into(),
},
..iced::widget::container::Style::default()
})
.into()
} else {
Space::new(0, 0).into()
};
let linked_posts_list: Element<'a, Message> = if state.linked_posts.is_empty() {
text(t(locale, "editor.linkedPostsEmpty"))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65))
.into()
} else {
let rows: Vec<Element<'a, Message>> = state
.linked_posts
.iter()
.map(|post| {
row![
button(text(post.title.clone()).size(12))
.on_press(Message::MediaEditor(MediaEditorMsg::OpenLinkedPost(post.post_id.clone())))
.padding([4, 0]),
Space::with_width(Length::Fill),
button(text(t(locale, "editor.unlinkMedia")).size(11))
.on_press(Message::MediaEditor(MediaEditorMsg::UnlinkPost(post.post_id.clone())))
.padding([3, 8])
.style(inputs::danger_button),
]
.align_y(iced::Alignment::Center)
.spacing(8)
.into()
})
.collect();
column(rows).spacing(6).into()
};
// Footer
let footer = row![
inputs::date_label(&t(locale, "editor.createdAt"), state.created_at),
@@ -326,6 +432,9 @@ pub fn view<'a>(
meta_row1,
meta_row2,
meta_row3,
linked_posts_header,
post_picker,
linked_posts_list,
footer,
]
.spacing(12)

View File

@@ -76,8 +76,9 @@ pub enum ConfirmAction {
DeleteMedia(String),
DeleteScript(String),
DeleteTemplate(String),
ForceDeleteTemplate(String),
DeleteTag(String),
MergeTags { source: String, target: String },
MergeTags { sources: Vec<String>, target: String },
}
// ── Modal backdrop style ──

View File

@@ -7,6 +7,8 @@ use bds_core::i18n::UiLocale;
use crate::app::Message;
use crate::i18n::t;
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
use crate::state::tabs::{Tab, TabType};
use crate::views::post_editor::ResolvedPostLink;
/// Panel background style.
fn panel_style(_theme: &Theme) -> container::Style {
@@ -66,6 +68,8 @@ pub fn view(
panel_tab: PanelTab,
task_snapshots: &[TaskSnapshot],
output_entries: &[OutputEntry],
post_outlinks: &[ResolvedPostLink],
post_backlinks: &[ResolvedPostLink],
locale: UiLocale,
active_tab_is_post: bool,
active_tab_is_post_or_media: bool,
@@ -183,10 +187,51 @@ pub fn view(
}
}
PanelTab::PostLinks => {
// Post Links content populated in M3 (editor integration)
container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
.padding(8)
if post_outlinks.is_empty() && post_backlinks.is_empty() {
container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
.padding(8)
.into()
} else {
let mut items: Vec<Element<'static, Message>> = vec![
text(t(locale, "editor.outlinks"))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.75, 0.77, 0.82))
.into(),
];
if post_outlinks.is_empty() {
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into());
} else {
for link in post_outlinks {
items.push(post_link_button(locale, link));
}
}
items.push(Space::with_height(8.0).into());
items.push(
text(t(locale, "editor.backlinks"))
.size(12)
.shaping(Shaping::Advanced)
.color(Color::from_rgb(0.75, 0.77, 0.82))
.into(),
);
if post_backlinks.is_empty() {
items.push(text(t(locale, "panel.postLinksPlaceholder")).size(11).color(muted).into());
} else {
for link in post_backlinks {
items.push(post_link_button(locale, link));
}
}
scrollable(
iced::widget::Column::with_children(items)
.spacing(4)
.padding(8),
)
.into()
}
}
PanelTab::GitLog => {
// Git Log content populated in extension bucket A (git integration)
@@ -204,3 +249,21 @@ pub fn view(
.style(panel_style)
.into()
}
fn post_link_button(locale: UiLocale, link: &ResolvedPostLink) -> Element<'static, Message> {
button(text(link.title.clone()).size(11).shaping(Shaping::Advanced))
.on_press(Message::OpenTab(Tab {
id: link.post_id.clone(),
title: if link.title.is_empty() {
t(locale, "editor.untitled")
} else {
link.title.clone()
},
tab_type: TabType::Post,
is_transient: false,
is_dirty: false,
}))
.padding([4, 8])
.style(tab_inactive)
.into()
}

View File

@@ -67,6 +67,7 @@ pub struct PostEditorState {
pub updated_at: i64,
pub published_at: Option<i64>,
pub is_dirty: bool,
pub last_edit_at_ms: i64,
pub metadata_expanded: bool,
pub excerpt_expanded: bool,
pub tags_input: String,
@@ -120,6 +121,7 @@ impl Clone for PostEditorState {
updated_at: self.updated_at,
published_at: self.published_at,
is_dirty: self.is_dirty,
last_edit_at_ms: self.last_edit_at_ms,
metadata_expanded: self.metadata_expanded,
excerpt_expanded: self.excerpt_expanded,
tags_input: self.tags_input.clone(),
@@ -180,6 +182,7 @@ impl PostEditorState {
updated_at: post.updated_at,
published_at: post.published_at,
is_dirty: false,
last_edit_at_ms: 0,
metadata_expanded: title.is_empty(),
excerpt_expanded: false,
tags_input: String::new(),
@@ -196,6 +199,11 @@ impl PostEditorState {
}
}
pub fn mark_dirty(&mut self) {
self.is_dirty = true;
self.last_edit_at_ms = bds_core::util::now_unix_ms();
}
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
let new_content = {
let mut buffer = self.editor_buffer.borrow_mut();
@@ -203,7 +211,7 @@ impl PostEditorState {
buffer.text()
};
self.content = new_content;
self.is_dirty = true;
self.mark_dirty();
}
/// Switch the editor to display a different language.

View File

@@ -178,12 +178,18 @@ pub fn view<'a>(
|k| Message::ScriptEditor(ScriptEditorMsg::KindChanged(k)),
);
// Entrypoint: show discovered functions as a select or text input
let entrypoint_input = inputs::labeled_input(
let mut entrypoint_options = state.discovered_entrypoints.clone();
if !entrypoint_options.iter().any(|entrypoint| entrypoint == &state.entrypoint) {
entrypoint_options.push(state.entrypoint.clone());
}
let selected_entrypoint = entrypoint_options
.iter()
.find(|entrypoint| *entrypoint == &state.entrypoint);
let entrypoint_input = inputs::labeled_select(
&t(locale, "editor.entrypoint"),
"render",
&state.entrypoint,
|s| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(s)),
&entrypoint_options,
selected_entrypoint,
|entrypoint| Message::ScriptEditor(ScriptEditorMsg::EntrypointChanged(entrypoint)),
);
let enabled_check = inputs::labeled_checkbox(

View File

@@ -8,16 +8,22 @@ use bds_core::model::Tag;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
use crate::i18n::{t, tw};
const DEFAULT_TAG_COLOR: &str = "#6495ed";
const COLOR_PRESETS: [&str; 17] = [
"#e63946", "#f77f00", "#fcbf49", "#90be6d", "#43aa8b", "#4d908e",
"#577590", "#277da1", "#4361ee", "#7209b7", "#b5179e", "#f72585",
"#9c6644", "#6c757d", "#adb5bd", "#2a9d8f", "#264653",
];
/// Active view within the Tags tab.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TagsSection {
Cloud,
Manage,
Merge,
Discover,
}
/// State for the tags view.
@@ -27,31 +33,64 @@ pub struct TagsViewState {
pub tags: Vec<Tag>,
pub tag_post_counts: HashMap<String, usize>,
pub search_query: String,
/// For "Manage": the currently editing tag
pub selected_tags: Vec<String>,
pub create_name: String,
pub create_color: String,
pub editing_tag: Option<EditingTag>,
/// For "Merge": source and target tag selection
pub merge_source: Option<String>,
pub merge_target: Option<String>,
pub template_options: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct EditingTag {
pub id: String,
pub original_name: String,
pub name: String,
pub color: String,
pub template_slug: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagOption {
pub id: String,
pub name: String,
}
impl std::fmt::Display for TagOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateOption {
pub slug: String,
pub label: String,
}
impl std::fmt::Display for TemplateOption {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label)
}
}
impl TagsViewState {
pub fn new(tags: Vec<Tag>, tag_post_counts: HashMap<String, usize>) -> Self {
pub fn new(
tags: Vec<Tag>,
tag_post_counts: HashMap<String, usize>,
template_options: Vec<String>,
) -> Self {
Self {
section: TagsSection::Cloud,
tags,
tag_post_counts,
search_query: String::new(),
selected_tags: Vec::new(),
create_name: String::new(),
create_color: String::new(),
editing_tag: None,
merge_source: None,
merge_target: None,
template_options,
}
}
}
@@ -61,28 +100,28 @@ impl TagsViewState {
pub enum TagsMsg {
SetSection(TagsSection),
SearchChanged(String),
SelectTag(String),
CreateTag(String),
ToggleTagSelection(String),
ClearSelection,
CreateNameChanged(String),
CreateColorChanged(String),
CreateTag,
EditTagName(String),
EditTagColor(String),
EditTagTemplate(String),
EditTagTemplate(TemplateOption),
SaveTag,
DeleteTag(String),
SetMergeSource(String),
SetMergeTarget(String),
SetMergeTarget(TagOption),
MergeTags,
SyncTags,
}
/// Render the tags management view.
pub fn view<'a>(
state: &'a TagsViewState,
locale: UiLocale,
) -> Element<'a, Message> {
// Section navigation tabs
pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
let section_nav = row![
section_tab(&t(locale, "tags.nav.cloud"), state.section == TagsSection::Cloud, TagsSection::Cloud),
section_tab(&t(locale, "tags.nav.manage"), state.section == TagsSection::Manage, TagsSection::Manage),
section_tab(&t(locale, "tags.nav.merge"), state.section == TagsSection::Merge, TagsSection::Merge),
section_tab(&t(locale, "tags.nav.discover"), state.section == TagsSection::Discover, TagsSection::Discover),
]
.spacing(4)
.padding(8);
@@ -91,6 +130,7 @@ pub fn view<'a>(
TagsSection::Cloud => view_cloud(state, locale),
TagsSection::Manage => view_manage(state, locale),
TagsSection::Merge => view_merge(state, locale),
TagsSection::Discover => view_discover(state, locale),
};
column![section_nav, content]
@@ -137,13 +177,18 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.into();
}
// Calculate min/max post counts for font scaling
let counts: Vec<usize> = state.tags.iter()
.map(|t| *state.tag_post_counts.get(&t.name.to_lowercase()).unwrap_or(&0))
let counts: Vec<usize> = state
.tags
.iter()
.map(|tag| *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0))
.collect();
let min_count = *counts.iter().min().unwrap_or(&0);
let max_count = *counts.iter().max().unwrap_or(&1);
let count_range = if max_count > min_count { (max_count - min_count) as f32 } else { 1.0 };
let count_range = if max_count > min_count {
(max_count - min_count) as f32
} else {
1.0
};
const MIN_FONT: f32 = 11.0;
const MAX_FONT: f32 = 24.0;
@@ -155,28 +200,56 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
let post_count = *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0);
let font_size = MIN_FONT + ((post_count - min_count) as f32 / count_range) * (MAX_FONT - MIN_FONT);
let vert_pad = ((MAX_FONT - font_size) / 4.0).max(2.0) as u16;
button(text(&tag.name).size(font_size).color(Color::WHITE))
.on_press(Message::Tags(TagsMsg::SelectTag(tag.id.clone())))
.padding([vert_pad, 10])
.style(move |_: &Theme, _| button::Style {
background: Some(Background::Color(color)),
border: iced::Border {
radius: 12.0.into(),
..iced::Border::default()
},
text_color: Color::WHITE,
..button::Style::default()
})
.into()
let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id);
button(
row![
text(&tag.name).size(font_size).color(Color::WHITE),
text(post_count.to_string()).size(11).color(Color::from_rgba(1.0, 1.0, 1.0, 0.85)),
]
.spacing(6)
.align_y(Alignment::Center),
)
.on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone())))
.padding([vert_pad, 10])
.style(move |_: &Theme, _| button::Style {
background: Some(Background::Color(color)),
border: iced::Border {
radius: 12.0.into(),
width: if selected { 2.0 } else { 0.0 },
color: if selected { Color::WHITE } else { Color::TRANSPARENT },
},
text_color: Color::WHITE,
..button::Style::default()
})
.into()
})
.collect();
let cloud = row(chips).spacing(6).wrap();
let selection_summary: Element<'a, Message> = if state.selected_tags.is_empty() {
text(t(locale, "tags.cloudHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)).into()
} else {
row![
text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())]))
.size(12)
.color(Color::from_rgb(0.75, 0.77, 0.82)),
button(text(t(locale, "tags.clearSelection")).size(12))
.on_press(Message::Tags(TagsMsg::ClearSelection))
.padding([4, 8]),
]
.spacing(8)
.align_y(Alignment::Center)
.into()
};
scrollable(
container(cloud)
.width(Length::Fill)
.padding(16),
column![
inputs::section_header(&t(locale, "tags.cloudSection")),
selection_summary,
row(chips).spacing(6).wrap(),
]
.spacing(12)
.width(Length::Fill)
.padding(16),
)
.width(Length::Fill)
.height(Length::Fill)
@@ -184,16 +257,38 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
}
fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
let create_panel = column![
inputs::section_header(&t(locale, "tags.createSection")),
inputs::labeled_input(
&t(locale, "tags.name"),
&t(locale, "tags.createPlaceholder"),
&state.create_name,
|value| Message::Tags(TagsMsg::CreateNameChanged(value)),
),
inputs::labeled_input(
&t(locale, "tags.color"),
DEFAULT_TAG_COLOR,
&state.create_color,
|value| Message::Tags(TagsMsg::CreateColorChanged(value)),
),
color_swatches(locale, true),
button(text(t(locale, "tags.createButton")).size(13))
.on_press_maybe((!state.create_name.trim().is_empty()).then_some(Message::Tags(TagsMsg::CreateTag)))
.style(inputs::primary_button)
.padding([6, 16]),
]
.spacing(8);
let search = text_input(&t(locale, "sidebar.filter.search"), &state.search_query)
.on_input(|s| Message::Tags(TagsMsg::SearchChanged(s)))
.on_input(|value| Message::Tags(TagsMsg::SearchChanged(value)))
.size(14);
let filtered: Vec<&Tag> = state
.tags
.iter()
.filter(|t| {
.filter(|tag| {
state.search_query.is_empty()
|| t.name.to_lowercase().contains(&state.search_query.to_lowercase())
|| tag.name.to_lowercase().contains(&state.search_query.to_lowercase())
})
.collect();
@@ -201,9 +296,11 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
.iter()
.map(|tag| {
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
row![
container(Space::new(12, 12))
.style(move |_: &Theme| container::Style {
let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id);
let count = state.tag_post_counts.get(&tag.name.to_lowercase()).copied().unwrap_or(0);
button(
row![
container(Space::new(12, 12)).style(move |_: &Theme| container::Style {
background: Some(Background::Color(color)),
border: iced::Border {
radius: 6.0.into(),
@@ -211,65 +308,92 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
},
..container::Style::default()
}),
text(&tag.name).size(14),
Space::with_width(Length::Fill),
button(text(t(locale, "modal.confirmDelete.delete")).size(12))
.on_press(Message::Tags(TagsMsg::DeleteTag(tag.id.clone())))
.style(inputs::danger_button)
.padding([3, 8])
]
.spacing(8)
.align_y(Alignment::Center)
.padding(4)
text(&tag.name).size(14),
text(format!("({count})")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
Space::with_width(Length::Fill),
]
.spacing(8)
.align_y(Alignment::Center),
)
.on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone())))
.padding([6, 8])
.style(move |_theme: &Theme, _status| button::Style {
background: Some(Background::Color(if selected {
Color::from_rgb(0.22, 0.24, 0.30)
} else {
Color::TRANSPARENT
})),
border: iced::Border {
radius: 6.0.into(),
width: if selected { 1.0 } else { 0.0 },
color: Color::from_rgb(0.35, 0.45, 0.65),
},
..button::Style::default()
})
.into()
})
.collect();
let tag_list = iced::widget::Column::with_children(rows).spacing(2);
let tag_list = iced::widget::Column::with_children(rows).spacing(4);
// Edit panel for selected tag
let edit_panel: Element<'a, Message> = if let Some(ref editing) = state.editing_tag {
let template_options = template_options(state, locale);
let selected_template = template_options.iter().find(|option| option.slug == editing.template_slug);
let delete_button: Element<'a, Message> = button(text(t(locale, "modal.confirmDelete.delete")).size(13))
.on_press(Message::Tags(TagsMsg::DeleteTag(editing.id.clone())))
.style(inputs::danger_button)
.padding([6, 16])
.into();
column![
inputs::section_header(&t(locale, "tags.editTag")),
inputs::labeled_input(
&t(locale, "tags.name"),
"",
&editing.name,
|s| Message::Tags(TagsMsg::EditTagName(s)),
|value| Message::Tags(TagsMsg::EditTagName(value)),
),
inputs::labeled_input(
&t(locale, "tags.color"),
"#3498db",
DEFAULT_TAG_COLOR,
&editing.color,
|s| Message::Tags(TagsMsg::EditTagColor(s)),
|value| Message::Tags(TagsMsg::EditTagColor(value)),
),
inputs::labeled_input(
color_swatches(locale, false),
inputs::labeled_select(
&t(locale, "tags.postTemplate"),
"",
&editing.template_slug,
|s| Message::Tags(TagsMsg::EditTagTemplate(s)),
&template_options,
selected_template,
|choice| Message::Tags(TagsMsg::EditTagTemplate(choice)),
),
button(text(t(locale, "common.save")).size(13))
.on_press(Message::Tags(TagsMsg::SaveTag))
.style(inputs::primary_button)
.padding([6, 16]),
row![
button(text(t(locale, "common.save")).size(13))
.on_press(Message::Tags(TagsMsg::SaveTag))
.style(inputs::primary_button)
.padding([6, 16]),
delete_button,
]
.spacing(8),
]
.spacing(8)
.padding(12)
.into()
} else {
Space::new(0, 0).into()
container(text(t(locale, "tags.selectTag")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)))
.padding([8, 0])
.into()
};
scrollable(
column![
create_panel,
inputs::section_header(&t(locale, "tags.manageSection")),
search,
tag_list,
edit_panel,
]
.spacing(12)
.padding(16)
.width(Length::Fill)
.width(Length::Fill),
)
.width(Length::Fill)
.height(Length::Fill)
@@ -277,35 +401,53 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
}
fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
let source_input = inputs::labeled_input(
&t(locale, "tags.mergeSource"),
&t(locale, "tags.selectTag"),
state.merge_source.as_deref().unwrap_or(""),
|s| Message::Tags(TagsMsg::SetMergeSource(s)),
);
let target_input = inputs::labeled_input(
&t(locale, "tags.mergeTarget"),
&t(locale, "tags.selectTag"),
state.merge_target.as_deref().unwrap_or(""),
|s| Message::Tags(TagsMsg::SetMergeTarget(s)),
);
if state.selected_tags.len() < 2 {
return container(text(t(locale, "tags.mergeHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)))
.padding(16)
.into();
}
let can_merge = state.merge_source.is_some() && state.merge_target.is_some();
let tag_options = selected_tag_options(state);
let selected_target = state
.merge_target
.as_ref()
.and_then(|target_id| tag_options.iter().find(|option| &option.id == target_id));
let merge_btn = if can_merge {
button(text(t(locale, "tags.merge")).size(13))
.on_press(Message::Tags(TagsMsg::MergeTags))
.style(inputs::primary_button)
.padding([6, 16])
} else {
button(text(t(locale, "tags.merge")).size(13))
.padding([6, 16])
};
let merge_preview = state
.merge_target
.as_ref()
.map(|target_id| {
selected_tag_options(state)
.into_iter()
.filter(|option| &option.id != target_id)
.map(|option| option.name)
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
column![
source_input,
target_input,
merge_btn,
inputs::section_header(&t(locale, "tags.mergeSection")),
text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())]))
.size(12)
.color(Color::from_rgb(0.75, 0.77, 0.82)),
inputs::labeled_select(
&t(locale, "tags.mergeTarget"),
&tag_options,
selected_target,
|choice| Message::Tags(TagsMsg::SetMergeTarget(choice)),
),
if merge_preview.is_empty() {
Element::from(Space::new(0, 0))
} else {
container(text(tw(locale, "tags.mergePreview", &[("tags", &merge_preview)])).size(12))
.padding([4, 0])
.into()
},
button(text(t(locale, "tags.merge")).size(13))
.on_press_maybe(state.merge_target.is_some().then_some(Message::Tags(TagsMsg::MergeTags)))
.style(inputs::primary_button)
.padding([6, 16]),
]
.spacing(12)
.padding(16)
@@ -313,6 +455,81 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.into()
}
fn view_discover<'a>(_state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
column![
inputs::section_header(&t(locale, "tags.discoverSection")),
text(t(locale, "tags.discoverDescription"))
.size(12)
.color(Color::from_rgb(0.60, 0.60, 0.65)),
button(text(t(locale, "tags.discoverButton")).size(13))
.on_press(Message::Tags(TagsMsg::SyncTags))
.style(inputs::primary_button)
.padding([6, 16]),
]
.spacing(12)
.padding(16)
.width(Length::Fill)
.into()
}
fn template_options(state: &TagsViewState, locale: UiLocale) -> Vec<TemplateOption> {
let mut options = vec![TemplateOption {
slug: String::new(),
label: t(locale, "tags.noTemplate"),
}];
options.extend(state.template_options.iter().map(|slug| TemplateOption {
slug: slug.clone(),
label: slug.clone(),
}));
options
}
fn selected_tag_options(state: &TagsViewState) -> Vec<TagOption> {
state
.selected_tags
.iter()
.filter_map(|tag_id| {
state.tags.iter().find(|tag| &tag.id == tag_id).map(|tag| TagOption {
id: tag.id.clone(),
name: tag.name.clone(),
})
})
.collect()
}
fn color_swatches<'a>(locale: UiLocale, create_mode: bool) -> Element<'a, Message> {
let buttons: Vec<Element<'a, Message>> = COLOR_PRESETS
.iter()
.map(|hex| {
let color = parse_tag_color(hex);
let msg = if create_mode {
TagsMsg::CreateColorChanged((*hex).to_string())
} else {
TagsMsg::EditTagColor((*hex).to_string())
};
button(Space::new(18, 18))
.on_press(Message::Tags(msg))
.padding(0)
.style(move |_theme: &Theme, _status| button::Style {
background: Some(Background::Color(color)),
border: iced::Border {
radius: 9.0.into(),
..iced::Border::default()
},
..button::Style::default()
})
.into()
})
.collect();
column![
text(t(locale, "tags.colorPresets")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
row(buttons).spacing(6).wrap(),
]
.spacing(6)
.into()
}
fn parse_tag_color(hex: &str) -> Color {
let hex = hex.trim_start_matches('#');
if hex.len() == 6 {
@@ -323,4 +540,4 @@ fn parse_tag_color(hex: &str) -> Color {
} else {
Color::from_rgb(0.4, 0.5, 0.7)
}
}
}

View File

@@ -141,12 +141,18 @@ pub fn view<'a>(
let active_tab_is_post = active_tab_type == Some(&TabType::Post);
let active_tab_is_post_or_media =
active_tab_is_post || active_tab_type == Some(&TabType::Media);
let (post_outlinks, post_backlinks) = active_tab
.and_then(|id| post_editors.get(id))
.map(|editor| (editor.outlinks.as_slice(), editor.backlinks.as_slice()))
.unwrap_or((&[], &[]));
right_col = right_col.push(separator_h());
right_col = right_col.push(panel::view(
panel_tab,
task_snapshots,
output_entries,
post_outlinks,
post_backlinks,
locale,
active_tab_is_post,
active_tab_is_post_or_media,

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Tag-Wolke",
"tags.nav.manage": "Tags verwalten",
"tags.nav.merge": "Tags zusammenführen",
"tags.nav.discover": "Tags entdecken",
"sidebar.chatYesterday": "Gestern",
"modal.confirmDelete.title": "Löschen bestätigen",
"modal.confirmDelete.warning": "Diese Aktion kann nicht rückgängig gemacht werden.",
@@ -236,6 +237,11 @@
"editor.linkedMedia": "Verknüpfte Medien",
"editor.linkedMediaEmpty": "Noch keine verknüpften Medien.",
"editor.linkExistingMedia": "Vorhandenes verknüpfen",
"editor.linkToPost": "Mit Beitrag verknüpfen",
"editor.linkedPosts": "Verknüpfte Beiträge",
"editor.linkedPostsEmpty": "Mit keinen Beiträgen verknüpft.",
"editor.postPickerSearch": "Beiträge suchen",
"editor.postPickerSearchPlaceholder": "Beiträge suchen...",
"editor.unlinkMedia": "Verknüpfung lösen",
"editor.linkedMediaKindImage": "Bild",
"editor.linkedMediaKindFile": "Datei",
@@ -244,14 +250,34 @@
"editor.updatedAt": "Aktualisiert",
"editor.publishedAt": "Veröffentlicht",
"tags.noTags": "Noch keine Tags",
"tags.cloudSection": "Wolke",
"tags.cloudHelp": "Wählen Sie einen oder mehrere Tags aus, um sie zu bearbeiten, zu löschen oder zusammenzuführen.",
"tags.selectedCount": "{count} ausgewählt",
"tags.clearSelection": "Auswahl aufheben",
"tags.createSection": "Tag erstellen",
"tags.createPlaceholder": "neues-tag",
"tags.createButton": "Erstellen",
"tags.editTag": "Tag bearbeiten",
"tags.manageSection": "Verwalten",
"tags.name": "Name",
"tags.color": "Farbe",
"tags.colorPresets": "Farbvorgaben",
"tags.postTemplate": "Beitragsvorlage",
"tags.noTemplate": "Keine",
"tags.merge": "Zusammenführen",
"tags.mergeSource": "Quell-Tag",
"tags.mergeTarget": "Ziel-Tag",
"tags.mergeSection": "Zusammenführen",
"tags.mergeHelp": "Wählen Sie mindestens zwei Tags aus der Wolke oder Liste aus, um sie zusammenzuführen.",
"tags.mergePreview": "Zu löschende Tags: {tags}",
"tags.mergeConfirmTitle": "Tags zusammenführen",
"tags.mergeConfirmMessage": "{count} Tags in {target} zusammenführen? Dies kann nicht rückgängig gemacht werden.",
"tags.deleteUsage": "Dieses Tag wird in {count} Beiträgen verwendet.",
"tags.discoverSection": "Entdecken",
"tags.discoverDescription": "Tags aus aktuellen Beiträgen entdecken und tags.json passend zur Datenbank neu schreiben.",
"tags.discoverButton": "Entdecken",
"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?",
"settings.projectName": "Projektname",
"settings.projectDescription": "Beschreibung",
"settings.dataPath": "Datenordner",

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Tag Cloud",
"tags.nav.manage": "Manage Tags",
"tags.nav.merge": "Merge Tags",
"tags.nav.discover": "Discover Tags",
"sidebar.chatYesterday": "Yesterday",
"modal.confirmDelete.title": "Confirm Delete",
"modal.confirmDelete.warning": "This action cannot be undone.",
@@ -233,6 +234,11 @@
"editor.linkedMedia": "Linked Media",
"editor.linkedMediaEmpty": "No linked media yet.",
"editor.linkExistingMedia": "Link Existing",
"editor.linkToPost": "Link to Post",
"editor.linkedPosts": "Linked Posts",
"editor.linkedPostsEmpty": "Not linked to any posts.",
"editor.postPickerSearch": "Search Posts",
"editor.postPickerSearchPlaceholder": "Search posts...",
"editor.unlinkMedia": "Unlink",
"editor.linkedMediaKindImage": "Image",
"editor.linkedMediaKindFile": "File",
@@ -244,14 +250,34 @@
"editor.insertMedia": "Insert Media",
"editor.gallery": "Gallery",
"tags.noTags": "No tags yet",
"tags.cloudSection": "Cloud",
"tags.cloudHelp": "Select one or more tags to edit, delete, or merge them.",
"tags.selectedCount": "{count} selected",
"tags.clearSelection": "Clear selection",
"tags.createSection": "Create Tag",
"tags.createPlaceholder": "new-tag",
"tags.createButton": "Create",
"tags.editTag": "Edit Tag",
"tags.manageSection": "Manage",
"tags.name": "Name",
"tags.color": "Color",
"tags.colorPresets": "Color presets",
"tags.postTemplate": "Post Template",
"tags.noTemplate": "None",
"tags.merge": "Merge",
"tags.mergeSource": "Source Tag",
"tags.mergeTarget": "Target Tag",
"tags.mergeSection": "Merge",
"tags.mergeHelp": "Select at least two tags from the cloud or list to merge them.",
"tags.mergePreview": "Tags to delete: {tags}",
"tags.mergeConfirmTitle": "Merge Tags",
"tags.mergeConfirmMessage": "Merge {count} tags into {target}? This cannot be undone.",
"tags.deleteUsage": "This tag is used in {count} posts.",
"tags.discoverSection": "Discover",
"tags.discoverDescription": "Discover tags from current posts and rewrite tags.json to match the database.",
"tags.discoverButton": "Discover",
"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?",
"settings.projectName": "Project Name",
"settings.projectDescription": "Description",
"settings.dataPath": "Data Folder",

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Nube de etiquetas",
"tags.nav.manage": "Gestionar etiquetas",
"tags.nav.merge": "Fusionar etiquetas",
"tags.nav.discover": "Descubrir etiquetas",
"sidebar.chatYesterday": "Ayer",
"modal.confirmDelete.title": "Confirmar eliminación",
"modal.confirmDelete.warning": "Esta acción no se puede deshacer.",
@@ -236,6 +237,11 @@
"editor.linkedMedia": "Medios vinculados",
"editor.linkedMediaEmpty": "Todavía no hay medios vinculados.",
"editor.linkExistingMedia": "Vincular existente",
"editor.linkToPost": "Vincular a artículo",
"editor.linkedPosts": "Artículos vinculados",
"editor.linkedPostsEmpty": "No está vinculado a ningún artículo.",
"editor.postPickerSearch": "Buscar artículos",
"editor.postPickerSearchPlaceholder": "Buscar artículos...",
"editor.unlinkMedia": "Desvincular",
"editor.linkedMediaKindImage": "Imagen",
"editor.linkedMediaKindFile": "Archivo",
@@ -244,14 +250,34 @@
"editor.updatedAt": "Actualizado",
"editor.publishedAt": "Publicado",
"tags.noTags": "Sin etiquetas",
"tags.cloudSection": "Nube",
"tags.cloudHelp": "Selecciona una o más etiquetas para editarlas, eliminarlas o fusionarlas.",
"tags.selectedCount": "{count} seleccionadas",
"tags.clearSelection": "Borrar selección",
"tags.createSection": "Crear etiqueta",
"tags.createPlaceholder": "nueva-etiqueta",
"tags.createButton": "Crear",
"tags.editTag": "Editar etiqueta",
"tags.manageSection": "Gestionar",
"tags.name": "Nombre",
"tags.color": "Color",
"tags.colorPresets": "Colores predefinidos",
"tags.postTemplate": "Plantilla de artículo",
"tags.noTemplate": "Ninguna",
"tags.merge": "Fusionar",
"tags.mergeSource": "Etiqueta origen",
"tags.mergeTarget": "Etiqueta destino",
"tags.mergeSection": "Fusionar",
"tags.mergeHelp": "Selecciona al menos dos etiquetas de la nube o la lista para fusionarlas.",
"tags.mergePreview": "Etiquetas para eliminar: {tags}",
"tags.mergeConfirmTitle": "Fusionar etiquetas",
"tags.mergeConfirmMessage": "¿Fusionar {count} etiquetas en {target}? Esta acción no se puede deshacer.",
"tags.deleteUsage": "Esta etiqueta se usa en {count} publicaciones.",
"tags.discoverSection": "Descubrir",
"tags.discoverDescription": "Descubre etiquetas a partir de las publicaciones actuales y reescribe tags.json para reflejar la base de datos.",
"tags.discoverButton": "Descubrir",
"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?",
"settings.projectName": "Nombre del proyecto",
"settings.projectDescription": "Descripción",
"settings.dataPath": "Carpeta de datos",

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Nuage de tags",
"tags.nav.manage": "Gérer les tags",
"tags.nav.merge": "Fusionner les tags",
"tags.nav.discover": "Découvrir les tags",
"sidebar.chatYesterday": "Hier",
"modal.confirmDelete.title": "Confirmer la suppression",
"modal.confirmDelete.warning": "Cette action est irréversible.",
@@ -236,6 +237,11 @@
"editor.linkedMedia": "Médias liés",
"editor.linkedMediaEmpty": "Aucun média lié pour le moment.",
"editor.linkExistingMedia": "Lier un existant",
"editor.linkToPost": "Lier à un article",
"editor.linkedPosts": "Articles liés",
"editor.linkedPostsEmpty": "Lié à aucun article.",
"editor.postPickerSearch": "Rechercher des articles",
"editor.postPickerSearchPlaceholder": "Rechercher des articles...",
"editor.unlinkMedia": "Dissocier",
"editor.linkedMediaKindImage": "Image",
"editor.linkedMediaKindFile": "Fichier",
@@ -244,14 +250,34 @@
"editor.updatedAt": "Mis à jour",
"editor.publishedAt": "Publié",
"tags.noTags": "Aucun tag",
"tags.cloudSection": "Nuage",
"tags.cloudHelp": "Sélectionnez un ou plusieurs tags pour les modifier, les supprimer ou les fusionner.",
"tags.selectedCount": "{count} sélectionnés",
"tags.clearSelection": "Effacer la sélection",
"tags.createSection": "Créer un tag",
"tags.createPlaceholder": "nouveau-tag",
"tags.createButton": "Créer",
"tags.editTag": "Modifier le tag",
"tags.manageSection": "Gérer",
"tags.name": "Nom",
"tags.color": "Couleur",
"tags.colorPresets": "Couleurs prédéfinies",
"tags.postTemplate": "Modèle d'article",
"tags.noTemplate": "Aucun",
"tags.merge": "Fusionner",
"tags.mergeSource": "Tag source",
"tags.mergeTarget": "Tag cible",
"tags.mergeSection": "Fusionner",
"tags.mergeHelp": "Sélectionnez au moins deux tags dans le nuage ou la liste pour les fusionner.",
"tags.mergePreview": "Tags à supprimer : {tags}",
"tags.mergeConfirmTitle": "Fusionner les tags",
"tags.mergeConfirmMessage": "Fusionner {count} tags dans {target} ? Cette action est irréversible.",
"tags.deleteUsage": "Ce tag est utilisé dans {count} articles.",
"tags.discoverSection": "Découvrir",
"tags.discoverDescription": "Découvrir les tags à partir des articles actuels et réécrire tags.json pour refléter la base de données.",
"tags.discoverButton": "Découvrir",
"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 ?",
"settings.projectName": "Nom du projet",
"settings.projectDescription": "Description",
"settings.dataPath": "Dossier de données",

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Nuvola di tag",
"tags.nav.manage": "Gestisci tag",
"tags.nav.merge": "Unisci tag",
"tags.nav.discover": "Scopri tag",
"sidebar.chatYesterday": "Ieri",
"modal.confirmDelete.title": "Conferma eliminazione",
"modal.confirmDelete.warning": "Questa azione non può essere annullata.",
@@ -236,6 +237,11 @@
"editor.linkedMedia": "Media collegati",
"editor.linkedMediaEmpty": "Nessun media collegato.",
"editor.linkExistingMedia": "Collega esistente",
"editor.linkToPost": "Collega a articolo",
"editor.linkedPosts": "Articoli collegati",
"editor.linkedPostsEmpty": "Non collegato ad alcun articolo.",
"editor.postPickerSearch": "Cerca articoli",
"editor.postPickerSearchPlaceholder": "Cerca articoli...",
"editor.unlinkMedia": "Scollega",
"editor.linkedMediaKindImage": "Immagine",
"editor.linkedMediaKindFile": "File",
@@ -244,14 +250,34 @@
"editor.updatedAt": "Aggiornato",
"editor.publishedAt": "Pubblicato",
"tags.noTags": "Nessun tag",
"tags.cloudSection": "Nuvola",
"tags.cloudHelp": "Seleziona uno o più tag per modificarli, eliminarli o unirli.",
"tags.selectedCount": "{count} selezionati",
"tags.clearSelection": "Cancella selezione",
"tags.createSection": "Crea tag",
"tags.createPlaceholder": "nuovo-tag",
"tags.createButton": "Crea",
"tags.editTag": "Modifica tag",
"tags.manageSection": "Gestisci",
"tags.name": "Nome",
"tags.color": "Colore",
"tags.colorPresets": "Colori predefiniti",
"tags.postTemplate": "Modello articolo",
"tags.noTemplate": "Nessuno",
"tags.merge": "Unisci",
"tags.mergeSource": "Tag sorgente",
"tags.mergeTarget": "Tag destinazione",
"tags.mergeSection": "Unisci",
"tags.mergeHelp": "Seleziona almeno due tag dalla nuvola o dall'elenco per unirli.",
"tags.mergePreview": "Tag da eliminare: {tags}",
"tags.mergeConfirmTitle": "Unisci tag",
"tags.mergeConfirmMessage": "Unire {count} tag in {target}? Questa azione non può essere annullata.",
"tags.deleteUsage": "Questo tag è usato in {count} post.",
"tags.discoverSection": "Scopri",
"tags.discoverDescription": "Scopri i tag dai post correnti e riscrivi tags.json per riflettere il database.",
"tags.discoverButton": "Scopri",
"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?",
"settings.projectName": "Nome progetto",
"settings.projectDescription": "Descrizione",
"settings.dataPath": "Cartella dati",