| 75 |
75 |
|
//! - **The onboarding banner.** `show_vfs_banner` explains what a vault is once.
|
| 76 |
76 |
|
//! A described first run is its own subject.
|
| 77 |
77 |
|
|
|
78 |
+ |
use quasi_declare::declare;
|
| 78 |
79 |
|
use quasi_router::layout::{Nesting, Tone};
|
| 79 |
|
- |
use quasi_router::{
|
| 80 |
|
- |
Act, Action, Node, RegionKind, Request, Response, RouteError, Router, Row, Slot, Tag,
|
| 81 |
|
- |
};
|
|
80 |
+ |
use quasi_router::{Action, Request, Response, RouteError, Router, Tag};
|
| 82 |
81 |
|
|
| 83 |
82 |
|
use super::{Holding, Panels};
|
| 84 |
83 |
|
|
| 175 |
174 |
|
/// What the shipped Delete says when there is only one vault left.
|
| 176 |
175 |
|
const LAST_VAULT: &str = "Create another vault first, audiofiles needs at least one.";
|
| 177 |
176 |
|
|
| 178 |
|
- |
/// The sidebar, as a region something else holds.
|
| 179 |
|
- |
pub fn body(state: &Panels<'_>) -> Slot {
|
| 180 |
|
- |
let side = Slot::new(SIDE, RegionKind::Sidebar);
|
| 181 |
|
- |
let side = vaults(side, state);
|
| 182 |
|
- |
let side = collections(side, state);
|
| 183 |
|
- |
tags(side, state)
|
|
177 |
+ |
/// What the sidebar draws, read off the app.
|
|
178 |
+ |
struct Sidebar {
|
|
179 |
+ |
/// The vaults, in the order the library answers them.
|
|
180 |
+ |
vaults: Vec<Vault>,
|
|
181 |
+ |
/// Whether there is only one, which is what deadens every Delete.
|
|
182 |
+ |
alone: bool,
|
|
183 |
+ |
/// The collections, manual and dynamic.
|
|
184 |
+ |
collections: Vec<Collection>,
|
|
185 |
+ |
/// Whether there are none.
|
|
186 |
+ |
uncollected: bool,
|
|
187 |
+ |
/// The tags as an outline, one row per segment in path order.
|
|
188 |
+ |
outline: Vec<Outlined>,
|
|
189 |
+ |
/// The same tags flat, for the removals.
|
|
190 |
+ |
///
|
|
191 |
+ |
/// Flat, and it stays flat: what is removed is one exact path, so an
|
|
192 |
+ |
/// outline here would group rows under a parent the act does not act on.
|
|
193 |
+ |
removable: Vec<Removable>,
|
|
194 |
+ |
/// Whether there are none.
|
|
195 |
+ |
untagged: bool,
|
| 184 |
196 |
|
}
|
| 185 |
197 |
|
|
| 186 |
|
- |
/// The vaults, and what can be done to one.
|
| 187 |
|
- |
fn vaults(side: Slot, state: &Panels<'_>) -> Slot {
|
| 188 |
|
- |
let all = state.library.vaults();
|
| 189 |
|
- |
let alone = all.len() < 2;
|
| 190 |
|
- |
|
| 191 |
|
- |
// The door to the described New Vault modal (see `naming`). It was an
|
| 192 |
|
- |
// `Intent::NewVault` that opened the *shipped* modal until 2026-08-17, which
|
| 193 |
|
- |
// was the one control on this screen whose answer was drawn by hand.
|
| 194 |
|
- |
let mut side = side
|
| 195 |
|
- |
.with(Node::section("Vaults"))
|
| 196 |
|
- |
.with(Node::Act(Act::new("New vault", Action::get("/vaults/new"))));
|
| 197 |
|
- |
|
| 198 |
|
- |
let mut rows = Vec::with_capacity(all.len());
|
| 199 |
|
- |
for vault in &all {
|
| 200 |
|
- |
let mut delete = Act::new(
|
| 201 |
|
- |
"Delete",
|
| 202 |
|
- |
Action::post(format!("/vaults/{}/delete", vault.id)),
|
| 203 |
|
- |
)
|
| 204 |
|
- |
.tone(Tone::Danger)
|
| 205 |
|
- |
// `ConfirmAction::DeleteVfs`, said where the action is. See the
|
| 206 |
|
- |
// module header.
|
| 207 |
|
- |
.confirm(format!(
|
| 208 |
|
- |
"Delete vault \"{}\" and all its contents?",
|
| 209 |
|
- |
vault.name
|
| 210 |
|
- |
));
|
| 211 |
|
- |
if alone {
|
| 212 |
|
- |
// Offered dead rather than hidden, which is the shipped menu's
|
| 213 |
|
- |
// choice: "Always render Delete so the user can see the capability
|
| 214 |
|
- |
// exists."
|
| 215 |
|
- |
delete = delete.disabled();
|
| 216 |
|
- |
}
|
| 217 |
|
- |
|
| 218 |
|
- |
// `offers` rather than `act`: the shipped affordance is a right-click
|
| 219 |
|
- |
// menu, and `Row::menu` is what "held back until the host asks" means.
|
| 220 |
|
- |
// An inline Delete on every vault row would be a different screen.
|
| 221 |
|
- |
let mut row = Row::new(&vault.name)
|
| 222 |
|
- |
.activate(Action::post(format!("/vaults/{}/open", vault.id)))
|
| 223 |
|
- |
.offers(Act::new(
|
| 224 |
|
- |
"Rename",
|
| 225 |
|
- |
Action::get(format!("/vaults/{}/rename", vault.id)),
|
| 226 |
|
- |
))
|
| 227 |
|
- |
.offers(delete);
|
| 228 |
|
- |
row.current = vault.current;
|
| 229 |
|
- |
rows.push(row);
|
| 230 |
|
- |
}
|
| 231 |
|
- |
side = side.with(Node::List { rows, more: None });
|
| 232 |
|
- |
|
| 233 |
|
- |
if alone {
|
| 234 |
|
- |
// The precondition, said beside the control rather than on it. THE
|
| 235 |
|
- |
// FINDING, fourth consumer -- see the module header.
|
| 236 |
|
- |
side = side.with(Node::Text {
|
| 237 |
|
- |
text: LAST_VAULT.to_owned(),
|
| 238 |
|
- |
tone: Tone::Info,
|
| 239 |
|
- |
});
|
| 240 |
|
- |
}
|
| 241 |
|
- |
side
|
|
198 |
+ |
/// One vault, and what can be done to it.
|
|
199 |
+ |
struct Vault {
|
|
200 |
+ |
/// The row's own id, which its addresses are built from.
|
|
201 |
+ |
id: i64,
|
|
202 |
+ |
/// What it is called.
|
|
203 |
+ |
name: String,
|
|
204 |
+ |
/// What deleting it says before it runs.
|
|
205 |
+ |
///
|
|
206 |
+ |
/// `ConfirmAction::DeleteVfs`, said where the action is. See the module
|
|
207 |
+ |
/// header.
|
|
208 |
+ |
confirm: String,
|
|
209 |
+ |
/// Whether it is the one being browsed.
|
|
210 |
+ |
current: bool,
|
| 242 |
211 |
|
}
|
| 243 |
212 |
|
|
| 244 |
|
- |
/// The collections, manual and dynamic.
|
| 245 |
|
- |
fn collections(side: Slot, state: &Panels<'_>) -> Slot {
|
| 246 |
|
- |
let all = state.library.collections();
|
| 247 |
|
- |
let mut side = side.with(Node::section("Collections"));
|
| 248 |
|
- |
|
| 249 |
|
- |
if all.is_empty() {
|
| 250 |
|
- |
return side.with(Node::empty("No collections yet."));
|
| 251 |
|
- |
}
|
| 252 |
|
- |
|
| 253 |
|
- |
let mut rows = Vec::with_capacity(all.len());
|
| 254 |
|
- |
for collection in &all {
|
| 255 |
|
- |
// What kind it is, as a token rather than a suffix on the name. The
|
| 256 |
|
- |
// shipped row appends " (auto)" or " (12)" to the label, with a comment
|
| 257 |
|
- |
// saying it is a text suffix "instead of a glyph (per the no-emoji brand
|
| 258 |
|
- |
// rule, and for accessibility)" -- which is right about the glyph and
|
| 259 |
|
- |
// still puts a second fact inside the name.
|
| 260 |
|
- |
let mark = match collection.holding {
|
| 261 |
|
- |
Holding::Dynamic => Tag::badge("auto"),
|
| 262 |
|
- |
Holding::Fixed(count) => Tag::badge(count.to_string()),
|
| 263 |
|
- |
};
|
| 264 |
|
- |
let mut row = Row::new(&collection.name)
|
| 265 |
|
- |
.token(mark)
|
| 266 |
|
- |
.activate(if collection.active {
|
| 267 |
|
- |
Action::post("/collections/close")
|
| 268 |
|
- |
} else {
|
| 269 |
|
- |
Action::post(format!("/collections/{}/open", collection.id))
|
| 270 |
|
- |
})
|
| 271 |
|
- |
.offers(
|
| 272 |
|
- |
Act::new(
|
| 273 |
|
- |
"Delete",
|
| 274 |
|
- |
Action::post(format!("/collections/{}/delete", collection.id)),
|
| 275 |
|
- |
)
|
| 276 |
|
- |
.tone(Tone::Danger)
|
| 277 |
|
- |
.confirm(format!("Delete collection \"{}\"?", collection.name)),
|
| 278 |
|
- |
);
|
| 279 |
|
- |
row.current = collection.active;
|
| 280 |
|
- |
rows.push(row);
|
| 281 |
|
- |
}
|
| 282 |
|
- |
|
| 283 |
|
- |
side = side.with(Node::List { rows, more: None });
|
| 284 |
|
- |
side
|
|
213 |
+ |
/// One collection, and what can be done to it.
|
|
214 |
+ |
struct Collection {
|
|
215 |
+ |
/// What it is called.
|
|
216 |
+ |
name: String,
|
|
217 |
+ |
/// What kind it is, as a token rather than a suffix on the name.
|
|
218 |
+ |
///
|
|
219 |
+ |
/// The shipped row appends " (auto)" or " (12)" to the label, with a comment
|
|
220 |
+ |
/// saying it is a text suffix "instead of a glyph (per the no-emoji brand
|
|
221 |
+ |
/// rule, and for accessibility)" -- which is right about the glyph and still
|
|
222 |
+ |
/// puts a second fact inside the name.
|
|
223 |
+ |
mark: String,
|
|
224 |
+ |
/// Where pressing the row goes: closing the one showing, opening any other.
|
|
225 |
+ |
opens: String,
|
|
226 |
+ |
/// What deleting it says before it runs.
|
|
227 |
+ |
confirm: String,
|
|
228 |
+ |
/// The row's own id, which the delete address is built from.
|
|
229 |
+ |
id: i64,
|
|
230 |
+ |
/// Whether it is the one being shown.
|
|
231 |
+ |
active: bool,
|
| 285 |
232 |
|
}
|
| 286 |
233 |
|
|
| 287 |
|
- |
/// The tags, flat.
|
| 288 |
|
- |
///
|
| 289 |
|
- |
/// See the module header: the hierarchy the shipped sidebar draws has no
|
| 290 |
|
- |
/// description, so what is here is every tag at its full path. The chips latch,
|
| 291 |
|
- |
/// because a tag filter is on or off and that is exactly what
|
| 292 |
|
- |
/// [`Token::Chip`]'s `latched` says.
|
| 293 |
|
- |
fn tags(side: Slot, state: &Panels<'_>) -> Slot {
|
| 294 |
|
- |
let all = state.library.tags();
|
| 295 |
|
- |
let mut side = side.with(Node::section("Tags"));
|
| 296 |
|
- |
|
| 297 |
|
- |
if all.is_empty() {
|
| 298 |
|
- |
return side.with(Node::empty("No tags yet."));
|
| 299 |
|
- |
}
|
| 300 |
|
- |
|
| 301 |
|
- |
// The outline. `ccaa7e4b`: a hierarchy of rows is a flat list of rows each
|
| 302 |
|
- |
// carrying its depth, so the dotted paths become an indented list with a
|
| 303 |
|
- |
// disclosure on every tag that holds others.
|
| 304 |
|
- |
//
|
| 305 |
|
- |
// Ticking a tag is the write, which is what `Row::toggling` says: the tick
|
| 306 |
|
- |
// is the only affordance for filtering by it, and a button beside a
|
| 307 |
|
- |
// checkbox that ignored clicks is what the member exists to stop. A tag
|
| 308 |
|
- |
// that only groups is not ticked and not tickable -- filtering by
|
| 309 |
|
- |
// `drums` when only `drums.kick` exists matches zero samples, and the
|
| 310 |
|
- |
// shipped sidebar refuses it by drawing its label inert. `Row::selected`
|
| 311 |
|
- |
// being `None` is that refusal, said in the vocabulary rather than by
|
| 312 |
|
- |
// leaving a control out.
|
| 313 |
|
- |
side = side.with(Node::List {
|
| 314 |
|
- |
rows: outline(&all),
|
| 315 |
|
- |
more: None,
|
| 316 |
|
- |
});
|
| 317 |
|
- |
|
| 318 |
|
- |
// Removing a tag from every sample is not a filter, so it is not a tick. It
|
| 319 |
|
- |
// is a list of the same tags with a destructive act on each, which is the
|
| 320 |
|
- |
// shipped right-click menu made visible -- and the second
|
| 321 |
|
- |
// `ConfirmAction` variant this port replaces.
|
| 322 |
|
- |
//
|
| 323 |
|
- |
// Flat, and it stays flat: what is removed is one exact path, so an outline
|
| 324 |
|
- |
// here would group rows under a parent that the act does not act on.
|
| 325 |
|
- |
side.with(Node::section("Remove a tag everywhere"))
|
| 326 |
|
- |
.with(Node::List {
|
| 327 |
|
- |
rows: all
|
| 328 |
|
- |
.iter()
|
| 329 |
|
- |
.map(|filter| {
|
| 330 |
|
- |
Row::new(&filter.path).offers(
|
| 331 |
|
- |
Act::new(
|
| 332 |
|
- |
"Remove",
|
| 333 |
|
- |
Action::post(format!("/tags/{}/remove", filter.path)),
|
| 334 |
|
- |
)
|
| 335 |
|
- |
.tone(Tone::Danger)
|
| 336 |
|
- |
.confirm(format!(
|
| 337 |
|
- |
"Remove tag \"{}\" from every sample that has it?",
|
| 338 |
|
- |
filter.path
|
| 339 |
|
- |
)),
|
| 340 |
|
- |
)
|
| 341 |
|
- |
})
|
| 342 |
|
- |
.collect(),
|
| 343 |
|
- |
more: None,
|
| 344 |
|
- |
})
|
| 345 |
|
- |
}
|
| 346 |
|
- |
|
| 347 |
|
- |
/// The tags as an outline: one row per segment, in path order.
|
|
234 |
+ |
/// One segment of the tag outline.
|
| 348 |
235 |
|
///
|
| 349 |
236 |
|
/// A parent appears whether or not it is a tag in its own right, because
|
| 350 |
237 |
|
/// `drums.kick` with no `drums` beside it is still a `drums` the reader can
|
| 351 |
238 |
|
/// fold. What separates the two is what the row offers: a real tag is ticked to
|
| 352 |
239 |
|
/// filter by it, and a parent that only groups offers the chevron and nothing
|
| 353 |
240 |
|
/// else.
|
|
241 |
+ |
struct Outlined {
|
|
242 |
+ |
/// The last segment, which is what the row reads.
|
|
243 |
+ |
label: String,
|
|
244 |
+ |
/// How far in it sits.
|
|
245 |
+ |
depth: Nesting,
|
|
246 |
+ |
/// The tick, for a segment that is a tag in its own right.
|
|
247 |
+ |
///
|
|
248 |
+ |
/// Absent for a segment that only groups: filtering by `drums` when only
|
|
249 |
+ |
/// `drums.kick` exists matches zero samples, and the shipped sidebar refuses
|
|
250 |
+ |
/// it by drawing its label inert. `Row::selected` staying `None` is that
|
|
251 |
+ |
/// refusal said in the vocabulary rather than by leaving a control out.
|
|
252 |
+ |
tick: Option<Tick>,
|
|
253 |
+ |
/// Whether it holds anything.
|
|
254 |
+ |
///
|
|
255 |
+ |
/// A disclosure on anything that holds something, and nothing on a leaf: a
|
|
256 |
+ |
/// chevron over an empty branch is an affordance that opens onto nothing.
|
|
257 |
+ |
holds: bool,
|
|
258 |
+ |
}
|
|
259 |
+ |
|
|
260 |
+ |
/// The filter a real tag carries.
|
|
261 |
+ |
struct Tick {
|
|
262 |
+ |
/// The whole dotted path, which the address carries.
|
|
263 |
+ |
path: String,
|
|
264 |
+ |
/// Whether the filter is in force.
|
|
265 |
+ |
on: bool,
|
|
266 |
+ |
}
|
|
267 |
+ |
|
|
268 |
+ |
/// One tag, and the way to take it off every sample that has it.
|
|
269 |
+ |
struct Removable {
|
|
270 |
+ |
/// The whole dotted path.
|
|
271 |
+ |
path: String,
|
|
272 |
+ |
/// What removing it says before it runs.
|
|
273 |
+ |
confirm: String,
|
|
274 |
+ |
}
|
|
275 |
+ |
|
|
276 |
+ |
/// What the sidebar draws, read off the app.
|
|
277 |
+ |
fn read(state: &Panels<'_>) -> Sidebar {
|
|
278 |
+ |
let vaults = state.library.vaults();
|
|
279 |
+ |
let collections = state.library.collections();
|
|
280 |
+ |
let tags = state.library.tags();
|
|
281 |
+ |
Sidebar {
|
|
282 |
+ |
alone: vaults.len() < 2,
|
|
283 |
+ |
vaults: vaults
|
|
284 |
+ |
.iter()
|
|
285 |
+ |
.map(|vault| Vault {
|
|
286 |
+ |
id: vault.id,
|
|
287 |
+ |
name: vault.name.clone(),
|
|
288 |
+ |
confirm: format!("Delete vault \"{}\" and all its contents?", vault.name),
|
|
289 |
+ |
current: vault.current,
|
|
290 |
+ |
})
|
|
291 |
+ |
.collect(),
|
|
292 |
+ |
uncollected: collections.is_empty(),
|
|
293 |
+ |
collections: collections
|
|
294 |
+ |
.iter()
|
|
295 |
+ |
.map(|collection| Collection {
|
|
296 |
+ |
name: collection.name.clone(),
|
|
297 |
+ |
mark: match collection.holding {
|
|
298 |
+ |
Holding::Dynamic => "auto".to_owned(),
|
|
299 |
+ |
Holding::Fixed(count) => count.to_string(),
|
|
300 |
+ |
},
|
|
301 |
+ |
opens: if collection.active {
|
|
302 |
+ |
"/collections/close".to_owned()
|
|
303 |
+ |
} else {
|
|
304 |
+ |
format!("/collections/{}/open", collection.id)
|
|
305 |
+ |
},
|
|
306 |
+ |
confirm: format!("Delete collection \"{}\"?", collection.name),
|
|
307 |
+ |
id: collection.id,
|
|
308 |
+ |
active: collection.active,
|
|
309 |
+ |
})
|
|
310 |
+ |
.collect(),
|
|
311 |
+ |
outline: outline(&tags),
|
|
312 |
+ |
untagged: tags.is_empty(),
|
|
313 |
+ |
removable: tags
|
|
314 |
+ |
.iter()
|
|
315 |
+ |
.map(|filter| Removable {
|
|
316 |
+ |
path: filter.path.clone(),
|
|
317 |
+ |
confirm: format!(
|
|
318 |
+ |
"Remove tag \"{}\" from every sample that has it?",
|
|
319 |
+ |
filter.path
|
|
320 |
+ |
),
|
|
321 |
+ |
})
|
|
322 |
+ |
.collect(),
|
|
323 |
+ |
}
|
|
324 |
+ |
}
|
|
325 |
+ |
|
|
326 |
+ |
/// The tags as an outline: one segment per row, in path order.
|
|
327 |
+ |
///
|
|
328 |
+ |
/// Data rather than rows, which is what took it out of the population: a shape
|
|
329 |
+ |
/// answering `Vec<Row>` can never be declared, and what this actually does is
|
|
330 |
+ |
/// flatten a hierarchy. The declaration writes the rows from it.
|
| 354 |
331 |
|
///
|
| 355 |
332 |
|
/// Every branch starts open, which is the shipped sidebar's own first frame.
|
| 356 |
333 |
|
/// Where it goes from there is the renderer's, and the reader's -- see
|
| 357 |
334 |
|
/// `Row::open`.
|
| 358 |
|
- |
fn outline(all: &[super::Filter]) -> Vec<Row> {
|
|
335 |
+ |
fn outline(all: &[super::Filter]) -> Vec<Outlined> {
|
| 359 |
336 |
|
// Sorted, so a segment's children follow it. The library answers in its own
|
| 360 |
337 |
|
// order and the outline is only an outline if `drums.kick` comes after
|
| 361 |
338 |
|
// `drums`.
|
| 362 |
339 |
|
let mut paths: Vec<&str> = all.iter().map(|filter| filter.path.as_str()).collect();
|
| 363 |
340 |
|
paths.sort_unstable();
|
| 364 |
341 |
|
|
| 365 |
|
- |
let mut rows: Vec<Row> = Vec::with_capacity(paths.len());
|
|
342 |
+ |
let mut rows: Vec<Outlined> = Vec::with_capacity(paths.len());
|
| 366 |
343 |
|
let mut said: Vec<String> = Vec::new();
|
| 367 |
344 |
|
for path in paths {
|
| 368 |
345 |
|
for (depth, _) in path
|
| 375 |
352 |
|
}
|
| 376 |
353 |
|
said.push(branch.to_owned());
|
| 377 |
354 |
|
let level = branch.matches('.').count();
|
| 378 |
|
- |
let label = branch.rsplit('.').next().unwrap_or(branch);
|
| 379 |
|
- |
let mut row =
|
| 380 |
|
- |
Row::new(label).depth(Nesting::at(u8::try_from(level).unwrap_or(u8::MAX)));
|
| 381 |
|
- |
// A tag in its own right is ticked to filter by it. A segment that
|
| 382 |
|
- |
// only groups is neither ticked nor tickable.
|
| 383 |
|
- |
if let Some(filter) = all.iter().find(|filter| filter.path == branch) {
|
| 384 |
|
- |
row = row.toggling(
|
| 385 |
|
- |
filter.on,
|
| 386 |
|
- |
Action::post(format!("/tags/{}/filter", filter.path)),
|
| 387 |
|
- |
);
|
| 388 |
|
- |
}
|
| 389 |
|
- |
// A disclosure on anything that holds something, and nothing on a
|
| 390 |
|
- |
// leaf: a chevron over an empty branch is an affordance that opens
|
| 391 |
|
- |
// onto nothing.
|
| 392 |
355 |
|
let prefix = format!("{branch}.");
|
| 393 |
|
- |
if all.iter().any(|filter| filter.path.starts_with(&prefix)) {
|
| 394 |
|
- |
row = row.disclosing(true);
|
| 395 |
|
- |
}
|
| 396 |
|
- |
rows.push(row);
|
|
356 |
+ |
rows.push(Outlined {
|
|
357 |
+ |
label: branch.rsplit('.').next().unwrap_or(branch).to_owned(),
|
|
358 |
+ |
depth: Nesting::at(u8::try_from(level).unwrap_or(u8::MAX)),
|
|
359 |
+ |
tick: all
|
|
360 |
+ |
.iter()
|
|
361 |
+ |
.find(|filter| filter.path == branch)
|
|
362 |
+ |
.map(|filter| Tick {
|
|
363 |
+ |
path: filter.path.clone(),
|
|
364 |
+ |
on: filter.on,
|
|
365 |
+ |
}),
|
|
366 |
+ |
holds: all.iter().any(|filter| filter.path.starts_with(&prefix)),
|
|
367 |
+ |
});
|
| 397 |
368 |
|
}
|
| 398 |
369 |
|
}
|
| 399 |
370 |
|
rows
|
| 400 |
371 |
|
}
|
|
372 |
+ |
|
|
373 |
+ |
/// The sidebar, as a region something else holds.
|
|
374 |
+ |
pub fn body(state: &Panels<'_>) -> quasi_router::Slot {
|
|
375 |
+ |
side(&read(state))
|
|
376 |
+ |
}
|
|
377 |
+ |
|
|
378 |
+ |
declare! {
|
|
379 |
+ |
/// The sidebar: vaults, collections, and the tags you can filter by.
|
|
380 |
+ |
shape side(sidebar: &Sidebar) -> Slot;
|
|
381 |
+ |
|
|
382 |
+ |
region SIDE as Sidebar {
|
|
383 |
+ |
extend vaults(sidebar);
|
|
384 |
+ |
extend collections(sidebar);
|
|
385 |
+ |
extend tags(sidebar);
|
|
386 |
+ |
}
|
|
387 |
+ |
}
|
|
388 |
+ |
|
|
389 |
+ |
declare! {
|
|
390 |
+ |
/// The vaults, and what can be done to one.
|
|
391 |
+ |
shape vaults(sidebar: &Sidebar) -> Vec<Node>;
|
|
392 |
+ |
|
|
393 |
+ |
section "Vaults";
|
|
394 |
+ |
|
|
395 |
+ |
// The door to the described New Vault modal (see `naming`). It was an
|
|
396 |
+ |
// `Intent::NewVault` that opened the *shipped* modal until 2026-08-17, which
|
|
397 |
+ |
// was the one control on this screen whose answer was drawn by hand.
|
|
398 |
+ |
act "New vault" to get "/vaults/new";
|
|
399 |
+ |
|
|
400 |
+ |
list {
|
|
401 |
+ |
for vault in sidebar.vaults.iter() {
|
|
402 |
+ |
row &vault.name {
|
|
403 |
+ |
current vault.current;
|
|
404 |
+ |
activate to post "/vaults/{vault.id}/open";
|
|
405 |
+ |
|
|
406 |
+ |
// `offers` rather than `act`: the shipped affordance is a
|
|
407 |
+ |
// right-click menu, and `Row::menu` is what "held back until
|
|
408 |
+ |
// the host asks" means. An inline Delete on every vault row
|
|
409 |
+ |
// would be a different screen.
|
|
410 |
+ |
offers "Rename" to get "/vaults/{vault.id}/rename";
|
|
411 |
+ |
offers "Delete" to post "/vaults/{vault.id}/delete" {
|
|
412 |
+ |
tone Danger;
|
|
413 |
+ |
confirm &vault.confirm;
|
|
414 |
+ |
// Offered dead rather than hidden, which is the shipped
|
|
415 |
+ |
// menu's choice: "Always render Delete so the user can see
|
|
416 |
+ |
// the capability exists."
|
|
417 |
+ |
disabled when sidebar.alone;
|
|
418 |
+ |
}
|
|
419 |
+ |
}
|
|
420 |
+ |
}
|
|
421 |
+ |
}
|
|
422 |
+ |
|
|
423 |
+ |
// The precondition, said beside the control rather than on it. THE FINDING,
|
|
424 |
+ |
// fourth consumer -- see the module header.
|
|
425 |
+ |
toned LAST_VAULT Tone::Info when sidebar.alone;
|
|
426 |
+ |
}
|
|
427 |
+ |
|
|
428 |
+ |
declare! {
|
|
429 |
+ |
/// The collections, manual and dynamic.
|
|
430 |
+ |
shape collections(sidebar: &Sidebar) -> Vec<Node>;
|
|
431 |
+ |
|
|
432 |
+ |
section "Collections";
|
|
433 |
+ |
empty "No collections yet." when sidebar.uncollected;
|
|
434 |
+ |
list {
|
|
435 |
+ |
for collection in sidebar.collections.iter() {
|
|
436 |
+ |
row &collection.name {
|
|
437 |
+ |
token Tag::badge(&collection.mark);
|
|
438 |
+ |
current collection.active;
|
|
439 |
+ |
activate to post &collection.opens;
|
|
440 |
+ |
offers "Delete" to post "/collections/{collection.id}/delete" {
|
|
441 |
+ |
tone Danger;
|
|
442 |
+ |
confirm &collection.confirm;
|
|
443 |
+ |
}
|
|
444 |
+ |
}
|
|
445 |
+ |
}
|
|
446 |
+ |
} unless sidebar.uncollected;
|
|
447 |
+ |
}
|
|
448 |
+ |
|
|
449 |
+ |
declare! {
|
|
450 |
+ |
/// The tags, as an outline and then as a list of things to remove.
|
|
451 |
+ |
///
|
|
452 |
+ |
/// See the module header. Ticking a tag is the write, which is what
|
|
453 |
+ |
/// `Row::toggling` says: the tick is the only affordance for filtering by
|
|
454 |
+ |
/// it, and a button beside a checkbox that ignored clicks is what the
|
|
455 |
+ |
/// member exists to stop.
|
|
456 |
+ |
///
|
|
457 |
+ |
/// Removing a tag from every sample is not a filter, so it is not a tick.
|
|
458 |
+ |
/// It is a list of the same tags with a destructive act on each, which is
|
|
459 |
+ |
/// the shipped right-click menu made visible -- and the second
|
|
460 |
+ |
/// `ConfirmAction` variant this port replaces.
|
|
461 |
+ |
shape tags(sidebar: &Sidebar) -> Vec<Node>;
|
|
462 |
+ |
|
|
463 |
+ |
section "Tags";
|
|
464 |
+ |
empty "No tags yet." when sidebar.untagged;
|
|
465 |
+ |
|
|
466 |
+ |
list {
|
|
467 |
+ |
for tag in sidebar.outline.iter() {
|
|
468 |
+ |
row &tag.label {
|
|
469 |
+ |
depth tag.depth;
|
|
470 |
+ |
for tick in tag.tick.iter() {
|
|
471 |
+ |
toggling tick.on Action::post("/tags/{tick.path}/filter");
|
|
472 |
+ |
}
|
|
473 |
+ |
disclosing true when tag.holds;
|
|
474 |
+ |
}
|
|
475 |
+ |
}
|
|
476 |
+ |
} unless sidebar.untagged;
|
|
477 |
+ |
|
|
478 |
+ |
section "Remove a tag everywhere" unless sidebar.untagged;
|
|
479 |
+ |
list {
|
|
480 |
+ |
for tag in sidebar.removable.iter() {
|
|
481 |
+ |
row &tag.path {
|
|
482 |
+ |
offers "Remove" to post "/tags/{tag.path}/remove" {
|
|
483 |
+ |
tone Danger;
|