mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Phase 2: Model Modal Tabs and Edit Features
- Implement Versions Tab with version cards, badges, and actions - Implement Recipes Tab with recipe cards grid - Add Usage Tips editing (add/remove parameters) - Add Trigger Words editing (add/remove/copy) - Optimize Notes textarea with auto-save indicator - Implement custom example upload area with drag-drop - Add missing i18n translation keys - Add CSS styles for versions, recipes, and upload components - Fix async/await syntax error in RecipesTab.js
This commit is contained in:
@@ -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...",
|
||||
|
||||
272
static/css/components/model-modal/recipes.css
Normal file
272
static/css/components/model-modal/recipes.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
163
static/css/components/model-modal/upload.css
Normal file
163
static/css/components/model-modal/upload.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
378
static/css/components/model-modal/versions.css
Normal file
378
static/css/components/model-modal/versions.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
<div class="metadata__header">
|
||||
<div class="metadata__title-row">
|
||||
<h2 class="metadata__name">${escapeHtml(m.model_name || 'Unknown')}</h2>
|
||||
<button class="metadata__edit-btn" data-action="edit-name" title="${translate('modals.model.actions.editName', {}, 'Edit name')}">
|
||||
<button class="metadata__edit-btn" data-action="edit-name" title="${translate('modals.model.actions.editModelName', {}, 'Edit model name')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -100,7 +110,7 @@ export class MetadataPanel {
|
||||
|
||||
<div class="metadata__info-item metadata__info-item--full">
|
||||
<span class="metadata__info-label">${translate('modals.model.metadata.location', {}, 'Location')}</span>
|
||||
<span class="metadata__info-value metadata__info-value--path" data-action="open-location" title="${translate('modals.model.actions.openLocation', {}, 'Open file location')}">
|
||||
<span class="metadata__info-value metadata__info-value--path" data-action="open-location" title="${translate('modals.model.actions.openFileLocation', {}, 'Open file location')}">
|
||||
${escapeHtml((m.file_path || '').replace(/[^/]+$/, '') || 'N/A')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -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 `
|
||||
<div class="metadata__section">
|
||||
<div class="metadata__section-header">
|
||||
<span class="metadata__section-title">${translate('modals.model.metadata.usageTips', {}, 'Usage Tips')}</span>
|
||||
<button class="metadata__section-edit" data-action="edit-usage-tips">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
${!this.isEditingUsageTips ? `
|
||||
<button class="metadata__section-edit" data-action="edit-usage-tips" title="${translate('modals.model.usageTips.add', {}, 'Add usage tip')}">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="metadata__tags--editable">
|
||||
${Object.entries(usageTips).map(([key, value]) => `
|
||||
<span class="metadata__tag metadata__tag--editable" data-key="${escapeHtml(key)}" data-value="${escapeHtml(String(value))}">
|
||||
<span class="metadata__tag metadata__tag--editable" data-key="${escapeHtml(key)}" data-action="remove-usage-tip" title="${translate('common.actions.delete', {}, 'Delete')}">
|
||||
${escapeHtml(key)}: ${escapeHtml(String(value))}
|
||||
</span>
|
||||
`).join('')}
|
||||
<span class="metadata__tag metadata__tag--add" data-action="add-usage-tip">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
${this.isEditingUsageTips ? this.renderUsageTipEditor() : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata__section">
|
||||
<div class="metadata__section-header">
|
||||
<span class="metadata__section-title">${translate('modals.model.metadata.triggerWords', {}, 'Trigger Words')}</span>
|
||||
<button class="metadata__section-edit" data-action="edit-trigger-words">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
<span class="metadata__section-title">${translate('modals.model.triggerWords.label', {}, 'Trigger Words')}</span>
|
||||
<div class="metadata__section-actions">
|
||||
${!this.isEditingTriggerWords ? `
|
||||
<button class="metadata__section-edit" data-action="copy-trigger-words" title="${translate('modals.model.triggerWords.copyWord', {}, 'Copy all trigger words')}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<button class="metadata__section-edit" data-action="edit-trigger-words" title="${translate('modals.model.triggerWords.edit', {}, 'Edit trigger words')}">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
` : `
|
||||
<button class="metadata__section-edit" data-action="cancel-trigger-words" title="${translate('common.actions.cancel', {}, 'Cancel')}">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<button class="metadata__section-edit metadata__section-edit--primary" data-action="save-trigger-words" title="${translate('common.actions.save', {}, 'Save')}">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata__tags--editable">
|
||||
${triggerWords.map(word => `
|
||||
<span class="metadata__tag metadata__tag--editable" data-word="${escapeHtml(word)}">
|
||||
<span class="metadata__tag ${this.isEditingTriggerWords ? 'metadata__tag--removable' : 'metadata__tag--editable'}"
|
||||
data-word="${escapeHtml(word)}"
|
||||
${this.isEditingTriggerWords ? 'data-action="remove-trigger-word"' : 'data-action="copy-trigger-word"'}
|
||||
title="${this.isEditingTriggerWords ? translate('common.actions.delete', {}, 'Delete') : translate('modals.model.triggerWords.copyWord', {}, 'Copy trigger word')}">
|
||||
${escapeHtml(word)}
|
||||
${this.isEditingTriggerWords ? '<i class="fas fa-times"></i>' : ''}
|
||||
</span>
|
||||
`).join('')}
|
||||
<span class="metadata__tag metadata__tag--add" data-action="add-trigger-word">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
${this.isEditingTriggerWords ? `
|
||||
<input type="text"
|
||||
class="metadata__tag-input"
|
||||
placeholder="${translate('modals.model.triggerWords.addPlaceholder', {}, 'Type to add...')}"
|
||||
data-action="add-trigger-word-input"
|
||||
autofocus>
|
||||
` : triggerWords.length === 0 ? `
|
||||
<span class="metadata__tag metadata__tag--placeholder">${translate('modals.model.triggerWords.noTriggerWordsNeeded', {}, 'No trigger words needed')}</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render usage tip editor
|
||||
*/
|
||||
renderUsageTipEditor() {
|
||||
return `
|
||||
<div class="usage-tip-editor">
|
||||
<select class="usage-tip-key" data-action="usage-tip-key-change">
|
||||
<option value="">${translate('modals.model.usageTips.addPresetParameter', {}, 'Select parameter...')}</option>
|
||||
<option value="strength">${translate('modals.model.usageTips.strength', {}, 'Strength')}</option>
|
||||
<option value="strength_min">${translate('modals.model.usageTips.strengthMin', {}, 'Strength Min')}</option>
|
||||
<option value="strength_max">${translate('modals.model.usageTips.strengthMax', {}, 'Strength Max')}</option>
|
||||
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
|
||||
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
||||
</select>
|
||||
<input type="text"
|
||||
class="usage-tip-value"
|
||||
placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}"
|
||||
data-action="usage-tip-value-input">
|
||||
<button class="usage-tip-add" data-action="add-usage-tip">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<button class="usage-tip-cancel" data-action="cancel-usage-tips">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render notes section
|
||||
*/
|
||||
renderNotes(notes) {
|
||||
return `
|
||||
<div class="metadata__section">
|
||||
<div class="metadata__section metadata__section--notes">
|
||||
<div class="metadata__section-header">
|
||||
<span class="metadata__section-title">${translate('modals.model.metadata.additionalNotes', {}, 'Notes')}</span>
|
||||
<button class="metadata__section-edit" data-action="edit-notes">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
<span class="metadata__save-indicator" data-save-indicator style="display: none;">
|
||||
<i class="fas fa-check"></i> Saved
|
||||
</span>
|
||||
</div>
|
||||
<textarea class="metadata__notes"
|
||||
placeholder="${translate('modals.model.metadata.addNotesPlaceholder', {}, 'Add your notes here...')}"
|
||||
data-action="save-notes">${escapeHtml(notes || '')}</textarea>
|
||||
data-action="notes-input">${escapeHtml(notes || '')}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -324,7 +385,7 @@ export class MetadataPanel {
|
||||
<div class="tab-panel ${this.activeTab === 'description' ? 'active' : ''}" data-panel="description">
|
||||
<div class="accordion expanded">
|
||||
<div class="accordion__header" data-action="toggle-accordion">
|
||||
<span class="accordion__title">${translate('modals.model.accordion.aboutVersion', {}, 'About this version')}</span>
|
||||
<span class="accordion__title">${translate('modals.model.metadata.aboutThisVersion', {}, 'About this version')}</span>
|
||||
<i class="accordion__icon fas fa-chevron-down"></i>
|
||||
</div>
|
||||
<div class="accordion__content">
|
||||
@@ -332,7 +393,7 @@ export class MetadataPanel {
|
||||
${civitai.description ? `
|
||||
<div class="markdown-content">${civitai.description}</div>
|
||||
` : `
|
||||
<p class="text-muted">${translate('modals.model.description.empty', {}, 'No description available')}</p>
|
||||
<p class="text-muted">${translate('modals.model.description.noDescription', {}, 'No description available')}</p>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,7 +409,7 @@ export class MetadataPanel {
|
||||
${civitai.model?.description ? `
|
||||
<div class="markdown-content">${civitai.model.description}</div>
|
||||
` : `
|
||||
<p class="text-muted">${translate('modals.model.description.empty', {}, 'No description available')}</p>
|
||||
<p class="text-muted">${translate('modals.model.description.noDescription', {}, 'No description available')}</p>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -356,18 +417,12 @@ export class MetadataPanel {
|
||||
</div>
|
||||
|
||||
<div class="tab-panel ${this.activeTab === 'versions' ? 'active' : ''}" data-panel="versions">
|
||||
<div class="versions-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${translate('modals.model.loading.versions', {}, 'Loading versions...')}</span>
|
||||
</div>
|
||||
<div class="versions-tab-container"></div>
|
||||
</div>
|
||||
|
||||
${this.modelType === 'loras' ? `
|
||||
<div class="tab-panel ${this.activeTab === 'recipes' ? 'active' : ''}" data-panel="recipes">
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${translate('modals.model.loading.recipes', {}, 'Loading recipes...')}</span>
|
||||
</div>
|
||||
<div class="recipes-tab-container"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -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 = '<i class="fas fa-spinner fa-spin"></i> 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 = '<i class="fas fa-check"></i> 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 = '<i class="fas fa-exclamation-triangle"></i> 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');
|
||||
}
|
||||
}
|
||||
|
||||
321
static/js/components/model-modal/RecipesTab.js
Normal file
321
static/js/components/model-modal/RecipesTab.js
Normal file
@@ -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 `
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${translate('modals.model.loading.recipes', {}, 'Loading recipes...')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="recipes-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>${escapeHtml(message || 'Failed to load recipes. Please try again later.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render empty state
|
||||
*/
|
||||
renderEmpty() {
|
||||
this.element.innerHTML = `
|
||||
<div class="recipes-empty">
|
||||
<i class="fas fa-book-open"></i>
|
||||
<p>${translate('recipes.noRecipesFound', {}, 'No recipes found that use this LoRA.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render recipes grid
|
||||
*/
|
||||
renderRecipes() {
|
||||
if (!this.recipes || this.recipes.length === 0) {
|
||||
this.renderEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
const loraName = this.model?.model_name || '';
|
||||
|
||||
this.element.innerHTML = `
|
||||
<div class="recipes-header">
|
||||
<div class="recipes-header__text">
|
||||
<span class="recipes-header__eyebrow">Linked recipes</span>
|
||||
<h3>${this.recipes.length} recipe${this.recipes.length > 1 ? 's' : ''} using this LoRA</h3>
|
||||
<p class="recipes-header__description">
|
||||
${loraName ? `Discover workflows crafted for ${escapeHtml(loraName)}.` : 'Discover workflows crafted for this model.'}
|
||||
</p>
|
||||
</div>
|
||||
<button class="recipes-header__view-all" data-action="view-all">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
<span>View all recipes</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="recipes-grid">
|
||||
${this.recipes.map(recipe => this.renderRecipeCard(recipe)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 `
|
||||
<article class="recipe-card"
|
||||
data-recipe-id="${escapeHtml(recipe.id || '')}"
|
||||
data-file-path="${escapeHtml(recipe.file_path || '')}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="${recipe.title ? `View recipe ${escapeHtml(recipe.title)}` : 'View recipe details'}">
|
||||
<div class="recipe-card__media">
|
||||
<img src="${escapeHtml(imageUrl)}"
|
||||
alt="${recipe.title ? escapeHtml(recipe.title) + ' preview' : 'Recipe preview'}"
|
||||
loading="lazy">
|
||||
<div class="recipe-card__media-top">
|
||||
<button class="recipe-card__copy" data-action="copy-recipe" title="Copy recipe syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-card__body">
|
||||
<h4 class="recipe-card__title" title="${escapeHtml(recipe.title || 'Untitled recipe')}">
|
||||
${escapeHtml(recipe.title || 'Untitled recipe')}
|
||||
</h4>
|
||||
<div class="recipe-card__meta">
|
||||
${baseModel ? `<span class="recipe-card__badge recipe-card__badge--base">${escapeHtml(baseModel)}</span>` : ''}
|
||||
<span class="recipe-card__badge recipe-card__badge--${statusClass}" title="${escapeHtml(statusTitle)}">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
<span>${escapeHtml(statusLabel)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="recipe-card__cta">
|
||||
<span>View details</span>
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
<div class="showcase__empty">
|
||||
<i class="fas fa-images"></i>
|
||||
<p>${translate('modals.model.examples.empty', {}, 'No example images available')}</p>
|
||||
<button class="showcase__add-btn" data-action="add-example">
|
||||
<i class="fas fa-plus"></i>
|
||||
${translate('modals.model.examples.addFirst', {}, 'Add your first example')}
|
||||
</button>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${this.renderThumbnailRail()}
|
||||
${this.renderUploadArea()}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -97,17 +108,6 @@ export class Showcase {
|
||||
* Render the thumbnail rail
|
||||
*/
|
||||
renderThumbnailRail() {
|
||||
if (this.images.length === 0) {
|
||||
return `
|
||||
<div class="thumbnail-rail">
|
||||
<button class="thumbnail-rail__add" data-action="add-example">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>${translate('modals.model.examples.add', {}, 'Add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="thumbnail-rail">
|
||||
${thumbnails}
|
||||
<button class="thumbnail-rail__add" data-action="add-example">
|
||||
<button class="thumbnail-rail__add" data-action="toggle-upload" title="${translate('modals.model.examples.add', {}, 'Add custom example')}">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>${translate('modals.model.examples.add', {}, 'Add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="thumbnail-rail__upload">
|
||||
<!-- Upload area will be expanded here -->
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the upload area
|
||||
*/
|
||||
renderUploadArea() {
|
||||
return `
|
||||
<div class="upload-area ${this.uploadAreaVisible ? 'visible' : ''}">
|
||||
<div class="upload-area__content">
|
||||
<div class="upload-area__dropzone" data-action="dropzone">
|
||||
<input type="file"
|
||||
class="upload-area__input"
|
||||
accept="image/*,video/mp4,video/webm"
|
||||
multiple
|
||||
data-action="file-select">
|
||||
<div class="upload-area__placeholder">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<p class="upload-area__title">${translate('modals.model.examples.dropFiles', {}, 'Drop files here or click to browse')}</p>
|
||||
<p class="upload-area__hint">${translate('modals.model.examples.supportedFormats', {}, 'Supports: JPG, PNG, WEBP, MP4, WEBM')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload-area__actions">
|
||||
<button class="upload-area__cancel" data-action="cancel-upload">
|
||||
${translate('common.actions.cancel', {}, 'Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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 = `
|
||||
<div class="upload-area__uploading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<p>${translate('modals.model.examples.uploading', {}, 'Uploading...')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<input type="file"
|
||||
class="upload-area__input"
|
||||
accept="image/*,video/mp4,video/webm"
|
||||
multiple
|
||||
data-action="file-select">
|
||||
<div class="upload-area__placeholder">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<p class="upload-area__title">${translate('modals.model.examples.dropFiles', {}, 'Drop files here or click to browse')}</p>
|
||||
<p class="upload-area__hint">${translate('modals.model.examples.supportedFormats', {}, 'Supports: JPG, PNG, WEBP, MP4, WEBM')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
627
static/js/components/model-modal/VersionsTab.js
Normal file
627
static/js/components/model-modal/VersionsTab.js
Normal file
@@ -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 `
|
||||
<div class="versions-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>${translate('modals.model.loading.versions', {}, 'Loading versions...')}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="versions-error">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p>${escapeHtml(message || translate('modals.model.versions.error', {}, 'Failed to load versions.'))}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render empty state
|
||||
*/
|
||||
renderEmpty() {
|
||||
this.element.innerHTML = `
|
||||
<div class="versions-empty">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>${translate('modals.model.versions.empty', {}, 'No version history available for this model yet.')}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()}
|
||||
<div class="versions-list">
|
||||
${filteredVersions.map(version => this.renderVersionCard(version, currentVersionId)).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
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()}
|
||||
<div class="versions-empty versions-empty-filter">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<p>${translate('modals.model.versions.filters.empty', { baseModel: baseModelLabel }, 'No versions match the current base model filter.')}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 `
|
||||
<header class="versions-toolbar">
|
||||
<div class="versions-toolbar-info">
|
||||
<div class="versions-toolbar-info-heading">
|
||||
<h3>${translate('modals.model.versions.heading', {}, 'Model versions')}</h3>
|
||||
<button class="versions-filter-toggle ${isFilteringActive ? 'active' : ''}"
|
||||
data-action="toggle-filter"
|
||||
title="${escapeHtml(toggleTooltip)}"
|
||||
type="button">
|
||||
<i class="fas fa-th-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p>${translate('modals.model.versions.copy', { count: this.record.versions.length }, 'Track and manage every version of this model in one place.')}</p>
|
||||
</div>
|
||||
<div class="versions-toolbar-actions">
|
||||
<button class="versions-toolbar-btn versions-toolbar-btn-primary" data-action="toggle-model-ignore">
|
||||
${escapeHtml(ignoreText)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(`<span class="version-meta-primary">${escapeHtml(version.baseModel)}</span>`);
|
||||
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 => `<span class="version-meta-item">${m}</span>`).join('<span class="version-meta-separator">•</span>')
|
||||
: 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 `
|
||||
<div class="version-card ${isCurrent ? 'is-current' : ''} ${civitaiUrl ? 'is-clickable' : ''}"
|
||||
data-version-id="${version.versionId}"
|
||||
${clickAction}>
|
||||
${this.renderMedia(version)}
|
||||
<div class="version-details">
|
||||
<div class="version-title">
|
||||
<span class="version-name">${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))}</span>
|
||||
</div>
|
||||
<div class="version-badges">${badges}</div>
|
||||
<div class="version-meta">${metaMarkup}</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
${actions}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<span class="version-badge version-badge-${tone}">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build actions HTML
|
||||
*/
|
||||
buildActions(version) {
|
||||
const actions = [];
|
||||
|
||||
if (!version.isInLibrary) {
|
||||
actions.push(`
|
||||
<button class="version-action version-action-primary" data-action="download">
|
||||
${escapeHtml(translate('modals.model.versions.actions.download', {}, 'Download'))}
|
||||
</button>
|
||||
`);
|
||||
} else if (version.filePath) {
|
||||
actions.push(`
|
||||
<button class="version-action version-action-danger" data-action="delete">
|
||||
${escapeHtml(translate('modals.model.versions.actions.delete', {}, 'Delete'))}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
|
||||
const ignoreLabel = version.shouldIgnore
|
||||
? translate('modals.model.versions.actions.unignore', {}, 'Unignore')
|
||||
: translate('modals.model.versions.actions.ignore', {}, 'Ignore');
|
||||
|
||||
actions.push(`
|
||||
<button class="version-action version-action-ghost" data-action="toggle-ignore">
|
||||
${escapeHtml(ignoreLabel)}
|
||||
</button>
|
||||
`);
|
||||
|
||||
return actions.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render media (image/video)
|
||||
*/
|
||||
renderMedia(version) {
|
||||
if (!version.previewUrl) {
|
||||
return `
|
||||
<div class="version-media version-media-placeholder">
|
||||
${escapeHtml(translate('modals.model.versions.media.placeholder', {}, 'No preview'))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.isVideoUrl(version.previewUrl)) {
|
||||
return `
|
||||
<div class="version-media">
|
||||
<video src="${escapeHtml(version.previewUrl)}"
|
||||
controls muted loop playsinline preload="metadata">
|
||||
</video>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="version-media">
|
||||
<img src="${escapeHtml(version.previewUrl)}"
|
||||
alt="${escapeHtml(version.name || 'preview')}"
|
||||
loading="lazy">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="modal-content delete-modal-content version-delete-modal">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p class="delete-message">${escapeHtml(message)}</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${version.previewUrl ? `
|
||||
<img src="${escapeHtml(version.previewUrl)}" alt="${escapeHtml(versionName)}"
|
||||
onerror="this.src='${PREVIEW_PLACEHOLDER_URL}'">
|
||||
` : `<img src="${PREVIEW_PLACEHOLDER_URL}" alt="${escapeHtml(versionName)}">`}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${escapeHtml(versionName)}</h3>
|
||||
${version.baseModel ? `<p class="version-base-model">${escapeHtml(version.baseModel)}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel">${escapeHtml(translate('common.actions.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="delete-btn" data-action="confirm">${escapeHtml(translate('common.actions.delete', {}, 'Delete'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user