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)?; tag_q::insert_tag(conn, &tag)?;
} }
} }
let all_tags = tag_q::list_tags_by_project(conn, project_id)?; let all_tags = tag_q::list_tags_by_project(conn, project_id)?;
Ok(all_tags) 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. /// Rewrite meta/tags.json from DB state.
pub fn rewrite_tags_json( pub fn rewrite_tags_json(
conn: &Connection, conn: &Connection,
@@ -520,7 +530,7 @@ mod tests {
#[test] #[test]
fn sync_tags_from_posts_creates_missing() { fn sync_tags_from_posts_creates_missing() {
let (db, dir) = setup(); let (db, _dir) = setup();
insert_post( insert_post(
db.conn(), db.conn(),
&make_post("x1", "a", vec!["rust".into(), "web".into()]), &make_post("x1", "a", vec!["rust".into(), "web".into()]),
@@ -540,6 +550,23 @@ mod tests {
assert!(names.contains(&"web")); 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] #[test]
fn import_tags_from_file_preserves_colors() { fn import_tags_from_file_preserves_colors() {
let (db, dir) = setup(); 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) { fn register(&mut self, action: MenuAction, item: &MenuItem) {
self.action_map.insert(item.id().clone(), action); self.action_map.insert(item.id().clone(), action);
self.id_map.insert(action, item.id().clone()); self.id_map.insert(action, item.id().clone());

View File

@@ -13,6 +13,12 @@ use crate::components::inputs;
use crate::i18n::t; use crate::i18n::t;
use crate::views::post_editor::TranslationFlag; 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. /// Saved draft content for a single media translation language.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MediaTranslationDraft { pub struct MediaTranslationDraft {
@@ -49,10 +55,19 @@ pub struct MediaEditorState {
pub blog_languages: Vec<String>, pub blog_languages: Vec<String>,
pub saved_canonical: Option<MediaTranslationDraft>, pub saved_canonical: Option<MediaTranslationDraft>,
pub translation_drafts: HashMap<String, 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 { 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 canonical_lang = media.language.clone().unwrap_or_else(|| "en".to_string());
let mut translation_drafts = HashMap::new(); let mut translation_drafts = HashMap::new();
@@ -89,6 +104,10 @@ impl MediaEditorState {
blog_languages: blog_languages.to_vec(), blog_languages: blog_languages.to_vec(),
saved_canonical: None, saved_canonical: None,
translation_drafts, 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), LanguageChanged(String),
TagsChanged(String), TagsChanged(String),
SwitchLanguage(String), SwitchLanguage(String),
TogglePostPicker,
PostPickerSearchChanged(String),
LinkPost(String),
OpenLinkedPost(String),
UnlinkPost(String),
Save, Save,
Delete, Delete,
} }
@@ -308,6 +332,88 @@ pub fn view<'a>(
let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill); 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 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 // Footer
let footer = row![ let footer = row![
inputs::date_label(&t(locale, "editor.createdAt"), state.created_at), inputs::date_label(&t(locale, "editor.createdAt"), state.created_at),
@@ -326,6 +432,9 @@ pub fn view<'a>(
meta_row1, meta_row1,
meta_row2, meta_row2,
meta_row3, meta_row3,
linked_posts_header,
post_picker,
linked_posts_list,
footer, footer,
] ]
.spacing(12) .spacing(12)

View File

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

View File

@@ -7,6 +7,8 @@ use bds_core::i18n::UiLocale;
use crate::app::Message; use crate::app::Message;
use crate::i18n::t; use crate::i18n::t;
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot}; use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
use crate::state::tabs::{Tab, TabType};
use crate::views::post_editor::ResolvedPostLink;
/// Panel background style. /// Panel background style.
fn panel_style(_theme: &Theme) -> container::Style { fn panel_style(_theme: &Theme) -> container::Style {
@@ -66,6 +68,8 @@ pub fn view(
panel_tab: PanelTab, panel_tab: PanelTab,
task_snapshots: &[TaskSnapshot], task_snapshots: &[TaskSnapshot],
output_entries: &[OutputEntry], output_entries: &[OutputEntry],
post_outlinks: &[ResolvedPostLink],
post_backlinks: &[ResolvedPostLink],
locale: UiLocale, locale: UiLocale,
active_tab_is_post: bool, active_tab_is_post: bool,
active_tab_is_post_or_media: bool, active_tab_is_post_or_media: bool,
@@ -183,10 +187,51 @@ pub fn view(
} }
} }
PanelTab::PostLinks => { PanelTab::PostLinks => {
// Post Links content populated in M3 (editor integration) if post_outlinks.is_empty() && post_backlinks.is_empty() {
container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted)) container(text(t(locale, "panel.postLinksPlaceholder")).size(12).shaping(Shaping::Advanced).color(muted))
.padding(8) .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() .into()
}
} }
PanelTab::GitLog => { PanelTab::GitLog => {
// Git Log content populated in extension bucket A (git integration) // Git Log content populated in extension bucket A (git integration)
@@ -204,3 +249,21 @@ pub fn view(
.style(panel_style) .style(panel_style)
.into() .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 updated_at: i64,
pub published_at: Option<i64>, pub published_at: Option<i64>,
pub is_dirty: bool, pub is_dirty: bool,
pub last_edit_at_ms: i64,
pub metadata_expanded: bool, pub metadata_expanded: bool,
pub excerpt_expanded: bool, pub excerpt_expanded: bool,
pub tags_input: String, pub tags_input: String,
@@ -120,6 +121,7 @@ impl Clone for PostEditorState {
updated_at: self.updated_at, updated_at: self.updated_at,
published_at: self.published_at, published_at: self.published_at,
is_dirty: self.is_dirty, is_dirty: self.is_dirty,
last_edit_at_ms: self.last_edit_at_ms,
metadata_expanded: self.metadata_expanded, metadata_expanded: self.metadata_expanded,
excerpt_expanded: self.excerpt_expanded, excerpt_expanded: self.excerpt_expanded,
tags_input: self.tags_input.clone(), tags_input: self.tags_input.clone(),
@@ -180,6 +182,7 @@ impl PostEditorState {
updated_at: post.updated_at, updated_at: post.updated_at,
published_at: post.published_at, published_at: post.published_at,
is_dirty: false, is_dirty: false,
last_edit_at_ms: 0,
metadata_expanded: title.is_empty(), metadata_expanded: title.is_empty(),
excerpt_expanded: false, excerpt_expanded: false,
tags_input: String::new(), 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) { pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
let new_content = { let new_content = {
let mut buffer = self.editor_buffer.borrow_mut(); let mut buffer = self.editor_buffer.borrow_mut();
@@ -203,7 +211,7 @@ impl PostEditorState {
buffer.text() buffer.text()
}; };
self.content = new_content; self.content = new_content;
self.is_dirty = true; self.mark_dirty();
} }
/// Switch the editor to display a different language. /// Switch the editor to display a different language.

View File

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

View File

@@ -8,16 +8,22 @@ use bds_core::model::Tag;
use crate::app::Message; use crate::app::Message;
use crate::components::inputs; use crate::components::inputs;
use crate::i18n::t; use crate::i18n::{t, tw};
const DEFAULT_TAG_COLOR: &str = "#6495ed"; 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. /// Active view within the Tags tab.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TagsSection { pub enum TagsSection {
Cloud, Cloud,
Manage, Manage,
Merge, Merge,
Discover,
} }
/// State for the tags view. /// State for the tags view.
@@ -27,31 +33,64 @@ pub struct TagsViewState {
pub tags: Vec<Tag>, pub tags: Vec<Tag>,
pub tag_post_counts: HashMap<String, usize>, pub tag_post_counts: HashMap<String, usize>,
pub search_query: String, 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>, pub editing_tag: Option<EditingTag>,
/// For "Merge": source and target tag selection
pub merge_source: Option<String>,
pub merge_target: Option<String>, pub merge_target: Option<String>,
pub template_options: Vec<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EditingTag { pub struct EditingTag {
pub id: String, pub id: String,
pub original_name: String,
pub name: String, pub name: String,
pub color: String, pub color: String,
pub template_slug: 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 { 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 { Self {
section: TagsSection::Cloud, section: TagsSection::Cloud,
tags, tags,
tag_post_counts, tag_post_counts,
search_query: String::new(), search_query: String::new(),
selected_tags: Vec::new(),
create_name: String::new(),
create_color: String::new(),
editing_tag: None, editing_tag: None,
merge_source: None,
merge_target: None, merge_target: None,
template_options,
} }
} }
} }
@@ -61,28 +100,28 @@ impl TagsViewState {
pub enum TagsMsg { pub enum TagsMsg {
SetSection(TagsSection), SetSection(TagsSection),
SearchChanged(String), SearchChanged(String),
SelectTag(String), ToggleTagSelection(String),
CreateTag(String), ClearSelection,
CreateNameChanged(String),
CreateColorChanged(String),
CreateTag,
EditTagName(String), EditTagName(String),
EditTagColor(String), EditTagColor(String),
EditTagTemplate(String), EditTagTemplate(TemplateOption),
SaveTag, SaveTag,
DeleteTag(String), DeleteTag(String),
SetMergeSource(String), SetMergeTarget(TagOption),
SetMergeTarget(String),
MergeTags, MergeTags,
SyncTags,
} }
/// Render the tags management view. /// Render the tags management view.
pub fn view<'a>( pub fn view<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
state: &'a TagsViewState,
locale: UiLocale,
) -> Element<'a, Message> {
// Section navigation tabs
let section_nav = row![ let section_nav = row![
section_tab(&t(locale, "tags.nav.cloud"), state.section == TagsSection::Cloud, TagsSection::Cloud), 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.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.merge"), state.section == TagsSection::Merge, TagsSection::Merge),
section_tab(&t(locale, "tags.nav.discover"), state.section == TagsSection::Discover, TagsSection::Discover),
] ]
.spacing(4) .spacing(4)
.padding(8); .padding(8);
@@ -91,6 +130,7 @@ pub fn view<'a>(
TagsSection::Cloud => view_cloud(state, locale), TagsSection::Cloud => view_cloud(state, locale),
TagsSection::Manage => view_manage(state, locale), TagsSection::Manage => view_manage(state, locale),
TagsSection::Merge => view_merge(state, locale), TagsSection::Merge => view_merge(state, locale),
TagsSection::Discover => view_discover(state, locale),
}; };
column![section_nav, content] column![section_nav, content]
@@ -137,13 +177,18 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.into(); .into();
} }
// Calculate min/max post counts for font scaling let counts: Vec<usize> = state
let counts: Vec<usize> = state.tags.iter() .tags
.map(|t| *state.tag_post_counts.get(&t.name.to_lowercase()).unwrap_or(&0)) .iter()
.map(|tag| *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0))
.collect(); .collect();
let min_count = *counts.iter().min().unwrap_or(&0); let min_count = *counts.iter().min().unwrap_or(&0);
let max_count = *counts.iter().max().unwrap_or(&1); 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 MIN_FONT: f32 = 11.0;
const MAX_FONT: f32 = 24.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 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 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; let vert_pad = ((MAX_FONT - font_size) / 4.0).max(2.0) as u16;
button(text(&tag.name).size(font_size).color(Color::WHITE)) let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id);
.on_press(Message::Tags(TagsMsg::SelectTag(tag.id.clone()))) button(
.padding([vert_pad, 10]) row![
.style(move |_: &Theme, _| button::Style { text(&tag.name).size(font_size).color(Color::WHITE),
background: Some(Background::Color(color)), text(post_count.to_string()).size(11).color(Color::from_rgba(1.0, 1.0, 1.0, 0.85)),
border: iced::Border { ]
radius: 12.0.into(), .spacing(6)
..iced::Border::default() .align_y(Alignment::Center),
}, )
text_color: Color::WHITE, .on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone())))
..button::Style::default() .padding([vert_pad, 10])
}) .style(move |_: &Theme, _| button::Style {
.into() 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(); .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( scrollable(
container(cloud) column![
.width(Length::Fill) inputs::section_header(&t(locale, "tags.cloudSection")),
.padding(16), selection_summary,
row(chips).spacing(6).wrap(),
]
.spacing(12)
.width(Length::Fill)
.padding(16),
) )
.width(Length::Fill) .width(Length::Fill)
.height(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> { 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) 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); .size(14);
let filtered: Vec<&Tag> = state let filtered: Vec<&Tag> = state
.tags .tags
.iter() .iter()
.filter(|t| { .filter(|tag| {
state.search_query.is_empty() 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(); .collect();
@@ -201,9 +296,11 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
.iter() .iter()
.map(|tag| { .map(|tag| {
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR)); let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
row![ let selected = state.selected_tags.iter().any(|selected_id| selected_id == &tag.id);
container(Space::new(12, 12)) let count = state.tag_post_counts.get(&tag.name.to_lowercase()).copied().unwrap_or(0);
.style(move |_: &Theme| container::Style { button(
row![
container(Space::new(12, 12)).style(move |_: &Theme| container::Style {
background: Some(Background::Color(color)), background: Some(Background::Color(color)),
border: iced::Border { border: iced::Border {
radius: 6.0.into(), radius: 6.0.into(),
@@ -211,65 +308,92 @@ fn view_manage<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Me
}, },
..container::Style::default() ..container::Style::default()
}), }),
text(&tag.name).size(14), text(&tag.name).size(14),
Space::with_width(Length::Fill), text(format!("({count})")).size(12).color(Color::from_rgb(0.55, 0.58, 0.65)),
button(text(t(locale, "modal.confirmDelete.delete")).size(12)) Space::with_width(Length::Fill),
.on_press(Message::Tags(TagsMsg::DeleteTag(tag.id.clone()))) ]
.style(inputs::danger_button) .spacing(8)
.padding([3, 8]) .align_y(Alignment::Center),
] )
.spacing(8) .on_press(Message::Tags(TagsMsg::ToggleTagSelection(tag.id.clone())))
.align_y(Alignment::Center) .padding([6, 8])
.padding(4) .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() .into()
}) })
.collect(); .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 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![ column![
inputs::section_header(&t(locale, "tags.editTag")), inputs::section_header(&t(locale, "tags.editTag")),
inputs::labeled_input( inputs::labeled_input(
&t(locale, "tags.name"), &t(locale, "tags.name"),
"", "",
&editing.name, &editing.name,
|s| Message::Tags(TagsMsg::EditTagName(s)), |value| Message::Tags(TagsMsg::EditTagName(value)),
), ),
inputs::labeled_input( inputs::labeled_input(
&t(locale, "tags.color"), &t(locale, "tags.color"),
"#3498db", DEFAULT_TAG_COLOR,
&editing.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"), &t(locale, "tags.postTemplate"),
"", &template_options,
&editing.template_slug, selected_template,
|s| Message::Tags(TagsMsg::EditTagTemplate(s)), |choice| Message::Tags(TagsMsg::EditTagTemplate(choice)),
), ),
button(text(t(locale, "common.save")).size(13)) row![
.on_press(Message::Tags(TagsMsg::SaveTag)) button(text(t(locale, "common.save")).size(13))
.style(inputs::primary_button) .on_press(Message::Tags(TagsMsg::SaveTag))
.padding([6, 16]), .style(inputs::primary_button)
.padding([6, 16]),
delete_button,
]
.spacing(8),
] ]
.spacing(8) .spacing(8)
.padding(12)
.into() .into()
} else { } 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( scrollable(
column![ column![
create_panel,
inputs::section_header(&t(locale, "tags.manageSection")),
search, search,
tag_list, tag_list,
edit_panel, edit_panel,
] ]
.spacing(12) .spacing(12)
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill),
) )
.width(Length::Fill) .width(Length::Fill)
.height(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> { fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Message> {
let source_input = inputs::labeled_input( if state.selected_tags.len() < 2 {
&t(locale, "tags.mergeSource"), return container(text(t(locale, "tags.mergeHelp")).size(12).color(Color::from_rgb(0.60, 0.60, 0.65)))
&t(locale, "tags.selectTag"), .padding(16)
state.merge_source.as_deref().unwrap_or(""), .into();
|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)),
);
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 { let merge_preview = state
button(text(t(locale, "tags.merge")).size(13)) .merge_target
.on_press(Message::Tags(TagsMsg::MergeTags)) .as_ref()
.style(inputs::primary_button) .map(|target_id| {
.padding([6, 16]) selected_tag_options(state)
} else { .into_iter()
button(text(t(locale, "tags.merge")).size(13)) .filter(|option| &option.id != target_id)
.padding([6, 16]) .map(|option| option.name)
}; .collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
column![ column![
source_input, inputs::section_header(&t(locale, "tags.mergeSection")),
target_input, text(tw(locale, "tags.selectedCount", &[("count", &state.selected_tags.len().to_string())]))
merge_btn, .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) .spacing(12)
.padding(16) .padding(16)
@@ -313,6 +455,81 @@ fn view_merge<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.into() .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 { fn parse_tag_color(hex: &str) -> Color {
let hex = hex.trim_start_matches('#'); let hex = hex.trim_start_matches('#');
if hex.len() == 6 { if hex.len() == 6 {

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 = active_tab_type == Some(&TabType::Post);
let active_tab_is_post_or_media = let active_tab_is_post_or_media =
active_tab_is_post || active_tab_type == Some(&TabType::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(separator_h());
right_col = right_col.push(panel::view( right_col = right_col.push(panel::view(
panel_tab, panel_tab,
task_snapshots, task_snapshots,
output_entries, output_entries,
post_outlinks,
post_backlinks,
locale, locale,
active_tab_is_post, active_tab_is_post,
active_tab_is_post_or_media, active_tab_is_post_or_media,

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Tag-Wolke", "tags.nav.cloud": "Tag-Wolke",
"tags.nav.manage": "Tags verwalten", "tags.nav.manage": "Tags verwalten",
"tags.nav.merge": "Tags zusammenführen", "tags.nav.merge": "Tags zusammenführen",
"tags.nav.discover": "Tags entdecken",
"sidebar.chatYesterday": "Gestern", "sidebar.chatYesterday": "Gestern",
"modal.confirmDelete.title": "Löschen bestätigen", "modal.confirmDelete.title": "Löschen bestätigen",
"modal.confirmDelete.warning": "Diese Aktion kann nicht rückgängig gemacht werden.", "modal.confirmDelete.warning": "Diese Aktion kann nicht rückgängig gemacht werden.",
@@ -236,6 +237,11 @@
"editor.linkedMedia": "Verknüpfte Medien", "editor.linkedMedia": "Verknüpfte Medien",
"editor.linkedMediaEmpty": "Noch keine verknüpften Medien.", "editor.linkedMediaEmpty": "Noch keine verknüpften Medien.",
"editor.linkExistingMedia": "Vorhandenes verknüpfen", "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.unlinkMedia": "Verknüpfung lösen",
"editor.linkedMediaKindImage": "Bild", "editor.linkedMediaKindImage": "Bild",
"editor.linkedMediaKindFile": "Datei", "editor.linkedMediaKindFile": "Datei",
@@ -244,14 +250,34 @@
"editor.updatedAt": "Aktualisiert", "editor.updatedAt": "Aktualisiert",
"editor.publishedAt": "Veröffentlicht", "editor.publishedAt": "Veröffentlicht",
"tags.noTags": "Noch keine Tags", "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.editTag": "Tag bearbeiten",
"tags.manageSection": "Verwalten",
"tags.name": "Name", "tags.name": "Name",
"tags.color": "Farbe", "tags.color": "Farbe",
"tags.colorPresets": "Farbvorgaben",
"tags.postTemplate": "Beitragsvorlage", "tags.postTemplate": "Beitragsvorlage",
"tags.noTemplate": "Keine",
"tags.merge": "Zusammenführen", "tags.merge": "Zusammenführen",
"tags.mergeSource": "Quell-Tag",
"tags.mergeTarget": "Ziel-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", "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.projectName": "Projektname",
"settings.projectDescription": "Beschreibung", "settings.projectDescription": "Beschreibung",
"settings.dataPath": "Datenordner", "settings.dataPath": "Datenordner",

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Tag Cloud", "tags.nav.cloud": "Tag Cloud",
"tags.nav.manage": "Manage Tags", "tags.nav.manage": "Manage Tags",
"tags.nav.merge": "Merge Tags", "tags.nav.merge": "Merge Tags",
"tags.nav.discover": "Discover Tags",
"sidebar.chatYesterday": "Yesterday", "sidebar.chatYesterday": "Yesterday",
"modal.confirmDelete.title": "Confirm Delete", "modal.confirmDelete.title": "Confirm Delete",
"modal.confirmDelete.warning": "This action cannot be undone.", "modal.confirmDelete.warning": "This action cannot be undone.",
@@ -233,6 +234,11 @@
"editor.linkedMedia": "Linked Media", "editor.linkedMedia": "Linked Media",
"editor.linkedMediaEmpty": "No linked media yet.", "editor.linkedMediaEmpty": "No linked media yet.",
"editor.linkExistingMedia": "Link Existing", "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.unlinkMedia": "Unlink",
"editor.linkedMediaKindImage": "Image", "editor.linkedMediaKindImage": "Image",
"editor.linkedMediaKindFile": "File", "editor.linkedMediaKindFile": "File",
@@ -244,14 +250,34 @@
"editor.insertMedia": "Insert Media", "editor.insertMedia": "Insert Media",
"editor.gallery": "Gallery", "editor.gallery": "Gallery",
"tags.noTags": "No tags yet", "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.editTag": "Edit Tag",
"tags.manageSection": "Manage",
"tags.name": "Name", "tags.name": "Name",
"tags.color": "Color", "tags.color": "Color",
"tags.colorPresets": "Color presets",
"tags.postTemplate": "Post Template", "tags.postTemplate": "Post Template",
"tags.noTemplate": "None",
"tags.merge": "Merge", "tags.merge": "Merge",
"tags.mergeSource": "Source Tag",
"tags.mergeTarget": "Target 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", "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.projectName": "Project Name",
"settings.projectDescription": "Description", "settings.projectDescription": "Description",
"settings.dataPath": "Data Folder", "settings.dataPath": "Data Folder",

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Nube de etiquetas", "tags.nav.cloud": "Nube de etiquetas",
"tags.nav.manage": "Gestionar etiquetas", "tags.nav.manage": "Gestionar etiquetas",
"tags.nav.merge": "Fusionar etiquetas", "tags.nav.merge": "Fusionar etiquetas",
"tags.nav.discover": "Descubrir etiquetas",
"sidebar.chatYesterday": "Ayer", "sidebar.chatYesterday": "Ayer",
"modal.confirmDelete.title": "Confirmar eliminación", "modal.confirmDelete.title": "Confirmar eliminación",
"modal.confirmDelete.warning": "Esta acción no se puede deshacer.", "modal.confirmDelete.warning": "Esta acción no se puede deshacer.",
@@ -236,6 +237,11 @@
"editor.linkedMedia": "Medios vinculados", "editor.linkedMedia": "Medios vinculados",
"editor.linkedMediaEmpty": "Todavía no hay medios vinculados.", "editor.linkedMediaEmpty": "Todavía no hay medios vinculados.",
"editor.linkExistingMedia": "Vincular existente", "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.unlinkMedia": "Desvincular",
"editor.linkedMediaKindImage": "Imagen", "editor.linkedMediaKindImage": "Imagen",
"editor.linkedMediaKindFile": "Archivo", "editor.linkedMediaKindFile": "Archivo",
@@ -244,14 +250,34 @@
"editor.updatedAt": "Actualizado", "editor.updatedAt": "Actualizado",
"editor.publishedAt": "Publicado", "editor.publishedAt": "Publicado",
"tags.noTags": "Sin etiquetas", "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.editTag": "Editar etiqueta",
"tags.manageSection": "Gestionar",
"tags.name": "Nombre", "tags.name": "Nombre",
"tags.color": "Color", "tags.color": "Color",
"tags.colorPresets": "Colores predefinidos",
"tags.postTemplate": "Plantilla de artículo", "tags.postTemplate": "Plantilla de artículo",
"tags.noTemplate": "Ninguna",
"tags.merge": "Fusionar", "tags.merge": "Fusionar",
"tags.mergeSource": "Etiqueta origen",
"tags.mergeTarget": "Etiqueta destino", "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", "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.projectName": "Nombre del proyecto",
"settings.projectDescription": "Descripción", "settings.projectDescription": "Descripción",
"settings.dataPath": "Carpeta de datos", "settings.dataPath": "Carpeta de datos",

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Nuage de tags", "tags.nav.cloud": "Nuage de tags",
"tags.nav.manage": "Gérer les tags", "tags.nav.manage": "Gérer les tags",
"tags.nav.merge": "Fusionner les tags", "tags.nav.merge": "Fusionner les tags",
"tags.nav.discover": "Découvrir les tags",
"sidebar.chatYesterday": "Hier", "sidebar.chatYesterday": "Hier",
"modal.confirmDelete.title": "Confirmer la suppression", "modal.confirmDelete.title": "Confirmer la suppression",
"modal.confirmDelete.warning": "Cette action est irréversible.", "modal.confirmDelete.warning": "Cette action est irréversible.",
@@ -236,6 +237,11 @@
"editor.linkedMedia": "Médias liés", "editor.linkedMedia": "Médias liés",
"editor.linkedMediaEmpty": "Aucun média lié pour le moment.", "editor.linkedMediaEmpty": "Aucun média lié pour le moment.",
"editor.linkExistingMedia": "Lier un existant", "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.unlinkMedia": "Dissocier",
"editor.linkedMediaKindImage": "Image", "editor.linkedMediaKindImage": "Image",
"editor.linkedMediaKindFile": "Fichier", "editor.linkedMediaKindFile": "Fichier",
@@ -244,14 +250,34 @@
"editor.updatedAt": "Mis à jour", "editor.updatedAt": "Mis à jour",
"editor.publishedAt": "Publié", "editor.publishedAt": "Publié",
"tags.noTags": "Aucun tag", "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.editTag": "Modifier le tag",
"tags.manageSection": "Gérer",
"tags.name": "Nom", "tags.name": "Nom",
"tags.color": "Couleur", "tags.color": "Couleur",
"tags.colorPresets": "Couleurs prédéfinies",
"tags.postTemplate": "Modèle d'article", "tags.postTemplate": "Modèle d'article",
"tags.noTemplate": "Aucun",
"tags.merge": "Fusionner", "tags.merge": "Fusionner",
"tags.mergeSource": "Tag source",
"tags.mergeTarget": "Tag cible", "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", "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.projectName": "Nom du projet",
"settings.projectDescription": "Description", "settings.projectDescription": "Description",
"settings.dataPath": "Dossier de données", "settings.dataPath": "Dossier de données",

View File

@@ -159,6 +159,7 @@
"tags.nav.cloud": "Nuvola di tag", "tags.nav.cloud": "Nuvola di tag",
"tags.nav.manage": "Gestisci tag", "tags.nav.manage": "Gestisci tag",
"tags.nav.merge": "Unisci tag", "tags.nav.merge": "Unisci tag",
"tags.nav.discover": "Scopri tag",
"sidebar.chatYesterday": "Ieri", "sidebar.chatYesterday": "Ieri",
"modal.confirmDelete.title": "Conferma eliminazione", "modal.confirmDelete.title": "Conferma eliminazione",
"modal.confirmDelete.warning": "Questa azione non può essere annullata.", "modal.confirmDelete.warning": "Questa azione non può essere annullata.",
@@ -236,6 +237,11 @@
"editor.linkedMedia": "Media collegati", "editor.linkedMedia": "Media collegati",
"editor.linkedMediaEmpty": "Nessun media collegato.", "editor.linkedMediaEmpty": "Nessun media collegato.",
"editor.linkExistingMedia": "Collega esistente", "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.unlinkMedia": "Scollega",
"editor.linkedMediaKindImage": "Immagine", "editor.linkedMediaKindImage": "Immagine",
"editor.linkedMediaKindFile": "File", "editor.linkedMediaKindFile": "File",
@@ -244,14 +250,34 @@
"editor.updatedAt": "Aggiornato", "editor.updatedAt": "Aggiornato",
"editor.publishedAt": "Pubblicato", "editor.publishedAt": "Pubblicato",
"tags.noTags": "Nessun tag", "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.editTag": "Modifica tag",
"tags.manageSection": "Gestisci",
"tags.name": "Nome", "tags.name": "Nome",
"tags.color": "Colore", "tags.color": "Colore",
"tags.colorPresets": "Colori predefiniti",
"tags.postTemplate": "Modello articolo", "tags.postTemplate": "Modello articolo",
"tags.noTemplate": "Nessuno",
"tags.merge": "Unisci", "tags.merge": "Unisci",
"tags.mergeSource": "Tag sorgente",
"tags.mergeTarget": "Tag destinazione", "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", "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.projectName": "Nome progetto",
"settings.projectDescription": "Descrizione", "settings.projectDescription": "Descrizione",
"settings.dataPath": "Cartella dati", "settings.dataPath": "Cartella dati",