/** * MetadataPanel - Right panel for model metadata and tabs * Features: * - Fixed header with model info * - Compact metadata grid * - Editable fields (usage tips, trigger words, notes) * - Tabs with accordion content */ import { escapeHtml, formatFileSize } from '../shared/utils.js'; import { translate } from '../../utils/i18nHelpers.js'; export class MetadataPanel { constructor(container) { this.element = container; this.model = null; this.modelType = null; this.activeTab = 'description'; } /** * Render the metadata panel */ render({ model, modelType }) { this.model = model; this.modelType = modelType; this.element.innerHTML = this.getTemplate(); this.bindEvents(); } /** * Get the HTML template */ getTemplate() { const m = this.model; const civitai = m.civitai || {}; const creator = civitai.creator || {}; return `
${creator.username ? ` ` : ''} ${m.from_civitai ? ` ` : ''} ${this.renderLicenseIcons()}
${this.renderTags(m.tags)}
${this.modelType === 'loras' ? this.renderLoraSpecific() : ''} ${this.renderNotes(m.notes)}
${this.renderTabs()} ${this.renderTabPanels()}
`; } /** * Render license icons */ renderLicenseIcons() { const license = this.model.civitai?.model; if (!license) return ''; const icons = []; if (license.allowNoCredit === false) { icons.push({ icon: 'user-check', title: translate('modals.model.license.creditRequired', {}, 'Creator credit required') }); } if (license.allowCommercialUse) { const restrictions = this.resolveCommercialRestrictions(license.allowCommercialUse); restrictions.forEach(r => { icons.push({ icon: r.icon, title: r.title }); }); } if (license.allowDerivatives === false) { icons.push({ icon: 'exchange-off', title: translate('modals.model.license.noDerivatives', {}, 'No sharing merges') }); } if (license.allowDifferentLicense === false) { icons.push({ icon: 'rotate-2', title: translate('modals.model.license.noReLicense', {}, 'Same permissions required') }); } if (icons.length === 0) return ''; return `
${icons.map(icon => ` `).join('')}
`; } /** * Resolve commercial restrictions */ resolveCommercialRestrictions(value) { const COMMERCIAL_CONFIG = [ { key: 'image', icon: 'photo-off', title: translate('modals.model.license.noImageSell', {}, 'No selling generated content') }, { key: 'rentcivit', icon: 'brush-off', title: translate('modals.model.license.noRentCivit', {}, 'No Civitai generation') }, { key: 'rent', icon: 'world-off', title: translate('modals.model.license.noRent', {}, 'No generation services') }, { key: 'sell', icon: 'shopping-cart-off', title: translate('modals.model.license.noSell', {}, 'No selling models') }, ]; // Parse and normalize values let allowed = new Set(); const values = Array.isArray(value) ? value : [value]; values.forEach(v => { if (!v && v !== '') return; const cleaned = String(v).trim().toLowerCase().replace(/[\s_-]+/g, '').replace(/[^a-z]/g, ''); if (cleaned) allowed.add(cleaned); }); // Apply hierarchy if (allowed.has('sell')) { allowed.add('rent'); allowed.add('rentcivit'); allowed.add('image'); } if (allowed.has('rent')) { allowed.add('rentcivit'); } // Return disallowed items return COMMERCIAL_CONFIG.filter(config => !allowed.has(config.key)); } /** * Render tags */ renderTags(tags) { if (!tags || tags.length === 0) return ''; const visibleTags = tags.slice(0, 8); const remaining = tags.length - visibleTags.length; return `
${visibleTags.map(tag => ` ${escapeHtml(tag)} `).join('')} ${remaining > 0 ? `+${remaining}` : ''}
`; } /** * Render LoRA specific sections */ renderLoraSpecific() { const m = this.model; const usageTips = m.usage_tips ? JSON.parse(m.usage_tips) : {}; const triggerWords = m.civitai?.trainedWords || []; return `
${Object.entries(usageTips).map(([key, value]) => ` `).join('')}
${triggerWords.map(word => ` `).join('')}
`; } /** * Render notes section */ renderNotes(notes) { return `
`; } /** * Render tabs */ renderTabs() { const tabs = [ { id: 'description', label: translate('modals.model.tabs.description', {}, 'Description') }, { id: 'versions', label: translate('modals.model.tabs.versions', {}, 'Versions') }, ]; if (this.modelType === 'loras') { tabs.push({ id: 'recipes', label: translate('modals.model.tabs.recipes', {}, 'Recipes') }); } return `
${tabs.map(tab => ` `).join('')}
`; } /** * Render tab panels */ renderTabPanels() { const civitai = this.model.civitai || {}; return `
${translate('modals.model.accordion.aboutVersion', {}, 'About this version')}
${civitai.description ? `
${civitai.description}
` : `

${translate('modals.model.description.empty', {}, 'No description available')}

`}
${translate('modals.model.accordion.modelDescription', {}, 'Model Description')}
${civitai.model?.description ? `
${civitai.model.description}
` : `

${translate('modals.model.description.empty', {}, 'No description available')}

`}
${translate('modals.model.loading.versions', {}, 'Loading versions...')}
${this.modelType === 'loras' ? `
${translate('modals.model.loading.recipes', {}, 'Loading recipes...')}
` : ''}
`; } /** * Bind event listeners */ bindEvents() { this.element.addEventListener('click', (e) => { const target = e.target.closest('[data-action]'); if (!target) return; const action = target.dataset.action; switch (action) { case 'switch-tab': const tabId = target.dataset.tab; this.switchTab(tabId); break; case 'toggle-accordion': target.closest('.accordion')?.classList.toggle('expanded'); break; case 'open-location': this.openFileLocation(); break; case 'view-creator': const username = target.dataset.username; if (username) { window.open(`https://civitai.com/user/${username}`, '_blank'); } break; case 'edit-name': case 'edit-usage-tips': case 'edit-trigger-words': case 'edit-notes': // TODO: Implement edit modes console.log('Edit:', action); break; } }); // Notes textarea auto-save const notesTextarea = this.element.querySelector('.metadata__notes'); if (notesTextarea) { notesTextarea.addEventListener('blur', () => { this.saveNotes(notesTextarea.value); }); } } /** * Switch active tab */ switchTab(tabId) { this.activeTab = tabId; // Update tab buttons this.element.querySelectorAll('.tab').forEach(tab => { tab.classList.toggle('active', tab.dataset.tab === tabId); }); // Update panels this.element.querySelectorAll('.tab-panel').forEach(panel => { panel.classList.toggle('active', panel.dataset.panel === tabId); }); // Load tab-specific data if (tabId === 'versions') { this.loadVersions(); } else if (tabId === 'recipes') { this.loadRecipes(); } } /** * Load versions data */ async loadVersions() { // TODO: Implement versions loading console.log('Load versions'); } /** * Load recipes data */ async loadRecipes() { // TODO: Implement recipes loading console.log('Load recipes'); } /** * Save notes */ async saveNotes(notes) { if (!this.model?.file_path) return; try { const { getModelApiClient } = await import('../../api/modelApiFactory.js'); await getModelApiClient().saveModelMetadata(this.model.file_path, { notes }); const { showToast } = await import('../../utils/uiHelpers.js'); showToast('modals.model.notes.saved', {}, 'success'); } catch (err) { console.error('Failed to save notes:', err); const { showToast } = await import('../../utils/i18nHelpers.js'); showToast('modals.model.notes.saveFailed', {}, 'error'); } } /** * Open file location */ async openFileLocation() { if (!this.model?.file_path) return; try { const response = await fetch('/api/lm/open-file-location', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file_path: this.model.file_path }) }); if (!response.ok) throw new Error('Failed to open file location'); const { showToast } = await import('../../utils/uiHelpers.js'); showToast('modals.model.openFileLocation.success', {}, 'success'); } catch (err) { console.error('Failed to open file location:', err); const { showToast } = await import('../../utils/uiHelpers.js'); showToast('modals.model.openFileLocation.failed', {}, 'error'); } } }