feat: first take at milestone M3

This commit is contained in:
2026-04-05 16:28:59 +02:00
parent 118633de81
commit b755c6bcbc
27 changed files with 5372 additions and 134 deletions

View File

@@ -0,0 +1,208 @@
use std::path::Path;
use iced::widget::{button, column, container, image, row, scrollable, text, Space};
use iced::{Color, Element, Length};
use bds_core::i18n::UiLocale;
use bds_core::model::Media;
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::t;
/// State for an open media editor.
#[derive(Debug, Clone)]
pub struct MediaEditorState {
pub media_id: String,
pub filename: String,
pub original_name: String,
pub mime_type: String,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub title: String,
pub alt: String,
pub caption: String,
pub author: String,
pub language: String,
pub file_path: String,
pub tags: Vec<String>,
pub created_at: i64,
pub updated_at: i64,
pub is_dirty: bool,
}
impl MediaEditorState {
pub fn from_media(media: &Media) -> Self {
Self {
media_id: media.id.clone(),
filename: media.filename.clone(),
original_name: media.original_name.clone(),
mime_type: media.mime_type.clone(),
size: media.size,
width: media.width,
height: media.height,
title: media.title.clone().unwrap_or_default(),
alt: media.alt.clone().unwrap_or_default(),
caption: media.caption.clone().unwrap_or_default(),
author: media.author.clone().unwrap_or_default(),
language: media.language.clone().unwrap_or_default(),
file_path: media.file_path.clone(),
tags: media.tags.clone(),
created_at: media.created_at,
updated_at: media.updated_at,
is_dirty: false,
}
}
}
/// Media editor messages.
#[derive(Debug, Clone)]
pub enum MediaEditorMsg {
TitleChanged(String),
AltChanged(String),
CaptionChanged(String),
AuthorChanged(String),
Save,
Delete,
}
/// Render the media editor view.
pub fn view<'a>(
state: &'a MediaEditorState,
locale: UiLocale,
data_dir: Option<&Path>,
) -> Element<'a, Message> {
let header = inputs::toolbar(
vec![
text(state.original_name.clone()).size(18).into(),
],
vec![
button(text(t(locale, "common.save")).size(13))
.on_press(Message::MediaEditor(MediaEditorMsg::Save))
.style(inputs::primary_button)
.padding([6, 16])
.into(),
button(text(t(locale, "modal.confirmDelete.delete")).size(13))
.on_press(Message::MediaEditor(MediaEditorMsg::Delete))
.style(inputs::danger_button)
.padding([6, 16])
.into(),
],
);
// Preview section
let preview: Element<'a, Message> = if state.mime_type.starts_with("image/") {
if let Some(dir) = data_dir {
let img_path = dir.join(&state.file_path);
if img_path.exists() {
container(
image(img_path.to_string_lossy().to_string())
.width(Length::Fill)
.height(Length::Fixed(300.0)),
)
.width(Length::Fill)
.into()
} else {
no_preview()
}
} else {
no_preview()
}
} else {
no_preview()
};
// File info
let dimensions = match (state.width, state.height) {
(Some(w), Some(h)) => format!("{w} × {h}"),
_ => String::new(),
};
let size_str = format_file_size(state.size);
let info = row![
text(format!("{}{}{}", state.mime_type, size_str, dimensions))
.size(12)
.color(Color::from_rgb(0.55, 0.58, 0.65)),
]
.padding(8);
// Metadata fields
let title_input = inputs::labeled_input(
&t(locale, "editor.title"),
&t(locale, "editor.titlePlaceholder"),
&state.title,
|s| Message::MediaEditor(MediaEditorMsg::TitleChanged(s)),
);
let alt_input = inputs::labeled_input(
&t(locale, "editor.alt"),
&t(locale, "editor.altPlaceholder"),
&state.alt,
|s| Message::MediaEditor(MediaEditorMsg::AltChanged(s)),
);
let meta_row1 = row![title_input, alt_input].spacing(16).width(Length::Fill);
let caption_input = inputs::labeled_input(
&t(locale, "editor.caption"),
"",
&state.caption,
|s| Message::MediaEditor(MediaEditorMsg::CaptionChanged(s)),
);
let author_input = inputs::labeled_input(
&t(locale, "editor.author"),
"",
&state.author,
|s| Message::MediaEditor(MediaEditorMsg::AuthorChanged(s)),
);
let meta_row2 = row![caption_input, author_input].spacing(16).width(Length::Fill);
// Footer
let footer = row![
inputs::date_label(&t(locale, "editor.createdAt"), state.created_at),
Space::with_width(Length::Fixed(24.0)),
inputs::date_label(&t(locale, "editor.updatedAt"), state.updated_at),
]
.padding(8);
let body = scrollable(
column![
header,
preview,
info,
inputs::section_header(&t(locale, "editor.metadata")),
meta_row1,
meta_row2,
footer,
]
.spacing(12)
.padding(16)
.width(Length::Fill)
);
container(body)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn no_preview<'a>() -> Element<'a, Message> {
container(
text("No preview available")
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
)
.width(Length::Fill)
.height(Length::Fixed(200.0))
.center_x(Length::Fill)
.center_y(Length::Fixed(200.0))
.into()
}
fn format_file_size(bytes: i64) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}