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 ( - '