feat(ui): media bin select mode with bulk delete

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 03:22:24 +03:00
co-authored by Claude Sonnet 5.5
parent ae52d63dce
commit 2c36794cbf
+34 -14
View File
@@ -14,12 +14,15 @@ export default {
let items = [];
const tell = (text) => c.toast.warn({ text });
const toggle = (id) => { if (chosen.has(id)) chosen.delete(id); else chosen.add(id); draw(true); };
const FILTERS = [{ value: "all", label: "All" }, { value: "video", label: "Video" }, { value: "audio", label: "Audio" }, { value: "image", label: "Images" }];
const SORTS = [{ value: "name", label: "Name A-Z" }, { value: "name-", label: "Name Z-A" }, { value: "kind", label: "Type" }, { value: "added", label: "Date added" }];
const VIEWS = [{ value: "adaptive", label: "Grid" }, { value: "list", label: "List" }, { value: "icons", label: "Icons" }];
const DENSITY = [{ value: "0", label: "Auto" }, ...[1, 2, 3, 4].map((n) => ({ value: String(n), label: `${n}×` }))];
const show = { filter: "all", sort: "name", view: "adaptive", cols: "0" };
let selecting = false;
const chosen = new Set();
const peek = (it) => { const item = items.find((m) => m.id === it.id); if (!item) return; app.mediaPeek.item = item; app.say("media.peek"); }; // a click looks at it on the monitor
const pick = (it) => { // an image becomes the selected scene's resolution source
const sc = p.selected, item = items.find((m) => m.id === it.id);
@@ -31,6 +34,21 @@ export default {
const context = (it, e) => c.menu.context({ x: e.clientX, y: e.clientY, onPick: (id) => act[id](it),
items: [{ id: "look", label: "Look at it on the monitor" }, { id: "res", label: "Use as the selected scene's resolution source", disabled: !p.selected || it.kind === "audio" || !items.some((m) => m.id === it.id && m.kind === "image") }, { id: "ref", label: "Use as a reference for the selected scene", disabled: !p.selected || (p.selected.references || []).includes(it.id) }, { id: "rename", label: "Rename…" },
{ id: "export", label: "Save to your computer", disabled: !items.some((m) => m.id === it.id && m.kind !== "audio") }, { separator: true }, { id: "delete", label: "Delete from the bin", danger: true }] });
/** Delete these from the bin, and nothing in this project may keep pointing at them. */
async function gone(ids) {
try {
for (const id of ids) await api.deleteMedia(id);
const dead = new Set(ids);
p.edit((pr) => pr.scenes.reduce((hit, s) => {
const had = dead.has(s.source_image) || (s.references || []).some((r) => dead.has(r));
if (dead.has(s.source_image)) s.source_image = "";
if (had) s.references = (s.references || []).filter((r) => !dead.has(r));
return hit || had;
}, false));
ids.forEach((id) => chosen.delete(id));
await refresh();
} catch (err) { tell(err.message); await refresh(); }
}
const act = {
look: (it) => peek(it), res: (it) => pick(it),
ref: (it) => p.setScene(p.selected.id, "references", [...(p.selected.references || []), it.id]),
@@ -43,19 +61,10 @@ export default {
delete: async (it) => {
const name = (items.find((m) => m.id === it.id) || {}).name || "this file";
if (!(await c.modal.dialogue({ title: "Delete media", message: `Delete “${name}” from the bin for good? Scenes in other projects that use it will lose it too.`, tone: "danger", confirmLabel: "Delete" }).result)) return;
try {
await api.deleteMedia(it.id);
p.edit((pr) => pr.scenes.reduce((hit, s) => {
const had = s.source_image === it.id || (s.references || []).includes(it.id);
if (s.source_image === it.id) s.source_image = "";
if (had) s.references = (s.references || []).filter((r) => r !== it.id);
return hit || had;
}, false)); // nothing may keep pointing at it
await refresh();
} catch (err) { tell(err.message); }
await gone([it.id]);
},
};
const props = { id: "media", items: [], empty: "No media yet. Drop images or clips here.", onActivate: peek, onContext: context, drag: { type: MEDIA_DRAG, data: (it) => ({ id: it.id, kind: (items.find((m) => m.id === it.id) || {}).kind }) } };
const props = { id: "media", items: [], empty: "No media yet. Drop images or clips here.", onActivate: (it) => (selecting ? toggle(it.id) : peek(it)), onContext: context, drag: { type: MEDIA_DRAG, data: (it) => ({ id: it.id, kind: (items.find((m) => m.id === it.id) || {}).kind }) } };
const galleries = { adaptive: c.gallery.adaptive(props), list: c.gallery.list(props), icons: c.gallery.icons(props) };
const shelf = c.region.stack({ gap: "none", children: [galleries.adaptive] });
const drop = c.dropzone.default({ label: "Drop or choose files", hint: "images, clips, audio",
@@ -64,12 +73,19 @@ export default {
} });
const seg = (label, options, key) => c.segmented.sm({ label, options, value: show[key], onChange: (v) => { show[key] = v; draw(true); } });
const sort = c.select.sm({ label: "Sort", options: SORTS, value: show.sort, onChange: (v) => { show.sort = v; draw(true); } });
host.append(c.toolbar.default({ items: [c.text.sm({ text: "Media" })] }).node, drop.node, seg("Show", FILTERS, "filter").node, sort.node,
const selectBtn = c.button.sm({ label: "Select", tone: "ghost", title: "Pick several files, then delete them together", onClick: () => { selecting = !selecting; chosen.clear(); draw(true); } });
const bulk = c.button.sm({ label: "Delete (0)", tone: "danger", onClick: async () => {
const ids = [...chosen];
if (!ids.length || !(await c.modal.dialogue({ title: "Delete media", message: `Delete ${ids.length} file${ids.length > 1 ? "s" : ""} from the bin for good? Scenes in other projects that use them will lose them too.`, tone: "danger", confirmLabel: "Delete" }).result)) return;
await gone(ids);
} });
bulk.node.hidden = true;
host.append(c.toolbar.default({ items: [c.text.sm({ text: "Media" })], trailing: [selectBtn, bulk] }).node, drop.node, seg("Show", FILTERS, "filter").node, sort.node,
seg("View", VIEWS, "view").node, seg("Tile size", DENSITY, "cols").node, shelf.node);
let drawn = "";
function draw(force) {
const key = `${(p.selected || {}).source_image}|${items.map((m) => m.id)}`;
const key = `${(p.selected || {}).source_image}|${items.map((m) => m.id)}|${selecting}|${[...chosen]}`;
if (!force && key === drawn) return; // typing elsewhere must not rebuild the thumbnails
drawn = key;
const rev = show.sort.endsWith("-") ? -1 : 1, by = show.sort.replace("-", "");
@@ -80,7 +96,11 @@ export default {
if (g.setCols) g.setCols(Number(show.cols));
g.setItems(shown.map((m) => ({ id: m.id, label: m.name, thumb: m.kind === "audio" ? undefined : `${base(m.id)}/thumb`, badge: m.kind })));
const sc = p.selected;
g.setValue(sc && sc.source_image ? [sc.source_image] : []);
g.setValue(selecting ? [...chosen] : sc && sc.source_image ? [sc.source_image] : []);
selectBtn.setLabel(selecting ? "Done" : "Select");
bulk.node.hidden = !selecting;
bulk.setLabel(`Delete (${chosen.size})`);
bulk.setDisabled(!chosen.size);
}
async function refresh() {
try { items = (await api.media()).media || []; learn(items); app.say("bin"); } catch (err) { tell(err.message); }