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:
Will Miao
2026-02-06 19:57:33 +08:00
parent 7bc63d7631
commit d27e3c8126
9 changed files with 2505 additions and 101 deletions

View File

@@ -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...",

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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';

View File

@@ -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');
}
}

View 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();
}
}

View File

@@ -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');
}
}
}

View 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();
}
}