feat(early-access): implement EA filtering and UI improvements

Add Early Access version support with filtering and improved UI:

Backend:
- Add is_early_access and early_access_ends_at fields to ModelVersionRecord
- Implement two-phase EA detection (bulk API + single API enrichment)
- Add hide_early_access_updates setting to filter EA updates
- Update has_update() and has_updates_bulk() to respect EA filter setting
- Add _enrich_early_access_details() for precise EA time fetching
- Fix setting propagation through base_model_service and model_update_service

Frontend:
- Add smart relative time display for EA (in Xh, in Xd, or date)
- Replace EA label with clock icon in metadata (fa-clock)
- Show Download button with bolt icon for EA versions (fa-bolt)
- Change EA badge color to #F59F00 (CivitAI Buzz theme)
- Fix toggle UI for hide_early_access_updates setting
- Add translation keys for EA time formatting

Tests:
- Update all tests to pass with new EA functionality
- Add test coverage for EA filtering logic

Closes #815
This commit is contained in:
Will Miao
2026-02-20 10:32:51 +08:00
parent e8b37365a6
commit 67869f19ff
22 changed files with 506 additions and 31 deletions

View File

@@ -123,7 +123,70 @@ function formatDateLabel(value) {
});
}
function buildMetaMarkup(version) {
/**
* Format EA end time as smart relative time
* - < 1 day: "in Xh" (hours)
* - 1-7 days: "in Xd" (days)
* - > 7 days: "Jan 15" (short date)
*/
function formatEarlyAccessTime(endsAt) {
if (!endsAt) {
return null;
}
const endDate = new Date(endsAt);
if (Number.isNaN(endDate.getTime())) {
return null;
}
const now = new Date();
const diffMs = endDate.getTime() - now.getTime();
const diffHours = diffMs / (1000 * 60 * 60);
const diffDays = diffHours / 24;
if (diffHours < 1) {
return translate('modals.model.versions.eaTime.endingSoon', {}, 'ending soon');
}
if (diffHours < 24) {
const hours = Math.ceil(diffHours);
return translate(
'modals.model.versions.eaTime.hours',
{ count: hours },
`in ${hours}h`
);
}
if (diffDays <= 7) {
const days = Math.ceil(diffDays);
return translate(
'modals.model.versions.eaTime.days',
{ count: days },
`in ${days}d`
);
}
// More than 7 days: show short date
return endDate.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
function isEarlyAccessActive(version) {
// Two-phase detection:
// 1. Use pre-computed isEarlyAccess flag if available (from backend)
// 2. Otherwise check exact end time if available
if (typeof version.isEarlyAccess === 'boolean') {
return version.isEarlyAccess;
}
if (!version.earlyAccessEndsAt) {
return false;
}
try {
return new Date(version.earlyAccessEndsAt) > new Date();
} catch {
return false;
}
}
function buildMetaMarkup(version, options = {}) {
const segments = [];
if (version.baseModel) {
segments.push(
@@ -138,6 +201,14 @@ function buildMetaMarkup(version) {
segments.push(escapeHtml(formatFileSize(version.sizeBytes)));
}
// Add early access info if applicable
if (options.showEarlyAccess && isEarlyAccessActive(version)) {
const eaTime = formatEarlyAccessTime(version.earlyAccessEndsAt);
if (eaTime) {
segments.push(`<span class="version-meta-ea"><i class="fas fa-clock"></i> ${escapeHtml(eaTime)}</span>`);
}
}
if (!segments.length) {
return escapeHtml(
translate('modals.model.versions.labels.noDetails', {}, 'No additional details')
@@ -235,6 +306,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
const strategy = state?.global?.settings?.update_flag_strategy;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
if (!sameBaseMode) {
return Boolean(record?.hasUpdate);
@@ -278,6 +350,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (version.isInLibrary || version.shouldIgnore) {
return false;
}
if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false;
}
const versionBase = normalizeBaseModelName(version.baseModel);
if (versionBase !== normalizedBase) {
return false;
@@ -349,6 +424,7 @@ function renderRow(version, options) {
const isNewer =
typeof latestLibraryVersionId === 'number' &&
version.versionId > latestLibraryVersionId;
const isEarlyAccess = isEarlyAccessActive(version);
const badges = [];
if (isCurrent) {
@@ -361,6 +437,10 @@ function renderRow(version, options) {
badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info'));
}
if (isEarlyAccess) {
badges.push(buildBadge(translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'), 'early-access'));
}
if (version.shouldIgnore) {
badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted'));
}
@@ -377,8 +457,10 @@ function renderRow(version, options) {
const actions = [];
if (!version.isInLibrary) {
// Download button with optional EA bolt icon
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
actions.push(
`<button class="version-action version-action-primary" data-version-action="download">${escapeHtml(downloadLabel)}</button>`
`<button class="version-action version-action-primary" data-version-action="download">${downloadIcon}${escapeHtml(downloadLabel)}</button>`
);
} else if (version.filePath) {
actions.push(
@@ -402,7 +484,7 @@ function renderRow(version, options) {
);
const rowAttributes = [
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}"`,
`class="model-version-row${isCurrent ? ' is-current' : ''}${linkTarget ? ' is-clickable' : ''}${isEarlyAccess ? ' is-early-access' : ''}"`,
`data-version-id="${escapeHtml(version.versionId)}"`,
];
if (linkTarget) {
@@ -419,7 +501,7 @@ function renderRow(version, options) {
</div>
<div class="version-badges">${badges.join('')}</div>
<div class="version-meta">
${buildMetaMarkup(version)}
${buildMetaMarkup(version, { showEarlyAccess: true })}
</div>
</div>
<div class="version-actions">