| 100 |
100 |
|
|
| 101 |
101 |
|
use quasi_router::layout::{Priority, Sort, Tone, Width};
|
| 102 |
102 |
|
use quasi_router::{
|
| 103 |
|
- |
Act, Action, Cell, Cells, Column, Node, RegionKind, Request, Response, RouteError, Router,
|
| 104 |
|
- |
Screen, Slot, Tag,
|
|
103 |
+ |
Act, Action, Cell, Cells, Choice, Column, Field, Node, RegionKind, Request, Response,
|
|
104 |
+ |
RouteError, Router, Screen, Slot, Tag,
|
| 105 |
105 |
|
};
|
| 106 |
106 |
|
|
| 107 |
|
- |
use super::{Panels, Sample};
|
|
107 |
+ |
use super::{Collection, Panels, Sample};
|
| 108 |
108 |
|
|
| 109 |
109 |
|
/// The region the screen answers into.
|
| 110 |
110 |
|
const BODY: &str = "files-body";
|
| 118 |
118 |
|
const TAGS: &str = "Tags";
|
| 119 |
119 |
|
const PLAY: &str = "Play";
|
| 120 |
120 |
|
|
|
121 |
+ |
/// What the Add to Collection act asks for, and what its handler reads back.
|
|
122 |
+ |
const COLLECTION: &str = "collection";
|
|
123 |
+ |
|
| 121 |
124 |
|
/// Register this screen's routes.
|
| 122 |
125 |
|
pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
|
| 123 |
126 |
|
router
|
| 141 |
144 |
|
.post("/files/{id}/delete", delete)
|
| 142 |
145 |
|
.post("/files/{id}/download", download)
|
| 143 |
146 |
|
.post("/files/{id}/collection/remove", remove_from_collection)
|
|
147 |
+ |
.post("/files/{id}/collection/add", add_to_collection)
|
| 144 |
148 |
|
}
|
| 145 |
149 |
|
|
| 146 |
150 |
|
/// `GET /files`
|
| 271 |
275 |
|
Ok(screen(state).into())
|
| 272 |
276 |
|
}
|
| 273 |
277 |
|
|
|
278 |
+ |
/// `POST /files/{id}/collection/add`
|
|
279 |
+ |
///
|
|
280 |
+ |
/// The collection arrives in the payload because the act asked for it, so this
|
|
281 |
+ |
/// reads a submitted value the same way a form's handler does. An id that names
|
|
282 |
+ |
/// no collection is a not-found rather than a silent no-op: the list the act
|
|
283 |
+ |
/// offered was built from `Library::collections`, so a value outside it means
|
|
284 |
+ |
/// the collection went away between the menu opening and the press.
|
|
285 |
+ |
fn add_to_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
|
|
286 |
+ |
let id = id_of(&request)?;
|
|
287 |
+ |
let chosen = request
|
|
288 |
+ |
.payload
|
|
289 |
+ |
.get(COLLECTION)
|
|
290 |
+ |
.and_then(|value| value.parse::<i64>().ok())
|
|
291 |
+ |
.ok_or_else(|| RouteError::not_found("no collection named"))?;
|
|
292 |
+ |
let named = state
|
|
293 |
+ |
.library
|
|
294 |
+ |
.collections()
|
|
295 |
+ |
.into_iter()
|
|
296 |
+ |
.find(|collection| collection.id == chosen)
|
|
297 |
+ |
.ok_or_else(|| RouteError::not_found("no such collection"))?;
|
|
298 |
+ |
state.files.add_to_collection(id, named.id);
|
|
299 |
+ |
Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name)))
|
|
300 |
+ |
}
|
|
301 |
+ |
|
| 274 |
302 |
|
/// `POST /files/sort/{column}`
|
| 275 |
303 |
|
///
|
| 276 |
304 |
|
/// The heading a user pressed. Which way it then sorts is the app's: pressing
|
| 318 |
346 |
|
let shown = state.files.columns();
|
| 319 |
347 |
|
let samples = state.files.samples();
|
| 320 |
348 |
|
let current = state.files.current();
|
| 321 |
|
- |
// Whether a collection is being shown, which decides one menu entry. Read
|
| 322 |
|
- |
// once for the table rather than per row: it is a fact about the screen and
|
| 323 |
|
- |
// every row would otherwise ask the library the same question.
|
| 324 |
|
- |
let collection = state
|
| 325 |
|
- |
.library
|
| 326 |
|
- |
.collections()
|
| 327 |
|
- |
.into_iter()
|
| 328 |
|
- |
.any(|collection| collection.active);
|
|
349 |
+ |
// The collections, which decide two menu entries: which one to take a sample
|
|
350 |
+ |
// out of, and the list to offer for putting one in. Read once for the table
|
|
351 |
+ |
// rather than per row -- it is a fact about the screen, and every row would
|
|
352 |
+ |
// otherwise ask the library the same question.
|
|
353 |
+ |
let collections = state.library.collections();
|
|
354 |
+ |
let collection = collections.iter().any(|collection| collection.active);
|
| 329 |
355 |
|
|
| 330 |
356 |
|
if samples.is_empty() {
|
| 331 |
357 |
|
// The sentence and the way out are both on the node rather than on the
|
| 340 |
366 |
|
columns: columns(state, shown),
|
| 341 |
367 |
|
rows: samples
|
| 342 |
368 |
|
.iter()
|
| 343 |
|
- |
.map(|sample| row(sample, shown, current, collection))
|
|
369 |
+ |
.map(|sample| row(sample, shown, current, collection, &collections))
|
| 344 |
370 |
|
.collect(),
|
| 345 |
371 |
|
// Everything the app has loaded and filtered is here. Windowing
|
| 346 |
372 |
|
// rows it already holds is a renderer's job, which is the note in
|
| 408 |
434 |
|
shown: super::ColumnsShown,
|
| 409 |
435 |
|
current: Option<i64>,
|
| 410 |
436 |
|
collection: bool,
|
|
437 |
+ |
collections: &[Collection],
|
| 411 |
438 |
|
) -> Cells {
|
| 412 |
439 |
|
let mut values = vec![Cell::new(&sample.name)];
|
| 413 |
440 |
|
if shown.duration {
|
| 462 |
489 |
|
|
| 463 |
490 |
|
let mut row = Cells::new(values).activate(Action::post(format!("/files/{}/open", sample.id)));
|
| 464 |
491 |
|
row.current = current == Some(sample.id);
|
| 465 |
|
- |
row.menu = menu(sample, collection);
|
|
492 |
+ |
row.menu = menu(sample, collection, collections);
|
| 466 |
493 |
|
row
|
| 467 |
494 |
|
}
|
| 468 |
495 |
|
|
| 474 |
501 |
|
///
|
| 475 |
502 |
|
/// # What is not here, and why each one is not a gap
|
| 476 |
503 |
|
///
|
| 477 |
|
- |
/// - **Add to Collection.** The one nested entry, over a list the app holds.
|
| 478 |
|
- |
/// [`Cells::menu`] is flat, matching `Row::menu`, and flattening this reads
|
| 479 |
|
- |
/// "Add to Kicks", "Add to Breaks" for as many collections as exist. Growing
|
| 480 |
|
- |
/// [`Act`] a child list wants a second consumer first, which is the rule the
|
| 481 |
|
- |
/// menu member itself was held back by.
|
| 482 |
504 |
|
/// - **The selection menu and the background menu.** Eleven entries and five,
|
| 483 |
505 |
|
/// neither of them per-row: `draw_multi_context_menu` acts on the ticked set
|
| 484 |
506 |
|
/// and the empty-space menu on the folder being shown. Neither has a container
|
| 486 |
508 |
|
/// surface -- and described as [`Outcome::Over`](quasi_router::Outcome::Over)
|
| 487 |
509 |
|
/// they become app-modal overlays, which is near enough and not exact. Filed as
|
| 488 |
510 |
|
/// `quasi:vocabulary:anchored-menu`; see this module's header.
|
| 489 |
|
- |
fn menu(sample: &Sample, collection: bool) -> Vec<Act> {
|
|
511 |
+ |
/// # Add to Collection, which needed no submenu after all
|
|
512 |
+ |
///
|
|
513 |
+ |
/// This entry was the module's one measured hole and was filed as a vocabulary
|
|
514 |
+ |
/// question with three options, all of them bad: flatten it and the menu grows
|
|
515 |
+ |
/// by one line per collection, grow [`Act`] a child list and every renderer
|
|
516 |
+ |
/// learns nesting for one consumer, or spend a screen and a gesture on a
|
|
517 |
+ |
/// chooser.
|
|
518 |
+ |
///
|
|
519 |
+ |
/// The premise was stale. [`Act::asking`] landed after that was written, and it
|
|
520 |
+ |
/// is exactly this shape: a control that wants a value before it fires, carried
|
|
521 |
+ |
/// by every renderer already. So the entry is one act with one
|
|
522 |
+ |
/// [`FieldKind::Select`](quasi_router::layout::FieldKind::Select) on it, the
|
|
523 |
+ |
/// menu stays one line however many collections exist, and nothing new was
|
|
524 |
+ |
/// added to the vocabulary. A submenu was the wrong question — the nesting was
|
|
525 |
+ |
/// never the point, picking one of a list was.
|
|
526 |
+ |
fn menu(sample: &Sample, collection: bool, collections: &[Collection]) -> Vec<Act> {
|
| 490 |
527 |
|
let id = sample.id;
|
| 491 |
528 |
|
let at = |verb: &str| Action::post(format!("/files/{id}/{verb}"));
|
| 492 |
529 |
|
|
| 529 |
566 |
|
acts.push(Act::new("Find Similar", at("similar")).key("shift+f"));
|
| 530 |
567 |
|
acts.push(Act::new("Find Duplicates", at("duplicates")).key("shift+d"));
|
| 531 |
568 |
|
|
|
569 |
+ |
// A collection to put it in, if there is one. Offered for a cloud-only
|
|
570 |
+ |
// sample too: membership is a fact about the sample rather than about the
|
|
571 |
+ |
// bytes, which is the same reason the shipped menu guards this on the hash
|
|
572 |
+ |
// and not on `cloud_only`.
|
|
573 |
+ |
if !collections.is_empty() {
|
|
574 |
+ |
acts.push(
|
|
575 |
+ |
Act::new("Add to Collection", at("collection/add")).asking(Field::select(
|
|
576 |
+ |
COLLECTION,
|
|
577 |
+ |
"Collection",
|
|
578 |
+ |
collections
|
|
579 |
+ |
.iter()
|
|
580 |
+ |
.map(|it| Choice::new(it.id.to_string(), it.name.clone()))
|
|
581 |
+ |
.collect(),
|
|
582 |
+ |
)),
|
|
583 |
+ |
);
|
|
584 |
+ |
}
|
|
585 |
+ |
|
| 532 |
586 |
|
if collection {
|
| 533 |
587 |
|
acts.push(Act::new("Remove from Collection", at("collection/remove")).tone(Tone::Danger));
|
| 534 |
588 |
|
}
|