mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
Add update functionality for recipe metadata in RecipeRoutes and RecipeModal
- Introduced a new API endpoint to update recipe metadata, allowing users to modify recipe titles and tags. - Enhanced RecipeModal to support inline editing of recipe titles and tags, improving user interaction. - Updated RecipeCard to reflect changes in recipe metadata, ensuring consistency across the application. - Improved error handling for metadata updates to provide clearer feedback to users.
This commit is contained in:
@@ -50,6 +50,9 @@ class RecipeRoutes:
|
|||||||
# Add new endpoint for getting recipe syntax
|
# Add new endpoint for getting recipe syntax
|
||||||
app.router.add_get('/api/recipe/{recipe_id}/syntax', routes.get_recipe_syntax)
|
app.router.add_get('/api/recipe/{recipe_id}/syntax', routes.get_recipe_syntax)
|
||||||
|
|
||||||
|
# Add new endpoint for updating recipe metadata (name and tags)
|
||||||
|
app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe)
|
||||||
|
|
||||||
# Start cache initialization
|
# Start cache initialization
|
||||||
app.on_startup.append(routes._init_cache)
|
app.on_startup.append(routes._init_cache)
|
||||||
|
|
||||||
@@ -984,3 +987,30 @@ class RecipeRoutes:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error generating recipe syntax: {e}", exc_info=True)
|
logger.error(f"Error generating recipe syntax: {e}", exc_info=True)
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def update_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
"""Update recipe metadata (name and tags)"""
|
||||||
|
try:
|
||||||
|
recipe_id = request.match_info['recipe_id']
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if 'title' not in data and 'tags' not in data:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "At least one field to update must be provided (title or tags)"
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Use the recipe scanner's update method
|
||||||
|
success = await self.recipe_scanner.update_recipe_metadata(recipe_id, data)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return web.json_response({"error": "Recipe not found or update failed"}, status=404)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"updates": data
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating recipe: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|||||||
@@ -37,18 +37,18 @@ class RecipeCache:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if the update was successful, False if the recipe wasn't found
|
bool: True if the update was successful, False if the recipe wasn't found
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
|
||||||
# Update in raw_data
|
|
||||||
for item in self.raw_data:
|
|
||||||
if item.get('id') == recipe_id:
|
|
||||||
item.update(metadata)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return False # Recipe not found
|
|
||||||
|
|
||||||
# Resort to reflect changes
|
# Update in raw_data
|
||||||
await self.resort()
|
for item in self.raw_data:
|
||||||
return True
|
if item.get('id') == recipe_id:
|
||||||
|
item.update(metadata)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return False # Recipe not found
|
||||||
|
|
||||||
|
# Resort to reflect changes
|
||||||
|
await self.resort()
|
||||||
|
return True
|
||||||
|
|
||||||
async def add_recipe(self, recipe_data: Dict) -> None:
|
async def add_recipe(self, recipe_data: Dict) -> None:
|
||||||
"""Add a new recipe to the cache
|
"""Add a new recipe to the cache
|
||||||
|
|||||||
@@ -431,6 +431,54 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool:
|
||||||
|
"""Update recipe metadata (like title and tags) in both file system and cache
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: The ID of the recipe to update
|
||||||
|
metadata: Dictionary containing metadata fields to update (title, tags, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
# First, find the recipe JSON file path
|
||||||
|
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
||||||
|
|
||||||
|
if not os.path.exists(recipe_json_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load existing recipe data
|
||||||
|
with open(recipe_json_path, 'r', encoding='utf-8') as f:
|
||||||
|
recipe_data = json.load(f)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
for key, value in metadata.items():
|
||||||
|
recipe_data[key] = value
|
||||||
|
|
||||||
|
# Save updated recipe
|
||||||
|
with open(recipe_json_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Update the cache if it exists
|
||||||
|
if self._cache is not None:
|
||||||
|
await self._cache.update_recipe_metadata(recipe_id, metadata)
|
||||||
|
|
||||||
|
# If the recipe has an image, update its EXIF metadata
|
||||||
|
from ..utils.exif_utils import ExifUtils
|
||||||
|
image_path = recipe_data.get('file_path')
|
||||||
|
if image_path and os.path.exists(image_path):
|
||||||
|
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error(f"Error updating recipe metadata: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
async def update_lora_filename_by_hash(self, hash_value: str, new_file_name: str) -> Tuple[int, int]:
|
async def update_lora_filename_by_hash(self, hash_value: str, new_file_name: str) -> Tuple[int, int]:
|
||||||
"""Update file_name in all recipes that contain a LoRA with the specified hash.
|
"""Update file_name in all recipes that contain a LoRA with the specified hash.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,110 @@
|
|||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editable content styles */
|
||||||
|
.editable-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-content.hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-content .content-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-icon {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editable-content:hover .edit-icon {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-icon:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content editor styles */
|
||||||
|
.content-editor {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-editor.active {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-editor input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 1em;
|
||||||
|
color: var(--text-color);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-editor.tags-editor input {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 删除不再需要的按钮样式 */
|
||||||
|
.editor-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Special styling for tags content */
|
||||||
|
.tags-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-display {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-tags {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recipe Tags styles */
|
/* Recipe Tags styles */
|
||||||
@@ -340,6 +444,12 @@
|
|||||||
border-left: 4px solid var(--lora-error);
|
border-left: 4px solid var(--lora-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recipe-lora-item.is-deleted {
|
||||||
|
background: rgba(127, 127, 127, 0.05);
|
||||||
|
border-left: 4px solid #777;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.recipe-lora-thumbnail {
|
.recipe-lora-thumbnail {
|
||||||
width: 46px;
|
width: 46px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
@@ -474,6 +584,38 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Deleted badge */
|
||||||
|
.deleted-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #777;
|
||||||
|
color: white;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-badge i {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe status partial state */
|
||||||
|
.recipe-status.partial {
|
||||||
|
background: rgba(127, 127, 127, 0.1);
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题输入框特定的样式 */
|
||||||
|
.title-input {
|
||||||
|
font-size: 1.2em !important; /* 调整为更合适的大小 */
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.recipe-top-section {
|
.recipe-top-section {
|
||||||
@@ -502,7 +644,8 @@
|
|||||||
|
|
||||||
/* Update the local-badge and missing-badge to be positioned within the badge-container */
|
/* Update the local-badge and missing-badge to be positioned within the badge-container */
|
||||||
.badge-container .local-badge,
|
.badge-container .local-badge,
|
||||||
.badge-container .missing-badge {
|
.badge-container .missing-badge,
|
||||||
|
.badge-container .deleted-badge {
|
||||||
position: static; /* Override absolute positioning */
|
position: static; /* Override absolute positioning */
|
||||||
transform: none; /* Remove the transform */
|
transform: none; /* Remove the transform */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class RecipeCard {
|
|||||||
const lorasCount = loras.length;
|
const lorasCount = loras.length;
|
||||||
|
|
||||||
// Check if all LoRAs are available in the library
|
// Check if all LoRAs are available in the library
|
||||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary).length;
|
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||||
|
|
||||||
// Ensure file_url exists, fallback to file_path if needed
|
// Ensure file_url exists, fallback to file_path if needed
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ class RecipeModal {
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
this.setupTooltipPositioning();
|
this.setupTooltipPositioning();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up document click handler to close edit fields
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
// Handle title edit
|
||||||
|
const titleEditor = document.getElementById('recipeTitleEditor');
|
||||||
|
if (titleEditor && titleEditor.classList.contains('active') &&
|
||||||
|
!titleEditor.contains(event.target) &&
|
||||||
|
!event.target.closest('.edit-icon')) {
|
||||||
|
this.saveTitleEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tags edit
|
||||||
|
const tagsEditor = document.getElementById('recipeTagsEditor');
|
||||||
|
if (tagsEditor && tagsEditor.classList.contains('active') &&
|
||||||
|
!tagsEditor.contains(event.target) &&
|
||||||
|
!event.target.closest('.edit-icon')) {
|
||||||
|
this.saveTagsEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tooltip positioning handler to ensure correct positioning of fixed tooltips
|
// Add tooltip positioning handler to ensure correct positioning of fixed tooltips
|
||||||
@@ -35,10 +54,37 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showRecipeDetails(recipe) {
|
showRecipeDetails(recipe) {
|
||||||
// Set modal title
|
// Store the full recipe for editing
|
||||||
|
this.currentRecipe = JSON.parse(JSON.stringify(recipe)); // 深拷贝以避免对原始对象的修改
|
||||||
|
|
||||||
|
// Set modal title with edit icon
|
||||||
const modalTitle = document.getElementById('recipeModalTitle');
|
const modalTitle = document.getElementById('recipeModalTitle');
|
||||||
if (modalTitle) {
|
if (modalTitle) {
|
||||||
modalTitle.textContent = recipe.title || 'Recipe Details';
|
modalTitle.innerHTML = `
|
||||||
|
<div class="editable-content">
|
||||||
|
<span class="content-text">${recipe.title || 'Recipe Details'}</span>
|
||||||
|
<button class="edit-icon" title="Edit recipe name"><i class="fas fa-pencil-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="recipeTitleEditor" class="content-editor">
|
||||||
|
<input type="text" class="title-input" value="${recipe.title || ''}">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listener for title editing
|
||||||
|
const editIcon = modalTitle.querySelector('.edit-icon');
|
||||||
|
editIcon.addEventListener('click', () => this.showTitleEditor());
|
||||||
|
|
||||||
|
// Add key event listener for Enter key
|
||||||
|
const titleInput = modalTitle.querySelector('.title-input');
|
||||||
|
titleInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.saveTitleEdit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.cancelTitleEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the recipe ID for copy syntax API call
|
// Store the recipe ID for copy syntax API call
|
||||||
@@ -48,59 +94,94 @@ class RecipeModal {
|
|||||||
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
const tagsCompactElement = document.getElementById('recipeTagsCompact');
|
||||||
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
|
const tagsTooltipContent = document.getElementById('recipeTagsTooltipContent');
|
||||||
|
|
||||||
if (tagsCompactElement && tagsTooltipContent && recipe.tags && recipe.tags.length > 0) {
|
if (tagsCompactElement) {
|
||||||
// Clear previous tags
|
// Add tags container with edit functionality
|
||||||
tagsCompactElement.innerHTML = '';
|
tagsCompactElement.innerHTML = `
|
||||||
tagsTooltipContent.innerHTML = '';
|
<div class="editable-content tags-content">
|
||||||
|
<div class="tags-display"></div>
|
||||||
|
<button class="edit-icon" title="Edit tags"><i class="fas fa-pencil-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="recipeTagsEditor" class="content-editor tags-editor">
|
||||||
|
<input type="text" class="tags-input" placeholder="Enter tags separated by commas">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
// Limit displayed tags to 5, show a "+X more" button if needed
|
const tagsDisplay = tagsCompactElement.querySelector('.tags-display');
|
||||||
const maxVisibleTags = 5;
|
|
||||||
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
|
|
||||||
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
|
|
||||||
|
|
||||||
// Add visible tags
|
if (recipe.tags && recipe.tags.length > 0) {
|
||||||
visibleTags.forEach(tag => {
|
// Limit displayed tags to 5, show a "+X more" button if needed
|
||||||
const tagElement = document.createElement('div');
|
const maxVisibleTags = 5;
|
||||||
tagElement.className = 'recipe-tag-compact';
|
const visibleTags = recipe.tags.slice(0, maxVisibleTags);
|
||||||
tagElement.textContent = tag;
|
const remainingTags = recipe.tags.length > maxVisibleTags ? recipe.tags.slice(maxVisibleTags) : [];
|
||||||
tagsCompactElement.appendChild(tagElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add "more" button if needed
|
// Add visible tags
|
||||||
if (remainingTags.length > 0) {
|
visibleTags.forEach(tag => {
|
||||||
const moreButton = document.createElement('div');
|
const tagElement = document.createElement('div');
|
||||||
moreButton.className = 'recipe-tag-more';
|
tagElement.className = 'recipe-tag-compact';
|
||||||
moreButton.textContent = `+${remainingTags.length} more`;
|
tagElement.textContent = tag;
|
||||||
tagsCompactElement.appendChild(moreButton);
|
tagsDisplay.appendChild(tagElement);
|
||||||
|
|
||||||
// Add tooltip functionality
|
|
||||||
moreButton.addEventListener('mouseenter', () => {
|
|
||||||
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
moreButton.addEventListener('mouseleave', () => {
|
// Add "more" button if needed
|
||||||
setTimeout(() => {
|
if (remainingTags.length > 0) {
|
||||||
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
const moreButton = document.createElement('div');
|
||||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
moreButton.className = 'recipe-tag-more';
|
||||||
}
|
moreButton.textContent = `+${remainingTags.length} more`;
|
||||||
}, 300);
|
tagsDisplay.appendChild(moreButton);
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
|
// Add tooltip functionality
|
||||||
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
moreButton.addEventListener('mouseenter', () => {
|
||||||
});
|
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
// Add all tags to tooltip
|
moreButton.addEventListener('mouseleave', () => {
|
||||||
recipe.tags.forEach(tag => {
|
setTimeout(() => {
|
||||||
const tooltipTag = document.createElement('div');
|
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
||||||
tooltipTag.className = 'tooltip-tag';
|
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||||||
tooltipTag.textContent = tag;
|
}
|
||||||
tagsTooltipContent.appendChild(tooltipTag);
|
}, 300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('recipeTagsTooltip').addEventListener('mouseleave', () => {
|
||||||
|
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add all tags to tooltip
|
||||||
|
if (tagsTooltipContent) {
|
||||||
|
tagsTooltipContent.innerHTML = '';
|
||||||
|
recipe.tags.forEach(tag => {
|
||||||
|
const tooltipTag = document.createElement('div');
|
||||||
|
tooltipTag.className = 'tooltip-tag';
|
||||||
|
tooltipTag.textContent = tag;
|
||||||
|
tagsTooltipContent.appendChild(tooltipTag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
||||||
}
|
}
|
||||||
} else if (tagsCompactElement) {
|
|
||||||
// No tags to display
|
// Add event listeners for tags editing
|
||||||
tagsCompactElement.innerHTML = '';
|
const editTagsIcon = tagsCompactElement.querySelector('.edit-icon');
|
||||||
|
const tagsInput = tagsCompactElement.querySelector('.tags-input');
|
||||||
|
|
||||||
|
// Set current tags in the input
|
||||||
|
if (recipe.tags && recipe.tags.length > 0) {
|
||||||
|
tagsInput.value = recipe.tags.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
editTagsIcon.addEventListener('click', () => this.showTagsEditor());
|
||||||
|
|
||||||
|
// Add key event listener for Enter key
|
||||||
|
tagsInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.saveTagsEdit();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.cancelTagsEdit();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set recipe image
|
// Set recipe image
|
||||||
@@ -195,30 +276,38 @@ class RecipeModal {
|
|||||||
const lorasListElement = document.getElementById('recipeLorasList');
|
const lorasListElement = document.getElementById('recipeLorasList');
|
||||||
const lorasCountElement = document.getElementById('recipeLorasCount');
|
const lorasCountElement = document.getElementById('recipeLorasCount');
|
||||||
|
|
||||||
// 检查所有 LoRAs 是否都在库中
|
// Check all LoRAs status
|
||||||
let allLorasAvailable = true;
|
let allLorasAvailable = true;
|
||||||
let missingLorasCount = 0;
|
let missingLorasCount = 0;
|
||||||
|
let deletedLorasCount = 0;
|
||||||
|
|
||||||
if (recipe.loras && recipe.loras.length > 0) {
|
if (recipe.loras && recipe.loras.length > 0) {
|
||||||
recipe.loras.forEach(lora => {
|
recipe.loras.forEach(lora => {
|
||||||
if (!lora.inLibrary) {
|
if (lora.isDeleted) {
|
||||||
|
deletedLorasCount++;
|
||||||
|
} else if (!lora.inLibrary) {
|
||||||
allLorasAvailable = false;
|
allLorasAvailable = false;
|
||||||
missingLorasCount++;
|
missingLorasCount++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置 LoRAs 计数和状态
|
// Set LoRAs count and status
|
||||||
if (lorasCountElement && recipe.loras) {
|
if (lorasCountElement && recipe.loras) {
|
||||||
const totalCount = recipe.loras.length;
|
const totalCount = recipe.loras.length;
|
||||||
|
|
||||||
// 创建状态指示器
|
// Create status indicator based on LoRA states
|
||||||
let statusHTML = '';
|
let statusHTML = '';
|
||||||
if (totalCount > 0) {
|
if (totalCount > 0) {
|
||||||
if (allLorasAvailable) {
|
if (allLorasAvailable && deletedLorasCount === 0) {
|
||||||
|
// All LoRAs are available
|
||||||
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
||||||
} else {
|
} else if (missingLorasCount > 0) {
|
||||||
|
// Some LoRAs are missing (prioritize showing missing over deleted)
|
||||||
statusHTML = `<div class="recipe-status missing"><i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing</div>`;
|
statusHTML = `<div class="recipe-status missing"><i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing</div>`;
|
||||||
|
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
|
||||||
|
// Some LoRAs are deleted but none are missing
|
||||||
|
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,17 +317,28 @@ class RecipeModal {
|
|||||||
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
if (lorasListElement && recipe.loras && recipe.loras.length > 0) {
|
||||||
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
lorasListElement.innerHTML = recipe.loras.map(lora => {
|
||||||
const existsLocally = lora.inLibrary;
|
const existsLocally = lora.inLibrary;
|
||||||
|
const isDeleted = lora.isDeleted;
|
||||||
const localPath = lora.localPath || '';
|
const localPath = lora.localPath || '';
|
||||||
|
|
||||||
// Create local status badge with a more stable structure
|
// Create status badge based on LoRA state
|
||||||
const localStatus = existsLocally ?
|
let localStatus;
|
||||||
`<div class="local-badge">
|
if (existsLocally) {
|
||||||
<i class="fas fa-check"></i> In Library
|
localStatus = `
|
||||||
<div class="local-path">${localPath}</div>
|
<div class="local-badge">
|
||||||
</div>` :
|
<i class="fas fa-check"></i> In Library
|
||||||
`<div class="missing-badge">
|
<div class="local-path">${localPath}</div>
|
||||||
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
</div>`;
|
||||||
</div>`;
|
} else if (isDeleted) {
|
||||||
|
localStatus = `
|
||||||
|
<div class="deleted-badge">
|
||||||
|
<i class="fas fa-trash-alt"></i> Deleted
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
localStatus = `
|
||||||
|
<div class="missing-badge">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if preview is a video
|
// Check if preview is a video
|
||||||
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
||||||
@@ -248,8 +348,18 @@ class RecipeModal {
|
|||||||
</video>` :
|
</video>` :
|
||||||
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
`<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}" alt="LoRA preview">`;
|
||||||
|
|
||||||
|
// Determine CSS class based on LoRA state
|
||||||
|
let loraItemClass = 'recipe-lora-item';
|
||||||
|
if (existsLocally) {
|
||||||
|
loraItemClass += ' exists-locally';
|
||||||
|
} else if (isDeleted) {
|
||||||
|
loraItemClass += ' is-deleted';
|
||||||
|
} else {
|
||||||
|
loraItemClass += ' missing-locally';
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="recipe-lora-item ${existsLocally ? 'exists-locally' : 'missing-locally'}">
|
<div class="${loraItemClass}">
|
||||||
<div class="recipe-lora-thumbnail">
|
<div class="recipe-lora-thumbnail">
|
||||||
${previewMedia}
|
${previewMedia}
|
||||||
</div>
|
</div>
|
||||||
@@ -280,6 +390,249 @@ class RecipeModal {
|
|||||||
modalManager.showModal('recipeModal');
|
modalManager.showModal('recipeModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Title editing methods
|
||||||
|
showTitleEditor() {
|
||||||
|
const titleContainer = document.getElementById('recipeModalTitle');
|
||||||
|
if (titleContainer) {
|
||||||
|
titleContainer.querySelector('.editable-content').classList.add('hide');
|
||||||
|
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||||
|
editor.classList.add('active');
|
||||||
|
const input = editor.querySelector('input');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTitleEdit() {
|
||||||
|
const titleContainer = document.getElementById('recipeModalTitle');
|
||||||
|
if (titleContainer) {
|
||||||
|
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||||
|
const input = editor.querySelector('input');
|
||||||
|
const newTitle = input.value.trim();
|
||||||
|
|
||||||
|
// Check if title changed
|
||||||
|
if (newTitle && newTitle !== this.currentRecipe.title) {
|
||||||
|
// Update title in the UI
|
||||||
|
titleContainer.querySelector('.content-text').textContent = newTitle;
|
||||||
|
|
||||||
|
// Update the recipe on the server
|
||||||
|
this.updateRecipeMetadata({ title: newTitle });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide editor
|
||||||
|
editor.classList.remove('active');
|
||||||
|
titleContainer.querySelector('.editable-content').classList.remove('hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTitleEdit() {
|
||||||
|
const titleContainer = document.getElementById('recipeModalTitle');
|
||||||
|
if (titleContainer) {
|
||||||
|
// Reset input value
|
||||||
|
const editor = titleContainer.querySelector('#recipeTitleEditor');
|
||||||
|
const input = editor.querySelector('input');
|
||||||
|
input.value = this.currentRecipe.title || '';
|
||||||
|
|
||||||
|
// Hide editor
|
||||||
|
editor.classList.remove('active');
|
||||||
|
titleContainer.querySelector('.editable-content').classList.remove('hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags editing methods
|
||||||
|
showTagsEditor() {
|
||||||
|
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||||
|
if (tagsContainer) {
|
||||||
|
tagsContainer.querySelector('.editable-content').classList.add('hide');
|
||||||
|
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||||
|
editor.classList.add('active');
|
||||||
|
const input = editor.querySelector('input');
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTagsEdit() {
|
||||||
|
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||||
|
if (tagsContainer) {
|
||||||
|
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||||
|
const input = editor.querySelector('input');
|
||||||
|
const tagsText = input.value.trim();
|
||||||
|
|
||||||
|
// Parse tags
|
||||||
|
let newTags = [];
|
||||||
|
if (tagsText) {
|
||||||
|
newTags = tagsText.split(',')
|
||||||
|
.map(tag => tag.trim())
|
||||||
|
.filter(tag => tag.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if tags changed
|
||||||
|
const oldTags = this.currentRecipe.tags || [];
|
||||||
|
const tagsChanged =
|
||||||
|
newTags.length !== oldTags.length ||
|
||||||
|
newTags.some((tag, index) => tag !== oldTags[index]);
|
||||||
|
|
||||||
|
if (tagsChanged) {
|
||||||
|
// Update the recipe on the server
|
||||||
|
this.updateRecipeMetadata({ tags: newTags });
|
||||||
|
|
||||||
|
// Update tags in the UI
|
||||||
|
const tagsDisplay = tagsContainer.querySelector('.tags-display');
|
||||||
|
tagsDisplay.innerHTML = '';
|
||||||
|
|
||||||
|
if (newTags.length > 0) {
|
||||||
|
// Limit displayed tags to 5, show a "+X more" button if needed
|
||||||
|
const maxVisibleTags = 5;
|
||||||
|
const visibleTags = newTags.slice(0, maxVisibleTags);
|
||||||
|
const remainingTags = newTags.length > maxVisibleTags ? newTags.slice(maxVisibleTags) : [];
|
||||||
|
|
||||||
|
// Add visible tags
|
||||||
|
visibleTags.forEach(tag => {
|
||||||
|
const tagElement = document.createElement('div');
|
||||||
|
tagElement.className = 'recipe-tag-compact';
|
||||||
|
tagElement.textContent = tag;
|
||||||
|
tagsDisplay.appendChild(tagElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add "more" button if needed
|
||||||
|
if (remainingTags.length > 0) {
|
||||||
|
const moreButton = document.createElement('div');
|
||||||
|
moreButton.className = 'recipe-tag-more';
|
||||||
|
moreButton.textContent = `+${remainingTags.length} more`;
|
||||||
|
tagsDisplay.appendChild(moreButton);
|
||||||
|
|
||||||
|
// Update tooltip content
|
||||||
|
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
|
||||||
|
if (tooltipContent) {
|
||||||
|
tooltipContent.innerHTML = '';
|
||||||
|
newTags.forEach(tag => {
|
||||||
|
const tooltipTag = document.createElement('div');
|
||||||
|
tooltipTag.className = 'tooltip-tag';
|
||||||
|
tooltipTag.textContent = tag;
|
||||||
|
tooltipContent.appendChild(tooltipTag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-add tooltip functionality
|
||||||
|
moreButton.addEventListener('mouseenter', () => {
|
||||||
|
document.getElementById('recipeTagsTooltip').classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
moreButton.addEventListener('mouseleave', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
|
||||||
|
document.getElementById('recipeTagsTooltip').classList.remove('visible');
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the current recipe object
|
||||||
|
this.currentRecipe.tags = newTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide editor
|
||||||
|
editor.classList.remove('active');
|
||||||
|
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTagsEdit() {
|
||||||
|
const tagsContainer = document.getElementById('recipeTagsCompact');
|
||||||
|
if (tagsContainer) {
|
||||||
|
// Reset input value
|
||||||
|
const editor = tagsContainer.querySelector('#recipeTagsEditor');
|
||||||
|
const input = editor.querySelector('input');
|
||||||
|
input.value = this.currentRecipe.tags ? this.currentRecipe.tags.join(', ') : '';
|
||||||
|
|
||||||
|
// Hide editor
|
||||||
|
editor.classList.remove('active');
|
||||||
|
tagsContainer.querySelector('.editable-content').classList.remove('hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update recipe metadata on the server
|
||||||
|
async updateRecipeMetadata(updates) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/recipe/${this.recipeId}/update`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// 显示保存成功的提示
|
||||||
|
if (updates.title) {
|
||||||
|
showToast('Recipe name updated successfully', 'success');
|
||||||
|
} else if (updates.tags) {
|
||||||
|
showToast('Recipe tags updated successfully', 'success');
|
||||||
|
} else {
|
||||||
|
showToast('Recipe updated successfully', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前recipe对象的属性
|
||||||
|
Object.assign(this.currentRecipe, updates);
|
||||||
|
|
||||||
|
// 确保这个更新也传播到卡片视图
|
||||||
|
// 尝试找到可能显示这个recipe的卡片并更新它
|
||||||
|
try {
|
||||||
|
const recipeCards = document.querySelectorAll('.recipe-card');
|
||||||
|
recipeCards.forEach(card => {
|
||||||
|
if (card.dataset.recipeId === this.recipeId) {
|
||||||
|
// 更新卡片标题
|
||||||
|
if (updates.title) {
|
||||||
|
const titleElement = card.querySelector('.recipe-title');
|
||||||
|
if (titleElement) {
|
||||||
|
titleElement.textContent = updates.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新卡片标签
|
||||||
|
if (updates.tags) {
|
||||||
|
const tagsElement = card.querySelector('.recipe-tags');
|
||||||
|
if (tagsElement) {
|
||||||
|
if (updates.tags.length > 0) {
|
||||||
|
tagsElement.innerHTML = updates.tags.map(
|
||||||
|
tag => `<div class="recipe-tag">${tag}</div>`
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
tagsElement.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Non-critical error updating recipe cards:", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重要:强制刷新recipes列表,确保从服务器获取最新数据
|
||||||
|
try {
|
||||||
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
|
// 异步刷新recipes列表,不阻塞用户界面
|
||||||
|
setTimeout(() => {
|
||||||
|
window.recipeManager.loadRecipes(true);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Error refreshing recipes list:", err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(`Failed to update recipe: ${data.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating recipe:', error);
|
||||||
|
showToast(`Error updating recipe: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Setup copy buttons for prompts and recipe syntax
|
// Setup copy buttons for prompts and recipe syntax
|
||||||
setupCopyButtons() {
|
setupCopyButtons() {
|
||||||
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||||||
|
|||||||
Reference in New Issue
Block a user