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 {
-
-
- ${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 = `
+
+
+ ${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 `
+
+
+
+
+ ${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')}
+
+
+
+
+ ${translate('common.actions.cancel', {}, 'Cancel')}
+
+
+
`;
}
@@ -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 `
+
+ `;
+ }
+
+ /**
+ * 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(`
+
+ ${escapeHtml(translate('modals.model.versions.actions.download', {}, 'Download'))}
+
+ `);
+ } else if (version.filePath) {
+ actions.push(`
+
+ ${escapeHtml(translate('modals.model.versions.actions.delete', {}, 'Delete'))}
+
+ `);
+ }
+
+ const ignoreLabel = version.shouldIgnore
+ ? translate('modals.model.versions.actions.unignore', {}, 'Unignore')
+ : translate('modals.model.versions.actions.ignore', {}, 'Ignore');
+
+ actions.push(`
+
+ ${escapeHtml(ignoreLabel)}
+
+ `);
+
+ 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 `
+
+ `;
+ }
+
+ /**
+ * 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 = `
+
+
${escapeHtml(title)}
+
${escapeHtml(message)}
+
+
+ ${version.previewUrl ? `
+
})
+ ` : `

`}
+
+
+
${escapeHtml(versionName)}
+ ${version.baseModel ? `
${escapeHtml(version.baseModel)}
` : ''}
+
+
+
+ ${escapeHtml(translate('common.actions.cancel', {}, 'Cancel'))}
+ ${escapeHtml(translate('common.actions.delete', {}, 'Delete'))}
+
+
+ `;
+
+ 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();
+ }
+}