add template edit

This commit is contained in:
phamhungd 2025-11-23 23:36:21 +07:00
parent 9b909dae9c
commit f6ff32a746
6 changed files with 1043 additions and 4 deletions

2
.gitignore vendored
View file

@ -6,3 +6,5 @@
/.venv /.venv
/static/uploads /static/uploads
.DS_Store .DS_Store
user_prompts.json
/static/preview

295
app.py
View file

@ -253,21 +253,308 @@ def get_prompts():
category = request.args.get('category') category = request.args.get('category')
try: try:
all_prompts = []
# Read prompts.json file # Read prompts.json file
prompts_path = os.path.join(os.path.dirname(__file__), 'prompts.json') prompts_path = os.path.join(os.path.dirname(__file__), 'prompts.json')
with open(prompts_path, 'r', encoding='utf-8') as f: if os.path.exists(prompts_path):
prompts = json.load(f) with open(prompts_path, 'r', encoding='utf-8') as 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 # Filter by category if specified
if category: 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" response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response return response
except Exception as e: except Exception as e:
return jsonify({'error': str(e)}), 500 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']) @app.route('/refine_prompt', methods=['POST'])
def refine_prompt(): def refine_prompt():
data = request.get_json() data = request.get_json()

View file

@ -105,6 +105,25 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
}; };
preview.appendChild(img); 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); card.appendChild(preview);
// Content // Content
@ -227,6 +246,22 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
}); });
controls.appendChild(categorySelect); 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); header.appendChild(controls);
container.appendChild(header); container.appendChild(header);

View file

@ -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('pointermove', handleCanvasPointerMove);
document.addEventListener('pointerup', () => { document.addEventListener('pointerup', () => {
if (isPanning && imageDisplayArea) { if (isPanning && imageDisplayArea) {

View file

@ -1251,3 +1251,200 @@ button#generate-btn:disabled {
max-height: 140px; 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;
}

View file

@ -156,6 +156,17 @@
stroke-linejoin="round" /> stroke-linejoin="round" />
</svg> </svg>
</button> </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-out"></button>
<button type="button" class="canvas-btn" data-action="zoom-in"></button> <button type="button" class="canvas-btn" data-action="zoom-in"></button>
<button type="button" class="canvas-btn icon-btn" data-action="zoom-reset" <button type="button" class="canvas-btn icon-btn" data-action="zoom-reset"
@ -222,6 +233,73 @@
</div> </div>
</div> </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">&times;</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 id="popup-overlay" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="popup-title">
<div class="popup-card"> <div class="popup-card">
<header class="popup-header"> <header class="popup-header">