From a7ee8832277b64806844cb36f8e8b8570549c81e Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 6 Nov 2025 21:04:59 +0800 Subject: [PATCH] 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 --- .../css/components/lora-modal/lora-modal.css | 38 ++- static/images/tabler/brush-off.svg | 1 + static/images/tabler/exchange-off.svg | 1 + static/images/tabler/photo-off.svg | 1 + static/images/tabler/rotate-2.svg | 1 + static/images/tabler/shopping-cart-off.svg | 1 + static/images/tabler/user-check.svg | 1 + static/images/tabler/world-off.svg | 1 + static/js/components/shared/ModelModal.js | 225 ++++++++++++++++-- 9 files changed, 250 insertions(+), 20 deletions(-) create mode 100644 static/images/tabler/brush-off.svg create mode 100644 static/images/tabler/exchange-off.svg create mode 100644 static/images/tabler/photo-off.svg create mode 100644 static/images/tabler/rotate-2.svg create mode 100644 static/images/tabler/shopping-cart-off.svg create mode 100644 static/images/tabler/user-check.svg create mode 100644 static/images/tabler/world-off.svg diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index a13a84b4..32b1bd3f 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -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; } diff --git a/static/images/tabler/brush-off.svg b/static/images/tabler/brush-off.svg new file mode 100644 index 00000000..30db3d59 --- /dev/null +++ b/static/images/tabler/brush-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/tabler/exchange-off.svg b/static/images/tabler/exchange-off.svg new file mode 100644 index 00000000..ce5ed6f0 --- /dev/null +++ b/static/images/tabler/exchange-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/tabler/photo-off.svg b/static/images/tabler/photo-off.svg new file mode 100644 index 00000000..6fb1537b --- /dev/null +++ b/static/images/tabler/photo-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/tabler/rotate-2.svg b/static/images/tabler/rotate-2.svg new file mode 100644 index 00000000..0b627305 --- /dev/null +++ b/static/images/tabler/rotate-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/tabler/shopping-cart-off.svg b/static/images/tabler/shopping-cart-off.svg new file mode 100644 index 00000000..a8a71aed --- /dev/null +++ b/static/images/tabler/shopping-cart-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/tabler/user-check.svg b/static/images/tabler/user-check.svg new file mode 100644 index 00000000..ce0a0cb1 --- /dev/null +++ b/static/images/tabler/user-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/tabler/world-off.svg b/static/images/tabler/world-off.svg new file mode 100644 index 00000000..f5376df2 --- /dev/null +++ b/static/images/tabler/world-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index d1cca163..53e8e946 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -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, '&') + .replace(/"/g, '"'); +} + +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 ``; +} + +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 `
+ ${icons.join('\n ')} +
`; +} + /** * 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 ? ` +
+ ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')} +
`.trim() : ''; + const creatorInfoAction = modelWithFullData.civitai?.creator ? ` +
+ ${modelWithFullData.civitai.creator.image ? + `
+ ${modelWithFullData.civitai.creator.username} +
` : + `
+ +
` + } + ${modelWithFullData.civitai.creator.username} +
`.trim() : ''; + const creatorActionItems = []; + if (viewOnCivitaiAction) { + creatorActionItems.push(indentMarkup(viewOnCivitaiAction, 24)); + } + if (creatorInfoAction) { + creatorActionItems.push(indentMarkup(creatorInfoAction, 24)); + } + const creatorActionsMarkup = creatorActionItems.length + ? [ + '
', + creatorActionItems.join('\n'), + '
' + ].join('\n') + : ''; + const headerActionItems = []; + if (creatorActionsMarkup) { + headerActionItems.push(creatorActionsMarkup); + } + if (licenseIcons) { + headerActionItems.push(indentMarkup(licenseIcons.trim(), 20)); + } + const headerActionsMarkup = headerActionItems.length + ? [ + ' ' + ].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) { -
- ${modelWithFullData.from_civitai ? ` -
- ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')} -
` : ''} - - ${modelWithFullData.civitai?.creator ? ` -
- ${modelWithFullData.civitai.creator.image ? - `
- ${modelWithFullData.civitai.creator.username} -
` : - `
- -
` - } - ${modelWithFullData.civitai.creator.username} -
` : ''} -
+ ${headerActionsMarkup} ${renderCompactTags(modelWithFullData.tags || [], modelWithFullData.file_path)}