feat(modal): add license restriction indicators to model modal

Add visual indicators for commercial license restrictions in the model modal. New CSS classes and JavaScript utilities handle the display of restriction icons for selling, renting, and image usage limitations. The modal header actions container has been restructured to accommodate the new license restriction section.

- Add `.modal-header-actions` and `.license-restrictions` CSS classes
- Implement commercial license icon configuration and rendering logic
- Normalize and sanitize commercial restriction values
- Update header layout to remove bottom margin for better visual alignment
This commit is contained in:
Will Miao
2025-11-06 21:04:59 +08:00
parent ddf9e33961
commit a7ee883227
9 changed files with 250 additions and 20 deletions

View File

@@ -9,6 +9,42 @@
border-bottom: 1px solid var(--lora-border);
}
.modal-header-actions {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
width: 100%;
margin-bottom: var(--space-1);
}
.modal-header-actions .license-restrictions {
margin-left: auto;
}
.license-restrictions {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.license-restrictions .license-icon {
width: 22px;
height: 22px;
display: inline-block;
background-color: var(--text-muted);
-webkit-mask: var(--license-icon-image) center/contain no-repeat;
mask: var(--license-icon-image) center/contain no-repeat;
transition: background-color 0.2s ease, transform 0.2s ease;
cursor: default;
}
.license-restrictions .license-icon:hover {
background-color: var(--text-color);
transform: translateY(-1px);
}
/* Info Grid */
.info-grid {
display: grid;
@@ -798,7 +834,7 @@
display: flex;
align-items: center;
gap: 10px;
margin-bottom: var(--space-1);
margin-bottom: 0;
flex-wrap: wrap;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brush-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 17a4 4 0 1 1 4 4h-4v-4z" /><path d="M21 3a16 16 0 0 0 -9.309 4.704m-1.795 2.212a15.993 15.993 0 0 0 -1.696 3.284" /><path d="M21 3a16 16 0 0 1 -4.697 9.302m-2.195 1.786a15.993 15.993 0 0 1 -3.308 1.712" /><path d="M3 3l18 18" /></svg>

After

Width:  |  Height:  |  Size: 546 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-exchange-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M19 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M19 8v5c0 .594 -.104 1.164 -.294 1.692m-1.692 2.298a4.978 4.978 0 0 1 -3.014 1.01h-3l3 -3" /><path d="M14 21l-3 -3" /><path d="M5 16v-5c0 -1.632 .782 -3.082 1.992 -4m3.008 -1h3l-3 -3" /><path d="M11.501 7.499l1.499 -1.499" /><path d="M3 3l18 18" /></svg>

After

Width:  |  Height:  |  Size: 670 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M7 3h11a3 3 0 0 1 3 3v11m-.856 3.099a2.991 2.991 0 0 1 -2.144 .901h-12a3 3 0 0 1 -3 -3v-12c0 -.845 .349 -1.608 .91 -2.153" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M16.33 12.338c.574 -.054 1.155 .166 1.67 .662l3 3" /><path d="M3 3l18 18" /></svg>

After

Width:  |  Height:  |  Size: 601 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-rotate-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 4.55a8 8 0 0 0 -6 14.9m0 -4.45v5h-5" /><path d="M18.37 7.16l0 .01" /><path d="M13 19.94l0 .01" /><path d="M16.84 18.37l0 .01" /><path d="M19.37 15.1l0 .01" /><path d="M19.94 11l0 .01" /></svg>

After

Width:  |  Height:  |  Size: 504 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-shopping-cart-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" /><path d="M17 17a2 2 0 1 0 2 2" /><path d="M17 17h-11v-11" /><path d="M9.239 5.231l10.761 .769l-1 7h-2m-4 0h-7" /><path d="M3 3l18 18" /></svg>

After

Width:  |  Height:  |  Size: 502 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-user-check"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h4" /><path d="M15 19l2 2l4 -4" /></svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-world-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5.657 5.615a9 9 0 1 0 12.717 12.739m1.672 -2.322a9 9 0 0 0 -12.066 -12.084" /><path d="M3.6 9h5.4m4 0h7.4" /><path d="M3.6 15h11.4m4 0h1.4" /><path d="M11.5 3a17.001 17.001 0 0 0 -1.493 3.022m-.847 3.145c-.68 4.027 .1 8.244 2.34 11.833" /><path d="M12.5 3a16.982 16.982 0 0 1 2.549 8.005m-.207 3.818a16.979 16.979 0 0 1 -2.342 6.177" /><path d="M3 3l18 18" /></svg>

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -29,6 +29,166 @@ function getModalFilePath(fallback = '') {
return fallback;
}
const COMMERCIAL_ICON_CONFIG = [
{
key: 'sell',
icon: 'shopping-cart-off.svg',
titleKey: 'modals.model.license.noSell',
fallback: 'Selling not allowed'
},
{
key: 'rent',
icon: 'world-off.svg',
titleKey: 'modals.model.license.noRent',
fallback: 'Rental not allowed'
},
{
key: 'rentcivit',
icon: 'brush-off.svg',
titleKey: 'modals.model.license.noRentCivit',
fallback: 'Civitai rental not allowed'
},
{
key: 'image',
icon: 'photo-off.svg',
titleKey: 'modals.model.license.noImageUse',
fallback: 'Image use not allowed'
}
];
function hasLicenseField(license, field) {
return Object.prototype.hasOwnProperty.call(license || {}, field);
}
function escapeAttribute(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;');
}
function indentMarkup(markup, spaces) {
if (!markup) {
return '';
}
const padding = ' '.repeat(spaces);
return markup
.split('\n')
.map(line => (line ? padding + line : line))
.join('\n');
}
function normalizeCommercialValues(value) {
if (!value && value !== '') {
return ['Sell'];
}
if (Array.isArray(value)) {
return value.filter(item => item !== null && item !== undefined);
}
if (typeof value === 'string') {
return [value];
}
if (value && typeof value[Symbol.iterator] === 'function') {
const result = [];
for (const item of value) {
if (item === null || item === undefined) {
continue;
}
result.push(String(item));
}
if (result.length > 0) {
return result;
}
}
return ['Sell'];
}
function sanitiseCommercialValue(value) {
if (!value && value !== '') {
return '';
}
return String(value)
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '')
.replace(/[^a-z]/g, '');
}
function resolveCommercialRestrictions(value) {
const normalizedValues = normalizeCommercialValues(value);
const allowed = new Set();
normalizedValues.forEach(item => {
const cleaned = sanitiseCommercialValue(item);
if (!cleaned) {
return;
}
allowed.add(cleaned);
});
if (allowed.has('sell')) {
allowed.add('rent');
allowed.add('rentcivit');
allowed.add('image');
}
if (allowed.has('rent')) {
allowed.add('rentcivit');
}
const disallowed = [];
COMMERCIAL_ICON_CONFIG.forEach(config => {
if (!allowed.has(config.key)) {
disallowed.push(config);
}
});
return disallowed;
}
function createLicenseIconMarkup(icon, label) {
const safeLabel = escapeAttribute(label);
const iconPath = `/loras_static/images/tabler/${icon}`;
return `<span class="license-icon" role="img" aria-label="${safeLabel}" title="${safeLabel}" style="--license-icon-image: url('${iconPath}')"></span>`;
}
function renderLicenseIcons(modelData) {
const license = modelData?.civitai?.model;
if (!license) {
return '';
}
const icons = [];
if (hasLicenseField(license, 'allowNoCredit') && license.allowNoCredit === false) {
const label = translate('modals.model.license.creditRequired', {}, 'Credit required');
icons.push(createLicenseIconMarkup('user-check.svg', label));
}
if (hasLicenseField(license, 'allowCommercialUse')) {
const restrictions = resolveCommercialRestrictions(license.allowCommercialUse);
restrictions.forEach(({ icon, titleKey, fallback }) => {
const label = translate(titleKey, {}, fallback);
icons.push(createLicenseIconMarkup(icon, label));
});
}
if (hasLicenseField(license, 'allowDerivatives') && license.allowDerivatives === false) {
const label = translate('modals.model.license.noDerivatives', {}, 'Derivatives not allowed');
icons.push(createLicenseIconMarkup('exchange-off.svg', label));
}
if (hasLicenseField(license, 'allowDifferentLicense') && license.allowDifferentLicense === false) {
const label = translate('modals.model.license.noReLicense', {}, 'Relicensing not allowed');
icons.push(createLicenseIconMarkup('rotate-2.svg', label));
}
if (!icons.length) {
return '';
}
const containerLabel = translate('modals.model.license.restrictionsLabel', {}, 'License restrictions');
const safeContainerLabel = escapeAttribute(containerLabel);
return `<div class="license-restrictions" aria-label="${safeContainerLabel}" role="group">
${icons.join('\n ')}
</div>`;
}
/**
* Display the model modal with the given model data
* @param {Object} model - Model data object
@@ -55,6 +215,51 @@ export async function showModelModal(model, modelType) {
...model,
civitai: completeCivitaiData
};
const licenseIcons = renderLicenseIcons(modelWithFullData);
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${modelWithFullData.file_path}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
</div>`.trim() : '';
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
${modelWithFullData.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='/loras_static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
</div>`.trim() : '';
const creatorActionItems = [];
if (viewOnCivitaiAction) {
creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24));
}
if (creatorInfoAction) {
creatorActionItems.push(indentMarkup(creatorInfoAction, 24));
}
const creatorActionsMarkup = creatorActionItems.length
? [
' <div class="creator-actions">',
creatorActionItems.join('\n'),
' </div>'
].join('\n')
: '';
const headerActionItems = [];
if (creatorActionsMarkup) {
headerActionItems.push(creatorActionsMarkup);
}
if (licenseIcons) {
headerActionItems.push(indentMarkup(licenseIcons.trim(), 20));
}
const headerActionsMarkup = headerActionItems.length
? [
' <div class="modal-header-actions">',
headerActionItems.join('\n'),
' </div>'
].join('\n')
: '';
const hasUpdateAvailable = Boolean(modelWithFullData.update_available);
// Prepare LoRA specific data with complete civitai data
@@ -172,25 +377,7 @@ export async function showModelModal(model, modelType) {
</button>
</div>
<div class="creator-actions">
${modelWithFullData.from_civitai ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${modelWithFullData.file_path}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
</div>` : ''}
${modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
${modelWithFullData.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
</div>` : ''}
</div>
${headerActionsMarkup}
${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}
</header>