add template edit
This commit is contained in:
parent
9b909dae9c
commit
f6ff32a746
6 changed files with 1043 additions and 4 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -6,3 +6,5 @@
|
|||
/.venv
|
||||
/static/uploads
|
||||
.DS_Store
|
||||
user_prompts.json
|
||||
/static/preview
|
||||
|
|
|
|||
293
app.py
293
app.py
|
|
@ -253,21 +253,308 @@ def get_prompts():
|
|||
category = request.args.get('category')
|
||||
|
||||
try:
|
||||
all_prompts = []
|
||||
|
||||
# Read prompts.json file
|
||||
prompts_path = os.path.join(os.path.dirname(__file__), 'prompts.json')
|
||||
if os.path.exists(prompts_path):
|
||||
with open(prompts_path, 'r', encoding='utf-8') as f:
|
||||
prompts = json.load(f)
|
||||
all_prompts.extend(json.load(f))
|
||||
|
||||
# Read user_prompts.json file and mark as user templates
|
||||
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
|
||||
if os.path.exists(user_prompts_path):
|
||||
try:
|
||||
with open(user_prompts_path, 'r', encoding='utf-8') as f:
|
||||
user_prompts = json.load(f)
|
||||
if isinstance(user_prompts, list):
|
||||
# Mark each user template and add index for editing
|
||||
for idx, template in enumerate(user_prompts):
|
||||
template['isUserTemplate'] = True
|
||||
template['userTemplateIndex'] = idx
|
||||
all_prompts.extend(user_prompts)
|
||||
except json.JSONDecodeError:
|
||||
pass # Ignore if empty or invalid
|
||||
|
||||
# Filter by category if specified
|
||||
if category:
|
||||
prompts = [p for p in prompts if p.get('category') == category]
|
||||
all_prompts = [p for p in all_prompts if p.get('category') == category]
|
||||
|
||||
response = jsonify({'prompts': prompts})
|
||||
response = jsonify({'prompts': all_prompts})
|
||||
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||
return response
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/save_template', methods=['POST'])
|
||||
def save_template():
|
||||
try:
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Handle multipart form data
|
||||
title = request.form.get('title')
|
||||
prompt = request.form.get('prompt')
|
||||
mode = request.form.get('mode', 'generate')
|
||||
category = request.form.get('category', 'User')
|
||||
|
||||
if not title or not prompt:
|
||||
return jsonify({'error': 'Title and prompt are required'}), 400
|
||||
|
||||
# Handle preview image
|
||||
preview_path = None
|
||||
preview_dir = os.path.join(app.static_folder, 'preview')
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
|
||||
# Check if file was uploaded
|
||||
if 'preview' in request.files:
|
||||
file = request.files['preview']
|
||||
if file.filename:
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
if not ext:
|
||||
ext = '.png'
|
||||
|
||||
filename = f"template_{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(preview_dir, filename)
|
||||
file.save(filepath)
|
||||
|
||||
preview_path = url_for('static', filename=f'preview/{filename}')
|
||||
|
||||
# If no file uploaded, check if URL/path provided
|
||||
if not preview_path:
|
||||
preview_url = request.form.get('preview_path')
|
||||
if preview_url:
|
||||
try:
|
||||
# Check if it's a URL or local path
|
||||
if preview_url.startswith('http://') or preview_url.startswith('https://'):
|
||||
# Download from URL
|
||||
response = requests.get(preview_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
# Determine extension from content-type or URL
|
||||
content_type = response.headers.get('content-type', '')
|
||||
if 'image/png' in content_type:
|
||||
ext = '.png'
|
||||
elif 'image/jpeg' in content_type or 'image/jpg' in content_type:
|
||||
ext = '.jpg'
|
||||
elif 'image/webp' in content_type:
|
||||
ext = '.webp'
|
||||
else:
|
||||
# Try to get from URL
|
||||
parsed = urlparse(preview_url)
|
||||
ext = os.path.splitext(parsed.path)[1] or '.png'
|
||||
|
||||
filename = f"template_{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(preview_dir, filename)
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
preview_path = url_for('static', filename=f'preview/{filename}')
|
||||
|
||||
elif preview_url.startswith('/static/'):
|
||||
# Local path - copy to preview folder
|
||||
rel_path = preview_url.split('/static/')[1]
|
||||
source_path = os.path.join(app.static_folder, rel_path)
|
||||
|
||||
if os.path.exists(source_path):
|
||||
ext = os.path.splitext(source_path)[1] or '.png'
|
||||
filename = f"template_{uuid.uuid4()}{ext}"
|
||||
dest_path = os.path.join(preview_dir, filename)
|
||||
|
||||
import shutil
|
||||
shutil.copy2(source_path, dest_path)
|
||||
|
||||
preview_path = url_for('static', filename=f'preview/{filename}')
|
||||
else:
|
||||
# File doesn't exist, use original path
|
||||
preview_path = preview_url
|
||||
else:
|
||||
# Use as-is if it's already a valid path
|
||||
preview_path = preview_url
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing preview image URL: {e}")
|
||||
# Use the original URL if processing fails
|
||||
preview_path = preview_url
|
||||
|
||||
new_template = {
|
||||
'title': title,
|
||||
'prompt': prompt,
|
||||
'mode': mode,
|
||||
'category': category,
|
||||
'preview': preview_path
|
||||
}
|
||||
|
||||
# Save to user_prompts.json
|
||||
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
|
||||
user_prompts = []
|
||||
|
||||
if os.path.exists(user_prompts_path):
|
||||
try:
|
||||
with open(user_prompts_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if content.strip():
|
||||
user_prompts = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
user_prompts.append(new_template)
|
||||
|
||||
with open(user_prompts_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(user_prompts, f, indent=4, ensure_ascii=False)
|
||||
|
||||
return jsonify({'success': True, 'template': new_template})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving template: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/update_template', methods=['POST'])
|
||||
def update_template():
|
||||
try:
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Get template index
|
||||
template_index = request.form.get('template_index')
|
||||
if template_index is None:
|
||||
return jsonify({'error': 'Template index is required'}), 400
|
||||
|
||||
template_index = int(template_index)
|
||||
|
||||
# Handle multipart form data
|
||||
title = request.form.get('title')
|
||||
prompt = request.form.get('prompt')
|
||||
mode = request.form.get('mode', 'generate')
|
||||
category = request.form.get('category', 'User')
|
||||
|
||||
if not title or not prompt:
|
||||
return jsonify({'error': 'Title and prompt are required'}), 400
|
||||
|
||||
# Handle preview image (same logic as save_template)
|
||||
preview_path = None
|
||||
preview_dir = os.path.join(app.static_folder, 'preview')
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
|
||||
# Check if file was uploaded
|
||||
if 'preview' in request.files:
|
||||
file = request.files['preview']
|
||||
if file.filename:
|
||||
ext = os.path.splitext(file.filename)[1]
|
||||
if not ext:
|
||||
ext = '.png'
|
||||
|
||||
filename = f"template_{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(preview_dir, filename)
|
||||
file.save(filepath)
|
||||
|
||||
preview_path = url_for('static', filename=f'preview/{filename}')
|
||||
|
||||
# If no file uploaded, check if URL/path provided
|
||||
if not preview_path:
|
||||
preview_url = request.form.get('preview_path')
|
||||
if preview_url:
|
||||
try:
|
||||
if preview_url.startswith('http://') or preview_url.startswith('https://'):
|
||||
response = requests.get(preview_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get('content-type', '')
|
||||
if 'image/png' in content_type:
|
||||
ext = '.png'
|
||||
elif 'image/jpeg' in content_type or 'image/jpg' in content_type:
|
||||
ext = '.jpg'
|
||||
elif 'image/webp' in content_type:
|
||||
ext = '.webp'
|
||||
else:
|
||||
parsed = urlparse(preview_url)
|
||||
ext = os.path.splitext(parsed.path)[1] or '.png'
|
||||
|
||||
filename = f"template_{uuid.uuid4()}{ext}"
|
||||
filepath = os.path.join(preview_dir, filename)
|
||||
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
preview_path = url_for('static', filename=f'preview/{filename}')
|
||||
|
||||
elif preview_url.startswith('/static/'):
|
||||
rel_path = preview_url.split('/static/')[1]
|
||||
source_path = os.path.join(app.static_folder, rel_path)
|
||||
|
||||
if os.path.exists(source_path):
|
||||
ext = os.path.splitext(source_path)[1] or '.png'
|
||||
filename = f"template_{uuid.uuid4()}{ext}"
|
||||
dest_path = os.path.join(preview_dir, filename)
|
||||
|
||||
import shutil
|
||||
shutil.copy2(source_path, dest_path)
|
||||
|
||||
preview_path = url_for('static', filename=f'preview/{filename}')
|
||||
else:
|
||||
preview_path = preview_url
|
||||
else:
|
||||
preview_path = preview_url
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing preview image URL: {e}")
|
||||
preview_path = preview_url
|
||||
|
||||
# Read existing user templates
|
||||
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
|
||||
user_prompts = []
|
||||
|
||||
if os.path.exists(user_prompts_path):
|
||||
try:
|
||||
with open(user_prompts_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if content.strip():
|
||||
user_prompts = json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Check if index is valid
|
||||
if template_index < 0 or template_index >= len(user_prompts):
|
||||
return jsonify({'error': 'Invalid template index'}), 400
|
||||
|
||||
# Get old template to check for old preview image
|
||||
old_template = user_prompts[template_index]
|
||||
old_preview = old_template.get('preview', '')
|
||||
|
||||
# Delete old preview image if it exists in the preview folder
|
||||
if old_preview and '/preview/' in old_preview:
|
||||
try:
|
||||
# Extract filename from URL
|
||||
old_filename = old_preview.split('/preview/')[-1]
|
||||
old_filepath = os.path.join(preview_dir, old_filename)
|
||||
|
||||
# Delete old file if it exists
|
||||
if os.path.exists(old_filepath):
|
||||
os.remove(old_filepath)
|
||||
print(f"Deleted old preview image: {old_filepath}")
|
||||
except Exception as e:
|
||||
print(f"Error deleting old preview image: {e}")
|
||||
|
||||
# Update the template
|
||||
user_prompts[template_index] = {
|
||||
'title': title,
|
||||
'prompt': prompt,
|
||||
'mode': mode,
|
||||
'category': category,
|
||||
'preview': preview_path
|
||||
}
|
||||
|
||||
# Save back to file
|
||||
with open(user_prompts_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(user_prompts, f, indent=4, ensure_ascii=False)
|
||||
|
||||
return jsonify({'success': True, 'template': user_prompts[template_index]})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error updating template: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/refine_prompt', methods=['POST'])
|
||||
def refine_prompt():
|
||||
data = request.get_json()
|
||||
|
|
|
|||
|
|
@ -105,6 +105,25 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
|||
};
|
||||
preview.appendChild(img);
|
||||
}
|
||||
|
||||
// Edit button (show on all templates)
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'template-edit-btn';
|
||||
editBtn.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.5 2.50001C18.8978 2.10219 19.4374 1.87869 20 1.87869C20.5626 1.87869 21.1022 2.10219 21.5 2.50001C21.8978 2.89784 22.1213 3.43741 22.1213 4.00001C22.1213 4.56262 21.8978 5.10219 21.5 5.50001L12 15L8 16L9 12L18.5 2.50001Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
editBtn.title = 'Edit Template';
|
||||
editBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.openEditTemplateModal) {
|
||||
window.openEditTemplateModal(template);
|
||||
}
|
||||
});
|
||||
preview.appendChild(editBtn);
|
||||
|
||||
card.appendChild(preview);
|
||||
|
||||
// Content
|
||||
|
|
@ -227,6 +246,22 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
|||
});
|
||||
controls.appendChild(categorySelect);
|
||||
|
||||
// Create Template button
|
||||
const createTemplateBtn = document.createElement('button');
|
||||
createTemplateBtn.className = 'template-create-btn';
|
||||
createTemplateBtn.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>Create Template</span>
|
||||
`;
|
||||
createTemplateBtn.addEventListener('click', () => {
|
||||
if (window.openCreateTemplateModal) {
|
||||
window.openCreateTemplateModal();
|
||||
}
|
||||
});
|
||||
controls.appendChild(createTemplateBtn);
|
||||
|
||||
header.appendChild(controls);
|
||||
container.appendChild(header);
|
||||
|
||||
|
|
|
|||
440
static/script.js
440
static/script.js
|
|
@ -319,6 +319,446 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Create Template Logic
|
||||
const createTemplateBtn = document.getElementById('create-template-btn');
|
||||
const createTemplateModal = document.getElementById('create-template-modal');
|
||||
const closeTemplateModalBtn = document.getElementById('close-template-modal');
|
||||
const saveTemplateBtn = document.getElementById('save-template-btn');
|
||||
|
||||
const templateTitleInput = document.getElementById('template-title');
|
||||
const templatePromptInput = document.getElementById('template-prompt');
|
||||
const templateModeSelect = document.getElementById('template-mode');
|
||||
const templateCategorySelect = document.getElementById('template-category-select');
|
||||
const templateCategoryInput = document.getElementById('template-category-input');
|
||||
const templatePreviewDropzone = document.getElementById('template-preview-dropzone');
|
||||
const templatePreviewImg = document.getElementById('template-preview-img');
|
||||
const dropzonePlaceholder = document.querySelector('.dropzone-placeholder');
|
||||
|
||||
let currentPreviewFile = null;
|
||||
let currentPreviewUrl = null;
|
||||
let editingTemplate = null; // Track if we're editing an existing template
|
||||
|
||||
// Global function for opening edit modal (called from templateGallery.js)
|
||||
window.openEditTemplateModal = async function(template) {
|
||||
editingTemplate = template;
|
||||
|
||||
// Pre-fill with template data
|
||||
templateTitleInput.value = template.title || '';
|
||||
templatePromptInput.value = template.prompt || '';
|
||||
templateModeSelect.value = template.mode || 'generate';
|
||||
templateCategoryInput.classList.add('hidden');
|
||||
templateCategoryInput.value = '';
|
||||
|
||||
// Populate categories
|
||||
try {
|
||||
const response = await fetch('/prompts');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.prompts) {
|
||||
const categories = new Set();
|
||||
data.prompts.forEach(t => {
|
||||
if (t.category) {
|
||||
const categoryText = typeof t.category === 'string'
|
||||
? t.category
|
||||
: (t.category.vi || t.category.en || '');
|
||||
if (categoryText) categories.add(categoryText);
|
||||
}
|
||||
});
|
||||
|
||||
templateCategorySelect.innerHTML = '';
|
||||
const sortedCategories = Array.from(categories).sort();
|
||||
sortedCategories.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat;
|
||||
option.textContent = cat;
|
||||
templateCategorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
const newOption = document.createElement('option');
|
||||
newOption.value = 'new';
|
||||
newOption.textContent = '+ New Category';
|
||||
templateCategorySelect.appendChild(newOption);
|
||||
|
||||
// Set to template's category
|
||||
const templateCategory = typeof template.category === 'string'
|
||||
? template.category
|
||||
: (template.category.vi || template.category.en || '');
|
||||
templateCategorySelect.value = templateCategory || 'User';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error);
|
||||
}
|
||||
|
||||
// Set preview image
|
||||
if (template.preview) {
|
||||
templatePreviewImg.src = template.preview;
|
||||
templatePreviewImg.classList.remove('hidden');
|
||||
dropzonePlaceholder.classList.add('hidden');
|
||||
currentPreviewUrl = template.preview;
|
||||
} else {
|
||||
templatePreviewImg.src = '';
|
||||
templatePreviewImg.classList.add('hidden');
|
||||
dropzonePlaceholder.classList.remove('hidden');
|
||||
currentPreviewUrl = null;
|
||||
}
|
||||
currentPreviewFile = null;
|
||||
|
||||
// Update button text
|
||||
saveTemplateBtn.innerHTML = '<span>Update Template</span><div class="btn-shine"></div>';
|
||||
|
||||
createTemplateModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
// Global function for opening create modal with empty values (called from templateGallery.js)
|
||||
window.openCreateTemplateModal = async function() {
|
||||
editingTemplate = null;
|
||||
|
||||
// Clear all fields
|
||||
templateTitleInput.value = '';
|
||||
templatePromptInput.value = '';
|
||||
templateModeSelect.value = 'generate';
|
||||
templateCategoryInput.classList.add('hidden');
|
||||
templateCategoryInput.value = '';
|
||||
|
||||
// Populate categories
|
||||
try {
|
||||
const response = await fetch('/prompts');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.prompts) {
|
||||
const categories = new Set();
|
||||
data.prompts.forEach(t => {
|
||||
if (t.category) {
|
||||
const categoryText = typeof t.category === 'string'
|
||||
? t.category
|
||||
: (t.category.vi || t.category.en || '');
|
||||
if (categoryText) categories.add(categoryText);
|
||||
}
|
||||
});
|
||||
|
||||
templateCategorySelect.innerHTML = '';
|
||||
const sortedCategories = Array.from(categories).sort();
|
||||
sortedCategories.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat;
|
||||
option.textContent = cat;
|
||||
templateCategorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
const newOption = document.createElement('option');
|
||||
newOption.value = 'new';
|
||||
newOption.textContent = '+ New Category';
|
||||
templateCategorySelect.appendChild(newOption);
|
||||
|
||||
if (sortedCategories.includes('User')) {
|
||||
templateCategorySelect.value = 'User';
|
||||
} else if (sortedCategories.length > 0) {
|
||||
templateCategorySelect.value = sortedCategories[0];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error);
|
||||
}
|
||||
|
||||
// Clear preview image
|
||||
templatePreviewImg.src = '';
|
||||
templatePreviewImg.classList.add('hidden');
|
||||
dropzonePlaceholder.classList.remove('hidden');
|
||||
currentPreviewUrl = null;
|
||||
currentPreviewFile = null;
|
||||
|
||||
// Update button text
|
||||
saveTemplateBtn.innerHTML = '<span>Save Template</span><div class="btn-shine"></div>';
|
||||
|
||||
createTemplateModal.classList.remove('hidden');
|
||||
};
|
||||
|
||||
if (createTemplateBtn) {
|
||||
createTemplateBtn.addEventListener('click', async () => {
|
||||
// Reset editing state
|
||||
editingTemplate = null;
|
||||
|
||||
// Pre-fill data
|
||||
templateTitleInput.value = '';
|
||||
templatePromptInput.value = promptInput.value;
|
||||
templateModeSelect.value = 'generate';
|
||||
templateCategoryInput.classList.add('hidden');
|
||||
templateCategoryInput.value = '';
|
||||
|
||||
// Populate categories dynamically from template library
|
||||
try {
|
||||
const response = await fetch('/prompts');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.prompts) {
|
||||
// Extract unique categories
|
||||
const categories = new Set();
|
||||
data.prompts.forEach(template => {
|
||||
if (template.category) {
|
||||
// Handle both string and object categories
|
||||
const categoryText = typeof template.category === 'string'
|
||||
? template.category
|
||||
: (template.category.vi || template.category.en || '');
|
||||
if (categoryText) {
|
||||
categories.add(categoryText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clear existing options except "new"
|
||||
templateCategorySelect.innerHTML = '';
|
||||
|
||||
// Add sorted categories
|
||||
const sortedCategories = Array.from(categories).sort();
|
||||
sortedCategories.forEach(cat => {
|
||||
const option = document.createElement('option');
|
||||
option.value = cat;
|
||||
option.textContent = cat;
|
||||
templateCategorySelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Add "new category" option at the end
|
||||
const newOption = document.createElement('option');
|
||||
newOption.value = 'new';
|
||||
newOption.textContent = '+ New Category';
|
||||
templateCategorySelect.appendChild(newOption);
|
||||
|
||||
// Set default to first category or "User" if it exists
|
||||
if (sortedCategories.includes('User')) {
|
||||
templateCategorySelect.value = 'User';
|
||||
} else if (sortedCategories.length > 0) {
|
||||
templateCategorySelect.value = sortedCategories[0];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error);
|
||||
// Fallback to default categories
|
||||
templateCategorySelect.innerHTML = `
|
||||
<option value="User">User</option>
|
||||
<option value="new">+ New Category</option>
|
||||
`;
|
||||
templateCategorySelect.value = 'User';
|
||||
}
|
||||
|
||||
// Set preview image from current generated image
|
||||
if (generatedImage.src && !generatedImage.src.endsWith('placeholder.png')) {
|
||||
templatePreviewImg.src = generatedImage.src;
|
||||
templatePreviewImg.classList.remove('hidden');
|
||||
dropzonePlaceholder.classList.add('hidden');
|
||||
currentPreviewUrl = generatedImage.src;
|
||||
} else {
|
||||
templatePreviewImg.src = '';
|
||||
templatePreviewImg.classList.add('hidden');
|
||||
dropzonePlaceholder.classList.remove('hidden');
|
||||
currentPreviewUrl = null;
|
||||
}
|
||||
currentPreviewFile = null;
|
||||
|
||||
// Update button text
|
||||
saveTemplateBtn.innerHTML = '<span>Save Template</span><div class="btn-shine"></div>';
|
||||
|
||||
createTemplateModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (closeTemplateModalBtn) {
|
||||
closeTemplateModalBtn.addEventListener('click', () => {
|
||||
createTemplateModal.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// Category select logic
|
||||
if (templateCategorySelect) {
|
||||
templateCategorySelect.addEventListener('change', (e) => {
|
||||
if (e.target.value === 'new') {
|
||||
templateCategoryInput.classList.remove('hidden');
|
||||
templateCategoryInput.focus();
|
||||
} else {
|
||||
templateCategoryInput.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag and drop for preview
|
||||
const templatePreviewUrlInput = document.getElementById('template-preview-url');
|
||||
let isUrlInputMode = false;
|
||||
|
||||
if (templatePreviewDropzone) {
|
||||
// Click to toggle URL input mode
|
||||
templatePreviewDropzone.addEventListener('click', (e) => {
|
||||
// Don't toggle if clicking on the input itself
|
||||
if (e.target === templatePreviewUrlInput) return;
|
||||
|
||||
if (!isUrlInputMode) {
|
||||
// Switch to URL input mode
|
||||
isUrlInputMode = true;
|
||||
templatePreviewImg.classList.add('hidden');
|
||||
dropzonePlaceholder.classList.add('hidden');
|
||||
templatePreviewUrlInput.classList.remove('hidden');
|
||||
templatePreviewUrlInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle URL input
|
||||
if (templatePreviewUrlInput) {
|
||||
templatePreviewUrlInput.addEventListener('blur', async () => {
|
||||
const url = templatePreviewUrlInput.value.trim();
|
||||
if (url) {
|
||||
try {
|
||||
// Try to load the image from URL
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
templatePreviewImg.src = url;
|
||||
templatePreviewImg.classList.remove('hidden');
|
||||
dropzonePlaceholder.classList.add('hidden');
|
||||
templatePreviewUrlInput.classList.add('hidden');
|
||||
currentPreviewUrl = url;
|
||||
currentPreviewFile = null;
|
||||
isUrlInputMode = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
alert('Failed to load image from URL. Please check the URL and try again.');
|
||||
templatePreviewUrlInput.focus();
|
||||
};
|
||||
img.src = url;
|
||||
} catch (error) {
|
||||
alert('Invalid image URL');
|
||||
templatePreviewUrlInput.focus();
|
||||
}
|
||||
} else {
|
||||
// If empty, go back to placeholder
|
||||
isUrlInputMode = false;
|
||||
templatePreviewUrlInput.classList.add('hidden');
|
||||
dropzonePlaceholder.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
templatePreviewUrlInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
templatePreviewUrlInput.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
templatePreviewUrlInput.value = '';
|
||||
templatePreviewUrlInput.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
templatePreviewDropzone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
templatePreviewDropzone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
templatePreviewDropzone.addEventListener('dragleave', (e) => {
|
||||
e.preventDefault();
|
||||
templatePreviewDropzone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
templatePreviewDropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
templatePreviewDropzone.classList.remove('drag-over');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type.startsWith('image/')) {
|
||||
currentPreviewFile = file;
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
templatePreviewImg.src = objectUrl;
|
||||
templatePreviewImg.classList.remove('hidden');
|
||||
dropzonePlaceholder.classList.add('hidden');
|
||||
templatePreviewUrlInput.classList.add('hidden');
|
||||
isUrlInputMode = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Save template
|
||||
if (saveTemplateBtn) {
|
||||
saveTemplateBtn.addEventListener('click', async () => {
|
||||
const title = templateTitleInput.value.trim();
|
||||
const prompt = templatePromptInput.value.trim();
|
||||
const mode = templateModeSelect.value;
|
||||
let category = templateCategorySelect.value;
|
||||
|
||||
if (category === 'new') {
|
||||
category = templateCategoryInput.value.trim();
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
alert('Please enter a title for the template.');
|
||||
return;
|
||||
}
|
||||
if (!prompt) {
|
||||
alert('Please enter a prompt.');
|
||||
return;
|
||||
}
|
||||
if (!category) {
|
||||
alert('Please select or enter a category.');
|
||||
return;
|
||||
}
|
||||
|
||||
saveTemplateBtn.disabled = true;
|
||||
saveTemplateBtn.textContent = editingTemplate ? 'Updating...' : 'Saving...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('title', title);
|
||||
formData.append('prompt', prompt);
|
||||
formData.append('mode', mode);
|
||||
formData.append('category', category);
|
||||
|
||||
if (currentPreviewFile) {
|
||||
formData.append('preview', currentPreviewFile);
|
||||
} else if (currentPreviewUrl) {
|
||||
formData.append('preview_path', currentPreviewUrl);
|
||||
}
|
||||
|
||||
// If editing, add the template index
|
||||
const endpoint = editingTemplate ? '/update_template' : '/save_template';
|
||||
if (editingTemplate) {
|
||||
formData.append('template_index', editingTemplate.userTemplateIndex);
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Success
|
||||
createTemplateModal.classList.add('hidden');
|
||||
|
||||
// Reload template gallery
|
||||
await templateGallery.load();
|
||||
|
||||
// Reset editing state
|
||||
editingTemplate = null;
|
||||
|
||||
} catch (error) {
|
||||
alert(`Failed to ${editingTemplate ? 'update' : 'save'} template: ` + error.message);
|
||||
} finally {
|
||||
saveTemplateBtn.disabled = false;
|
||||
saveTemplateBtn.innerHTML = '<span>Save Template</span><div class="btn-shine"></div>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
if (createTemplateModal) {
|
||||
createTemplateModal.addEventListener('click', (e) => {
|
||||
if (e.target === createTemplateModal) {
|
||||
createTemplateModal.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handleCanvasPointerMove);
|
||||
document.addEventListener('pointerup', () => {
|
||||
if (isPanning && imageDisplayArea) {
|
||||
|
|
|
|||
197
static/style.css
197
static/style.css
|
|
@ -1251,3 +1251,200 @@ button#generate-btn:disabled {
|
|||
max-height: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Template Create Modal */
|
||||
.template-form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.template-preview-dropzone {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
border: 2px dashed rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.template-preview-dropzone:hover {
|
||||
border-color: var(--accent-color);
|
||||
background: rgba(251, 191, 36, 0.05);
|
||||
box-shadow: 0 0 15px rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
|
||||
.template-preview-dropzone.drag-over {
|
||||
border-color: var(--accent-hover);
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.template-preview-dropzone img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dropzone-placeholder {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dropzone-placeholder::before {
|
||||
content: '+';
|
||||
font-size: 2rem;
|
||||
color: var(--accent-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.category-input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#create-template-modal .popup-card {
|
||||
background: rgba(10, 11, 22, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#save-template-btn {
|
||||
padding: 0.6rem 1.5rem;
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#save-template-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 5px 15px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
#save-template-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.template-preview-url-input {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 90%;
|
||||
padding: 0.75rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--accent-color);
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.template-preview-url-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-hover);
|
||||
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.template-edit-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.template-card:hover .template-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.template-edit-btn:hover {
|
||||
background: var(--accent-color);
|
||||
color: #000;
|
||||
border-color: var(--accent-color);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.template-edit-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.template-create-btn {
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: transform 0.1s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.template-create-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 5px 15px rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.template-create-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,17 @@
|
|||
stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" id="create-template-btn" class="canvas-btn icon-btn"
|
||||
aria-label="Create Template" title="Tạo Template">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 14H12M12 14H14M12 14V16M12 14V12" stroke="currentColor"
|
||||
stroke-width="1.5" stroke-linecap="round" />
|
||||
<path
|
||||
d="M22 11.7979C22 9.16554 22 7.84935 21.2305 6.99383C21.1598 6.91514 21.0849 6.84024 21.0062 6.76946C20.1506 6 18.8345 6 16.2021 6H15.8284C14.6747 6 14.0979 6 13.5604 5.84678C13.2651 5.7626 12.9804 5.64471 12.7121 5.49543C12.2237 5.22367 11.8158 4.81578 11 4L10.4497 3.44975C10.1763 3.17633 10.0396 3.03961 9.89594 2.92051C9.27652 2.40704 8.51665 2.09229 7.71557 2.01738C7.52976 2 7.33642 2 6.94975 2C6.06722 2 5.62595 2 5.25839 2.06935C3.64031 2.37464 2.37464 3.64031 2.06935 5.25839C2 5.62595 2 6.06722 2 6.94975M21.9913 16C21.9554 18.4796 21.7715 19.8853 20.8284 20.8284C19.6569 22 17.7712 22 14 22H10C6.22876 22 4.34315 22 3.17157 20.8284C2 19.6569 2 17.7712 2 14V11"
|
||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="canvas-btn" data-action="zoom-out">−</button>
|
||||
<button type="button" class="canvas-btn" data-action="zoom-in">+</button>
|
||||
<button type="button" class="canvas-btn icon-btn" data-action="zoom-reset"
|
||||
|
|
@ -222,6 +233,73 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Template Modal -->
|
||||
<div id="create-template-modal" class="popup-overlay hidden">
|
||||
<div class="popup-card" style="max-width: 600px;">
|
||||
<header class="popup-header">
|
||||
<h2>Create Template</h2>
|
||||
<button id="close-template-modal" type="button" class="popup-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="popup-body">
|
||||
<div class="template-form-grid">
|
||||
<div class="form-group">
|
||||
<label for="template-title">Title</label>
|
||||
<input type="text" id="template-title" placeholder="Template Name">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Preview Image</label>
|
||||
<div id="template-preview-dropzone" class="template-preview-dropzone">
|
||||
<img id="template-preview-img" src="" alt="Preview" class="hidden">
|
||||
<div class="dropzone-placeholder">
|
||||
<span>Drag & Drop Image or Click to Enter URL</span>
|
||||
</div>
|
||||
<input type="text" id="template-preview-url" class="template-preview-url-input hidden"
|
||||
placeholder="Enter image URL or path">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="template-prompt">Prompt</label>
|
||||
<textarea id="template-prompt" rows="3" placeholder="Template Prompt"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="template-mode">Mode</label>
|
||||
<select id="template-mode">
|
||||
<option value="generate">Generate</option>
|
||||
<option value="edit">Edit</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="template-category">Category</label>
|
||||
<div class="category-input-wrapper">
|
||||
<select id="template-category-select">
|
||||
<option value="User">User</option>
|
||||
<option value="Cinematic">Cinematic</option>
|
||||
<option value="Anime">Anime</option>
|
||||
<option value="Photography">Photography</option>
|
||||
<option value="Digital Art">Digital Art</option>
|
||||
<option value="new">+ New Category</option>
|
||||
</select>
|
||||
<input type="text" id="template-category-input" class="hidden"
|
||||
placeholder="New Category Name">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-footer" style="margin-top: 1.5rem; justify-content: flex-end;">
|
||||
<button id="save-template-btn">
|
||||
<span>Save Template</span>
|
||||
<div class="btn-shine"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="popup-overlay" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="popup-title">
|
||||
<div class="popup-card">
|
||||
<header class="popup-header">
|
||||
|
|
|
|||
Loading…
Reference in a new issue