feat: use thumbnails in the media sidebar
This commit is contained in:
@@ -912,6 +912,7 @@ impl BdsApp {
|
|||||||
self.ui_locale,
|
self.ui_locale,
|
||||||
&self.toasts,
|
&self.toasts,
|
||||||
self.active_modal.as_ref(),
|
self.active_modal.as_ref(),
|
||||||
|
self.data_dir.as_deref(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Space};
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
use bds_core::util::paths::thumbnail_path;
|
||||||
|
|
||||||
use bds_core::i18n::UiLocale;
|
use bds_core::i18n::UiLocale;
|
||||||
use bds_core::model::{Media, Post, Script, Template};
|
use bds_core::model::{Media, Post, Script, Template};
|
||||||
|
|
||||||
@@ -47,6 +51,18 @@ fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 40×40 thumbnail container: rounded corners, dark background.
|
||||||
|
fn thumbnail_container_style(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(Background::Color(Color::from_rgb(0.14, 0.14, 0.17))),
|
||||||
|
border: Border {
|
||||||
|
radius: 4.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..container::Style::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the appropriate empty-state message key for each sidebar view.
|
/// Get the appropriate empty-state message key for each sidebar view.
|
||||||
fn placeholder_key(view: SidebarView) -> &'static str {
|
fn placeholder_key(view: SidebarView) -> &'static str {
|
||||||
match view {
|
match view {
|
||||||
@@ -85,6 +101,29 @@ fn truncate_to_fit(s: &str, available_px: f32) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format a file size in bytes to a human-readable string (B / KB / MB).
|
||||||
|
fn format_file_size(size: i64) -> String {
|
||||||
|
if size < 1024 {
|
||||||
|
format!("{size} B")
|
||||||
|
} else if size < 1024 * 1024 {
|
||||||
|
format!("{:.1} KB", size as f64 / 1024.0)
|
||||||
|
} else {
|
||||||
|
format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the small thumbnail path for a media item on disk.
|
||||||
|
/// Returns Some(path) if the media is an image and the thumbnail file exists.
|
||||||
|
fn resolve_thumbnail_path(data_dir: Option<&Path>, media: &Media) -> Option<PathBuf> {
|
||||||
|
let data_dir = data_dir?;
|
||||||
|
if !media.mime_type.starts_with("image/") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rel = thumbnail_path(&media.id, "small", "webp");
|
||||||
|
let full = data_dir.join(&rel);
|
||||||
|
if full.exists() { Some(full) } else { None }
|
||||||
|
}
|
||||||
|
|
||||||
/// Truncate a media title to the max length, appending "..." if over limit.
|
/// Truncate a media title to the max length, appending "..." if over limit.
|
||||||
/// Per sidebar_views.allium: JS hard limit of 60 chars on title (substring + "...").
|
/// Per sidebar_views.allium: JS hard limit of 60 chars on title (substring + "...").
|
||||||
fn truncate_media_title(title: &str) -> String {
|
fn truncate_media_title(title: &str) -> String {
|
||||||
@@ -482,6 +521,7 @@ pub fn view(
|
|||||||
width: f32,
|
width: f32,
|
||||||
active_tab: Option<&str>,
|
active_tab: Option<&str>,
|
||||||
locale: UiLocale,
|
locale: UiLocale,
|
||||||
|
data_dir: Option<&Path>,
|
||||||
) -> Element<'static, Message> {
|
) -> Element<'static, Message> {
|
||||||
let header_text = t(locale, sidebar_view.i18n_key());
|
let header_text = t(locale, sidebar_view.i18n_key());
|
||||||
let muted = Color::from_rgb(0.50, 0.50, 0.55);
|
let muted = Color::from_rgb(0.50, 0.50, 0.55);
|
||||||
@@ -702,16 +742,60 @@ pub fn view(
|
|||||||
Some(title) => truncate_media_title(title),
|
Some(title) => truncate_media_title(title),
|
||||||
None => m.original_name.clone(),
|
None => m.original_name.clone(),
|
||||||
};
|
};
|
||||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 16.0;
|
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX - 48.0;
|
||||||
let display_name = truncate_to_fit(&display_name, text_px);
|
let display_name = truncate_to_fit(&display_name, text_px);
|
||||||
let label = format!("\u{1F5BC} {display_name}");
|
let style_fn = if is_active { item_active_style } else { item_style };
|
||||||
let label_text = text(label)
|
|
||||||
|
// Left: 40x40 thumbnail or file icon
|
||||||
|
let thumb_path = resolve_thumbnail_path(data_dir, m);
|
||||||
|
let left: Element<'static, Message> = if let Some(path) = thumb_path {
|
||||||
|
container(
|
||||||
|
image(path.to_string_lossy().to_string())
|
||||||
|
.width(Length::Fixed(40.0))
|
||||||
|
.height(Length::Fixed(40.0))
|
||||||
|
.content_fit(iced::ContentFit::Cover)
|
||||||
|
)
|
||||||
|
.width(Length::Fixed(40.0))
|
||||||
|
.height(Length::Fixed(40.0))
|
||||||
|
.clip(true)
|
||||||
|
.style(thumbnail_container_style)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
container(
|
||||||
|
text("\u{1F4C4}") // 📄 generic file icon
|
||||||
|
.size(20)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
)
|
||||||
|
.width(Length::Fixed(40.0))
|
||||||
|
.height(Length::Fixed(40.0))
|
||||||
|
.align_x(iced::alignment::Horizontal::Center)
|
||||||
|
.align_y(iced::alignment::Vertical::Center)
|
||||||
|
.style(thumbnail_container_style)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Right column: name + metadata line
|
||||||
|
let name_text = text(display_name.clone())
|
||||||
.size(12)
|
.size(12)
|
||||||
.shaping(Shaping::Advanced)
|
.shaping(Shaping::Advanced)
|
||||||
.wrapping(iced::widget::text::Wrapping::None);
|
.wrapping(iced::widget::text::Wrapping::None);
|
||||||
let style_fn = if is_active { item_active_style } else { item_style };
|
|
||||||
|
let file_size = format_file_size(m.size);
|
||||||
|
let meta_label = match (m.width, m.height) {
|
||||||
|
(Some(w), Some(h)) => format!("{file_size} · {w}×{h}"),
|
||||||
|
_ => file_size,
|
||||||
|
};
|
||||||
|
let meta_text = text(meta_label)
|
||||||
|
.size(10)
|
||||||
|
.shaping(Shaping::Advanced)
|
||||||
|
.color(Color::from_rgb(0.50, 0.50, 0.55));
|
||||||
|
|
||||||
|
let right = column![name_text, meta_text].spacing(1);
|
||||||
|
|
||||||
|
let content = row![left, right].spacing(8).align_y(iced::Alignment::Center);
|
||||||
|
|
||||||
button(
|
button(
|
||||||
container(label_text)
|
container(content)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.clip(true)
|
.clip(true)
|
||||||
)
|
)
|
||||||
@@ -722,7 +806,7 @@ pub fn view(
|
|||||||
is_transient: true,
|
is_transient: true,
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
}))
|
}))
|
||||||
.padding([3, 6])
|
.padding([4, 6])
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.style(style_fn)
|
.style(style_fn)
|
||||||
.into()
|
.into()
|
||||||
@@ -971,4 +1055,24 @@ mod tests {
|
|||||||
assert!(result.ends_with('\u{2026}'));
|
assert!(result.ends_with('\u{2026}'));
|
||||||
assert!(result.chars().count() >= 2);
|
assert!(result.chars().count() >= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_file_size_bytes() {
|
||||||
|
assert_eq!(format_file_size(0), "0 B");
|
||||||
|
assert_eq!(format_file_size(512), "512 B");
|
||||||
|
assert_eq!(format_file_size(1023), "1023 B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_file_size_kilobytes() {
|
||||||
|
assert_eq!(format_file_size(1024), "1.0 KB");
|
||||||
|
assert_eq!(format_file_size(1536), "1.5 KB");
|
||||||
|
assert_eq!(format_file_size(1024 * 1024 - 1), "1024.0 KB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_file_size_megabytes() {
|
||||||
|
assert_eq!(format_file_size(1024 * 1024), "1.0 MB");
|
||||||
|
assert_eq!(format_file_size(5 * 1024 * 1024), "5.0 MB");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
|
use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
|
||||||
use iced::widget::text::Shaping;
|
use iced::widget::text::Shaping;
|
||||||
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
||||||
@@ -77,6 +79,8 @@ pub fn view(
|
|||||||
toasts: &[Toast],
|
toasts: &[Toast],
|
||||||
// Modal
|
// Modal
|
||||||
active_modal: Option<&modal::ModalState>,
|
active_modal: Option<&modal::ModalState>,
|
||||||
|
// Data directory (for thumbnail paths)
|
||||||
|
data_dir: Option<&Path>,
|
||||||
) -> Element<'static, Message> {
|
) -> Element<'static, Message> {
|
||||||
// Activity bar (leftmost column)
|
// Activity bar (leftmost column)
|
||||||
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
||||||
@@ -114,7 +118,7 @@ pub fn view(
|
|||||||
let mut main_row = row![activity];
|
let mut main_row = row![activity];
|
||||||
|
|
||||||
if sidebar_visible {
|
if sidebar_visible {
|
||||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, post_filter, media_filter, sidebar_width, active_tab, locale));
|
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, post_filter, media_filter, sidebar_width, active_tab, locale, data_dir));
|
||||||
|
|
||||||
// Resize drag handle: 4px wide strip between sidebar and content
|
// Resize drag handle: 4px wide strip between sidebar and content
|
||||||
let handle = container(Space::new(0, 0))
|
let handle = container(Space::new(0, 0))
|
||||||
|
|||||||
@@ -177,9 +177,10 @@ value MediaView {
|
|||||||
|
|
||||||
value MediaGridItem {
|
value MediaGridItem {
|
||||||
media_id: String
|
media_id: String
|
||||||
has_thumbnail: Boolean -- image uses bds-thumb://{id} protocol; non-image uses file icon SVG
|
thumbnail_path: String? -- small (150px) thumbnail on disk when image; null for non-image
|
||||||
name: String -- title truncated to config.media_title_max_length + "..."; fallback originalName (no truncation)
|
name: String -- title truncated to config.media_title_max_length + "..."; fallback originalName (no truncation)
|
||||||
file_size: String -- formatted (B / KB / MB)
|
file_size: String -- formatted (B / KB / MB)
|
||||||
|
dimensions: String? -- "WxH" when width and height known; null otherwise
|
||||||
tooltip: String -- caption ?? originalName
|
tooltip: String -- caption ?? originalName
|
||||||
active: Boolean -- true when activeTabId = media.id
|
active: Boolean -- true when activeTabId = media.id
|
||||||
}
|
}
|
||||||
@@ -188,9 +189,10 @@ surface MediaGridItemEntry {
|
|||||||
context item: MediaGridItem
|
context item: MediaGridItem
|
||||||
|
|
||||||
exposes:
|
exposes:
|
||||||
item.has_thumbnail
|
item.thumbnail_path when item.thumbnail_path != null
|
||||||
item.name
|
item.name
|
||||||
item.file_size
|
item.file_size
|
||||||
|
item.dimensions when item.dimensions != null
|
||||||
item.tooltip
|
item.tooltip
|
||||||
item.active
|
item.active
|
||||||
|
|
||||||
@@ -200,10 +202,12 @@ surface MediaGridItemEntry {
|
|||||||
|
|
||||||
@guarantee CellLayout
|
@guarantee CellLayout
|
||||||
-- Grid cell, row layout.
|
-- Grid cell, row layout.
|
||||||
-- Left: 40x40 thumbnail (rounded, object-fit cover) when has_thumbnail is true;
|
-- Left: 40x40 thumbnail (rounded, object-fit cover) loaded from
|
||||||
-- otherwise generic file SVG icon of same dimensions.
|
-- thumbnail_path (small 150px WebP) when present;
|
||||||
|
-- otherwise generic file icon of same dimensions.
|
||||||
-- Right column, line 1: name (truncated with ellipsis).
|
-- Right column, line 1: name (truncated with ellipsis).
|
||||||
-- Right column, line 2: file_size (smaller, muted).
|
-- Right column, line 2: file_size (smaller, muted) followed by
|
||||||
|
-- dimensions when present, separated by " · ".
|
||||||
|
|
||||||
@guarantee NameTruncation
|
@guarantee NameTruncation
|
||||||
-- Title is hard-truncated at config.media_title_max_length characters
|
-- Title is hard-truncated at config.media_title_max_length characters
|
||||||
|
|||||||
Reference in New Issue
Block a user