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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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__ ) );
|
||||||
|
|||||||
Reference in New Issue
Block a user