feat(filters): save current filter as smart collection — the bridge (item 3.5)

The bookshelf's save modal grows a second action: "Save as Collection"
POSTs the current (completed, scope-stamped) tree to
/api/collections — the same validated write path every tree
collection uses — and lands you on the new collection's page. The qf
engine then drives it everywhere: the shelf's live shelf-count, the
collections page, the replay chain for episode trees. The legacy
"Save Filter" (bookmark the wire params) stays as the sibling action.

This is the plan's bridge: a book search graduated into a live shelf
with one click, authored in the ONE filter system.
This commit is contained in:
John O'Keefe committed 2026-10-30 19:25:02 -04:00
1 parent 4f71eda6be
commit 94bd69d22d
3 files changed
+47 -3

No files matched your search

+7 -2
View File
@@ -268,6 +268,7 @@ templ BookShelf(
</button> </button>
</div> </div>
<form <form
id="save-filter-form"
hx-post="/api/saved-filters" hx-post="/api/saved-filters"
hx-target="#saved-filters-list" hx-target="#saved-filters-list"
hx-swap="beforeend" hx-swap="beforeend"
@@ -276,13 +277,17 @@ templ BookShelf(
> >
<div class="mb-4"> <div class="mb-4">
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label> <label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Filter Name</label>
<input type="text" name="filter_name" placeholder="My Custom Filter" class="input" required/> <input type="text" id="save-filter-name" name="filter_name" placeholder="My Custom Filter" class="input" required/>
<div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></div> <div id="filter-save-error" style="display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;"></div>
<input type="hidden" name="resource_type" value="media-items"/> <input type="hidden" name="resource_type" value="media-items"/>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button> <button type="button" @click="showSaveModal = false" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-secondary">Save Filter</button>
<!-- Item 3.5: the bridge — the current tree becomes a live
smart collection (the qf engine drives it everywhere:
shelf, collections, play chains). -->
<button type="button" @click="saveFilterAsCollection()" class="btn btn-primary">Save as Collection</button>
</div> </div>
<input type="hidden" name="limit" value="50"/> <input type="hidden" name="limit" value="50"/>
<input type="hidden" name="offset" value="0"/> <input type="hidden" name="offset" value="0"/>
+1 -1
View File
@@ -548,7 +548,7 @@ func BookShelf(
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</button></div><form id=\"save-filter-form\" hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" id=\"save-filter-name\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-secondary\">Save Filter</button><!-- Item 3.5: the bridge — the current tree becomes a live\n\t\t\t\t\t\t\t smart collection (the qf engine drives it everywhere:\n\t\t\t\t\t\t\t shelf, collections, play chains). --><button type=\"button\" @click=\"saveFilterAsCollection()\" class=\"btn btn-primary\">Save as Collection</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+39
View File
@@ -179,6 +179,45 @@ Alpine.data("bookshelf", () => ({
// Show success toast // Show success toast
showToast("Filter saved successfully", "success"); showToast("Filter saved successfully", "success");
}, },
// Item 3.5 bridge: the current tree becomes a live smart collection.
// The qf engine then drives it everywhere — shelf, collections page,
// play chains — with zero re-authoring.
async saveFilterAsCollection() {
const token = localStorage.getItem("token");
if (!token) {
showToast("Not authenticated", "error");
return;
}
const nameInput = document.getElementById("save-filter-name") as HTMLInputElement | null;
const name = nameInput?.value?.trim() ?? "";
if (!name) {
showToast("Give the collection a name first", "error");
return;
}
const builder = builderData();
const tree = builder?.scopedTree?.();
if (!tree) {
showToast("Add a complete filter rule first", "error");
return;
}
try {
const response = await fetch("/api/collections", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ name, filter_tree: tree }),
});
if (response.ok) {
this.showSaveModal = false;
showToast("Saved as a smart collection", "success");
window.location.href = `/collections/${((await response.json()) as { id: string }).id}`;
} else {
const body = (await response.json().catch(() => ({}))) as { error?: string };
showToast(body.error || "Failed to save the collection", "error");
}
} catch {
showToast("Failed to save the collection", "error");
}
},
// Load a saved filter into the form // Load a saved filter into the form
async loadFilter(event: Event) { async loadFilter(event: Event) {
const button = event.target as HTMLElement; const button = event.target as HTMLElement;