The star is only for favorites (maintainer ruling 2026-10-28). The workspace no longer shows or edits a prompt rating: no Rating field in the Library editor, no stars on Library cards, no "Highest rated" sort on Images or the Library, and no rating column or rated-images total in System > Statistics. The editor never sends `rating`, so a workspace save keeps a stored rating as it is. A saved view or Library visit with the retired rating sort opens on Recently added. Data and API are unchanged; legacy pages keep their ratings. Recorded as deviation D153 (amends D14, D23, D96); concept comparisons map the concept through it.
129 lines
6.3 KiB
JavaScript
129 lines
6.3 KiB
JavaScript
/**
|
||
* The concept's shared page toolbar (docs/concepts/workspace/app.js toolbar(), a347–360), used by Images and the
|
||
* Prompt library: `search.toolbar` > `label.search-field` (search icon + `input#search`), `select#category-filter`,
|
||
* `select#sort`, `Save view`, then `Filters`. Recorded deviations (#249): the sort lists the concept's three options
|
||
* first and a page's extra ones after them (D14); `Filters` reads "Filters · N" while N "Refine this view" filters are
|
||
* active (D44).
|
||
*/
|
||
import { createButton } from '../shared/toolkit/core.js';
|
||
import { createWorkspaceIcon } from './icons.mjs';
|
||
|
||
/** The search waits this long after the last keystroke before it reports a change. */
|
||
export const SEARCH_DELAY = 250;
|
||
|
||
// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
|
||
// other label is one of the page's appended options.
|
||
// No page passes "Highest rated" since D153 (#447); it stays here only to keep the concept's order.
|
||
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
|
||
|
||
const isText = value => typeof value === 'string';
|
||
|
||
/** The Filters label: plain without active refinements, else "Filters · N" (D44). Its text is its accessible name. */
|
||
function filtersLabel(count) {
|
||
if (!Number.isSafeInteger(count) || count < 0) throw new TypeError('The active filter count must be a whole number');
|
||
return count ? `Filters · ${count}` : 'Filters';
|
||
}
|
||
|
||
/** `sorts` as [value, label] pairs, the concept's options first in its order, then the rest in the given order (D14). */
|
||
export function orderSorts(sorts) {
|
||
if (!Array.isArray(sorts) || sorts.length === 0 ||
|
||
sorts.some(pair => !Array.isArray(pair) || pair.length !== 2 || !isText(pair[0]) || !isText(pair[1]))) {
|
||
throw new TypeError('Sorts must be a non-empty list of [value, label] pairs');
|
||
}
|
||
const rank = ([, label]) => {
|
||
const index = CONCEPT_SORT_LABELS.indexOf(label);
|
||
return index === -1 ? CONCEPT_SORT_LABELS.length : index;
|
||
};
|
||
return sorts.map((pair, index) => ({ pair, index }))
|
||
.sort((a, b) => rank(a.pair) - rank(b.pair) || a.index - b.index)
|
||
.map(({ pair }) => [...pair]);
|
||
}
|
||
|
||
function option(value, label) {
|
||
const node = document.createElement('option');
|
||
node.value = value;
|
||
node.textContent = label;
|
||
return node;
|
||
}
|
||
|
||
function categoryOptions(categories) {
|
||
if (!Array.isArray(categories) || categories.some(value => !isText(value))) {
|
||
throw new TypeError('Categories must be a list of strings');
|
||
}
|
||
const values = [...new Set(categories.filter(Boolean))];
|
||
return [option('', 'All categories'), ...values.map(value => option(value, value))];
|
||
}
|
||
|
||
/**
|
||
* Mount the toolbar into `container`. `value` is `{q, category, sort}`; `onChange(value)` receives the whole new
|
||
* value after a select change or, debounced, after typing; `onSaveView(trigger)` opens the page's Save view dialog;
|
||
* `onFilters(trigger)` opens the page's "Refine this view"; `activeFilters` is how many of its filters are set. The toolbar leaves `container` when `scope` is disposed.
|
||
* Returns `{element, value, setValue, setCategories, setActiveFilters}`.
|
||
*/
|
||
export function mountToolbar(container, { scope, categories = [], sorts, value = {}, activeFilters = 0, onChange, onFilters, onSaveView }) {
|
||
if (typeof scope?.listen !== 'function' || typeof scope.debounce !== 'function' || typeof scope.cleanup !== 'function') {
|
||
throw new TypeError('The toolbar needs the page scope');
|
||
}
|
||
if (typeof onChange !== 'function' || typeof onFilters !== 'function' || typeof onSaveView !== 'function') {
|
||
throw new TypeError('The toolbar needs onChange, onFilters and onSaveView');
|
||
}
|
||
const root = document.createElement('search');
|
||
root.className = 'toolbar';
|
||
|
||
const label = document.createElement('label');
|
||
label.className = 'search-field';
|
||
label.htmlFor = 'search';
|
||
const search = document.createElement('input');
|
||
search.id = 'search';
|
||
search.type = 'search';
|
||
search.placeholder = 'Search prompts, tags, filenames…';
|
||
// The listing API rejects longer search text (py/api/images.py _workspace_filters).
|
||
search.maxLength = 500;
|
||
search.setAttribute('aria-label', 'Search workspace');
|
||
label.append(createWorkspaceIcon('search'), search);
|
||
|
||
const category = document.createElement('select');
|
||
category.id = 'category-filter';
|
||
category.setAttribute('aria-label', 'Filter by category');
|
||
category.replaceChildren(...categoryOptions(categories));
|
||
|
||
const sort = document.createElement('select');
|
||
sort.id = 'sort';
|
||
sort.setAttribute('aria-label', 'Sort results');
|
||
sort.replaceChildren(...orderSorts(sorts).map(([id, text]) => option(id, text)));
|
||
|
||
const saveView = createButton('Save view');
|
||
const filters = createButton(filtersLabel(activeFilters));
|
||
root.append(label, category, sort, saveView, filters);
|
||
|
||
const read = () => ({ q: search.value, category: category.value, sort: sort.value });
|
||
function setValue(next) {
|
||
if (isText(next?.q)) search.value = next.q;
|
||
if (isText(next?.category)) category.value = next.category;
|
||
if (isText(next?.sort)) sort.value = [...sort.options].some(node => node.value === next.sort) ? next.sort : sort.options[0].value;
|
||
}
|
||
function setCategories(next) {
|
||
const selected = category.value;
|
||
category.replaceChildren(...categoryOptions(next));
|
||
category.value = [...category.options].some(node => node.value === selected) ? selected : '';
|
||
}
|
||
function setActiveFilters(count) {
|
||
const next = filtersLabel(count);
|
||
// Every listing reload reports the count; only a changed label rewrites the text node, because WebKit drops a
|
||
// click whose mousedown hit a text node that was replaced before mouseup.
|
||
if (filters.textContent !== next) filters.textContent = next;
|
||
}
|
||
setValue(value);
|
||
|
||
const report = () => onChange(read());
|
||
scope.listen(search, 'input', scope.debounce(report, SEARCH_DELAY));
|
||
scope.listen(category, 'change', report);
|
||
scope.listen(sort, 'change', report);
|
||
scope.listen(saveView, 'click', () => onSaveView(saveView));
|
||
scope.listen(filters, 'click', () => onFilters(filters));
|
||
scope.cleanup(() => root.remove());
|
||
|
||
container.append(root);
|
||
return { element: root, value: read, setValue, setCategories, setActiveFilters };
|
||
}
|