From 86f680107950187883253cac1f290c1eb71e2187 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 24 Apr 2026 09:05:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(model-picker):=20sortable=20column=20heade?= =?UTF-8?q?rs=20=E2=80=94=20Model,=20Price,=20Context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 5 + assets/css/model-picker.css | 55 ++++++ assets/js/model-picker.js | 347 ++++++++++++++++++++++-------------- prautoblogger.php | 4 +- 4 files changed, 274 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c156970..88a54f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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.13.1] - 2026-04-24 diff --git a/assets/css/model-picker.css b/assets/css/model-picker.css index ebb5176..2110897 100644 --- a/assets/css/model-picker.css +++ b/assets/css/model-picker.css @@ -205,3 +205,58 @@ .ab-mp-row { padding: 10px 14px; } .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; +} diff --git a/assets/js/model-picker.js b/assets/js/model-picker.js index a2e268a..30a0493 100644 --- a/assets/js/model-picker.js +++ b/assets/js/model-picker.js @@ -1,9 +1,10 @@ /** * Model picker popup for PRAutoBlogger admin settings. * - * Fetches the OpenRouter model registry via AJAX, then renders a searchable - * popup overlay when the user clicks a model-select trigger button. Shows - * name, provider, input/output cost, and context window per model. + * Fetches the OpenRouter model registry via AJAX, then renders a searchable, + * sortable popup overlay when the user clicks a model-select trigger button. + * 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 services/class-open-router-model-registry.php — Server-side registry source. @@ -11,54 +12,57 @@ (function ($) { 'use strict'; - var config = window.prautobloggerAdmin || {}; - var cachedModels = null; + var config = window.prautobloggerAdmin || {}; + var cachedModels = 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). * * @param {string} capability Filter capability (e.g. 'text→text'). * @return {jQuery.Deferred} Resolves with model array. */ - function fetchModels(capability) { + function fetchModels( capability ) { // Image models are embedded in the page config — no AJAX needed. - if (capability === 'image_generation') { + if ( capability === 'image_generation' ) { var imgModels = config.imageModels || []; - return $.Deferred().resolve(imgModels); + return $.Deferred().resolve( imgModels ); } - if (cachedModels) { - return $.Deferred().resolve(filterModels(cachedModels, capability)); + if ( cachedModels ) { + return $.Deferred().resolve( filterModels( cachedModels, capability ) ); } - if (fetchInProgress) { - return fetchInProgress.then(function () { - return filterModels(cachedModels, capability); - }); + if ( fetchInProgress ) { + return fetchInProgress.then( function () { + return filterModels( cachedModels, capability ); + } ); } - fetchInProgress = $.ajax({ - url: config.ajaxUrl, + fetchInProgress = $.ajax( { + url: config.ajaxUrl, method: 'POST', data: { action: 'prautoblogger_get_models', - nonce: config.modelsNonce + nonce: config.modelsNonce }, timeout: 15000 - }).then(function (response) { + } ).then( function ( response ) { fetchInProgress = null; - if (response.success && Array.isArray(response.data)) { + if ( response.success && Array.isArray( response.data ) ) { cachedModels = response.data; - return filterModels(cachedModels, capability); + return filterModels( cachedModels, capability ); } cachedModels = []; return []; }, function () { fetchInProgress = null; - cachedModels = []; + cachedModels = []; return []; - }); + } ); return fetchInProgress; } @@ -70,11 +74,48 @@ * @param {string} capability Capability string to match. * @return {Array} Filtered models. */ - function filterModels(models, capability) { - if (!capability) return models; - return models.filter(function (m) { - return m.capabilities && m.capabilities.indexOf(capability) !== -1; - }); + function filterModels( models, capability ) { + if ( ! capability ) return models; + return models.filter( function ( m ) { + return m.capabilities && m.capabilities.indexOf( capability ) !== -1; + } ); + } + + /** + * 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; + } ); } /** @@ -83,11 +124,11 @@ * @param {number} price Price per 1M tokens. * @return {string} Formatted string like "$0.25" or "Free". */ - function formatPrice(price) { - if (!price || price <= 0) return 'Free'; - if (price < 0.01) return '$' + price.toFixed(4); - if (price < 1) return '$' + price.toFixed(2); - return '$' + price.toFixed(2); + function formatPrice( price ) { + if ( ! price || price <= 0 ) return 'Free'; + if ( price < 0.01 ) return '$' + price.toFixed( 4 ); + if ( price < 1 ) return '$' + price.toFixed( 2 ); + return '$' + price.toFixed( 2 ); } /** @@ -96,10 +137,10 @@ * @param {number} ctx Context length in tokens. * @return {string} Formatted string like "128K" or "1M". */ - function formatContext(ctx) { - if (!ctx) return '—'; - if (ctx >= 1000000) return (ctx / 1000000).toFixed(0) + 'M'; - if (ctx >= 1000) return Math.round(ctx / 1000) + 'K'; + function formatContext( ctx ) { + if ( ! ctx ) return '—'; + if ( ctx >= 1000000 ) return ( ctx / 1000000 ).toFixed( 0 ) + 'M'; + if ( ctx >= 1000 ) return Math.round( ctx / 1000 ) + 'K'; return ctx.toString(); } @@ -109,27 +150,29 @@ * @param {Object} model Normalized model record. * @return {string} HTML string. */ - function renderModelRow(model) { - var deprecated = model.deprecated ? ' ab-mp-deprecated' : ''; + function renderModelRow( model ) { + var deprecated = model.deprecated ? ' ab-mp-deprecated' : ''; var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0; var priceHtml, priceTitle, metaRight; - if (isImageModel) { - priceHtml = '$' + model.cost_per_image.toFixed(4) + '/image'; + if ( isImageModel ) { + priceHtml = '$' + model.cost_per_image.toFixed( 4 ) + '/image'; priceTitle = 'Cost per generated image'; - metaRight = model.description ? '' + escHtml(model.description) + '' : ''; + metaRight = model.description + ? '' + escHtml( model.description ) + '' + : ''; } 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'; - metaRight = '' + formatContext(model.context_length) + ''; + metaRight = '' + formatContext( model.context_length ) + ''; } return ( - '
' + + '
' + '
' + - '' + escHtml(model.name) + '' + - '' + escHtml(model.provider) + '' + - (model.deprecated ? 'deprecated' : '') + + '' + escHtml( model.name ) + '' + + '' + escHtml( model.provider ) + '' + + ( model.deprecated ? 'deprecated' : '' ) + '
' + '
' + '' + priceHtml + '' + @@ -140,14 +183,14 @@ } /** Minimal HTML escaping for display. */ - function escHtml(str) { - if (!str) return ''; - return str.replace(/&/g, '&').replace(//g, '>'); + function escHtml( str ) { + if ( ! str ) return ''; + return str.replace( /&/g, '&' ).replace( //g, '>' ); } /** Escape a string for use in an HTML attribute. */ - function escAttr(str) { - return escHtml(str).replace(/"/g, '"'); + function escAttr( str ) { + return escHtml( str ).replace( /"/g, '"' ); } /** @@ -155,16 +198,16 @@ * * @param {jQuery} $trigger The .ab-mp-trigger button that was clicked. */ - function openPicker($trigger) { - var fieldId = $trigger.data('field-id'); - var capability = $trigger.data('capability') || ''; + function openPicker( $trigger ) { + var fieldId = $trigger.data( 'field-id' ); + var capability = $trigger.data( 'capability' ) || ''; - // Build overlay + popup structure. var isImageCap = capability === 'image_generation'; var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)'; - var $overlay = $('
'); - var $popup = $( + // ── Build overlay + popup ─────────────────────────────────────── + var $overlay = $( '
' ); + var $popup = $( '
' + '
' + 'Select Model' + @@ -173,6 +216,13 @@ '
' + '' + '
' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
' + '
Loading models…
' + '' ); - $overlay.append($popup); - $('body').append($overlay); + $overlay.append( $popup ); + $( 'body' ).append( $overlay ); - var $search = $popup.find('.ab-mp-search'); - var $list = $popup.find('.ab-mp-list'); - var $count = $popup.find('.ab-mp-count'); - var allModels = []; + var $search = $popup.find( '.ab-mp-search' ); + var $list = $popup.find( '.ab-mp-list' ); + var $count = $popup.find( '.ab-mp-count' ); + var $colHeaders = $popup.find( '.ab-mp-col-headers' ); + var allModels = []; + var currentQuery = ''; // Focus search after render. - setTimeout(function () { $search.focus(); }, 50); + setTimeout( function () { $search.focus(); }, 50 ); - // Fetch and render model list. - fetchModels(capability).then(function (models) { - allModels = models; - renderList(models, ''); - }); - - /** Render filtered model list. */ - function renderList(models, query) { - var filtered = models; - if (query) { - var q = query.toLowerCase(); - filtered = models.filter(function (m) { - return (m.name && m.name.toLowerCase().indexOf(q) !== -1) || - (m.id && m.id.toLowerCase().indexOf(q) !== -1) || - (m.provider && m.provider.toLowerCase().indexOf(q) !== -1); - }); - } - - // Sort: non-deprecated first, then by provider + name. - 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) { - $list.html('
No models found.
'); - } else { - var html = ''; - for (var i = 0; i < filtered.length; i++) { - html += renderModelRow(filtered[i]); + // ── Sort header indicators ────────────────────────────────────── + /** Update active-column highlight and sort arrow glyphs. */ + function updateSortHeaders() { + $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( ' ⇅' ); } - $list.html(html); - } - $count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : '')); + } ); } - // Search input handler (debounced). + // ── Render ────────────────────────────────────────────────────── + /** Render filtered + sorted model list. */ + function renderList( models, query ) { + var filtered = models; + if ( query ) { + var q = query.toLowerCase(); + filtered = models.filter( function ( m ) { + return ( m.name && m.name.toLowerCase().indexOf( q ) !== -1 ) || + ( m.id && m.id.toLowerCase().indexOf( q ) !== -1 ) || + ( m.provider && m.provider.toLowerCase().indexOf( q ) !== -1 ); + } ); + } + + filtered = applySort( filtered ); + + if ( filtered.length === 0 ) { + $list.html( '
No models found.
' ); + } else { + var html = ''; + for ( var i = 0; i < filtered.length; i++ ) { + html += renderModelRow( filtered[ i ] ); + } + $list.html( html ); + } + $count.text( filtered.length + ' model' + ( filtered.length !== 1 ? 's' : '' ) ); + updateSortHeaders(); + } + + // 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; - $search.on('input', function () { - var q = $(this).val(); - clearTimeout(searchTimer); - searchTimer = setTimeout(function () { - renderList(allModels, q); - }, 150); - }); + $search.on( 'input', function () { + currentQuery = $( this ).val(); + clearTimeout( searchTimer ); + searchTimer = setTimeout( function () { + renderList( allModels, currentQuery ); + }, 150 ); + } ); - // Select a model. - $list.on('click', '.ab-mp-row', function () { - var id = $(this).data('model-id'); - var model = allModels.find(function (m) { return m.id === id; }); - if (!model) return; + // ── Select a model ────────────────────────────────────────────── + $list.on( 'click', '.ab-mp-row', function () { + var id = $( this ).data( 'model-id' ); + var model = null; + for ( var i = 0; i < allModels.length; i++ ) { + if ( allModels[ i ].id === id ) { model = allModels[ i ]; break; } + } + if ( ! model ) return; - // Update hidden input + display. - $('#' + fieldId).val(model.id); - $trigger.find('.ab-mp-display-name').text(model.name || model.id); + $( '#' + fieldId ).val( model.id ); + $trigger.find( '.ab-mp-display-name' ).text( model.name || model.id ); var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0; - var priceText = isImageModel - ? '$' + model.cost_per_image.toFixed(4) + '/image' - : formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m); - $trigger.find('.ab-mp-display-price').text(priceText); + var priceText = isImageModel + ? '$' + model.cost_per_image.toFixed( 4 ) + '/image' + : formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m ); + $trigger.find( '.ab-mp-display-price' ).text( priceText ); closePicker(); - }); + } ); - // Close handlers. + // ── Close ─────────────────────────────────────────────────────── function closePicker() { $overlay.remove(); } - $popup.find('.ab-mp-close').on('click', closePicker); - $overlay.on('click', function (e) { - if ($(e.target).is('.ab-mp-overlay')) closePicker(); - }); - $(document).on('keydown.abModelPicker', function (e) { - if (e.key === 'Escape') { + $popup.find( '.ab-mp-close' ).on( 'click', closePicker ); + $overlay.on( 'click', function ( e ) { + if ( $( e.target ).is( '.ab-mp-overlay' ) ) closePicker(); + } ); + $( document ).on( 'keydown.abModelPicker', function ( e ) { + if ( e.key === 'Escape' ) { closePicker(); - $(document).off('keydown.abModelPicker'); + $( document ).off( 'keydown.abModelPicker' ); } - }); + } ); } // Bind click on all model picker triggers. - $(document).on('click', '.ab-mp-trigger', function (e) { + $( document ).on( 'click', '.ab-mp-trigger', function ( e ) { e.preventDefault(); - openPicker($(this)); - }); + openPicker( $( this ) ); + } ); -})(jQuery); +} )( jQuery ); diff --git a/prautoblogger.php b/prautoblogger.php index dc779df..8369449 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: 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. - * Version: 0.13.1 + * Version: 0.13.2 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | 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_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );