1use std::cell::Cell;
9use std::path::PathBuf;
10use std::rc::Rc;
11use std::sync::Arc;
12
13use glib::clone;
14use gtk::prelude::*;
15use libadwaita::prelude::*;
16
17use crate::api_client::LibraryAlbum;
18use crate::app_context::AppContext;
19use crate::library::asset_object::AssetObject;
20use crate::library::masonry::build_grid_view;
21use crate::library::style;
22use crate::library::upload_picker;
23use crate::media_kinds;
24
25pub fn build_staging_window(
30 app: &libadwaita::Application,
31 ctx: Arc<AppContext>,
32 files: Vec<PathBuf>,
33 auto_upload: bool,
34) {
35 style::ensure_registered();
36 crate::library::register_app_icons();
37
38 let window = libadwaita::ApplicationWindow::builder()
39 .application(app)
40 .title("Mimick - Upload")
41 .name("mimick-staging-window")
42 .default_width(960)
43 .default_height(640)
44 .width_request(360)
45 .height_request(400)
46 .build();
47
48 let (upload_btn, upload_album_btn, select_all_btn, grid, status_label) =
49 build_staging_ui(&window, &ctx, &files);
50 setup_narrow_breakpoint(&window, &grid);
51
52 let file_count = grid.model.n_items();
53 let files_rc = Rc::new(files);
54 let selection = grid.selection.clone();
55
56 connect_select_all(&select_all_btn, &selection, &status_label, file_count);
57 connect_selection_tracking(&selection, &status_label);
58 connect_upload_buttons(
59 &upload_btn,
60 &upload_album_btn,
61 &ctx,
62 &selection,
63 &files_rc,
64 &window,
65 );
66
67 connect_staging_drop_target(&window, &grid.model, &status_label);
69
70 window.present();
71
72 if auto_upload {
73 let all_paths = files_rc.to_vec();
74 selection.select_all();
75 glib::idle_add_local_once(clone!(
76 #[strong]
77 window,
78 #[strong]
79 ctx,
80 move || {
81 show_album_picker(window, ctx, all_paths);
82 }
83 ));
84 }
85}
86
87fn build_staging_ui(
89 window: &libadwaita::ApplicationWindow,
90 ctx: &Arc<AppContext>,
91 files: &[PathBuf],
92) -> (
93 gtk::Button,
94 gtk::Button,
95 gtk::Button,
96 crate::library::masonry::grid_view::GridViewParts,
97 gtk::Label,
98) {
99 let (header, upload_btn, upload_album_btn, select_all_btn) = build_staging_header();
100 let toolbar = libadwaita::ToolbarView::builder().build();
101 toolbar.add_top_bar(&header);
102
103 let select_toggle = gtk::ToggleButton::builder().active(true).build();
104 let narrow = Rc::new(Cell::new(false));
105 let grid = build_grid_view(ctx.clone(), select_toggle.clone(), narrow.clone());
106 grid.canvas.set_select_mode(true);
107 grid.model.reset_with_objects(build_staging_assets(files));
108
109 let status_label = gtk::Label::builder()
110 .halign(gtk::Align::Start)
111 .hexpand(true)
112 .margin_start(12)
113 .margin_end(12)
114 .margin_top(4)
115 .margin_bottom(4)
116 .build();
117 status_label.set_text(&status_text(0, grid.model.n_items()));
118
119 toolbar.set_content(Some(&grid.scrolled));
120 toolbar.add_bottom_bar(&status_label);
121 window.set_content(Some(&toolbar));
122
123 (
124 upload_btn,
125 upload_album_btn,
126 select_all_btn,
127 grid,
128 status_label,
129 )
130}
131
132fn build_staging_header() -> (libadwaita::HeaderBar, gtk::Button, gtk::Button, gtk::Button) {
134 let header = libadwaita::HeaderBar::builder()
135 .show_start_title_buttons(true)
136 .show_end_title_buttons(true)
137 .build();
138 let upload_btn = gtk::Button::builder()
139 .label("Upload")
140 .tooltip_text("Upload selected files to library")
141 .css_classes(["suggested-action", "mimick-pressable"])
142 .build();
143 let album_btn = gtk::Button::builder()
144 .label("Album")
145 .tooltip_text("Upload selected files to an album")
146 .css_classes(["mimick-pressable"])
147 .build();
148 let select_btn = gtk::Button::builder()
149 .icon_name("edit-select-all-symbolic")
150 .tooltip_text("Select all / Deselect all")
151 .css_classes(["mimick-pressable"])
152 .build();
153 header.pack_start(&upload_btn);
154 header.pack_start(&album_btn);
155 header.pack_end(&select_btn);
156 (header, upload_btn, album_btn, select_btn)
157}
158
159fn setup_narrow_breakpoint(
161 window: &libadwaita::ApplicationWindow,
162 grid: &crate::library::masonry::grid_view::GridViewParts,
163) {
164 let bp = libadwaita::Breakpoint::new(
165 libadwaita::BreakpointCondition::parse("max-width: 600sp")
166 .expect("valid breakpoint condition"),
167 );
168 let c1 = grid.canvas.clone();
169 bp.connect_apply(move |_| c1.set_narrow(true));
170 let c2 = grid.canvas.clone();
171 bp.connect_unapply(move |_| c2.set_narrow(false));
172 window.add_breakpoint(bp);
173}
174
175fn connect_select_all(
176 btn: >k::Button,
177 selection: >k::MultiSelection,
178 status_label: >k::Label,
179 file_count: u32,
180) {
181 let sel = selection.clone();
182 let lbl = status_label.clone();
183 let n = file_count;
184 let all_selected = Rc::new(Cell::new(false));
185 btn.connect_clicked(move |btn| {
186 if all_selected.get() {
187 sel.unselect_all();
188 btn.set_icon_name("edit-select-all-symbolic");
189 btn.set_tooltip_text(Some("Select all"));
190 all_selected.set(false);
191 lbl.set_text(&status_text(0, n));
192 } else {
193 sel.select_all();
194 btn.set_icon_name("edit-clear-all-symbolic");
195 btn.set_tooltip_text(Some("Deselect all"));
196 all_selected.set(true);
197 lbl.set_text(&status_text(n, n));
198 }
199 });
200}
201
202fn connect_selection_tracking(selection: >k::MultiSelection, status_label: >k::Label) {
203 let sel = selection.clone();
204 let lbl = status_label.clone();
205 sel.connect_selection_changed(move |sel, _, _| {
206 let total = sel.n_items();
207 let selected = count_selected(sel);
208 lbl.set_text(&status_text(selected, total));
209 });
210}
211
212fn status_text(selected: u32, total: u32) -> String {
214 if selected == 0 {
215 format!("{} file(s) staged", total)
216 } else {
217 format!("{} of {} selected", selected, total)
218 }
219}
220
221fn connect_upload_buttons(
223 upload_btn: >k::Button,
224 album_btn: >k::Button,
225 ctx: &Arc<AppContext>,
226 selection: >k::MultiSelection,
227 files_rc: &Rc<Vec<PathBuf>>,
228 window: &libadwaita::ApplicationWindow,
229) {
230 let (sel1, files1, ctx1, win1) = (
231 selection.clone(),
232 files_rc.clone(),
233 ctx.clone(),
234 window.clone(),
235 );
236 upload_btn.connect_clicked(move |_| {
237 let paths = selected_paths(&sel1, &files1);
238 if paths.is_empty() {
239 return;
240 }
241 let w = win1.clone();
242 upload_picker::spawn_enqueue_with_callback(
243 ctx1.clone(),
244 None,
245 paths,
246 move |queued, skipped| show_upload_result(&w, queued, skipped),
247 );
248 });
249
250 let (sel2, files2, ctx2, win2) = (
251 selection.clone(),
252 files_rc.clone(),
253 ctx.clone(),
254 window.clone(),
255 );
256 album_btn.connect_clicked(move |_| {
257 let paths = selected_paths(&sel2, &files2);
258 if paths.is_empty() {
259 return;
260 }
261 show_album_picker(win2.clone(), ctx2.clone(), paths);
262 });
263}
264
265fn build_staging_assets(files: &[PathBuf]) -> Vec<AssetObject> {
267 files
268 .iter()
269 .filter(|path| media_kinds::is_supported_path(path))
270 .filter_map(|path| {
271 let filename = path.file_name()?.to_string_lossy().to_string();
272 let mime = media_kinds::mime_for_path(path);
273 let kind = media_kinds::asset_kind(mime);
274 let asset_type = match kind {
275 media_kinds::AssetKind::Image => "IMAGE",
276 media_kinds::AssetKind::Video => "VIDEO",
277 };
278 let id = format!("local::{}", path.display());
279 let mtime_str = std::fs::metadata(path)
280 .ok()
281 .and_then(|m| m.modified().ok())
282 .map(systemtime_to_iso)
283 .unwrap_or_default();
284
285 Some(AssetObject::new_local(
286 &id,
287 &filename,
288 mime,
289 &mtime_str,
290 asset_type,
291 &path.to_string_lossy(),
292 ))
293 })
294 .collect()
295}
296
297fn systemtime_to_iso(t: std::time::SystemTime) -> String {
299 use chrono::{DateTime, Local};
300 let dt: DateTime<Local> = t.into();
301 dt.to_rfc3339()
302}
303
304fn count_selected(sel: >k::MultiSelection) -> u32 {
306 let bs = sel.selection();
307 let mut count = 0u32;
308 let n = bs.size();
309 for i in 0..n {
310 if bs.nth(i as u32) != gtk::INVALID_LIST_POSITION {
311 count += 1;
312 }
313 }
314 if count == 0 && n > 0 {
316 count = n as u32;
317 }
318 count
319}
320
321fn selected_paths(sel: >k::MultiSelection, files: &[PathBuf]) -> Vec<PathBuf> {
323 let bs = sel.selection();
324 let total = sel.n_items();
325 let mut paths = Vec::new();
326
327 let mut selected_indices = Vec::new();
329 let n_ranges = bs.size();
330 if n_ranges > 0 {
331 for i in 0..total {
332 if sel.is_selected(i) {
333 selected_indices.push(i as usize);
334 }
335 }
336 }
337
338 if selected_indices.is_empty() {
339 return files
341 .iter()
342 .filter(|p| media_kinds::is_supported_path(p))
343 .cloned()
344 .collect();
345 }
346
347 for idx in selected_indices {
348 if let Some(item) = sel.item(idx as u32).and_downcast::<AssetObject>() {
349 let local_path = item.property::<String>("local-path");
350 if !local_path.is_empty() {
351 paths.push(PathBuf::from(local_path));
352 }
353 }
354 }
355 paths
356}
357
358pub(crate) fn show_album_picker(
360 parent: libadwaita::ApplicationWindow,
361 ctx: Arc<AppContext>,
362 paths: Vec<PathBuf>,
363) {
364 let dialog = simple_alert("Upload to Album", "Loading albums...", "cancel", "Cancel");
365 dialog.present(Some(&parent));
366
367 let ctx_c = ctx.clone();
368 let parent_c = parent.clone();
369 glib::MainContext::default().spawn_local(async move {
370 let albums = match ctx_c.api_client.fetch_library_albums().await {
371 Ok(a) => a,
372 Err(err) => {
373 dialog.force_close();
374 simple_alert("Failed to Load Albums", &err, "ok", "OK").present(Some(&parent_c));
375 return;
376 }
377 };
378 dialog.force_close();
379 present_album_list(parent_c, ctx_c, albums, paths);
380 });
381}
382
383fn present_album_list(
385 parent: libadwaita::ApplicationWindow,
386 ctx: Arc<AppContext>,
387 albums: Vec<LibraryAlbum>,
388 paths: Vec<PathBuf>,
389) {
390 let parent_for_result = parent.clone();
391 let dialog = libadwaita::AlertDialog::builder()
392 .heading("Select Album")
393 .body("Choose an album for the upload:")
394 .build();
395 dialog.add_response("cancel", "Cancel");
396
397 let (scroll, listbox) = build_album_listbox(&albums);
398 dialog.set_extra_child(Some(&scroll));
399
400 let albums_rc = Rc::new(albums);
401 let paths_rc = Rc::new(paths);
402 let ctx_c = ctx.clone();
403 dialog.connect_response(None, move |_dialog, response| {
404 if response == "cancel" {
405 return;
406 }
407 handle_album_upload(&listbox, &albums_rc, &ctx_c, &paths_rc, &parent_for_result);
408 });
409
410 dialog.add_response("upload", "Upload");
411 dialog.set_response_appearance("upload", libadwaita::ResponseAppearance::Suggested);
412 dialog.set_default_response(Some("upload"));
413 dialog.present(Some(&parent));
414}
415
416fn handle_album_upload(
418 listbox: >k::ListBox,
419 albums: &[LibraryAlbum],
420 ctx: &Arc<AppContext>,
421 paths: &Rc<Vec<PathBuf>>,
422 parent: &libadwaita::ApplicationWindow,
423) {
424 let Some(row) = listbox.selected_row() else {
425 return;
426 };
427 let idx = row.index() as usize;
428 if let Some(album) = albums.get(idx) {
429 let album_arg = Some((album.id.clone(), album.album_name.clone()));
430 let parent_c = parent.clone();
431 upload_picker::spawn_enqueue_with_callback(
432 ctx.clone(),
433 album_arg,
434 paths.to_vec(),
435 move |queued, skipped| show_upload_result(&parent_c, queued, skipped),
436 );
437 }
438}
439
440fn build_album_listbox(albums: &[LibraryAlbum]) -> (gtk::ScrolledWindow, gtk::ListBox) {
442 let listbox = gtk::ListBox::builder()
443 .selection_mode(gtk::SelectionMode::Single)
444 .css_classes(["boxed-list"])
445 .build();
446 for album in albums {
447 let row = libadwaita::ActionRow::builder()
448 .title(&album.album_name)
449 .subtitle(format!("{} assets", album.asset_count))
450 .activatable(true)
451 .build();
452 listbox.append(&row);
453 }
454 let scroll = gtk::ScrolledWindow::builder()
455 .child(&listbox)
456 .min_content_height(200)
457 .max_content_height(400)
458 .hscrollbar_policy(gtk::PolicyType::Never)
459 .build();
460 (scroll, listbox)
461}
462
463fn show_upload_result(parent: &libadwaita::ApplicationWindow, queued: usize, skipped: usize) {
465 let (heading, body) = if skipped == 0 {
466 (
467 "Upload Queued",
468 format!(
469 "{} file(s) queued for upload. Progress is visible in the queue inspector.",
470 queued
471 ),
472 )
473 } else if queued == 0 {
474 (
475 "Upload Failed",
476 format!(
477 "All {} file(s) failed to enqueue. Check the logs for details.",
478 skipped
479 ),
480 )
481 } else {
482 (
483 "Upload Partially Queued",
484 format!(
485 "{} file(s) queued, {} skipped (hash or path errors).",
486 queued, skipped
487 ),
488 )
489 };
490
491 simple_alert(heading, &body, "ok", "OK").present(Some(parent));
492}
493
494fn simple_alert(
496 heading: &str,
497 body: &str,
498 response_id: &str,
499 response_label: &str,
500) -> libadwaita::AlertDialog {
501 let dialog = libadwaita::AlertDialog::builder()
502 .heading(heading)
503 .body(body)
504 .build();
505 dialog.add_response(response_id, response_label);
506 dialog
507}
508
509fn connect_staging_drop_target(
511 window: &libadwaita::ApplicationWindow,
512 model: &crate::library::asset_model::LibraryAssetModel,
513 status_label: >k::Label,
514) {
515 use std::collections::HashSet;
516
517 let drop_target = gtk::DropTarget::new(
518 gtk::gdk::FileList::static_type(),
519 gtk::gdk::DragAction::COPY,
520 );
521
522 let model = model.clone();
523 let label = status_label.clone();
524 drop_target.connect_drop(move |_target, value, _x, _y| {
525 let file_list = match value.get::<gtk::gdk::FileList>() {
526 Ok(fl) => fl,
527 Err(_) => return false,
528 };
529
530 let mut existing: HashSet<String> = HashSet::new();
532 for i in 0..model.n_items() {
533 if let Some(obj) = model.item(i).and_downcast::<AssetObject>() {
534 let lp = obj.property::<String>("local-path");
535 if !lp.is_empty() {
536 existing.insert(lp);
537 }
538 }
539 }
540
541 let new_paths: Vec<PathBuf> = file_list
542 .files()
543 .iter()
544 .filter_map(|f| f.path())
545 .filter(|p| media_kinds::is_supported_path(p))
546 .filter(|p| !existing.contains(&p.to_string_lossy().to_string()))
547 .collect();
548
549 if new_paths.is_empty() {
550 return true;
551 }
552
553 let new_assets = build_staging_assets(&new_paths);
554 model.append_objects(&new_assets);
555
556 let total = model.n_items();
557 label.set_text(&status_text(0, total));
558 true
559 });
560
561 window.add_controller(drop_target);
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn build_staging_assets_filters_unsupported() {
570 let files = vec![
571 PathBuf::from("/tmp/photo.jpg"),
572 PathBuf::from("/tmp/document.pdf"),
573 PathBuf::from("/tmp/video.mp4"),
574 PathBuf::from("/tmp/readme.txt"),
575 ];
576 let assets = build_staging_assets(&files);
577 assert_eq!(assets.len(), 2);
579 assert_eq!(assets[0].property::<String>("filename"), "photo.jpg");
580 assert_eq!(assets[1].property::<String>("filename"), "video.mp4");
581 }
582
583 #[test]
584 fn build_staging_assets_sets_asset_type() {
585 let files = vec![
586 PathBuf::from("/tmp/photo.png"),
587 PathBuf::from("/tmp/clip.mkv"),
588 ];
589 let assets = build_staging_assets(&files);
590 assert_eq!(assets.len(), 2);
591 assert_eq!(assets[0].property::<String>("asset-type"), "IMAGE");
592 assert_eq!(assets[1].property::<String>("asset-type"), "VIDEO");
593 }
594}