diff --git a/locales/en.json b/locales/en.json index 970549ae..47b9524b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -911,7 +911,12 @@ "viewOnCivitai": "View on Civitai", "viewOnCivitaiText": "View on Civitai", "viewCreatorProfile": "View Creator Profile", - "openFileLocation": "Open File Location" + "openFileLocation": "Open File Location", + "viewParams": "View parameters", + "setPreview": "Set as preview", + "previewSet": "Preview updated successfully", + "previewFailed": "Failed to update preview", + "delete": "Delete" }, "openFileLocation": { "success": "File location opened successfully", @@ -930,13 +935,15 @@ "additionalNotes": "Additional Notes", "notesHint": "Press Enter to save, Shift+Enter for new line", "addNotesPlaceholder": "Add your notes here...", - "aboutThisVersion": "About this version" + "aboutThisVersion": "About this version", + "triggerWords": "Trigger Words" }, "notes": { "saved": "Notes saved successfully", "saveFailed": "Failed to save notes" }, "usageTips": { + "add": "Add", "addPresetParameter": "Add preset parameter...", "strengthMin": "Strength Min", "strengthMax": "Strength Max", @@ -945,17 +952,24 @@ "clipStrength": "Clip Strength", "clipSkip": "Clip Skip", "valuePlaceholder": "Value", - "add": "Add", "invalidRange": "Invalid range format. Use x.x-y.y" }, + "params": { + "title": "Generation Parameters", + "prompt": "Prompt", + "negativePrompt": "Negative Prompt", + "noData": "No generation data available", + "promptCopied": "Prompt copied to clipboard" + }, "triggerWords": { "label": "Trigger Words", - "noTriggerWordsNeeded": "No trigger word needed", + "noTriggerWordsNeeded": "No trigger words needed", "edit": "Edit trigger words", "cancel": "Cancel editing", "save": "Save changes", - "addPlaceholder": "Type to add or click suggestions below", + "addPlaceholder": "Type to add trigger word...", "copyWord": "Copy trigger word", + "copyAll": "Copy all trigger words", "deleteWord": "Delete trigger word", "suggestions": { "noSuggestions": "No suggestions available", @@ -965,6 +979,9 @@ "wordSuggestions": "Word Suggestions", "wordsFound": "{count} words found", "loading": "Loading suggestions..." + }, + "validation": { + "duplicate": "This trigger word already exists" } }, "description": { @@ -990,7 +1007,11 @@ "previousWithShortcut": "Previous model (←)", "nextWithShortcut": "Next model (→)", "noPrevious": "No previous model available", - "noNext": "No next model available" + "noNext": "No next model available", + "previous": "Previous", + "next": "Next", + "switchModel": "Switch model", + "browseExamples": "Browse examples" }, "license": { "noImageSell": "No selling generated content", @@ -1002,6 +1023,23 @@ "noReLicense": "Same permissions required", "restrictionsLabel": "License restrictions" }, + "examples": { + "add": "Add", + "addFirst": "Add your first example", + "dropFiles": "Drop files here or click to browse", + "supportedFormats": "Supports: JPG, PNG, WEBP, MP4, WEBM", + "uploading": "Uploading...", + "uploadSuccess": "Example uploaded successfully", + "uploadFailed": "Failed to upload example", + "confirmDelete": "Delete this example image?", + "deleted": "Example deleted successfully", + "deleteFailed": "Failed to delete example", + "title": "Example", + "empty": "No example images available" + }, + "accordion": { + "modelDescription": "Model Description" + }, "loading": { "exampleImages": "Loading example images...", "description": "Loading model description...", diff --git a/static/css/components/model-modal/recipes.css b/static/css/components/model-modal/recipes.css new file mode 100644 index 00000000..b95b2809 --- /dev/null +++ b/static/css/components/model-modal/recipes.css @@ -0,0 +1,272 @@ +/* Recipes Tab Styles */ + +.recipes-loading, +.recipes-error, +.recipes-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-8) var(--space-4); + text-align: center; + color: var(--text-color); + opacity: 0.7; +} + +.recipes-loading i, +.recipes-error i, +.recipes-empty i { + font-size: 2rem; + margin-bottom: var(--space-3); + opacity: 0.5; +} + +.recipes-error i { + color: var(--lora-error); + opacity: 1; +} + +/* Header */ +.recipes-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: var(--space-3); + background: var(--lora-surface); + border-bottom: 1px solid var(--lora-border); + margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2); +} + +.recipes-header__text { + flex: 1; +} + +.recipes-header__eyebrow { + display: block; + font-size: 0.75em; + text-transform: uppercase; + letter-spacing: 0.1em; + opacity: 0.6; + margin-bottom: var(--space-1); +} + +.recipes-header h3 { + margin: 0 0 var(--space-1); + font-size: 1.1em; + font-weight: 600; +} + +.recipes-header__description { + margin: 0; + font-size: 0.85em; + opacity: 0.7; +} + +.recipes-header__view-all { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + background: transparent; + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + color: var(--text-color); + font-size: 0.8em; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.recipes-header__view-all:hover { + border-color: var(--lora-accent); + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); +} + +/* Recipe Cards Grid */ +.recipes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--space-3); + padding: var(--space-1); +} + +/* Recipe Card */ +.recipe-card { + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + overflow: hidden; + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; +} + +.recipe-card:hover { + border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); +} + +.recipe-card:focus { + outline: none; + border-color: var(--lora-accent); + box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2); +} + +/* Recipe Card Media */ +.recipe-card__media { + position: relative; + aspect-ratio: 16 / 10; + overflow: hidden; + background: var(--bg-color); +} + +.recipe-card__media img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s; +} + +.recipe-card:hover .recipe-card__media img { + transform: scale(1.05); +} + +.recipe-card__media-top { + position: absolute; + top: var(--space-2); + right: var(--space-2); + display: flex; + gap: var(--space-1); + opacity: 0; + transition: opacity 0.2s; +} + +.recipe-card:hover .recipe-card__media-top { + opacity: 1; +} + +.recipe-card__copy { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + border: none; + border-radius: 50%; + color: white; + cursor: pointer; + transition: all 0.2s; + backdrop-filter: blur(4px); +} + +.recipe-card__copy:hover { + background: var(--lora-accent); + transform: scale(1.1); +} + +/* Recipe Card Body */ +.recipe-card__body { + padding: var(--space-2); + display: flex; + flex-direction: column; + flex: 1; +} + +.recipe-card__title { + margin: 0 0 var(--space-1); + font-size: 0.9em; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recipe-card__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: var(--space-2); +} + +.recipe-card__badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.7em; + font-weight: 500; +} + +.recipe-card__badge--base { + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); + color: var(--lora-accent); +} + +.recipe-card__badge--empty { + background: var(--lora-border); + color: var(--text-color); + opacity: 0.6; +} + +.recipe-card__badge--ready { + background: oklch(60% 0.15 145); + color: white; +} + +.recipe-card__badge--missing { + background: oklch(60% 0.15 30); + color: white; +} + +.recipe-card__cta { + margin-top: auto; + display: flex; + align-items: center; + justify-content: space-between; + padding-top: var(--space-2); + border-top: 1px solid var(--lora-border); + font-size: 0.8em; + font-weight: 500; + color: var(--lora-accent); + opacity: 0.8; + transition: opacity 0.2s; +} + +.recipe-card:hover .recipe-card__cta { + opacity: 1; +} + +.recipe-card__cta i { + transition: transform 0.2s; +} + +.recipe-card:hover .recipe-card__cta i { + transform: translateX(4px); +} + +/* Mobile Adjustments */ +@media (max-width: 768px) { + .recipes-header { + flex-direction: column; + gap: var(--space-2); + } + + .recipes-header__view-all { + width: 100%; + justify-content: center; + } + + .recipes-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--space-2); + } + + .recipe-card__media-top { + opacity: 1; + } +} diff --git a/static/css/components/model-modal/upload.css b/static/css/components/model-modal/upload.css new file mode 100644 index 00000000..e5c2d442 --- /dev/null +++ b/static/css/components/model-modal/upload.css @@ -0,0 +1,163 @@ +/* Upload Area Styles */ + +.upload-area { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--card-bg); + border-top: 1px solid var(--lora-border); + transform: translateY(100%); + transition: transform 0.3s ease; + z-index: 10; + max-height: 50%; +} + +.upload-area.visible { + transform: translateY(0); +} + +.upload-area__content { + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* Dropzone */ +.upload-area__dropzone { + border: 2px dashed var(--lora-border); + border-radius: var(--border-radius-md); + padding: var(--space-6); + text-align: center; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.upload-area__dropzone:hover { + border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5); + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02); +} + +.upload-area__dropzone.dragover { + border-color: var(--lora-accent); + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08); +} + +.upload-area__input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; +} + +/* Placeholder */ +.upload-area__placeholder { + pointer-events: none; +} + +.upload-area__placeholder i { + font-size: 2.5rem; + color: var(--lora-accent); + opacity: 0.6; + margin-bottom: var(--space-2); +} + +.upload-area__title { + margin: 0 0 var(--space-1); + font-size: 1em; + font-weight: 500; + color: var(--text-color); +} + +.upload-area__hint { + margin: 0; + font-size: 0.8em; + opacity: 0.6; +} + +/* Uploading State */ +.upload-area__uploading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-4); +} + +.upload-area__uploading i { + font-size: 2rem; + color: var(--lora-accent); + margin-bottom: var(--space-2); +} + +.upload-area__uploading p { + margin: 0; + color: var(--text-color); + opacity: 0.8; +} + +/* Actions */ +.upload-area__actions { + display: flex; + justify-content: center; +} + +.upload-area__cancel { + padding: var(--space-2) var(--space-4); + background: transparent; + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + color: var(--text-color); + font-size: 0.9em; + cursor: pointer; + transition: all 0.2s; +} + +.upload-area__cancel:hover { + border-color: var(--lora-error); + color: var(--lora-error); +} + +/* Add Button in Empty State */ +.showcase__add-btn { + margin-top: var(--space-4); + padding: var(--space-2) var(--space-4); + background: var(--lora-accent); + border: none; + border-radius: var(--border-radius-sm); + color: white; + font-size: 0.9em; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: var(--space-2); +} + +.showcase__add-btn:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +/* Mobile Adjustments */ +@media (max-width: 768px) { + .upload-area { + max-height: 60%; + } + + .upload-area__content { + padding: var(--space-3); + } + + .upload-area__dropzone { + padding: var(--space-4); + } + + .upload-area__placeholder i { + font-size: 2rem; + } +} diff --git a/static/css/components/model-modal/versions.css b/static/css/components/model-modal/versions.css new file mode 100644 index 00000000..f2854659 --- /dev/null +++ b/static/css/components/model-modal/versions.css @@ -0,0 +1,378 @@ +/* Versions Tab Styles */ + +.versions-loading, +.versions-error, +.versions-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-8) var(--space-4); + text-align: center; + color: var(--text-color); + opacity: 0.7; +} + +.versions-loading i, +.versions-error i, +.versions-empty i { + font-size: 2rem; + margin-bottom: var(--space-3); + opacity: 0.5; +} + +.versions-error i { + color: var(--lora-error); + opacity: 1; +} + +.versions-empty-filter { + opacity: 0.6; +} + +/* Toolbar */ +.versions-toolbar { + padding: var(--space-3); + background: var(--lora-surface); + border-bottom: 1px solid var(--lora-border); + margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2); +} + +.versions-toolbar-info-heading { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-1); +} + +.versions-toolbar-info-heading h3 { + margin: 0; + font-size: 1em; + font-weight: 600; +} + +.versions-toolbar-info p { + margin: 0; + font-size: 0.85em; + opacity: 0.7; +} + +.versions-toolbar-actions { + margin-top: var(--space-2); + display: flex; + gap: var(--space-2); +} + +.versions-filter-toggle { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + color: var(--text-color); + cursor: pointer; + opacity: 0.6; + transition: all 0.2s; +} + +.versions-filter-toggle:hover { + opacity: 1; + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); +} + +.versions-filter-toggle.active { + opacity: 1; + background: var(--lora-accent); + border-color: var(--lora-accent); + color: white; +} + +.versions-toolbar-btn { + padding: var(--space-1) var(--space-3); + border-radius: var(--border-radius-sm); + font-size: 0.85em; + cursor: pointer; + transition: all 0.2s; + border: 1px solid var(--lora-border); +} + +.versions-toolbar-btn-primary { + background: var(--lora-accent); + border-color: var(--lora-accent); + color: white; +} + +.versions-toolbar-btn-primary:hover { + opacity: 0.9; +} + +/* Version Cards List */ +.versions-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +/* Version Card */ +.version-card { + display: grid; + grid-template-columns: 80px 1fr auto; + gap: var(--space-3); + padding: var(--space-3); + background: var(--lora-surface); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + transition: all 0.2s; +} + +.version-card:hover { + border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.version-card.is-current { + border-color: var(--lora-accent); + background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05); +} + +.version-card.is-clickable { + cursor: pointer; +} + +.version-card.is-clickable:hover { + border-color: var(--lora-accent); +} + +/* Version Media */ +.version-media { + width: 80px; + height: 80px; + border-radius: var(--border-radius-xs); + overflow: hidden; + background: var(--bg-color); + display: flex; + align-items: center; + justify-content: center; +} + +.version-media img, +.version-media video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.version-media-placeholder { + font-size: 0.75em; + color: var(--text-color); + opacity: 0.5; + text-align: center; + padding: var(--space-1); +} + +/* Version Details */ +.version-details { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; +} + +.version-name { + font-weight: 600; + font-size: 0.95em; + margin-bottom: var(--space-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.version-badges { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: var(--space-1); +} + +.version-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--border-radius-xs); + font-size: 0.7em; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.version-badge-current { + background: var(--lora-accent); + color: white; +} + +.version-badge-success { + background: var(--lora-success); + color: white; +} + +.version-badge-info { + background: var(--badge-update-bg); + color: var(--badge-update-text); +} + +.version-badge-muted { + background: var(--lora-border); + color: var(--text-color); + opacity: 0.7; +} + +.version-meta { + font-size: 0.8em; + opacity: 0.7; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1); +} + +.version-meta-separator { + opacity: 0.5; +} + +.version-meta-primary { + color: var(--lora-accent); + font-weight: 500; +} + +/* Version Actions */ +.version-actions { + display: flex; + flex-direction: column; + gap: var(--space-1); + justify-content: center; +} + +.version-action { + padding: var(--space-1) var(--space-3); + border-radius: var(--border-radius-xs); + font-size: 0.8em; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: 1px solid transparent; + white-space: nowrap; +} + +.version-action-primary { + background: var(--lora-accent); + color: white; +} + +.version-action-primary:hover { + opacity: 0.9; +} + +.version-action-danger { + background: transparent; + border-color: var(--lora-error); + color: var(--lora-error); +} + +.version-action-danger:hover { + background: var(--lora-error); + color: white; +} + +.version-action-ghost { + background: transparent; + border-color: var(--lora-border); + color: var(--text-color); + opacity: 0.7; +} + +.version-action-ghost:hover { + opacity: 1; + border-color: var(--text-color); +} + +.version-action:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Delete Modal for Version */ +.version-delete-modal .delete-model-info { + display: grid; + grid-template-columns: 100px 1fr; + gap: var(--space-3); + margin: var(--space-3) 0; + padding: var(--space-3); + background: var(--lora-surface); + border-radius: var(--border-radius-sm); +} + +.version-delete-modal .delete-preview { + width: 100px; + height: 100px; + border-radius: var(--border-radius-xs); + overflow: hidden; + background: var(--bg-color); +} + +.version-delete-modal .delete-preview img, +.version-delete-modal .delete-preview video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.version-delete-modal .delete-info h3 { + margin: 0 0 var(--space-1); + font-size: 1em; +} + +.version-delete-modal .version-base-model { + margin: 0; + opacity: 0.7; + font-size: 0.9em; +} + +/* Mobile Adjustments */ +@media (max-width: 768px) { + .version-card { + grid-template-columns: 60px 1fr auto; + gap: var(--space-2); + padding: var(--space-2); + } + + .version-media { + width: 60px; + height: 60px; + } + + .version-name { + font-size: 0.9em; + } + + .version-actions { + flex-direction: row; + flex-wrap: wrap; + } + + .version-action { + padding: 4px 8px; + font-size: 0.75em; + } + + .versions-toolbar-actions { + flex-direction: column; + } + + .versions-toolbar-btn { + width: 100%; + text-align: center; + } +} diff --git a/static/css/style.css b/static/css/style.css index 058dd0a6..fcc8e7b8 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -34,6 +34,11 @@ @import 'components/model-modal/thumbnail-rail.css'; @import 'components/model-modal/metadata.css'; @import 'components/model-modal/tabs.css'; + +/* Model Modal Phase 2 - Tabs and Upload */ +@import 'components/model-modal/versions.css'; +@import 'components/model-modal/recipes.css'; +@import 'components/model-modal/upload.css'; @import 'components/shared/edit-metadata.css'; @import 'components/search-filter.css'; @import 'components/bulk.css'; diff --git a/static/js/components/model-modal/MetadataPanel.js b/static/js/components/model-modal/MetadataPanel.js index f28c00a1..6664e6b9 100644 --- a/static/js/components/model-modal/MetadataPanel.js +++ b/static/js/components/model-modal/MetadataPanel.js @@ -4,11 +4,15 @@ * - Fixed header with model info * - Compact metadata grid * - Editable fields (usage tips, trigger words, notes) - * - Tabs with accordion content + * - Tabs with accordion content (Description, Versions, Recipes) */ import { escapeHtml, formatFileSize } from '../shared/utils.js'; import { translate } from '../../utils/i18nHelpers.js'; +import { showToast } from '../../utils/uiHelpers.js'; +import { getModelApiClient } from '../../api/modelApiFactory.js'; +import { VersionsTab } from './VersionsTab.js'; +import { RecipesTab } from './RecipesTab.js'; export class MetadataPanel { constructor(container) { @@ -16,6 +20,12 @@ export class MetadataPanel { this.model = null; this.modelType = null; this.activeTab = 'description'; + this.versionsTab = null; + this.recipesTab = null; + this.notesDebounceTimer = null; + this.isEditingUsageTips = false; + this.isEditingTriggerWords = false; + this.editingTriggerWords = []; } /** @@ -41,7 +51,7 @@ export class MetadataPanel {
-
@@ -100,7 +110,7 @@ export class MetadataPanel {
-
@@ -173,7 +183,6 @@ export class MetadataPanel { { 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]; @@ -183,7 +192,6 @@ export class MetadataPanel { if (cleaned) allowed.add(cleaned); }); - // Apply hierarchy if (allowed.has('sell')) { allowed.add('rent'); allowed.add('rentcivit'); @@ -193,7 +201,6 @@ export class MetadataPanel { allowed.add('rentcivit'); } - // Return disallowed items return COMMERCIAL_CONFIG.filter(config => !allowed.has(config.key)); } @@ -217,69 +224,123 @@ export class MetadataPanel { } /** - * Render LoRA specific sections + * Render LoRA specific sections with editing */ renderLoraSpecific() { const m = this.model; const usageTips = m.usage_tips ? JSON.parse(m.usage_tips) : {}; - const triggerWords = m.civitai?.trainedWords || []; + const triggerWords = this.isEditingTriggerWords + ? this.editingTriggerWords + : (m.civitai?.trainedWords || []); return `
`; } + /** + * Render usage tip editor + */ + renderUsageTipEditor() { + return ` +
+ + + + +
+ `; + } + /** * Render notes section */ renderNotes(notes) { return ` -
+ `; } @@ -324,7 +385,7 @@ export class MetadataPanel {
- ${translate('modals.model.accordion.aboutVersion', {}, 'About this version')} + ${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}
@@ -332,7 +393,7 @@ export class MetadataPanel { ${civitai.description ? `
${civitai.description}
` : ` -

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

+

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

`}
@@ -348,7 +409,7 @@ export class MetadataPanel { ${civitai.model?.description ? `
${civitai.model.description}
` : ` -

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

+

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

`}
@@ -356,18 +417,12 @@ export class MetadataPanel {
-
- - ${translate('modals.model.loading.versions', {}, 'Loading versions...')} -
+
${this.modelType === 'loras' ? `
-
- - ${translate('modals.model.loading.recipes', {}, 'Loading recipes...')} -
+
` : ''} @@ -396,27 +451,78 @@ export class MetadataPanel { this.openFileLocation(); break; case 'view-creator': - const username = target.dataset.username; + const username = target.dataset.username || target.closest('[data-username]')?.dataset.username; if (username) { window.open(`https://civitai.com/user/${username}`, '_blank'); } break; case 'edit-name': + this.editModelName(); + break; case 'edit-usage-tips': + this.startEditingUsageTips(); + break; + case 'cancel-usage-tips': + this.cancelEditingUsageTips(); + break; + case 'add-usage-tip': + this.addUsageTip(); + break; + case 'remove-usage-tip': + const key = target.dataset.key; + if (key) this.removeUsageTip(key); + break; case 'edit-trigger-words': - case 'edit-notes': - // TODO: Implement edit modes - console.log('Edit:', action); + this.startEditingTriggerWords(); + break; + case 'cancel-trigger-words': + this.cancelEditingTriggerWords(); + break; + case 'save-trigger-words': + this.saveTriggerWords(); + break; + case 'copy-trigger-words': + this.copyAllTriggerWords(); + break; + case 'copy-trigger-word': + const word = target.dataset.word; + if (word) this.copyTriggerWord(word); + break; + case 'remove-trigger-word': + const wordToRemove = target.dataset.word || target.closest('[data-word]')?.dataset.word; + if (wordToRemove) this.removeTriggerWord(wordToRemove); break; } }); - // Notes textarea auto-save - const notesTextarea = this.element.querySelector('.metadata__notes'); - if (notesTextarea) { - notesTextarea.addEventListener('blur', () => { - this.saveNotes(notesTextarea.value); - }); + // Handle input events + this.element.addEventListener('input', (e) => { + if (e.target.dataset.action === 'notes-input') { + this.handleNotesInput(e.target.value); + } + }); + + this.element.addEventListener('keydown', (e) => { + if (e.target.dataset.action === 'add-trigger-word-input' && e.key === 'Enter') { + e.preventDefault(); + const value = e.target.value.trim(); + if (value) { + this.addTriggerWord(value); + e.target.value = ''; + } + } + + if (e.target.dataset.action === 'usage-tip-value-input' && e.key === 'Enter') { + e.preventDefault(); + this.addUsageTip(); + } + }); + + // Load initial tab content + if (this.activeTab === 'versions') { + this.loadVersionsTab(); + } else if (this.activeTab === 'recipes') { + this.loadRecipesTab(); } } @@ -438,47 +544,309 @@ export class MetadataPanel { // Load tab-specific data if (tabId === 'versions') { - this.loadVersions(); + this.loadVersionsTab(); } else if (tabId === 'recipes') { - this.loadRecipes(); + this.loadRecipesTab(); } } /** - * Load versions data + * Load versions tab */ - async loadVersions() { - // TODO: Implement versions loading - console.log('Load versions'); + loadVersionsTab() { + if (!this.versionsTab) { + const container = this.element.querySelector('.versions-tab-container'); + if (container) { + this.versionsTab = new VersionsTab(container); + this.versionsTab.render({ model: this.model, modelType: this.modelType }); + } + } } /** - * Load recipes data + * Load recipes tab */ - async loadRecipes() { - // TODO: Implement recipes loading - console.log('Load recipes'); + loadRecipesTab() { + if (!this.recipesTab) { + const container = this.element.querySelector('.recipes-tab-container'); + if (container) { + this.recipesTab = new RecipesTab(container); + this.recipesTab.render({ model: this.model }); + } + } } /** - * Save notes + * Handle notes input with auto-save + */ + handleNotesInput(value) { + // Clear existing timer + if (this.notesDebounceTimer) { + clearTimeout(this.notesDebounceTimer); + } + + // Show saving indicator + const indicator = this.element.querySelector('[data-save-indicator]'); + if (indicator) { + indicator.innerHTML = ' Saving...'; + indicator.style.display = 'inline-flex'; + } + + // Debounce save + this.notesDebounceTimer = setTimeout(() => { + this.saveNotes(value); + }, 800); + } + + /** + * Save notes to server */ 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 client = getModelApiClient(this.modelType); + await client.saveModelMetadata(this.model.file_path, { notes }); + + const indicator = this.element.querySelector('[data-save-indicator]'); + if (indicator) { + indicator.innerHTML = ' Saved'; + setTimeout(() => { + indicator.style.display = 'none'; + }, 2000); + } - 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'); + + const indicator = this.element.querySelector('[data-save-indicator]'); + if (indicator) { + indicator.innerHTML = ' Failed'; + } + showToast('modals.model.notes.saveFailed', {}, 'error'); } } + /** + * Start editing usage tips + */ + startEditingUsageTips() { + this.isEditingUsageTips = true; + this.refreshLoraSpecificSection(); + } + + /** + * Cancel editing usage tips + */ + cancelEditingUsageTips() { + this.isEditingUsageTips = false; + this.refreshLoraSpecificSection(); + } + + /** + * Add usage tip + */ + async addUsageTip() { + const keySelect = this.element.querySelector('.usage-tip-key'); + const valueInput = this.element.querySelector('.usage-tip-value'); + + const key = keySelect?.value; + const value = valueInput?.value.trim(); + + if (!key || !value) return; + + try { + const usageTips = this.model.usage_tips ? JSON.parse(this.model.usage_tips) : {}; + usageTips[key] = value; + + const client = getModelApiClient(this.modelType); + await client.saveModelMetadata(this.model.file_path, { usage_tips: JSON.stringify(usageTips) }); + + this.model.usage_tips = JSON.stringify(usageTips); + this.isEditingUsageTips = false; + this.refreshLoraSpecificSection(); + showToast('common.actions.save', {}, 'success'); + } catch (err) { + console.error('Failed to save usage tip:', err); + showToast('modals.model.notes.saveFailed', {}, 'error'); + } + } + + /** + * Remove usage tip + */ + async removeUsageTip(key) { + try { + const usageTips = this.model.usage_tips ? JSON.parse(this.model.usage_tips) : {}; + delete usageTips[key]; + + const client = getModelApiClient(this.modelType); + await client.saveModelMetadata(this.model.file_path, { + usage_tips: Object.keys(usageTips).length > 0 ? JSON.stringify(usageTips) : null + }); + + this.model.usage_tips = Object.keys(usageTips).length > 0 ? JSON.stringify(usageTips) : null; + this.refreshLoraSpecificSection(); + showToast('common.actions.delete', {}, 'success'); + } catch (err) { + console.error('Failed to remove usage tip:', err); + showToast('modals.model.notes.saveFailed', {}, 'error'); + } + } + + /** + * Start editing trigger words + */ + startEditingTriggerWords() { + this.isEditingTriggerWords = true; + this.editingTriggerWords = [...(this.model.civitai?.trainedWords || [])]; + this.refreshLoraSpecificSection(); + + // Focus input + setTimeout(() => { + const input = this.element.querySelector('.metadata__tag-input'); + if (input) input.focus(); + }, 0); + } + + /** + * Cancel editing trigger words + */ + cancelEditingTriggerWords() { + this.isEditingTriggerWords = false; + this.editingTriggerWords = []; + this.refreshLoraSpecificSection(); + } + + /** + * Add trigger word during editing + */ + addTriggerWord(word) { + if (!word.trim()) return; + if (this.editingTriggerWords.includes(word.trim())) { + showToast('modals.model.triggerWords.validation.duplicate', {}, 'warning'); + return; + } + this.editingTriggerWords.push(word.trim()); + this.refreshLoraSpecificSection(); + + // Focus input again + setTimeout(() => { + const input = this.element.querySelector('.metadata__tag-input'); + if (input) { + input.value = ''; + input.focus(); + } + }, 0); + } + + /** + * Remove trigger word during editing + */ + removeTriggerWord(word) { + this.editingTriggerWords = this.editingTriggerWords.filter(w => w !== word); + this.refreshLoraSpecificSection(); + } + + /** + * Save trigger words + */ + async saveTriggerWords() { + try { + const client = getModelApiClient(this.modelType); + await client.saveModelMetadata(this.model.file_path, { + trained_words: this.editingTriggerWords + }); + + // Update local model data + if (!this.model.civitai) this.model.civitai = {}; + this.model.civitai.trainedWords = [...this.editingTriggerWords]; + + this.isEditingTriggerWords = false; + this.editingTriggerWords = []; + this.refreshLoraSpecificSection(); + showToast('common.actions.save', {}, 'success'); + } catch (err) { + console.error('Failed to save trigger words:', err); + showToast('modals.model.notes.saveFailed', {}, 'error'); + } + } + + /** + * Copy single trigger word + */ + async copyTriggerWord(word) { + try { + await navigator.clipboard.writeText(word); + showToast('modals.model.triggerWords.copyWord', {}, 'success'); + } catch (err) { + console.error('Failed to copy trigger word:', err); + } + } + + /** + * Copy all trigger words + */ + async copyAllTriggerWords() { + const words = this.model.civitai?.trainedWords || []; + if (words.length === 0) return; + + try { + await navigator.clipboard.writeText(words.join(', ')); + showToast('modals.model.triggerWords.copyWord', {}, 'success'); + } catch (err) { + console.error('Failed to copy trigger words:', err); + } + } + + /** + * Refresh LoRA specific section + */ + refreshLoraSpecificSection() { + if (this.modelType !== 'loras') return; + + const sections = this.element.querySelectorAll('.metadata__section'); + // First two sections are usage tips and trigger words + if (sections.length >= 2) { + const newHtml = this.renderLoraSpecific(); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = newHtml; + + const newSections = tempDiv.querySelectorAll('.metadata__section'); + if (newSections.length >= 2) { + sections[0].replaceWith(newSections[0]); + sections[1].replaceWith(newSections[1]); + } + } + } + + /** + * Edit model name + */ + async editModelName() { + const currentName = this.model.model_name || ''; + const newName = prompt( + translate('modals.model.actions.editModelName', {}, 'Edit model name'), + currentName + ); + + if (newName !== null && newName.trim() !== '' && newName !== currentName) { + try { + const client = getModelApiClient(this.modelType); + await client.saveModelMetadata(this.model.file_path, { model_name: newName.trim() }); + + this.model.model_name = newName.trim(); + this.element.querySelector('.metadata__name').textContent = newName.trim(); + showToast('common.actions.save', {}, 'success'); + } catch (err) { + console.error('Failed to save model name:', err); + showToast('modals.model.notes.saveFailed', {}, 'error'); + } + } + } + /** * Open file location */ @@ -494,11 +862,9 @@ export class MetadataPanel { 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'); } } diff --git a/static/js/components/model-modal/RecipesTab.js b/static/js/components/model-modal/RecipesTab.js new file mode 100644 index 00000000..9fbba77a --- /dev/null +++ b/static/js/components/model-modal/RecipesTab.js @@ -0,0 +1,321 @@ +/** + * RecipesTab - Recipe cards grid component for LoRA models + * Features: + * - Recipe cards grid layout + * - Copy/View actions + * - LoRA availability status badges + */ + +import { escapeHtml } from '../shared/utils.js'; +import { translate } from '../../utils/i18nHelpers.js'; +import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; +import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; + +export class RecipesTab { + constructor(container) { + this.element = container; + this.model = null; + this.recipes = []; + this.isLoading = false; + } + + /** + * Render the recipes tab + */ + async render({ model }) { + this.model = model; + this.element.innerHTML = this.getLoadingTemplate(); + + await this.loadRecipes(); + } + + /** + * Get loading template + */ + getLoadingTemplate() { + return ` +
+ + ${translate('modals.model.loading.recipes', {}, 'Loading recipes...')} +
+ `; + } + + /** + * Load recipes from API + */ + async loadRecipes() { + const sha256 = this.model?.sha256; + + if (!sha256) { + this.renderError('Missing model hash'); + return; + } + + this.isLoading = true; + + try { + const response = await fetch(`/api/lm/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`); + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to load recipes'); + } + + this.recipes = data.recipes || []; + this.renderRecipes(); + } catch (error) { + console.error('Failed to load recipes:', error); + this.renderError(error.message); + } finally { + this.isLoading = false; + } + } + + /** + * Render error state + */ + renderError(message) { + this.element.innerHTML = ` +
+ +

${escapeHtml(message || 'Failed to load recipes. Please try again later.')}

+
+ `; + } + + /** + * Render empty state + */ + renderEmpty() { + this.element.innerHTML = ` +
+ +

${translate('recipes.noRecipesFound', {}, 'No recipes found that use this LoRA.')}

+
+ `; + } + + /** + * Render recipes grid + */ + renderRecipes() { + if (!this.recipes || this.recipes.length === 0) { + this.renderEmpty(); + return; + } + + const loraName = this.model?.model_name || ''; + + this.element.innerHTML = ` +
+
+ Linked recipes +

${this.recipes.length} recipe${this.recipes.length > 1 ? 's' : ''} using this LoRA

+

+ ${loraName ? `Discover workflows crafted for ${escapeHtml(loraName)}.` : 'Discover workflows crafted for this model.'} +

+
+ +
+
+ ${this.recipes.map(recipe => this.renderRecipeCard(recipe)).join('')} +
+ `; + + this.bindEvents(); + } + + /** + * Render a single recipe card + */ + renderRecipeCard(recipe) { + const baseModel = recipe.base_model || ''; + const loras = recipe.loras || []; + const lorasCount = loras.length; + const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length; + const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0; + + let statusClass = 'empty'; + let statusLabel = 'No linked LoRAs'; + let statusTitle = 'No LoRAs in this recipe'; + + if (lorasCount > 0) { + if (allLorasAvailable) { + statusClass = 'ready'; + statusLabel = `${lorasCount} LoRA${lorasCount > 1 ? 's' : ''} ready`; + statusTitle = 'All LoRAs available - Ready to use'; + } else { + statusClass = 'missing'; + statusLabel = `Missing ${missingLorasCount} of ${lorasCount}`; + statusTitle = `${missingLorasCount} of ${lorasCount} LoRAs missing`; + } + } + + const imageUrl = recipe.file_url || + (recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` : + '/loras_static/images/no-preview.png'); + + return ` +
+
+ ${recipe.title ? escapeHtml(recipe.title) + ' preview' : 'Recipe preview'} +
+ +
+
+
+

+ ${escapeHtml(recipe.title || 'Untitled recipe')} +

+
+ ${baseModel ? `${escapeHtml(baseModel)}` : ''} + + + ${escapeHtml(statusLabel)} + +
+
+ View details + +
+
+
+ `; + } + + /** + * Bind event listeners + */ + bindEvents() { + this.element.addEventListener('click', async (e) => { + const target = e.target.closest('[data-action]'); + + if (target) { + const action = target.dataset.action; + + if (action === 'view-all') { + await this.navigateToRecipesPage(); + return; + } + + if (action === 'copy-recipe') { + const card = target.closest('.recipe-card'); + const recipeId = card?.dataset.recipeId; + if (recipeId) { + e.stopPropagation(); + this.copyRecipeSyntax(recipeId); + } + return; + } + } + + // Card click - navigate to recipe + const card = e.target.closest('.recipe-card'); + if (card && !e.target.closest('[data-action]')) { + const recipeId = card.dataset.recipeId; + if (recipeId) { + await this.navigateToRecipeDetails(recipeId); + } + } + }); + + // Keyboard navigation for cards + this.element.addEventListener('keydown', async (e) => { + if (e.key === 'Enter' || e.key === ' ') { + const card = e.target.closest('.recipe-card'); + if (card) { + e.preventDefault(); + const recipeId = card.dataset.recipeId; + if (recipeId) { + await this.navigateToRecipeDetails(recipeId); + } + } + } + }); + } + + /** + * Copy recipe syntax to clipboard + */ + async copyRecipeSyntax(recipeId) { + if (!recipeId) { + showToast('toast.recipes.noRecipeId', {}, 'error'); + return; + } + + try { + const response = await fetch(`/api/lm/recipe/${recipeId}/syntax`); + const data = await response.json(); + + if (data.success && data.syntax) { + await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard'); + } else { + throw new Error(data.error || 'No syntax returned'); + } + } catch (err) { + console.error('Failed to copy recipe syntax:', err); + showToast('toast.recipes.copyFailed', { message: err.message }, 'error'); + } + } + + /** + * Navigate to recipes page with filter + */ + async navigateToRecipesPage() { + // Close the modal + const { ModelModal } = await import('./ModelModal.js'); + ModelModal.close(); + + // Clear any previous filters + removeSessionItem('filterLoraName'); + removeSessionItem('filterLoraHash'); + removeSessionItem('viewRecipeId'); + + // Store the LoRA name and hash filter in sessionStorage + setSessionItem('lora_to_recipe_filterLoraName', this.model?.model_name || ''); + setSessionItem('lora_to_recipe_filterLoraHash', this.model?.sha256 || ''); + + // Navigate to recipes page + window.location.href = '/loras/recipes'; + } + + /** + * Navigate to specific recipe details + */ + async navigateToRecipeDetails(recipeId) { + // Close the modal + const { ModelModal } = await import('./ModelModal.js'); + ModelModal.close(); + + // Clear any previous filters + removeSessionItem('filterLoraName'); + removeSessionItem('filterLoraHash'); + removeSessionItem('viewRecipeId'); + + // Store the recipe ID in sessionStorage to load on recipes page + setSessionItem('viewRecipeId', recipeId); + + // Navigate to recipes page + window.location.href = '/loras/recipes'; + } + + /** + * Refresh recipes + */ + async refresh() { + await this.loadRecipes(); + } +} diff --git a/static/js/components/model-modal/Showcase.js b/static/js/components/model-modal/Showcase.js index fb1e92cb..ff2f9c06 100644 --- a/static/js/components/model-modal/Showcase.js +++ b/static/js/components/model-modal/Showcase.js @@ -4,11 +4,14 @@ * - Main image display with navigation * - Thumbnail rail for quick switching * - Params panel for image metadata + * - Upload area for custom examples * - Keyboard navigation support (← →) */ import { escapeHtml } from '../shared/utils.js'; import { translate } from '../../utils/i18nHelpers.js'; +import { showToast } from '../../utils/uiHelpers.js'; +import { getModelApiClient } from '../../api/modelApiFactory.js'; export class Showcase { constructor(container) { @@ -18,6 +21,8 @@ export class Showcase { this.modelHash = ''; this.filePath = ''; this.paramsVisible = false; + this.uploadAreaVisible = false; + this.isUploading = false; } /** @@ -29,6 +34,7 @@ export class Showcase { this.filePath = filePath || ''; this.currentIndex = 0; this.paramsVisible = false; + this.uploadAreaVisible = false; this.element.innerHTML = this.getTemplate(); this.bindEvents(); @@ -85,11 +91,16 @@ export class Showcase {

${translate('modals.model.examples.empty', {}, 'No example images available')}

+
`} ${this.renderThumbnailRail()} + ${this.renderUploadArea()} `; } @@ -97,17 +108,6 @@ export class Showcase { * Render the thumbnail rail */ renderThumbnailRail() { - if (this.images.length === 0) { - return ` -
- -
- `; - } - const thumbnails = this.images.map((img, index) => { const url = img.url || img; const isNsfw = img.nsfw || false; @@ -124,13 +124,39 @@ export class Showcase { return `
${thumbnails} -
-
- + `; + } + + /** + * Render the upload area + */ + renderUploadArea() { + return ` +
+
+
+ +
+ +

${translate('modals.model.examples.dropFiles', {}, 'Drop files here or click to browse')}

+

${translate('modals.model.examples.supportedFormats', {}, 'Supports: JPG, PNG, WEBP, MP4, WEBM')}

+
+
+
+ +
+
`; } @@ -171,13 +197,194 @@ export class Showcase { this.deleteExample(); break; case 'add-example': - this.showUploadArea(); + case 'toggle-upload': + this.toggleUploadArea(); + break; + case 'cancel-upload': + this.hideUploadArea(); break; case 'copy-prompt': this.copyPrompt(); break; } }); + + // File input change + const fileInput = this.element.querySelector('.upload-area__input'); + if (fileInput) { + fileInput.addEventListener('change', (e) => { + this.handleFileSelect(e.target.files); + }); + } + + // Drag and drop + const dropzone = this.element.querySelector('.upload-area__dropzone'); + if (dropzone) { + dropzone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropzone.classList.add('dragover'); + }); + + dropzone.addEventListener('dragleave', () => { + dropzone.classList.remove('dragover'); + }); + + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('dragover'); + this.handleFileSelect(e.dataTransfer.files); + }); + + dropzone.addEventListener('click', () => { + fileInput?.click(); + }); + } + } + + /** + * Toggle upload area visibility + */ + toggleUploadArea() { + this.uploadAreaVisible = !this.uploadAreaVisible; + const uploadArea = this.element.querySelector('.upload-area'); + if (uploadArea) { + uploadArea.classList.toggle('visible', this.uploadAreaVisible); + } + } + + /** + * Hide upload area + */ + hideUploadArea() { + this.uploadAreaVisible = false; + const uploadArea = this.element.querySelector('.upload-area'); + if (uploadArea) { + uploadArea.classList.remove('visible'); + } + } + + /** + * Handle file selection + */ + async handleFileSelect(files) { + if (!files || files.length === 0) return; + if (this.isUploading) return; + + this.isUploading = true; + + const uploadArea = this.element.querySelector('.upload-area'); + const dropzone = this.element.querySelector('.upload-area__dropzone'); + + // Show loading state + if (dropzone) { + dropzone.innerHTML = ` +
+ +

${translate('modals.model.examples.uploading', {}, 'Uploading...')}

+
+ `; + } + + try { + for (const file of files) { + await this.uploadFile(file); + } + + showToast('modals.model.examples.uploadSuccess', {}, 'success'); + this.hideUploadArea(); + + // Refresh the showcase by reloading model data + this.refreshShowcase(); + } catch (error) { + console.error('Failed to upload file:', error); + showToast('modals.model.examples.uploadFailed', {}, 'error'); + + // Reset dropzone + if (dropzone) { + dropzone.innerHTML = ` + +
+ +

${translate('modals.model.examples.dropFiles', {}, 'Drop files here or click to browse')}

+

${translate('modals.model.examples.supportedFormats', {}, 'Supports: JPG, PNG, WEBP, MP4, WEBM')}

+
+ `; + } + } finally { + this.isUploading = false; + } + } + + /** + * Upload a single file + */ + async uploadFile(file) { + if (!this.filePath) { + throw new Error('No file path available'); + } + + // Check file type + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg', 'video/mp4', 'video/webm']; + if (!allowedTypes.includes(file.type)) { + throw new Error(`Unsupported file type: ${file.type}`); + } + + // Check file size (100MB limit) + const maxSize = 100 * 1024 * 1024; + if (file.size > maxSize) { + throw new Error('File too large (max 100MB)'); + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('model_path', this.filePath); + + const response = await fetch('/api/lm/upload-example', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error || 'Upload failed'); + } + + return response.json(); + } + + /** + * Refresh showcase after upload + */ + async refreshShowcase() { + if (!this.filePath) return; + + try { + const client = getModelApiClient(); + const metadata = await client.fetchModelMetadata(this.filePath); + + if (metadata) { + const regularImages = metadata.images || []; + const customImages = metadata.customImages || []; + const allImages = [...regularImages, ...customImages]; + + this.images = allImages; + this.currentIndex = allImages.length - 1; + + // Re-render + this.render({ images: allImages, modelHash: this.modelHash, filePath: this.filePath }); + + // Load the newly uploaded image + if (this.currentIndex >= 0) { + this.loadImage(this.currentIndex); + } + } + } catch (error) { + console.error('Failed to refresh showcase:', error); + } } /** @@ -339,7 +546,6 @@ export class Showcase { try { await navigator.clipboard.writeText(prompt); - const { showToast } = await import('../../utils/uiHelpers.js'); showToast('modals.model.params.promptCopied', {}, 'success'); } catch (err) { console.error('Failed to copy prompt:', err); @@ -356,14 +562,12 @@ export class Showcase { const url = image.url || image; try { - const { getModelApiClient } = await import('../../api/modelApiFactory.js'); - await getModelApiClient().setModelPreview(this.filePath, url); + const client = getModelApiClient(); + await client.setModelPreview(this.filePath, url); - const { showToast } = await import('../../utils/uiHelpers.js'); showToast('modals.model.actions.previewSet', {}, 'success'); } catch (err) { console.error('Failed to set preview:', err); - const { showToast } = await import('../../utils/uiHelpers.js'); showToast('modals.model.actions.previewFailed', {}, 'error'); } } @@ -375,15 +579,45 @@ export class Showcase { const image = this.images[this.currentIndex]; if (!image || !this.filePath) return; - // TODO: Implement delete confirmation and API call - console.log('Delete example:', image); - } + const url = image.url || image; + const isCustom = image.isCustom || false; + + // Confirm deletion + const confirmed = confirm(translate('modals.model.examples.confirmDelete', {}, 'Delete this example image?')); + if (!confirmed) return; - /** - * Show upload area for adding new examples - */ - showUploadArea() { - // TODO: Implement upload area expansion - console.log('Show upload area'); + try { + const response = await fetch('/api/lm/delete-example', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_path: this.filePath, + image_url: url, + is_custom: isCustom + }) + }); + + if (!response.ok) { + throw new Error('Failed to delete example'); + } + + showToast('modals.model.examples.deleted', {}, 'success'); + + // Remove from local array and refresh + this.images.splice(this.currentIndex, 1); + if (this.currentIndex >= this.images.length) { + this.currentIndex = Math.max(0, this.images.length - 1); + } + + // Re-render + this.render({ images: this.images, modelHash: this.modelHash, filePath: this.filePath }); + + if (this.images.length > 0) { + this.loadImage(this.currentIndex); + } + } catch (err) { + console.error('Failed to delete example:', err); + showToast('modals.model.examples.deleteFailed', {}, 'error'); + } } } diff --git a/static/js/components/model-modal/VersionsTab.js b/static/js/components/model-modal/VersionsTab.js new file mode 100644 index 00000000..97e91450 --- /dev/null +++ b/static/js/components/model-modal/VersionsTab.js @@ -0,0 +1,627 @@ +/** + * VersionsTab - Model versions list component + * Features: + * - Version cards with preview, badges, and actions + * - Download/Delete/Ignore actions + * - Base model filter toggle + * - Reference: static/js/components/shared/ModelVersionsTab.js + */ + +import { escapeHtml, formatFileSize } from '../shared/utils.js'; +import { translate } from '../../utils/i18nHelpers.js'; +import { showToast } from '../../utils/uiHelpers.js'; +import { getModelApiClient } from '../../api/modelApiFactory.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; +import { modalManager } from '../../managers/ModalManager.js'; + +const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv']; +const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png'; + +const DISPLAY_FILTER_MODES = Object.freeze({ + SAME_BASE: 'same_base', + ANY: 'any', +}); + +export class VersionsTab { + constructor(container) { + this.element = container; + this.model = null; + this.modelType = null; + this.versions = []; + this.isLoading = false; + this.displayMode = DISPLAY_FILTER_MODES.ANY; + this.record = null; + } + + /** + * Render the versions tab + */ + async render({ model, modelType }) { + this.model = model; + this.modelType = modelType; + this.element.innerHTML = this.getLoadingTemplate(); + + await this.loadVersions(); + } + + /** + * Get loading template + */ + getLoadingTemplate() { + return ` +
+ + ${translate('modals.model.loading.versions', {}, 'Loading versions...')} +
+ `; + } + + /** + * Load versions from API + */ + async loadVersions() { + const modelId = this.model?.civitai?.modelId; + + if (!modelId) { + this.renderError(translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.')); + return; + } + + this.isLoading = true; + + try { + const client = getModelApiClient(this.modelType); + const response = await client.fetchModelUpdateVersions(modelId, { refresh: false }); + + if (!response?.success) { + throw new Error(response?.error || 'Failed to load versions'); + } + + this.record = response.record; + this.renderVersions(); + } catch (error) { + console.error('Failed to load versions:', error); + this.renderError(error.message); + } finally { + this.isLoading = false; + } + } + + /** + * Render error state + */ + renderError(message) { + this.element.innerHTML = ` +
+ +

${escapeHtml(message || translate('modals.model.versions.error', {}, 'Failed to load versions.'))}

+
+ `; + } + + /** + * Render empty state + */ + renderEmpty() { + this.element.innerHTML = ` +
+ +

${translate('modals.model.versions.empty', {}, 'No version history available for this model yet.')}

+
+ `; + } + + /** + * Render versions list + */ + renderVersions() { + if (!this.record || !Array.isArray(this.record.versions) || this.record.versions.length === 0) { + this.renderEmpty(); + return; + } + + const currentVersionId = this.model?.civitai?.versionId; + const sortedVersions = [...this.record.versions].sort((a, b) => Number(b.versionId) - Number(a.versionId)); + + // Filter versions based on display mode + const filteredVersions = this.filterVersions(sortedVersions, currentVersionId); + + if (filteredVersions.length === 0) { + this.renderFilteredEmpty(); + return; + } + + this.element.innerHTML = ` + ${this.renderToolbar()} +
+ ${filteredVersions.map(version => this.renderVersionCard(version, currentVersionId)).join('')} +
+ `; + + this.bindEvents(); + } + + /** + * Filter versions based on display mode + */ + filterVersions(versions, currentVersionId) { + const currentVersion = versions.find(v => v.versionId === currentVersionId); + const currentBaseModel = currentVersion?.baseModel; + + if (this.displayMode !== DISPLAY_FILTER_MODES.SAME_BASE || !currentBaseModel) { + return versions; + } + + return versions.filter(version => { + const versionBase = version.baseModel?.toLowerCase().trim(); + const targetBase = currentBaseModel.toLowerCase().trim(); + return versionBase === targetBase; + }); + } + + /** + * Render filtered empty state + */ + renderFilteredEmpty() { + const currentVersion = this.record.versions.find(v => v.versionId === this.model?.civitai?.versionId); + const baseModelLabel = currentVersion?.baseModel || translate('modals.model.metadata.unknown', {}, 'Unknown'); + + this.element.innerHTML = ` + ${this.renderToolbar()} +
+ +

${translate('modals.model.versions.filters.empty', { baseModel: baseModelLabel }, 'No versions match the current base model filter.')}

+
+ `; + + this.bindEvents(); + } + + /** + * Render toolbar with actions + */ + renderToolbar() { + const ignoreText = this.record.shouldIgnore + ? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model') + : translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model'); + + const isFilteringActive = this.displayMode === DISPLAY_FILTER_MODES.SAME_BASE; + const toggleTooltip = isFilteringActive + ? translate('modals.model.versions.filters.tooltip.showAllVersions', {}, 'Switch to showing all versions') + : translate('modals.model.versions.filters.tooltip.showSameBaseVersions', {}, 'Switch to showing only versions with the current base model'); + + return ` +
+
+
+

${translate('modals.model.versions.heading', {}, 'Model versions')}

+ +
+

${translate('modals.model.versions.copy', { count: this.record.versions.length }, 'Track and manage every version of this model in one place.')}

+
+
+ +
+
+ `; + } + + /** + * Render a single version card + */ + renderVersionCard(version, currentVersionId) { + const isCurrent = version.versionId === currentVersionId; + const isInLibrary = version.isInLibrary; + const isNewer = this.isNewerVersion(version); + const badges = this.buildBadges(version, isCurrent, isNewer); + const actions = this.buildActions(version); + + const metaParts = []; + if (version.baseModel) metaParts.push(`${escapeHtml(version.baseModel)}`); + if (version.releasedAt) { + const date = new Date(version.releasedAt); + if (!isNaN(date.getTime())) { + metaParts.push(escapeHtml(date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }))); + } + } + if (version.sizeBytes > 0) metaParts.push(escapeHtml(formatFileSize(version.sizeBytes))); + + const metaMarkup = metaParts.length > 0 + ? metaParts.map(m => `${m}`).join('') + : escapeHtml(translate('modals.model.versions.labels.noDetails', {}, 'No additional details')); + + const civitaiUrl = this.buildCivitaiUrl(version.modelId, version.versionId); + const clickAction = civitaiUrl ? `data-civitai-url="${escapeHtml(civitaiUrl)}"` : ''; + + return ` +
+ ${this.renderMedia(version)} +
+
+ ${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))} +
+
${badges}
+
${metaMarkup}
+
+
+ ${actions} +
+
+ `; + } + + /** + * Check if version is newer than any in library + */ + isNewerVersion(version) { + if (!this.record?.inLibraryVersionIds?.length) return false; + if (version.isInLibrary) return false; + const maxInLibrary = Math.max(...this.record.inLibraryVersionIds); + return version.versionId > maxInLibrary; + } + + /** + * Build badges HTML + */ + buildBadges(version, isCurrent, isNewer) { + const badges = []; + + if (isCurrent) { + badges.push(this.createBadge( + translate('modals.model.versions.badges.current', {}, 'Current Version'), + 'current' + )); + } + + if (version.isInLibrary) { + badges.push(this.createBadge( + translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), + 'success' + )); + } else if (isNewer && !version.shouldIgnore) { + badges.push(this.createBadge( + translate('modals.model.versions.badges.newer', {}, 'Newer Version'), + 'info' + )); + } + + if (version.shouldIgnore) { + badges.push(this.createBadge( + translate('modals.model.versions.badges.ignored', {}, 'Ignored'), + 'muted' + )); + } + + return badges.join(''); + } + + /** + * Create a badge element + */ + createBadge(label, tone) { + return `${escapeHtml(label)}`; + } + + /** + * Build actions HTML + */ + buildActions(version) { + const actions = []; + + if (!version.isInLibrary) { + actions.push(` + + `); + } else if (version.filePath) { + actions.push(` + + `); + } + + const ignoreLabel = version.shouldIgnore + ? translate('modals.model.versions.actions.unignore', {}, 'Unignore') + : translate('modals.model.versions.actions.ignore', {}, 'Ignore'); + + actions.push(` + + `); + + return actions.join(''); + } + + /** + * Render media (image/video) + */ + renderMedia(version) { + if (!version.previewUrl) { + return ` +
+ ${escapeHtml(translate('modals.model.versions.media.placeholder', {}, 'No preview'))} +
+ `; + } + + if (this.isVideoUrl(version.previewUrl)) { + return ` +
+ +
+ `; + } + + return ` +
+ ${escapeHtml(version.name || 'preview')} +
+ `; + } + + /** + * Check if URL is a video + */ + isVideoUrl(url) { + if (!url) return false; + const extension = url.split('.').pop()?.toLowerCase()?.split('?')[0]; + return VIDEO_EXTENSIONS.includes(`.${extension}`); + } + + /** + * Build Civitai URL + */ + buildCivitaiUrl(modelId, versionId) { + if (!modelId || !versionId) return null; + return `https://civitai.com/models/${encodeURIComponent(modelId)}?modelVersionId=${encodeURIComponent(versionId)}`; + } + + /** + * Bind event listeners + */ + bindEvents() { + this.element.addEventListener('click', (e) => { + const target = e.target.closest('[data-action]'); + if (!target) { + // Check if clicked on a clickable card + const card = e.target.closest('.version-card.is-clickable'); + if (card && !e.target.closest('.version-actions')) { + const url = card.dataset.civitaiUrl; + if (url) window.open(url, '_blank', 'noopener,noreferrer'); + } + return; + } + + const action = target.dataset.action; + const card = target.closest('.version-card'); + const versionId = card ? parseInt(card.dataset.versionId, 10) : null; + + switch (action) { + case 'toggle-filter': + this.toggleFilterMode(); + break; + case 'toggle-model-ignore': + this.handleToggleModelIgnore(); + break; + case 'download': + if (versionId) this.handleDownload(versionId, target); + break; + case 'delete': + if (versionId) this.handleDelete(versionId, target); + break; + case 'toggle-ignore': + if (versionId) this.handleToggleVersionIgnore(versionId, target); + break; + } + }); + } + + /** + * Toggle filter mode + */ + toggleFilterMode() { + this.displayMode = this.displayMode === DISPLAY_FILTER_MODES.SAME_BASE + ? DISPLAY_FILTER_MODES.ANY + : DISPLAY_FILTER_MODES.SAME_BASE; + this.renderVersions(); + } + + /** + * Handle toggle model ignore + */ + async handleToggleModelIgnore() { + if (!this.record) return; + + const modelId = this.record.modelId; + const nextValue = !this.record.shouldIgnore; + + try { + const client = getModelApiClient(this.modelType); + const response = await client.setModelUpdateIgnore(modelId, nextValue); + + if (!response?.success) { + throw new Error(response?.error || 'Request failed'); + } + + this.record = response.record; + this.renderVersions(); + + const toastKey = nextValue + ? 'modals.model.versions.toast.modelIgnored' + : 'modals.model.versions.toast.modelResumed'; + showToast(toastKey, {}, 'success'); + } catch (error) { + console.error('Failed to toggle model ignore:', error); + showToast(error?.message || 'Failed to update ignore preference', {}, 'error'); + } + } + + /** + * Handle download version + */ + async handleDownload(versionId, button) { + const version = this.record.versions.find(v => v.versionId === versionId); + if (!version) return; + + button.disabled = true; + + try { + await downloadManager.downloadVersionWithDefaults( + this.modelType, + this.record.modelId, + versionId, + { versionName: version.name || `#${versionId}` } + ); + + // Reload versions after download starts + setTimeout(() => this.loadVersions(), 1000); + } catch (error) { + console.error('Failed to download version:', error); + } finally { + button.disabled = false; + } + } + + /** + * Handle delete version + */ + async handleDelete(versionId, button) { + const version = this.record.versions.find(v => v.versionId === versionId); + if (!version?.filePath) return; + + const confirmed = await this.showDeleteConfirmation(version); + if (!confirmed) return; + + button.disabled = true; + + try { + const client = getModelApiClient(this.modelType); + await client.deleteModel(version.filePath); + + showToast('modals.model.versions.toast.versionDeleted', {}, 'success'); + await this.loadVersions(); + } catch (error) { + console.error('Failed to delete version:', error); + showToast(error?.message || 'Failed to delete version', {}, 'error'); + button.disabled = false; + } + } + + /** + * Show delete confirmation modal + */ + async showDeleteConfirmation(version) { + return new Promise((resolve) => { + const modalRecord = modalManager?.getModal?.('deleteModal'); + if (!modalRecord?.element) { + // Fallback to browser confirm + const message = translate('modals.model.versions.confirm.delete', {}, 'Delete this version from your library?'); + resolve(window.confirm(message)); + return; + } + + const title = translate('modals.model.versions.actions.delete', {}, 'Delete'); + const message = translate('modals.model.versions.confirm.delete', {}, 'Delete this version from your library?'); + const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'); + + const content = ` + + `; + + modalManager.showModal('deleteModal', content); + + const modalElement = modalRecord.element; + const handleAction = (e) => { + const action = e.target.closest('[data-action]')?.dataset.action; + if (action === 'confirm') { + modalManager.closeModal('deleteModal'); + resolve(true); + } else if (action === 'cancel') { + modalManager.closeModal('deleteModal'); + resolve(false); + } + }; + + modalElement.addEventListener('click', handleAction, { once: true }); + }); + } + + /** + * Handle toggle version ignore + */ + async handleToggleVersionIgnore(versionId, button) { + const version = this.record.versions.find(v => v.versionId === versionId); + if (!version) return; + + const nextValue = !version.shouldIgnore; + button.disabled = true; + + try { + const client = getModelApiClient(this.modelType); + const response = await client.setVersionUpdateIgnore( + this.record.modelId, + versionId, + nextValue + ); + + if (!response?.success) { + throw new Error(response?.error || 'Request failed'); + } + + this.record = response.record; + this.renderVersions(); + + const updatedVersion = response.record.versions.find(v => v.versionId === versionId); + const toastKey = updatedVersion?.shouldIgnore + ? 'modals.model.versions.toast.versionIgnored' + : 'modals.model.versions.toast.versionUnignored'; + showToast(toastKey, {}, 'success'); + } catch (error) { + console.error('Failed to toggle version ignore:', error); + showToast(error?.message || 'Failed to update version preference', {}, 'error'); + button.disabled = false; + } + } + + /** + * Refresh versions + */ + async refresh() { + await this.loadVersions(); + } +}