Counts in the filter panel chips (base models, tags, model types), the All Types
dropdown and the Refresh scope rows were rendered as raw digits, so a library with
10k+ models turned every chip into a five digit pill that dominated the row it sat
in — and the Refresh menu grouped its numbers while the filter panel did not.
One formatter now renders all of them (`formatCount`): exact and locale-grouped
below 10,000, abbreviated to three significant digits above it, and abbreviated
only where the locale's Intl compact form is a real unit and narrower than the
plain number — German keeps 18.475 instead of the rounded 18.500, Russian keeps
18 475 instead of the wider 18,5 тыс.
Their hover text is always present and always exact ({name} — {count} models, and
... recipes on the Recipes page): an affordance that only exists above a threshold
is undiscoverable, and the tooltip is also what reveals a chip label that CSS has
ellipsized.
Chips gain a two-slot layout (the name ellipsizes, the count keeps a tabular
right-aligned slot) so a long tag name can no longer push the count out of the
chip; Refresh rows move from float:right to a flex row, which keeps the counts in
a right-hand column and lets a long root label ellipsize. The All Types group
tooltip moves onto its trigger so it stops leaking onto the menu options.
The two new strings ship as [TODO: Translate] placeholders in the nine locales;
the translation pass is the next commit.
102 lines
3.8 KiB
JavaScript
102 lines
3.8 KiB
JavaScript
import { i18n } from '../i18n/index.js';
|
|
import { translate } from './i18nHelpers.js';
|
|
|
|
/**
|
|
* Magnitude from which counts are abbreviated in dense UI (filter chips,
|
|
* dropdown rows). Below it the count is shown exact, so ordinary libraries
|
|
* look exactly like before; above it a five digit number would otherwise
|
|
* become the widest and loudest element of the row it sits in. The exact value
|
|
* stays available through `formatCountExact` for tooltips and aria-labels.
|
|
*/
|
|
const COUNT_COMPACT_FROM = 10000;
|
|
|
|
/**
|
|
* Format an entry count (models, recipes, tags) for dense UI.
|
|
*
|
|
* Abbreviated to ~3 significant digits above `compactFrom`, so a five digit
|
|
* count does not become the widest element of the row it sits in. The
|
|
* abbreviation is left to Intl, which follows the UI language ("19.9K",
|
|
* "1.99万", "19,9 k").
|
|
*
|
|
* Locales without a compact unit for the magnitude are detected and fall back
|
|
* to the exact, grouped count: German has no unit below a million, so its
|
|
* "compact" form is a *rounded* plain number ("18.500" for 18,475) that reads
|
|
* as exact but is not, and Russian's "19,9 тыс." is wider than "18 475". An
|
|
* abbreviation is only used when the locale actually produced one and it is
|
|
* shorter than the exact count.
|
|
*
|
|
* @param {number} value - The count
|
|
* @param {Object} [options]
|
|
* @param {number} [options.compactFrom] - Magnitude to start abbreviating at
|
|
* @returns {string} Formatted count (e.g. "42", "5,210", "19.9K")
|
|
*/
|
|
export function formatCount(value, { compactFrom = COUNT_COMPACT_FROM } = {}) {
|
|
const count = Number(value) || 0;
|
|
const exact = i18n.formatNumber(count);
|
|
if (count < compactFrom) {
|
|
return exact;
|
|
}
|
|
|
|
const compact = i18n.formatNumber(count, {
|
|
notation: 'compact',
|
|
maximumSignificantDigits: 3,
|
|
});
|
|
const roundedPlain = i18n.formatNumber(Number(count.toPrecision(3)));
|
|
|
|
return compact !== roundedPlain && compact.length < exact.length ? compact : exact;
|
|
}
|
|
|
|
/**
|
|
* Format an entry count in full, for tooltips and aria-labels that must not
|
|
* lose precision to `formatCount`'s abbreviation.
|
|
*
|
|
* @param {number} value - The count
|
|
* @returns {string} Exact, locale-grouped count (e.g. "19,876")
|
|
*/
|
|
export function formatCountExact(value) {
|
|
return i18n.formatNumber(Number(value) || 0);
|
|
}
|
|
|
|
/**
|
|
* Hover text for a counted chip or dropdown row: the full name plus the exact
|
|
* count.
|
|
*
|
|
* It is set unconditionally rather than only when the rendered count was
|
|
* abbreviated, for two reasons: the name is what CSS ellipsizes when a chip is
|
|
* narrow, so the tooltip is the only place the full label can be read; and an
|
|
* affordance that appears at some magnitudes and not others is unpredictable.
|
|
*
|
|
* @param {string} name - Label the count belongs to
|
|
* @param {number} value - The count
|
|
* @param {string} [key] - Translation key for the sentence
|
|
* @returns {string} e.g. "character — 18,475 models"
|
|
*/
|
|
export function formatCountTitle(name, value, key = 'header.filter.countTitle') {
|
|
const exact = formatCountExact(value);
|
|
return translate(key, { name, count: exact }, `${name} — ${exact} models`);
|
|
}
|
|
|
|
/**
|
|
* Format a file size in bytes to a human-readable string
|
|
* @param {number} bytes - The size in bytes
|
|
* @returns {string} Formatted size string (e.g., "1.5 MB")
|
|
*/
|
|
export function formatFileSize(bytes) {
|
|
if (!bytes || isNaN(bytes)) return '';
|
|
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Convert timestamp to human readable date string
|
|
* @param {number} modified - Timestamp in seconds
|
|
* @returns {string} Formatted date string
|
|
*/
|
|
export function formatDate(modified) {
|
|
if (!modified) return '';
|
|
const date = new Date(modified * 1000);
|
|
return date.toLocaleString();
|
|
}
|