feat(model-picker): sortable column headers — Model, Price, Context

- Add .ab-mp-col-headers bar between search and list with three
  clickable column headers: Model (sorts by name), Price in/out
  (sorts by input_price_per_m), Context (sorts by context_length)
- Track sortState at module level so preference persists across
  open/close; clicking same column toggles asc/desc
- Deprecated models always sink to bottom regardless of sort column
- Add CSS for .ab-mp-col-headers, .ab-mp-col-header, .ab-mp-sort-arrow,
  .ab-mp-col-active; widths mirror existing row flex layout so columns
  visually align with data

Bumps version 0.13.1 → 0.13.2
This commit is contained in:
peptiderepo
2026-04-24 09:05:54 +00:00
parent 89c2bafeec
commit 86f6801079
4 changed files with 274 additions and 137 deletions

View File

@@ -5,6 +5,11 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/). and this project uses [Semantic Versioning](https://semver.org/).
## [0.13.2] - 2026-04-24
### Changed
- Model picker: added sortable column headers (Model, Price, Context); clicking a header sorts the list ascending/descending; active column highlighted with sort arrow; sort preference persists while the picker is open.
## [0.12.3] — 2026-04-24 ## [0.12.3] — 2026-04-24
## [0.13.1] - 2026-04-24 ## [0.13.1] - 2026-04-24

View File

@@ -205,3 +205,58 @@
.ab-mp-row { padding: 10px 14px; } .ab-mp-row { padding: 10px 14px; }
.ab-mp-row-meta { gap: 8px; } .ab-mp-row-meta { gap: 8px; }
} }
/* ── Column headers (sortable) ──────────────────────────────────────── */
.ab-mp-col-headers {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 20px;
border-bottom: 1px solid var(--ab-border, #e2e8f0);
background: var(--ab-bg, #f8fafc);
gap: 12px; /* must match .ab-mp-row gap */
}
.ab-mp-col-headers-meta {
display: flex; align-items: center;
gap: 16px; /* must match .ab-mp-row-meta gap */
flex-shrink: 0;
}
.ab-mp-col-header {
display: inline-flex; align-items: center;
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ab-text-muted, #64748b);
cursor: pointer; user-select: none;
background: none; border: none; padding: 3px 0;
white-space: nowrap; line-height: 1.2;
transition: color 0.1s ease;
}
.ab-mp-col-header:hover {
color: var(--ab-text, #1e293b);
}
.ab-mp-col-header.ab-mp-col-active {
color: var(--ab-primary, #4f46e5);
}
/* "Model" header spans the left flex-1 area */
.ab-mp-col-model {
flex: 1; min-width: 0;
}
/* "Price" header width matches .ab-mp-price */
.ab-mp-col-price {
font-variant-numeric: tabular-nums;
}
/* "Context" header width matches .ab-mp-ctx (min-width 36px + padding) */
.ab-mp-col-ctx {
min-width: 48px; justify-content: center; text-align: center;
}
.ab-mp-sort-arrow {
font-size: 9px; opacity: 0.5;
transition: opacity 0.1s ease;
}
.ab-mp-col-header.ab-mp-col-active .ab-mp-sort-arrow {
opacity: 1;
}

View File

@@ -1,9 +1,10 @@
/** /**
* Model picker popup for PRAutoBlogger admin settings. * Model picker popup for PRAutoBlogger admin settings.
* *
* Fetches the OpenRouter model registry via AJAX, then renders a searchable * Fetches the OpenRouter model registry via AJAX, then renders a searchable,
* popup overlay when the user clicks a model-select trigger button. Shows * sortable popup overlay when the user clicks a model-select trigger button.
* name, provider, input/output cost, and context window per model. * Columns: Model, Price (in / out per 1M tokens), Context window.
* Each column header is clickable to sort ascending/descending.
* *
* @see admin/class-admin-page.php — Renders model_select fields and localizes config. * @see admin/class-admin-page.php — Renders model_select fields and localizes config.
* @see services/class-open-router-model-registry.php — Server-side registry source. * @see services/class-open-router-model-registry.php — Server-side registry source.
@@ -15,6 +16,9 @@
var cachedModels = null; var cachedModels = null;
var fetchInProgress = null; var fetchInProgress = null;
// Persists across open/close so the user's preferred sort is remembered.
var sortState = { col: 'name', dir: 'asc' };
/** /**
* Fetch models from the server (cached after first call). * Fetch models from the server (cached after first call).
* *
@@ -77,6 +81,43 @@
} ); } );
} }
/**
* Sort a model array according to the current sortState.
* Deprecated models always sink to the bottom regardless of column.
*
* @param {Array} arr Model array to sort (not mutated — returns a new array).
* @return {Array} Sorted copy.
*/
function applySort( arr ) {
var col = sortState.col;
var dir = sortState.dir;
return arr.slice().sort( function ( a, b ) {
// Deprecated always last.
if ( a.deprecated !== b.deprecated ) {
return a.deprecated ? 1 : -1;
}
var valA, valB;
if ( col === 'price' ) {
valA = a.input_price_per_m || 0;
valB = b.input_price_per_m || 0;
} else if ( col === 'context' ) {
valA = a.context_length || 0;
valB = b.context_length || 0;
} else {
// Default: sort by model name.
valA = ( a.name || '' ).toLowerCase();
valB = ( b.name || '' ).toLowerCase();
}
if ( valA < valB ) return dir === 'asc' ? -1 : 1;
if ( valA > valB ) return dir === 'asc' ? 1 : -1;
return 0;
} );
}
/** /**
* Format a price-per-million-tokens value for display. * Format a price-per-million-tokens value for display.
* *
@@ -117,7 +158,9 @@
if ( isImageModel ) { if ( isImageModel ) {
priceHtml = '$' + model.cost_per_image.toFixed( 4 ) + '/image'; priceHtml = '$' + model.cost_per_image.toFixed( 4 ) + '/image';
priceTitle = 'Cost per generated image'; priceTitle = 'Cost per generated image';
metaRight = model.description ? '<span class="ab-mp-ctx" title="Details">' + escHtml(model.description) + '</span>' : ''; metaRight = model.description
? '<span class="ab-mp-ctx" title="Details">' + escHtml( model.description ) + '</span>'
: '';
} else { } else {
priceHtml = formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m ); priceHtml = formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m );
priceTitle = 'Input / Output per 1M tokens'; priceTitle = 'Input / Output per 1M tokens';
@@ -159,10 +202,10 @@
var fieldId = $trigger.data( 'field-id' ); var fieldId = $trigger.data( 'field-id' );
var capability = $trigger.data( 'capability' ) || ''; var capability = $trigger.data( 'capability' ) || '';
// Build overlay + popup structure.
var isImageCap = capability === 'image_generation'; var isImageCap = capability === 'image_generation';
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)'; var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
// ── Build overlay + popup ───────────────────────────────────────
var $overlay = $( '<div class="ab-mp-overlay"></div>' ); var $overlay = $( '<div class="ab-mp-overlay"></div>' );
var $popup = $( var $popup = $(
'<div class="ab-mp-popup">' + '<div class="ab-mp-popup">' +
@@ -173,6 +216,13 @@
'<div class="ab-mp-search-wrap">' + '<div class="ab-mp-search-wrap">' +
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' + '<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
'</div>' + '</div>' +
'<div class="ab-mp-col-headers">' +
'<button type="button" class="ab-mp-col-header ab-mp-col-model" data-col="name">Model <span class="ab-mp-sort-arrow"></span></button>' +
'<div class="ab-mp-col-headers-meta">' +
'<button type="button" class="ab-mp-col-header ab-mp-col-price" data-col="price">Price (in / out) <span class="ab-mp-sort-arrow"></span></button>' +
'<button type="button" class="ab-mp-col-header ab-mp-col-ctx" data-col="context">Context <span class="ab-mp-sort-arrow"></span></button>' +
'</div>' +
'</div>' +
'<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' + '<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' +
'<div class="ab-mp-footer">' + '<div class="ab-mp-footer">' +
'<span class="ab-mp-count"></span>' + '<span class="ab-mp-count"></span>' +
@@ -187,18 +237,31 @@
var $search = $popup.find( '.ab-mp-search' ); var $search = $popup.find( '.ab-mp-search' );
var $list = $popup.find( '.ab-mp-list' ); var $list = $popup.find( '.ab-mp-list' );
var $count = $popup.find( '.ab-mp-count' ); var $count = $popup.find( '.ab-mp-count' );
var $colHeaders = $popup.find( '.ab-mp-col-headers' );
var allModels = []; var allModels = [];
var currentQuery = '';
// Focus search after render. // Focus search after render.
setTimeout( function () { $search.focus(); }, 50 ); setTimeout( function () { $search.focus(); }, 50 );
// Fetch and render model list. // ── Sort header indicators ──────────────────────────────────────
fetchModels(capability).then(function (models) { /** Update active-column highlight and sort arrow glyphs. */
allModels = models; function updateSortHeaders() {
renderList(models, ''); $colHeaders.find( '.ab-mp-col-header' ).each( function () {
var col = $( this ).data( 'col' );
var $arrow = $( this ).find( '.ab-mp-sort-arrow' );
if ( col === sortState.col ) {
$( this ).addClass( 'ab-mp-col-active' );
$arrow.text( sortState.dir === 'asc' ? ' ▲' : ' ▼' );
} else {
$( this ).removeClass( 'ab-mp-col-active' );
$arrow.text( ' ⇅' );
}
} ); } );
}
/** Render filtered model list. */ // ── Render ──────────────────────────────────────────────────────
/** Render filtered + sorted model list. */
function renderList( models, query ) { function renderList( models, query ) {
var filtered = models; var filtered = models;
if ( query ) { if ( query ) {
@@ -210,14 +273,7 @@
} ); } );
} }
// Sort: non-deprecated first, then by provider + name. filtered = applySort( filtered );
filtered.sort(function (a, b) {
if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1;
var pa = (a.provider || '').toLowerCase();
var pb = (b.provider || '').toLowerCase();
if (pa !== pb) return pa < pb ? -1 : 1;
return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase());
});
if ( filtered.length === 0 ) { if ( filtered.length === 0 ) {
$list.html( '<div class="ab-mp-empty">No models found.</div>' ); $list.html( '<div class="ab-mp-empty">No models found.</div>' );
@@ -229,25 +285,46 @@
$list.html( html ); $list.html( html );
} }
$count.text( filtered.length + ' model' + ( filtered.length !== 1 ? 's' : '' ) ); $count.text( filtered.length + ' model' + ( filtered.length !== 1 ? 's' : '' ) );
updateSortHeaders();
} }
// Search input handler (debounced). // Initial render after data loads.
fetchModels( capability ).then( function ( models ) {
allModels = models;
renderList( allModels, currentQuery );
} );
// ── Column header sort clicks ───────────────────────────────────
$colHeaders.on( 'click', '.ab-mp-col-header', function () {
var col = $( this ).data( 'col' );
if ( sortState.col === col ) {
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
} else {
sortState.col = col;
sortState.dir = 'asc';
}
renderList( allModels, currentQuery );
} );
// ── Search (debounced) ──────────────────────────────────────────
var searchTimer = null; var searchTimer = null;
$search.on( 'input', function () { $search.on( 'input', function () {
var q = $(this).val(); currentQuery = $( this ).val();
clearTimeout( searchTimer ); clearTimeout( searchTimer );
searchTimer = setTimeout( function () { searchTimer = setTimeout( function () {
renderList(allModels, q); renderList( allModels, currentQuery );
}, 150 ); }, 150 );
} ); } );
// Select a model. // ── Select a model ──────────────────────────────────────────────
$list.on( 'click', '.ab-mp-row', function () { $list.on( 'click', '.ab-mp-row', function () {
var id = $( this ).data( 'model-id' ); var id = $( this ).data( 'model-id' );
var model = allModels.find(function (m) { return m.id === id; }); var model = null;
for ( var i = 0; i < allModels.length; i++ ) {
if ( allModels[ i ].id === id ) { model = allModels[ i ]; break; }
}
if ( ! model ) return; if ( ! model ) return;
// Update hidden input + display.
$( '#' + fieldId ).val( model.id ); $( '#' + fieldId ).val( model.id );
$trigger.find( '.ab-mp-display-name' ).text( model.name || model.id ); $trigger.find( '.ab-mp-display-name' ).text( model.name || model.id );
@@ -259,7 +336,7 @@
closePicker(); closePicker();
} ); } );
// Close handlers. // ── Close ───────────────────────────────────────────────────────
function closePicker() { function closePicker() {
$overlay.remove(); $overlay.remove();
} }

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger * Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger * Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.13.1 * Version: 0.13.2
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: PeptideRepo * Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions, | Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings. | and limits without magic strings.
*/ */
define( 'PRAUTOBLOGGER_VERSION', '0.13.1' ); define( 'PRAUTOBLOGGER_VERSION', '0.13.2' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );