update ver

This commit is contained in:
phamhungd 2025-11-24 00:53:11 +07:00
parent 8b476165ec
commit 2a0d6c35ae
6 changed files with 732 additions and 200 deletions

1
.gitignore vendored
View file

@ -8,3 +8,4 @@
.DS_Store
user_prompts.json
/static/preview
template_favorites.json

186
app.py
View file

@ -14,6 +14,62 @@ from PIL import Image, PngImagePlugin
app = Flask(__name__)
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
FAVORITES_FILE = os.path.join(os.path.dirname(__file__), 'template_favorites.json')
def load_template_favorites():
if os.path.exists(FAVORITES_FILE):
try:
with open(FAVORITES_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
return [item for item in data if isinstance(item, str)]
except json.JSONDecodeError:
pass
return []
def save_template_favorites(favorites):
try:
with open(FAVORITES_FILE, 'w', encoding='utf-8') as f:
json.dump(favorites, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"Failed to persist template favorites: {e}")
def parse_tags_field(value):
tags = []
if isinstance(value, list):
tags = value
elif isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, list):
tags = parsed
else:
tags = [parsed]
except json.JSONDecodeError:
tags = [value]
else:
return []
result = []
for tag in tags:
if isinstance(tag, dict):
fallback = tag.get('vi') or tag.get('en')
if fallback:
normalized = fallback.strip()
else:
continue
elif isinstance(tag, str):
normalized = tag.strip()
else:
continue
if normalized:
result.append(normalized)
if len(result) >= 12:
break
return result
# Ensure generated directory exists inside Flask static folder
GENERATED_DIR = os.path.join(app.static_folder, 'generated')
os.makedirs(GENERATED_DIR, exist_ok=True)
@ -259,7 +315,15 @@ def get_prompts():
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:
all_prompts.extend(json.load(f))
try:
builtin_prompts = json.load(f)
if isinstance(builtin_prompts, list):
for idx, prompt in enumerate(builtin_prompts):
prompt['builtinTemplateIndex'] = idx
prompt['tags'] = parse_tags_field(prompt.get('tags'))
all_prompts.extend(builtin_prompts)
except json.JSONDecodeError:
pass
# Read user_prompts.json file and mark as user templates
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
@ -272,6 +336,7 @@ def get_prompts():
for idx, template in enumerate(user_prompts):
template['isUserTemplate'] = True
template['userTemplateIndex'] = idx
template['tags'] = parse_tags_field(template.get('tags'))
all_prompts.extend(user_prompts)
except json.JSONDecodeError:
pass # Ignore if empty or invalid
@ -280,12 +345,34 @@ def get_prompts():
if category:
all_prompts = [p for p in all_prompts if p.get('category') == category]
response = jsonify({'prompts': all_prompts})
favorites = load_template_favorites()
response = jsonify({'prompts': all_prompts, 'favorites': favorites})
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/template_favorite', methods=['POST'])
def template_favorite():
data = request.get_json() or {}
key = data.get('key')
favorite = data.get('favorite')
if not key or not isinstance(favorite, bool):
return jsonify({'error': 'Invalid favorite payload'}), 400
favorites = load_template_favorites()
if favorite:
if key not in favorites:
favorites.append(key)
else:
favorites = [item for item in favorites if item != key]
save_template_favorites(favorites)
return jsonify({'favorites': favorites})
@app.route('/save_template', methods=['POST'])
def save_template():
try:
@ -297,6 +384,8 @@ def save_template():
prompt = request.form.get('prompt')
mode = request.form.get('mode', 'generate')
category = request.form.get('category', 'User')
tags_field = request.form.get('tags')
tags = parse_tags_field(tags_field)
if not title or not prompt:
return jsonify({'error': 'Title and prompt are required'}), 400
@ -383,7 +472,8 @@ def save_template():
'prompt': prompt,
'mode': mode,
'category': category,
'preview': preview_path
'preview': preview_path,
'tags': tags
}
# Save to user_prompts.json
@ -416,42 +506,48 @@ def update_template():
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
builtin_index_raw = request.form.get('builtin_index')
builtin_index = None
try:
if builtin_index_raw:
builtin_index = int(builtin_index_raw)
except ValueError:
return jsonify({'error': 'Invalid builtin template index'}), 400
if template_index is None and builtin_index is None:
return jsonify({'error': 'Template index or builtin index is required'}), 400
if template_index is not None:
try:
template_index = int(template_index)
except ValueError:
return jsonify({'error': 'Invalid template index'}), 400
# 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')
tags_field = request.form.get('tags')
tags = parse_tags_field(tags_field)
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'
ext = os.path.splitext(file.filename)[1] or '.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:
@ -501,7 +597,48 @@ def update_template():
print(f"Error processing preview image URL: {e}")
preview_path = preview_url
# Read existing user templates
if builtin_index is not None:
prompts_path = os.path.join(os.path.dirname(__file__), 'prompts.json')
if not os.path.exists(prompts_path):
return jsonify({'error': 'Prompts file not found'}), 404
try:
with open(prompts_path, 'r', encoding='utf-8') as f:
builtin_prompts = json.load(f)
except json.JSONDecodeError:
return jsonify({'error': 'Unable to read prompts.json'}), 500
if not isinstance(builtin_prompts, list) or builtin_index < 0 or builtin_index >= len(builtin_prompts):
return jsonify({'error': 'Invalid builtin template index'}), 400
existing_template = builtin_prompts[builtin_index]
old_preview = existing_template.get('preview', '')
if preview_path and old_preview and '/preview/' in old_preview:
try:
old_filename = old_preview.split('/preview/')[-1]
old_filepath = os.path.join(preview_dir, old_filename)
if os.path.exists(old_filepath):
os.remove(old_filepath)
except Exception as e:
print(f"Error deleting old preview image: {e}")
existing_template['title'] = title
existing_template['prompt'] = prompt
existing_template['mode'] = mode
existing_template['category'] = category
if preview_path:
existing_template['preview'] = preview_path
existing_template['tags'] = tags
builtin_prompts[builtin_index] = existing_template
with open(prompts_path, 'w', encoding='utf-8') as f:
json.dump(builtin_prompts, f, indent=4, ensure_ascii=False)
existing_template['builtinTemplateIndex'] = builtin_index
return jsonify({'success': True, 'template': existing_template})
# Fallback to user template update
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
user_prompts = []
@ -514,41 +651,34 @@ def update_template():
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:
if preview_path and 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
'preview': preview_path,
'tags': tags
}
# 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)
user_prompts[template_index]['isUserTemplate'] = True
user_prompts[template_index]['userTemplateIndex'] = template_index
return jsonify({'success': True, 'template': user_prompts[template_index]})
except Exception as e:

View file

@ -10,30 +10,123 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
let currentCategory = 'all';
let currentMode = 'all';
let searchQuery = '';
let favoriteTemplateKeys = new Set();
let favoriteFilterActive = false;
let userTemplateFilterActive = false;
function setFavoriteKeys(keys) {
if (Array.isArray(keys)) {
favoriteTemplateKeys = new Set(keys.filter(Boolean));
} else {
favoriteTemplateKeys = new Set();
}
}
function getTemplateKey(template) {
if (!template) return '';
if (template.userTemplateIndex !== undefined && template.userTemplateIndex !== null) {
return `user-${template.userTemplateIndex}`;
}
const title = i18n.getText(template.title);
const promptText = i18n.getText(template.prompt);
const categoryText = i18n.getText(template.category);
const modeText = template.mode || 'generate';
return `builtin-${title}||${promptText}||${categoryText}||${modeText}`;
}
function isFavoriteKey(key) {
return Boolean(key) && favoriteTemplateKeys.has(key);
}
async function updateFavoriteState(key, favorite) {
if (!key || typeof favorite !== 'boolean') return;
/**
* Fetch templates from API
*/
async function load() {
try {
const response = await fetch('/prompts');
const response = await fetch('/template_favorite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, favorite }),
});
const data = await response.json();
if (data.prompts) {
allTemplates = data.prompts;
render();
return true;
if (!response.ok) {
throw new Error(data.error || 'Failed to update favorite');
}
return false;
setFavoriteKeys(data.favorites);
} catch (error) {
console.error('Failed to load templates:', error);
return false;
throw error;
}
}
/**
* Get unique categories from templates
*/
function resolveTagText(tag) {
if (!tag) return '';
return i18n.getText(tag);
}
function getTemplateTags(template) {
if (!template) return [];
const rawTags = template.tags;
if (!rawTags) return [];
let normalized = [];
if (Array.isArray(rawTags)) {
normalized = rawTags;
} else if (typeof rawTags === 'string') {
normalized = rawTags.split(',');
} else {
return [];
}
return normalized
.map(tag => resolveTagText(tag).trim())
.filter(Boolean);
}
function filterTemplates() {
let filtered = allTemplates;
if (currentCategory !== 'all') {
filtered = filtered.filter(t => {
const categoryText = i18n.getText(t.category);
return categoryText === currentCategory;
});
}
if (currentMode !== 'all') {
filtered = filtered.filter(t => {
return (t.mode || 'generate') === currentMode;
});
}
if (favoriteFilterActive) {
filtered = filtered.filter(t => {
const key = getTemplateKey(t);
return isFavoriteKey(key);
});
}
if (userTemplateFilterActive) {
filtered = filtered.filter(t => Boolean(t.isUserTemplate));
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(t => {
const title = i18n.getText(t.title).toLowerCase();
const prompt = i18n.getText(t.prompt).toLowerCase();
const category = i18n.getText(t.category).toLowerCase();
const tagText = getTemplateTags(t).join(' ').toLowerCase();
return title.includes(query) ||
prompt.includes(query) ||
category.includes(query) ||
tagText.includes(query);
});
}
return filtered;
}
function getCategories() {
const categories = new Set();
allTemplates.forEach(t => {
@ -45,53 +138,12 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
return Array.from(categories).sort();
}
/**
* Filter templates based on category and search
*/
function filterTemplates() {
let filtered = allTemplates;
// Filter by category
if (currentCategory !== 'all') {
filtered = filtered.filter(t => {
const categoryText = i18n.getText(t.category);
return categoryText === currentCategory;
});
}
// Filter by mode
if (currentMode !== 'all') {
filtered = filtered.filter(t => {
return (t.mode || 'generate') === currentMode;
});
}
// Filter by search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(t => {
const title = i18n.getText(t.title).toLowerCase();
const prompt = i18n.getText(t.prompt).toLowerCase();
const category = i18n.getText(t.category).toLowerCase();
return title.includes(query) ||
prompt.includes(query) ||
category.includes(query);
});
}
return filtered;
}
/**
* Create a template card element
*/
function createTemplateCard(template) {
const card = document.createElement('div');
card.className = 'template-card';
card.setAttribute('data-category', i18n.getText(template.category) || '');
card.setAttribute('data-mode', template.mode || 'generate');
// Preview image
const preview = document.createElement('div');
preview.className = 'template-card-preview';
if (template.preview) {
@ -99,14 +151,42 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
img.src = template.preview;
img.alt = i18n.getText(template.title) || 'Template preview';
img.loading = 'lazy';
img.onerror = function() {
img.onerror = function () {
this.onerror = null;
this.src = '/static/eror.png';
};
preview.appendChild(img);
}
// Edit button (show on all templates)
const previewActions = document.createElement('div');
previewActions.className = 'template-card-preview-actions';
const templateKey = getTemplateKey(template);
const isFavorite = isFavoriteKey(templateKey);
const favoriteBtn = document.createElement('button');
favoriteBtn.type = 'button';
favoriteBtn.className = `template-card-favorite-btn${isFavorite ? ' active' : ''}`;
favoriteBtn.setAttribute('aria-pressed', isFavorite ? 'true' : 'false');
favoriteBtn.title = isFavorite ? 'Remove from favorites' : 'Mark as favorite';
favoriteBtn.innerHTML = `
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.5 4.5 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.5 3 22 5.5 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>`;
favoriteBtn.addEventListener('click', async (event) => {
event.stopPropagation();
favoriteBtn.disabled = true;
try {
await updateFavoriteState(templateKey, !isFavorite);
render();
} catch (error) {
console.error('Failed to update favorite:', error);
favoriteBtn.disabled = false;
}
});
previewActions.appendChild(favoriteBtn);
if (template.isUserTemplate || template.builtinTemplateIndex !== undefined) {
const editBtn = document.createElement('button');
editBtn.className = 'template-edit-btn';
editBtn.innerHTML = `
@ -122,15 +202,15 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
window.openEditTemplateModal(template);
}
});
preview.appendChild(editBtn);
previewActions.appendChild(editBtn);
}
preview.appendChild(previewActions);
card.appendChild(preview);
// Content
const content = document.createElement('div');
content.className = 'template-card-content';
// Title
const title = document.createElement('h4');
title.className = 'template-card-title';
title.textContent = i18n.getText(template.title) || 'Untitled Template';
@ -138,7 +218,6 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
card.appendChild(content);
// Click handler
card.addEventListener('click', () => {
onSelectTemplate?.(template);
});
@ -146,9 +225,24 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
return card;
}
/**
* Render the gallery
*/
async function load() {
try {
const response = await fetch('/prompts');
const data = await response.json();
if (data.prompts) {
allTemplates = data.prompts;
setFavoriteKeys(data.favorites);
render();
return true;
}
return false;
} catch (error) {
console.error('Failed to load templates:', error);
return false;
}
}
function render() {
if (!container) return;
@ -157,21 +251,17 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
container.innerHTML = '';
// Create header with controls
const header = document.createElement('div');
header.className = 'template-gallery-header';
// Title
const title = document.createElement('h2');
title.className = 'template-gallery-title';
title.textContent = i18n.t('promptTemplates');
header.appendChild(title);
// Controls container
const controls = document.createElement('div');
controls.className = 'template-gallery-controls';
// Search input
const searchContainer = document.createElement('div');
searchContainer.className = 'template-search-container';
const searchInput = document.createElement('input');
@ -180,7 +270,6 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
searchInput.placeholder = i18n.t('searchPlaceholder');
searchInput.value = searchQuery;
// Only search on Enter
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
searchQuery = e.target.value;
@ -188,7 +277,6 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
}
});
// Also update on blur to ensure value is captured if user clicks away
searchInput.addEventListener('blur', (e) => {
if (searchQuery !== e.target.value) {
searchQuery = e.target.value;
@ -199,23 +287,19 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
searchContainer.appendChild(searchInput);
controls.appendChild(searchContainer);
// Mode filter
const modeSelect = document.createElement('select');
modeSelect.className = 'template-mode-select';
const modes = [
{ value: 'all', label: 'All Modes' },
{ value: 'edit', label: 'Edit' },
{ value: 'generate', label: 'Generate' }
];
modes.forEach(mode => {
const option = document.createElement('option');
option.value = mode.value;
option.textContent = mode.label;
modeSelect.appendChild(option);
});
modeSelect.value = currentMode;
modeSelect.addEventListener('change', (e) => {
currentMode = e.target.value;
@ -223,22 +307,18 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
});
controls.appendChild(modeSelect);
// Category filter
const categorySelect = document.createElement('select');
categorySelect.className = 'template-category-select';
const allOption = document.createElement('option');
allOption.value = 'all';
allOption.textContent = i18n.t('allCategories');
categorySelect.appendChild(allOption);
categories.forEach(cat => {
const option = document.createElement('option');
option.value = cat;
option.textContent = cat;
categorySelect.appendChild(option);
});
categorySelect.value = currentCategory;
categorySelect.addEventListener('change', (e) => {
currentCategory = e.target.value;
@ -246,14 +326,48 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
});
controls.appendChild(categorySelect);
// Create Template button
const favoritesToggle = document.createElement('button');
favoritesToggle.type = 'button';
favoritesToggle.className = `template-favorites-toggle${favoriteFilterActive ? ' active' : ''}`;
favoritesToggle.setAttribute('aria-pressed', favoriteFilterActive ? 'true' : 'false');
favoritesToggle.setAttribute('aria-label', favoriteFilterActive ? 'Show all templates' : 'Show favorites only');
favoritesToggle.title = favoriteFilterActive ? 'Show all templates' : 'Show favorites only';
favoritesToggle.innerHTML = `
<span class="template-favorites-toggle-icon" aria-hidden="true">
<svg viewBox="0 0 24 20" width="16" height="16" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.5 4.5 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.5 3 22 5.5 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
</span>
`;
favoritesToggle.addEventListener('click', () => {
favoriteFilterActive = !favoriteFilterActive;
render();
});
controls.appendChild(favoritesToggle);
const userToggle = document.createElement('button');
userToggle.type = 'button';
userToggle.className = `template-user-toggle${userTemplateFilterActive ? ' active' : ''}`;
userToggle.setAttribute('aria-pressed', userTemplateFilterActive ? 'true' : 'false');
userToggle.setAttribute('aria-label', userTemplateFilterActive ? 'Show all templates' : 'Show my templates');
userToggle.title = userTemplateFilterActive ? 'Show all templates' : 'Show my templates';
userToggle.innerHTML = `
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
</svg>
`;
userToggle.addEventListener('click', () => {
userTemplateFilterActive = !userTemplateFilterActive;
render();
});
controls.appendChild(userToggle);
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) {
@ -265,16 +379,13 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
header.appendChild(controls);
container.appendChild(header);
// Results count
const count = document.createElement('div');
count.className = 'template-results-count';
count.textContent = i18n.t('resultsCount', filtered.length);
container.appendChild(count);
// Create grid
const grid = document.createElement('div');
grid.className = 'template-card-grid';
if (filtered.length === 0) {
const empty = document.createElement('div');
empty.className = 'template-empty-state';
@ -289,28 +400,18 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
container.appendChild(grid);
}
/**
* Show the gallery
*/
function show() {
return {
load,
render,
show() {
if (container) {
container.classList.remove('hidden');
}
}
/**
* Hide the gallery
*/
function hide() {
},
hide() {
if (container) {
container.classList.add('hidden');
}
}
return {
load,
render,
show,
hide
};
}

View file

@ -333,14 +333,101 @@ document.addEventListener('DOMContentLoaded', () => {
const templatePreviewDropzone = document.getElementById('template-preview-dropzone');
const templatePreviewImg = document.getElementById('template-preview-img');
const dropzonePlaceholder = document.querySelector('.dropzone-placeholder');
const templateTagList = document.getElementById('template-tag-list');
const templateTagInput = document.getElementById('template-tags-input');
let currentPreviewFile = null;
let currentPreviewUrl = null;
let editingTemplate = null; // Track if we're editing an existing template
let editingTemplateSource = null;
let editingBuiltinIndex = null;
const TEMPLATE_TAG_LIMIT = 8;
let templateTags = [];
function normalizeRawTags(raw) {
if (!raw) return [];
if (Array.isArray(raw)) {
return raw.map(tag => typeof tag === 'string' ? tag.trim() : '').filter(Boolean);
}
if (typeof raw === 'string') {
return raw.split(',').map(tag => tag.trim()).filter(Boolean);
}
return [];
}
function renderTemplateTags() {
if (!templateTagList) return;
templateTagList.innerHTML = '';
templateTags.forEach(tag => {
const chip = document.createElement('span');
chip.className = 'template-tag-chip';
const textSpan = document.createElement('span');
textSpan.className = 'template-tag-chip-text';
textSpan.textContent = tag;
chip.appendChild(textSpan);
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'template-tag-remove';
removeBtn.setAttribute('aria-label', `Remove ${tag}`);
removeBtn.innerHTML = '&times;';
removeBtn.addEventListener('click', () => {
removeTemplateTag(tag);
});
chip.appendChild(removeBtn);
templateTagList.appendChild(chip);
});
}
function setTemplateTags(raw) {
const normalized = normalizeRawTags(raw);
templateTags = normalized.slice(0, TEMPLATE_TAG_LIMIT);
renderTemplateTags();
}
function addTemplateTag(value) {
if (!value) return;
const normalized = value.trim();
if (!normalized || templateTags.length >= TEMPLATE_TAG_LIMIT) return;
const exists = templateTags.some(tag => tag.toLowerCase() === normalized.toLowerCase());
if (exists) return;
templateTags = [...templateTags, normalized];
renderTemplateTags();
}
function removeTemplateTag(tagToRemove) {
templateTags = templateTags.filter(tag => tag.toLowerCase() !== tagToRemove.toLowerCase());
renderTemplateTags();
}
function flushTemplateTagInput() {
if (!templateTagInput) return;
const raw = templateTagInput.value;
if (!raw.trim()) return;
const parts = raw.split(',').map(part => part.trim()).filter(Boolean);
parts.forEach(part => addTemplateTag(part));
templateTagInput.value = '';
}
if (templateTagInput) {
templateTagInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault();
flushTemplateTagInput();
}
});
templateTagInput.addEventListener('blur', () => {
flushTemplateTagInput();
});
}
// Global function for opening edit modal (called from templateGallery.js)
window.openEditTemplateModal = async function(template) {
editingTemplate = template;
editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin';
editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null;
// Pre-fill with template data
templateTitleInput.value = template.title || '';
@ -403,6 +490,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
currentPreviewFile = null;
setTemplateTags(template.tags || []);
if (templateTagInput) {
templateTagInput.value = '';
}
// Update button text
saveTemplateBtn.innerHTML = '<span>Update Template</span><div class="btn-shine"></div>';
@ -412,6 +504,13 @@ document.addEventListener('DOMContentLoaded', () => {
// Global function for opening create modal with empty values (called from templateGallery.js)
window.openCreateTemplateModal = async function() {
editingTemplate = null;
editingTemplateSource = 'user';
editingBuiltinIndex = null;
setTemplateTags([]);
if (templateTagInput) {
templateTagInput.value = '';
}
// Clear all fields
templateTitleInput.value = '';
@ -477,6 +576,8 @@ document.addEventListener('DOMContentLoaded', () => {
createTemplateBtn.addEventListener('click', async () => {
// Reset editing state
editingTemplate = null;
editingTemplateSource = 'user';
editingBuiltinIndex = null;
// Pre-fill data
templateTitleInput.value = '';
@ -554,6 +655,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
currentPreviewFile = null;
setTemplateTags([]);
if (templateTagInput) {
templateTagInput.value = '';
}
// Update button text
saveTemplateBtn.innerHTML = '<span>Save Template</span><div class="btn-shine"></div>';
@ -564,6 +670,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (closeTemplateModalBtn) {
closeTemplateModalBtn.addEventListener('click', () => {
createTemplateModal.classList.add('hidden');
editingTemplate = null;
editingTemplateSource = null;
editingBuiltinIndex = null;
});
}
@ -708,6 +817,7 @@ document.addEventListener('DOMContentLoaded', () => {
formData.append('prompt', prompt);
formData.append('mode', mode);
formData.append('category', category);
formData.append('tags', JSON.stringify(templateTags));
if (currentPreviewFile) {
formData.append('preview', currentPreviewFile);
@ -718,7 +828,11 @@ document.addEventListener('DOMContentLoaded', () => {
// If editing, add the template index
const endpoint = editingTemplate ? '/update_template' : '/save_template';
if (editingTemplate) {
if (editingTemplateSource === 'user') {
formData.append('template_index', editingTemplate.userTemplateIndex);
} else if (editingTemplateSource === 'builtin' && editingBuiltinIndex !== null) {
formData.append('builtin_index', editingBuiltinIndex);
}
}
const response = await fetch(endpoint, {
@ -740,6 +854,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Reset editing state
editingTemplate = null;
editingTemplateSource = null;
editingBuiltinIndex = null;
} catch (error) {
alert(`Failed to ${editingTemplate ? 'update' : 'save'} template: ` + error.message);
@ -755,6 +871,9 @@ document.addEventListener('DOMContentLoaded', () => {
createTemplateModal.addEventListener('click', (e) => {
if (e.target === createTemplateModal) {
createTemplateModal.classList.add('hidden');
editingTemplate = null;
editingTemplateSource = null;
editingBuiltinIndex = null;
}
});
}

View file

@ -270,7 +270,7 @@ select {
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.15);
background-color: rgba(6, 7, 20, 0.95);
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.3), 0 10px 25px rgba(0, 0, 0, 0.25);
/* box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.3), 0 10px 25px rgba(0, 0, 0, 0.25); */
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
@ -1043,6 +1043,61 @@ button#generate-btn:disabled {
position: relative;
}
.template-card-preview-actions {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
z-index: 5;
opacity: 0;
transition: opacity 0.2s ease;
}
.template-card-preview-actions button {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(0, 0, 0, 0.6);
color: var(--text-primary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s, border 0.2s, background 0.2s;
}
.template-card-preview-actions button:hover {
transform: scale(1.05);
}
.template-card-favorite-btn {
color: var(--accent-color);
border-color: rgba(251, 191, 36, 0.45);
background: rgba(0, 0, 0, 0.75);
}
.template-card-favorite-btn.active {
background: rgba(251, 191, 36, 0.9);
color: #0a0f32;
border-color: transparent;
}
.template-card-favorite-btn svg {
display: block;
}
.template-card:hover .template-card-preview-actions {
opacity: 1;
}
.template-card-preview-actions button {
width: 28px;
height: 28px;
}
.template-card-preview img {
width: 100%;
height: 100%;
@ -1281,6 +1336,76 @@ button#generate-btn:disabled {
margin-left: 0.25rem;
}
.form-hint {
font-size: 0.7rem;
color: var(--text-secondary);
margin-left: 0.25rem;
}
.template-tag-control {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
min-height: 44px;
padding: 0.35rem 0.75rem;
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.02);
}
.template-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
flex: 1 1 auto;
min-height: 26px;
}
.template-tag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.15rem 0.6rem;
border-radius: 999px;
background: rgba(251, 191, 36, 0.15);
color: var(--accent-color);
font-size: 0.75rem;
border: 1px solid rgba(251, 191, 36, 0.4);
}
.template-tag-chip-text {
line-height: 1.2;
}
.template-tag-remove {
border: none;
background: transparent;
color: inherit;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
line-height: 1;
}
.template-tag-control input {
flex: 1 1 150px;
min-width: 120px;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 0.85rem;
padding: 0.2rem 0;
}
.template-tag-control input:focus {
outline: none;
}
.template-preview-dropzone {
width: 100%;
height: 220px;
@ -1390,33 +1515,24 @@ button#generate-btn:disabled {
}
.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);
background: rgba(0, 0, 0, 0.75);
border: 1px solid rgba(255, 255, 255, 0.25);
color: var(--text-primary);
display: flex;
display: inline-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;
transition: transform 0.2s, border 0.2s, background 0.2s;
}
.template-edit-btn:hover {
background: var(--accent-color);
color: #000;
border-color: var(--accent-color);
transform: scale(1.1);
transform: scale(1.05);
}
.template-edit-btn svg {
@ -1425,11 +1541,13 @@ button#generate-btn:disabled {
}
.template-create-btn {
width:43.5px;
height: 43.5px;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
color: #000;
border: none;
border-radius: 0.5rem;
border-radius: 0.85rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
@ -1448,3 +1566,56 @@ button#generate-btn:disabled {
width: 16px;
height: 16px;
}
.template-favorites-toggle {
width: 43.5px;
height: 43.5px;
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.55);
color: var(--text-primary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
}
.template-favorites-toggle.active {
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
color: #0a0f32;
box-shadow: 0 0 12px rgba(251, 191, 36, 0.4);
}
.template-favorites-toggle:hover {
transform: translateY(-1px) scale(1.02);
}
.template-favorites-toggle-icon svg {
width: 20px;
height: 20px;
}
.template-user-toggle {
width: 43.5px;
height: 43.5px;
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.55);
color: var(--text-primary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;}
.template-user-toggle.active {
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
color: #0a0f32;
box-shadow: 0 0 12px rgba(251, 191, 36, 0.4);
}
.template-user-toggle svg {
width: 18px;
height: 18px;
}

View file

@ -157,7 +157,7 @@
</svg>
</button>
<button type="button" id="create-template-btn" class="canvas-btn icon-btn"
aria-label="Create Template" title="Tạo Template">
aria-label="Create Template" title="Create 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"
@ -289,6 +289,16 @@
</div>
</div>
</div>
<div class="form-group">
<label for="template-tags-input">Tags</label>
<div class="template-tag-control">
<div id="template-tag-list" class="template-tag-list" aria-live="polite"></div>
<input type="text" id="template-tags-input"
placeholder="Add keywords and press Enter (max 8)">
</div>
<p class="form-hint">Use short keywords to surface this template in searches.</p>
</div>
</div>
<div class="controls-footer" style="margin-top: 1.5rem; justify-content: flex-end;">