Skip to main content

mimick/library/
explore_view.rs

1//! Sectioned landing page mirroring Immich web's Explore tab.
2//!
3//! Four rows: People (round avatars), Recently Added (date tiles),
4//! Places (city tiles), Things (tag tiles). Tile clicks invoke
5//! caller-provided closures so dispatch lives in `mod.rs`.
6
7use std::cell::{Cell, RefCell};
8use std::rc::Rc;
9use std::sync::Arc;
10
11use gdk4::Texture;
12use glib::Bytes;
13use gtk::prelude::*;
14
15use crate::api_client::{ExploreSection, Person, PlaceItem, ThumbnailSize};
16use crate::app_context::AppContext;
17
18type ExploreClick = Rc<dyn Fn(&str, String, String)>;
19type PersonClick = Rc<dyn Fn(String, String)>;
20
21const INITIAL_TILE_COUNT: usize = 16;
22const RECENTS_EXPANDED_COUNT: usize = 30;
23
24/// Contains references to individual grid widgets of the explore tab dashboard display.
25pub struct ExploreViewParts {
26    pub root: gtk::ScrolledWindow,
27    pub populated: Rc<Cell<bool>>,
28    people_row: gtk::Box,
29    recents_grid: gtk::FlowBox,
30    places_grid: gtk::FlowBox,
31    things_grid: gtk::FlowBox,
32    people_section: gtk::Box,
33    recents_section: gtk::Box,
34    places_section: gtk::Box,
35    things_section: gtk::Box,
36    people_spinner: gtk::Spinner,
37    recents_spinner: gtk::Spinner,
38    places_spinner: gtk::Spinner,
39    things_spinner: gtk::Spinner,
40    pub people_filter_button: gtk::MenuButton,
41    cached_people: Rc<RefCell<Vec<Person>>>,
42    cached_people_click: Rc<RefCell<Option<PersonClick>>>,
43    cached_places: Rc<RefCell<Vec<PlaceItem>>>,
44    cached_places_click: Rc<RefCell<Option<ExploreClick>>>,
45    pub search_query: Rc<RefCell<String>>,
46    pub filter_entry: gtk::SearchEntry,
47    cached_ctx: Rc<RefCell<Option<Arc<AppContext>>>>,
48}
49
50/// Construct the hierarchical panels and containers for the explore dashboard view.
51pub fn build_explore_view() -> ExploreViewParts {
52    let outer = gtk::Box::builder()
53        .orientation(gtk::Orientation::Vertical)
54        .spacing(24)
55        .margin_top(16)
56        .margin_bottom(16)
57        .margin_start(16)
58        .margin_end(16)
59        .build();
60
61    let (people_section, people_row, people_spinner, people_filter_button) = build_people_section();
62    let (recents_section, recents_grid, recents_spinner) = build_tile_section("Recently Added");
63    let (places_section, places_grid, places_spinner) = build_tile_section("Places");
64    let (things_section, things_grid, things_spinner) = build_tile_section("Things");
65
66    let filter_entry = gtk::SearchEntry::builder()
67        .placeholder_text("Filter people and places")
68        .hexpand(true)
69        .max_width_chars(20)
70        .build();
71    outer.append(&filter_entry);
72
73    outer.append(&people_section);
74    outer.append(&places_section);
75    outer.append(&recents_section);
76    outer.append(&things_section);
77
78    let root = gtk::ScrolledWindow::builder()
79        .child(&outer)
80        .hscrollbar_policy(gtk::PolicyType::Never)
81        .vexpand(true)
82        .hexpand(true)
83        .build();
84
85    ExploreViewParts {
86        root,
87        populated: Rc::new(Cell::new(false)),
88        people_row,
89        recents_grid,
90        places_grid,
91        things_grid,
92        people_section,
93        recents_section,
94        places_section,
95        things_section,
96        people_spinner,
97        recents_spinner,
98        places_spinner,
99        things_spinner,
100        people_filter_button,
101        cached_people: Rc::new(RefCell::new(Vec::new())),
102        cached_people_click: Rc::new(RefCell::new(None)),
103        cached_places: Rc::new(RefCell::new(Vec::new())),
104        cached_places_click: Rc::new(RefCell::new(None)),
105        search_query: Rc::new(RefCell::new(String::new())),
106        filter_entry,
107        cached_ctx: Rc::new(RefCell::new(None)),
108    }
109}
110
111/// Reveal each section with its spinner active, so the user gets immediate
112/// visual feedback that data is on the way. Each `populate_*` call clears
113/// its own spinner when results arrive.
114pub fn show_loading(parts: &ExploreViewParts) {
115    for (section, spinner) in [
116        (&parts.people_section, &parts.people_spinner),
117        (&parts.recents_section, &parts.recents_spinner),
118        (&parts.places_section, &parts.places_spinner),
119        (&parts.things_section, &parts.things_spinner),
120    ] {
121        section.set_visible(true);
122        spinner.set_visible(true);
123        spinner.start();
124    }
125}
126
127fn stop_spinner(spinner: &gtk::Spinner) {
128    spinner.stop();
129    spinner.set_visible(false);
130}
131
132/// Build a horizontal scrolled gallery row dedicated to recognized people circles.
133fn build_people_section() -> (gtk::Box, gtk::Box, gtk::Spinner, gtk::MenuButton) {
134    let section = gtk::Box::builder()
135        .orientation(gtk::Orientation::Vertical)
136        .spacing(8)
137        .visible(false)
138        .build();
139
140    let header = gtk::Box::builder()
141        .orientation(gtk::Orientation::Horizontal)
142        .spacing(8)
143        .build();
144    let title = heading("People");
145    title.set_hexpand(true);
146    header.append(&title);
147    let spinner = gtk::Spinner::builder()
148        .visible(false)
149        .valign(gtk::Align::Center)
150        .build();
151    header.append(&spinner);
152    let filter_button = gtk::MenuButton::builder()
153        .icon_name("view-more-symbolic")
154        .tooltip_text("Filter people")
155        .css_classes(["flat"])
156        .valign(gtk::Align::Center)
157        .build();
158    header.append(&filter_button);
159    section.append(&header);
160
161    let row = gtk::Box::builder()
162        .orientation(gtk::Orientation::Horizontal)
163        .spacing(12)
164        .build();
165    let scroller = gtk::ScrolledWindow::builder()
166        .child(&row)
167        .vscrollbar_policy(gtk::PolicyType::Never)
168        .hscrollbar_policy(gtk::PolicyType::Automatic)
169        .height_request(140)
170        .build();
171    section.append(&scroller);
172    (section, row, spinner, filter_button)
173}
174
175/// Build a flow grid section mapping image tiles for Places or Things category.
176fn build_tile_section(title: &str) -> (gtk::Box, gtk::FlowBox, gtk::Spinner) {
177    let section = gtk::Box::builder()
178        .orientation(gtk::Orientation::Vertical)
179        .spacing(8)
180        .visible(false)
181        .build();
182    let header = gtk::Box::builder()
183        .orientation(gtk::Orientation::Horizontal)
184        .spacing(8)
185        .build();
186    let title_label = heading(title);
187    title_label.set_hexpand(true);
188    header.append(&title_label);
189    let spinner = gtk::Spinner::builder()
190        .visible(false)
191        .valign(gtk::Align::Center)
192        .build();
193    header.append(&spinner);
194    section.append(&header);
195    let grid = gtk::FlowBox::builder()
196        .selection_mode(gtk::SelectionMode::None)
197        .row_spacing(8)
198        .column_spacing(8)
199        .min_children_per_line(2)
200        .max_children_per_line(20)
201        .homogeneous(true)
202        .halign(gtk::Align::Start)
203        .build();
204    section.append(&grid);
205    (section, grid, spinner)
206}
207
208/// Helper to create a styled section heading label.
209fn heading(text: &str) -> gtk::Label {
210    gtk::Label::builder()
211        .label(text)
212        .xalign(0.0)
213        .css_classes(vec!["title-2".to_string()])
214        .build()
215}
216
217/// Populate the round avatar buttons for recognized people in the dashboard section.
218///
219/// Stores the full fetched list so face-visibility toggles can re-filter without
220/// re-querying the server. Always call with `include_hidden=true` upstream so the
221/// `Show hidden` toggle can apply locally.
222pub fn populate_people<F>(
223    parts: &ExploreViewParts,
224    ctx: Arc<AppContext>,
225    people: Vec<Person>,
226    on_click: F,
227) where
228    F: Fn(String, String) + 'static,
229{
230    stop_spinner(&parts.people_spinner);
231    *parts.cached_people.borrow_mut() = people;
232    *parts.cached_people_click.borrow_mut() = Some(Rc::new(on_click));
233    *parts.cached_ctx.borrow_mut() = Some(ctx.clone());
234    render_people(parts, ctx);
235}
236
237/// Apply (or clear) the search filter on the people row. Pass an empty string
238/// to disable filtering. Caller drives this from the header-bar search entry
239/// when the Explore view is the active content stack child.
240pub fn set_people_search(parts: &ExploreViewParts, query: &str) {
241    *parts.search_query.borrow_mut() = query.to_string();
242    let Some(ctx) = parts.cached_ctx.borrow().clone() else {
243        return;
244    };
245    render_people(parts, ctx.clone());
246    render_places_filtered(parts, ctx);
247}
248
249/// Re-render the places section applying the current search query.
250fn render_places_filtered(parts: &ExploreViewParts, ctx: Arc<AppContext>) {
251    while let Some(child) = parts.places_grid.first_child() {
252        parts.places_grid.remove(&child);
253    }
254    let places = parts.cached_places.borrow();
255    let query = parts.search_query.borrow().to_ascii_lowercase();
256    let filtered: Vec<&PlaceItem> = if query.is_empty() {
257        places.iter().collect()
258    } else {
259        places
260            .iter()
261            .filter(|p| p.city.to_ascii_lowercase().contains(&query))
262            .collect()
263    };
264    parts.places_section.set_visible(!filtered.is_empty());
265    let on_click = match parts.cached_places_click.borrow().clone() {
266        Some(cb) => cb,
267        None => return,
268    };
269    for place in filtered.into_iter().take(INITIAL_TILE_COUNT) {
270        let tile = explore_tile(
271            ctx.clone(),
272            "place",
273            &place.city,
274            &place.asset_id,
275            on_click.clone(),
276        );
277        parts.places_grid.append(&tile);
278    }
279}
280
281fn render_people(parts: &ExploreViewParts, ctx: Arc<AppContext>) {
282    while let Some(child) = parts.people_row.first_child() {
283        parts.people_row.remove(&child);
284    }
285    let (show_unnamed, show_hidden) = {
286        let cfg = ctx.config.read();
287        (cfg.data.show_unnamed_faces, cfg.data.show_hidden_faces)
288    };
289    let cached = parts.cached_people.borrow();
290    let query = parts.search_query.borrow().to_ascii_lowercase();
291    let filtered: Vec<&Person> = cached
292        .iter()
293        .filter(|p| show_hidden || !p.is_hidden)
294        .filter(|p| show_unnamed || !p.name.is_empty())
295        .filter(|p| query.is_empty() || p.name.to_ascii_lowercase().contains(&query))
296        .collect();
297    parts.people_section.set_visible(!filtered.is_empty());
298    let on_click = parts.cached_people_click.borrow().clone();
299    let Some(on_click) = on_click else {
300        return;
301    };
302    for person in filtered.into_iter().take(40) {
303        let tile = person_tile(ctx.clone(), person, on_click.clone());
304        parts.people_row.append(&tile);
305    }
306}
307
308/// Wire the filter MenuButton popover. Called once after the explore view is built.
309/// `on_change` is invoked when a toggle flips so the caller can re-fetch with a
310/// different `include_hidden` flag if needed.
311pub fn wire_people_filter<F>(parts: &ExploreViewParts, ctx: Arc<AppContext>, on_change: F)
312where
313    F: Fn() + 'static,
314{
315    let popover = gtk::Popover::builder().build();
316    let body = gtk::Box::builder()
317        .orientation(gtk::Orientation::Vertical)
318        .spacing(6)
319        .margin_top(8)
320        .margin_bottom(8)
321        .margin_start(8)
322        .margin_end(8)
323        .build();
324
325    let (show_unnamed, show_hidden) = {
326        let cfg = ctx.config.read();
327        (cfg.data.show_unnamed_faces, cfg.data.show_hidden_faces)
328    };
329
330    let unnamed_check = gtk::CheckButton::builder()
331        .label("Show unnamed")
332        .active(show_unnamed)
333        .build();
334    let hidden_check = gtk::CheckButton::builder()
335        .label("Show hidden")
336        .active(show_hidden)
337        .build();
338    body.append(&unnamed_check);
339    body.append(&hidden_check);
340    popover.set_child(Some(&body));
341    parts.people_filter_button.set_popover(Some(&popover));
342
343    let on_change = Rc::new(on_change);
344
345    let ctx_a = ctx.clone();
346    let parts_a = clone_parts_handles(parts);
347    let on_change_a = on_change.clone();
348    unnamed_check.connect_toggled(move |btn| {
349        {
350            let mut cfg = ctx_a.config.write();
351            cfg.data.show_unnamed_faces = btn.is_active();
352            if !cfg.save() {
353                log::error!("Failed to save config after toggling show_unnamed_faces");
354            }
355        }
356        render_people(&parts_a, ctx_a.clone());
357        on_change_a();
358    });
359
360    let ctx_b = ctx.clone();
361    let parts_b = clone_parts_handles(parts);
362    let on_change_b = on_change.clone();
363    hidden_check.connect_toggled(move |btn| {
364        let active = btn.is_active();
365        let hidden_count = parts_b
366            .cached_people
367            .borrow()
368            .iter()
369            .filter(|p| p.is_hidden)
370            .count();
371        log::debug!(
372            "show_hidden_faces toggled to {} ({} hidden people in cache)",
373            active,
374            hidden_count
375        );
376        {
377            let mut cfg = ctx_b.config.write();
378            cfg.data.show_hidden_faces = active;
379            if !cfg.save() {
380                log::error!("Failed to save config after toggling show_hidden_faces");
381            }
382        }
383        render_people(&parts_b, ctx_b.clone());
384        on_change_b();
385    });
386}
387
388/// Build a lightweight `ExploreViewParts` snapshot containing only the widget/handle
389/// references render_people needs, sharing the same `Rc` data with the original.
390fn clone_parts_handles(parts: &ExploreViewParts) -> ExploreViewParts {
391    ExploreViewParts {
392        root: parts.root.clone(),
393        populated: parts.populated.clone(),
394        people_row: parts.people_row.clone(),
395        recents_grid: parts.recents_grid.clone(),
396        places_grid: parts.places_grid.clone(),
397        things_grid: parts.things_grid.clone(),
398        people_section: parts.people_section.clone(),
399        recents_section: parts.recents_section.clone(),
400        places_section: parts.places_section.clone(),
401        things_section: parts.things_section.clone(),
402        people_spinner: parts.people_spinner.clone(),
403        recents_spinner: parts.recents_spinner.clone(),
404        places_spinner: parts.places_spinner.clone(),
405        things_spinner: parts.things_spinner.clone(),
406        people_filter_button: parts.people_filter_button.clone(),
407        cached_people: parts.cached_people.clone(),
408        cached_people_click: parts.cached_people_click.clone(),
409        cached_places: parts.cached_places.clone(),
410        cached_places_click: parts.cached_places_click.clone(),
411        search_query: parts.search_query.clone(),
412        filter_entry: parts.filter_entry.clone(),
413        cached_ctx: parts.cached_ctx.clone(),
414    }
415}
416
417/// Populate the city tiles representing locations in the places dashboard section.
418///
419/// Caches places data so subsequent visits don't re-fetch from the server.
420/// Shows the first 16 tiles with a "See More" button to expand.
421pub fn populate_places<F>(
422    parts: &ExploreViewParts,
423    ctx: Arc<AppContext>,
424    places: Vec<PlaceItem>,
425    on_click: F,
426) where
427    F: Fn(&str, String, String) + 'static,
428{
429    stop_spinner(&parts.places_spinner);
430    *parts.cached_places.borrow_mut() = places;
431    *parts.cached_places_click.borrow_mut() = Some(Rc::new(on_click));
432    render_places(parts, ctx, false);
433}
434
435/// Check if places are already cached and render them without fetching.
436pub fn has_cached_places(parts: &ExploreViewParts) -> bool {
437    !parts.cached_places.borrow().is_empty()
438}
439
440/// Re-render places from cache (used when navigating back to explore).
441pub fn render_cached_places(parts: &ExploreViewParts, ctx: Arc<AppContext>) {
442    stop_spinner(&parts.places_spinner);
443    render_places(parts, ctx, false);
444}
445
446fn render_places(parts: &ExploreViewParts, ctx: Arc<AppContext>, expanded: bool) {
447    while let Some(child) = parts.places_grid.first_child() {
448        parts.places_grid.remove(&child);
449    }
450    let places = parts.cached_places.borrow();
451    parts.places_section.set_visible(!places.is_empty());
452    let on_click = match parts.cached_places_click.borrow().clone() {
453        Some(cb) => cb,
454        None => return,
455    };
456    let limit = if expanded {
457        places.len()
458    } else {
459        INITIAL_TILE_COUNT
460    };
461    for place in places.iter().take(limit) {
462        let tile = explore_tile(
463            ctx.clone(),
464            "place",
465            &place.city,
466            &place.asset_id,
467            on_click.clone(),
468        );
469        parts.places_grid.append(&tile);
470    }
471    if !expanded && places.len() > INITIAL_TILE_COUNT {
472        let remaining = places.len() - INITIAL_TILE_COUNT;
473        drop(places);
474        append_see_more_button(&parts.places_grid, remaining, {
475            let parts = clone_parts_handles(parts);
476            let ctx = ctx.clone();
477            move || render_places(&parts, ctx.clone(), true)
478        });
479    } else if expanded && places.len() > INITIAL_TILE_COUNT {
480        drop(places);
481        append_show_less_button(&parts.places_grid, {
482            let parts = clone_parts_handles(parts);
483            let ctx = ctx.clone();
484            move || render_places(&parts, ctx.clone(), false)
485        });
486    }
487}
488
489/// Populate explore sections: Things tiles + Recently Added tiles.
490///
491/// Sections by `field_name`:
492///   - `exifInfo.city`          -- skipped (populated by `populate_places`)
493///   - `createdAt`              -- rendered as "Recently Added" tiles
494///   - `smartInfo.objects/tags` -- rendered as "Things" tiles
495pub fn populate_explore<F>(
496    parts: &ExploreViewParts,
497    ctx: Arc<AppContext>,
498    sections: Vec<ExploreSection>,
499    on_click: F,
500) where
501    F: Fn(&str, String, String) + 'static,
502{
503    stop_spinner(&parts.things_spinner);
504    stop_spinner(&parts.recents_spinner);
505    while let Some(child) = parts.things_grid.first_child() {
506        parts.things_grid.remove(&child);
507    }
508    while let Some(child) = parts.recents_grid.first_child() {
509        parts.recents_grid.remove(&child);
510    }
511    let mut had_things = false;
512    let mut had_recents = false;
513
514    log::debug!(
515        "Explore sections: [{}]",
516        sections
517            .iter()
518            .map(|s| format!("{}({})", s.field_name, s.items.len()))
519            .collect::<Vec<_>>()
520            .join(", ")
521    );
522
523    let on_click: ExploreClick = Rc::new(on_click);
524    for section in sections {
525        if section.field_name.contains("city") {
526            continue;
527        }
528
529        // Immich v3 recently-added section.
530        if section.field_name == "createdAt" || section.field_name == "updatedAt" {
531            had_recents = true;
532            let mut items: Vec<_> = section.items.into_iter().collect();
533            items.sort_by(|a, b| b.value.cmp(&a.value));
534            render_recents_tiles(parts, &ctx, &items, &on_click, false);
535            if items.len() > INITIAL_TILE_COUNT {
536                let remaining = items.len().min(RECENTS_EXPANDED_COUNT) - INITIAL_TILE_COUNT;
537                let parts_clone = clone_parts_handles(parts);
538                let ctx_clone = ctx.clone();
539                let on_click_clone = on_click.clone();
540                append_see_more_button(&parts.recents_grid, remaining, move || {
541                    render_recents_tiles(&parts_clone, &ctx_clone, &items, &on_click_clone, true);
542                });
543            }
544            continue;
545        }
546
547        // Only render smartInfo sections as Things tiles.
548        if !section.field_name.starts_with("smartInfo") {
549            log::debug!("Skipping unknown section '{}'", section.field_name);
550            continue;
551        }
552
553        had_things = true;
554        for item in section.items.into_iter().take(24) {
555            let tile = explore_tile(
556                ctx.clone(),
557                "thing",
558                &item.value,
559                &item.data.id,
560                on_click.clone(),
561            );
562            parts.things_grid.append(&tile);
563        }
564    }
565
566    parts.things_section.set_visible(had_things);
567    parts.recents_section.set_visible(had_recents);
568}
569
570/// Render recently-added tiles into the recents grid.
571fn render_recents_tiles(
572    parts: &ExploreViewParts,
573    ctx: &Arc<AppContext>,
574    items: &[crate::api_client::ExploreItem],
575    on_click: &ExploreClick,
576    expanded: bool,
577) {
578    while let Some(child) = parts.recents_grid.first_child() {
579        parts.recents_grid.remove(&child);
580    }
581    let limit = if expanded {
582        RECENTS_EXPANDED_COUNT
583    } else {
584        INITIAL_TILE_COUNT
585    };
586    for item in items.iter().take(limit) {
587        let label = format_relative_date(&item.value);
588        let tile = explore_tile(
589            ctx.clone(),
590            "recent",
591            &label,
592            &item.data.id,
593            on_click.clone(),
594        );
595        parts.recents_grid.append(&tile);
596    }
597}
598
599fn append_action_button<F: Fn() + 'static>(
600    grid: &gtk::FlowBox,
601    icon_name: &str,
602    label_text: &str,
603    on_click: F,
604) {
605    let icon = gtk::Image::builder()
606        .icon_name(icon_name)
607        .pixel_size(24)
608        .halign(gtk::Align::Center)
609        .build();
610    let label = gtk::Label::builder()
611        .label(label_text)
612        .css_classes(["caption-heading"])
613        .halign(gtk::Align::Center)
614        .build();
615    // Spacer forces the same min-height as explore tiles.
616    let spacer = gtk::Box::builder()
617        .css_classes(["mimick-explore-spacer"])
618        .build();
619    let content = gtk::Box::builder()
620        .orientation(gtk::Orientation::Vertical)
621        .spacing(6)
622        .halign(gtk::Align::Center)
623        .valign(gtk::Align::Center)
624        .build();
625    content.append(&icon);
626    content.append(&label);
627    // Overlay the centered label on top of the spacer so the button
628    // has the same footprint as a regular tile.
629    let overlay = gtk::Overlay::builder()
630        .overflow(gtk::Overflow::Hidden)
631        .css_classes(["mimick-see-more-tile"])
632        .build();
633    overlay.set_child(Some(&spacer));
634    overlay.add_overlay(&content);
635
636    let btn = gtk::Button::builder()
637        .child(&overlay)
638        .css_classes(["flat"])
639        .build();
640    let grid_ref = grid.clone();
641    btn.connect_clicked(move |button| {
642        if let Some(parent) = button.parent() {
643            grid_ref.remove(&parent);
644        }
645        on_click();
646    });
647    grid.append(&btn);
648}
649
650/// Append a card-sized "See More" tile to a FlowBox grid.
651///
652/// Matches the dimensions of adjacent explore tiles so the button fills a
653/// full card slot rather than appearing as a small inline text link.
654fn append_see_more_button<F: Fn() + 'static>(grid: &gtk::FlowBox, remaining: usize, on_expand: F) {
655    append_action_button(
656        grid,
657        "view-more-symbolic",
658        &format!("See {remaining} more"),
659        on_expand,
660    );
661}
662
663/// Append a card-sized "Show Less" tile to collapse an expanded section.
664fn append_show_less_button<F: Fn() + 'static>(grid: &gtk::FlowBox, on_collapse: F) {
665    append_action_button(grid, "go-up-symbolic", "Show Less", on_collapse);
666}
667fn parse_iso_date(iso: &str) -> Option<(i64, u32, u32, u32, u32, u32)> {
668    let parsed = iso
669        .replace('T', " ")
670        .replace('Z', "")
671        .chars()
672        .take(19)
673        .collect::<String>();
674
675    let parts: Vec<&str> = parsed.split(&['-', ' ', ':'][..]).collect();
676    if parts.len() < 6 {
677        return None;
678    }
679    match (
680        parts[0].parse::<i64>(),
681        parts[1].parse::<u32>(),
682        parts[2].parse::<u32>(),
683        parts[3].parse::<u32>(),
684        parts[4].parse::<u32>(),
685        parts[5].parse::<u32>(),
686    ) {
687        (Ok(y), Ok(mo), Ok(d), Ok(h), Ok(mi), Ok(s)) => Some((y, mo, d, h, mi, s)),
688        _ => None,
689    }
690}
691
692fn compute_epoch_seconds(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> i64 {
693    let days_in_month = [0u32, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
694    let mut total_days: i64 = 0;
695    for y in 1970..year {
696        total_days += if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) {
697            366
698        } else {
699            365
700        };
701    }
702    let is_leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
703    for m in 1..month {
704        total_days += days_in_month[m as usize] as i64;
705        if m == 2 && is_leap {
706            total_days += 1;
707        }
708    }
709    total_days += (day - 1) as i64;
710    total_days * 86400 + hour as i64 * 3600 + min as i64 * 60 + sec as i64
711}
712
713/// Format an ISO 8601 timestamp into a human-readable relative label.
714fn format_relative_date(iso: &str) -> String {
715    use std::time::{SystemTime, UNIX_EPOCH};
716
717    let Some((year, month, day, hour, min, sec)) = parse_iso_date(iso) else {
718        return iso.chars().take(10).collect();
719    };
720
721    let ts = compute_epoch_seconds(year, month, day, hour, min, sec);
722    let now_secs = SystemTime::now()
723        .duration_since(UNIX_EPOCH)
724        .map(|d| d.as_secs() as i64)
725        .unwrap_or(0);
726    let diff = now_secs - ts;
727
728    if diff < 60 {
729        "Just now".to_string()
730    } else if diff < 3600 {
731        let m = diff / 60;
732        format!("{m} min ago")
733    } else if diff < 86400 {
734        let h = diff / 3600;
735        format!("{h}h ago")
736    } else if diff < 86400 * 7 {
737        let d = diff / 86400;
738        format!("{d}d ago")
739    } else {
740        format!("{year:04}-{month:02}-{day:02}")
741    }
742}
743
744/// Construct an individual circular avatar widget representing a recognized person face.
745fn person_tile(
746    ctx: Arc<AppContext>,
747    person: &Person,
748    on_click: Rc<dyn Fn(String, String)>,
749) -> gtk::Button {
750    let avatar = gtk::Picture::builder()
751        .width_request(96)
752        .height_request(96)
753        .can_shrink(true)
754        .content_fit(gtk::ContentFit::Cover)
755        .css_classes(vec!["mimick-person-avatar".to_string()])
756        .build();
757    let label = gtk::Label::builder()
758        .label(if person.name.is_empty() {
759            "Unnamed"
760        } else {
761            &person.name
762        })
763        .ellipsize(gtk::pango::EllipsizeMode::End)
764        .max_width_chars(12)
765        .build();
766    let inner = gtk::Box::builder()
767        .orientation(gtk::Orientation::Vertical)
768        .spacing(6)
769        .halign(gtk::Align::Center)
770        .build();
771    inner.append(&avatar);
772    inner.append(&label);
773
774    let button = gtk::Button::builder()
775        .child(&inner)
776        .css_classes(vec!["flat".to_string()])
777        .build();
778
779    let id = person.id.clone();
780    let name = if person.name.is_empty() {
781        "Unnamed".to_string()
782    } else {
783        person.name.clone()
784    };
785    button.connect_clicked(move |_| on_click(id.clone(), name.clone()));
786
787    spawn_person_thumbnail(ctx, person.id.clone(), avatar);
788    button
789}
790
791/// Construct a rectangular tile representation for a specific explore category node.
792fn explore_tile(
793    ctx: Arc<AppContext>,
794    kind: &'static str,
795    value: &str,
796    asset_id: &str,
797    on_click: ExploreClick,
798) -> gtk::Button {
799    // Fixed-height thumbnail container: the Overlay sizes itself from the
800    // spacer child (100px) so portrait thumbnails cannot inflate the row
801    // height.  The Picture overlay fills that space with ContentFit::Cover.
802    let thumb = gtk::Overlay::builder()
803        .overflow(gtk::Overflow::Hidden)
804        .css_classes(vec!["mimick-explore-tile".to_string()])
805        .build();
806    let spacer = gtk::Box::builder()
807        .css_classes(vec!["mimick-explore-spacer".to_string()])
808        .build();
809    let picture = gtk::Picture::builder()
810        .can_shrink(true)
811        .content_fit(gtk::ContentFit::Cover)
812        .build();
813    thumb.set_child(Some(&spacer));
814    thumb.add_overlay(&picture);
815
816    let label = gtk::Label::builder()
817        .label(value)
818        .xalign(0.0)
819        .width_chars(1)
820        .max_width_chars(1)
821        .ellipsize(gtk::pango::EllipsizeMode::End)
822        .css_classes(vec!["caption-heading".to_string()])
823        .build();
824    let inner = gtk::Box::builder()
825        .orientation(gtk::Orientation::Vertical)
826        .spacing(4)
827        .build();
828    inner.append(&thumb);
829    inner.append(&label);
830
831    let button = gtk::Button::builder()
832        .child(&inner)
833        .css_classes(vec!["flat".to_string()])
834        .build();
835
836    let value_owned = value.to_string();
837    let asset_id_owned = asset_id.to_string();
838    button.connect_clicked(move |_| on_click(kind, value_owned.clone(), asset_id_owned.clone()));
839
840    spawn_asset_thumbnail(ctx, asset_id.to_string(), picture);
841    button
842}
843
844/// Helper to asynchronously request and bind an asset cover art thumbnail image.
845fn spawn_asset_thumbnail(ctx: Arc<AppContext>, asset_id: String, picture: gtk::Picture) {
846    if let Some(texture) = ctx
847        .thumbnail_cache
848        .get_cached(&asset_id, ThumbnailSize::Thumbnail)
849    {
850        picture.set_paintable(Some(&texture));
851        return;
852    }
853    glib::timeout_add_local_once(std::time::Duration::from_millis(120), move || {
854        glib::MainContext::default().spawn_local(async move {
855            if let Ok(texture) = ctx
856                .thumbnail_cache
857                .load_thumbnail(&asset_id, ThumbnailSize::Thumbnail)
858                .await
859            {
860                picture.set_paintable(Some(&texture));
861            }
862        });
863    });
864}
865
866/// Helper to asynchronously request and render a round avatar person face thumbnail.
867fn spawn_person_thumbnail(ctx: Arc<AppContext>, person_id: String, picture: gtk::Picture) {
868    glib::timeout_add_local_once(std::time::Duration::from_millis(120), move || {
869        glib::MainContext::default().spawn_local(async move {
870            let bytes = match ctx.api_client.fetch_person_thumbnail(&person_id).await {
871                Ok(b) => b,
872                Err(_) => return,
873            };
874            let texture = tokio::task::spawn_blocking(move || -> Option<Texture> {
875                Texture::from_bytes(&Bytes::from(&bytes[..])).ok()
876            })
877            .await
878            .ok()
879            .flatten();
880            if let Some(texture) = texture {
881                picture.set_paintable(Some(&texture));
882            }
883        });
884    });
885}