update ver
This commit is contained in:
parent
8b476165ec
commit
2a0d6c35ae
6 changed files with 732 additions and 200 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@
|
||||||
.DS_Store
|
.DS_Store
|
||||||
user_prompts.json
|
user_prompts.json
|
||||||
/static/preview
|
/static/preview
|
||||||
|
template_favorites.json
|
||||||
|
|
|
||||||
188
app.py
188
app.py
|
|
@ -14,6 +14,62 @@ from PIL import Image, PngImagePlugin
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
|
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
|
# Ensure generated directory exists inside Flask static folder
|
||||||
GENERATED_DIR = os.path.join(app.static_folder, 'generated')
|
GENERATED_DIR = os.path.join(app.static_folder, 'generated')
|
||||||
os.makedirs(GENERATED_DIR, exist_ok=True)
|
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')
|
prompts_path = os.path.join(os.path.dirname(__file__), 'prompts.json')
|
||||||
if os.path.exists(prompts_path):
|
if os.path.exists(prompts_path):
|
||||||
with open(prompts_path, 'r', encoding='utf-8') as f:
|
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
|
# Read user_prompts.json file and mark as user templates
|
||||||
user_prompts_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
|
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):
|
for idx, template in enumerate(user_prompts):
|
||||||
template['isUserTemplate'] = True
|
template['isUserTemplate'] = True
|
||||||
template['userTemplateIndex'] = idx
|
template['userTemplateIndex'] = idx
|
||||||
|
template['tags'] = parse_tags_field(template.get('tags'))
|
||||||
all_prompts.extend(user_prompts)
|
all_prompts.extend(user_prompts)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass # Ignore if empty or invalid
|
pass # Ignore if empty or invalid
|
||||||
|
|
@ -280,12 +345,34 @@ def get_prompts():
|
||||||
if category:
|
if category:
|
||||||
all_prompts = [p for p in all_prompts if p.get('category') == 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"
|
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('/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'])
|
@app.route('/save_template', methods=['POST'])
|
||||||
def save_template():
|
def save_template():
|
||||||
try:
|
try:
|
||||||
|
|
@ -297,6 +384,8 @@ def save_template():
|
||||||
prompt = request.form.get('prompt')
|
prompt = request.form.get('prompt')
|
||||||
mode = request.form.get('mode', 'generate')
|
mode = request.form.get('mode', 'generate')
|
||||||
category = request.form.get('category', 'User')
|
category = request.form.get('category', 'User')
|
||||||
|
tags_field = request.form.get('tags')
|
||||||
|
tags = parse_tags_field(tags_field)
|
||||||
|
|
||||||
if not title or not prompt:
|
if not title or not prompt:
|
||||||
return jsonify({'error': 'Title and prompt are required'}), 400
|
return jsonify({'error': 'Title and prompt are required'}), 400
|
||||||
|
|
@ -383,7 +472,8 @@ def save_template():
|
||||||
'prompt': prompt,
|
'prompt': prompt,
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'category': category,
|
'category': category,
|
||||||
'preview': preview_path
|
'preview': preview_path,
|
||||||
|
'tags': tags
|
||||||
}
|
}
|
||||||
|
|
||||||
# Save to user_prompts.json
|
# Save to user_prompts.json
|
||||||
|
|
@ -416,42 +506,48 @@ def update_template():
|
||||||
import requests
|
import requests
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
# Get template index
|
|
||||||
template_index = request.form.get('template_index')
|
template_index = request.form.get('template_index')
|
||||||
if template_index is None:
|
builtin_index_raw = request.form.get('builtin_index')
|
||||||
return jsonify({'error': 'Template index is required'}), 400
|
builtin_index = None
|
||||||
|
|
||||||
template_index = int(template_index)
|
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')
|
title = request.form.get('title')
|
||||||
prompt = request.form.get('prompt')
|
prompt = request.form.get('prompt')
|
||||||
mode = request.form.get('mode', 'generate')
|
mode = request.form.get('mode', 'generate')
|
||||||
category = request.form.get('category', 'User')
|
category = request.form.get('category', 'User')
|
||||||
|
tags_field = request.form.get('tags')
|
||||||
|
tags = parse_tags_field(tags_field)
|
||||||
|
|
||||||
if not title or not prompt:
|
if not title or not prompt:
|
||||||
return jsonify({'error': 'Title and prompt are required'}), 400
|
return jsonify({'error': 'Title and prompt are required'}), 400
|
||||||
|
|
||||||
# Handle preview image (same logic as save_template)
|
|
||||||
preview_path = None
|
preview_path = None
|
||||||
preview_dir = os.path.join(app.static_folder, 'preview')
|
preview_dir = os.path.join(app.static_folder, 'preview')
|
||||||
os.makedirs(preview_dir, exist_ok=True)
|
os.makedirs(preview_dir, exist_ok=True)
|
||||||
|
|
||||||
# Check if file was uploaded
|
|
||||||
if 'preview' in request.files:
|
if 'preview' in request.files:
|
||||||
file = request.files['preview']
|
file = request.files['preview']
|
||||||
if file.filename:
|
if file.filename:
|
||||||
ext = os.path.splitext(file.filename)[1]
|
ext = os.path.splitext(file.filename)[1] or '.png'
|
||||||
if not ext:
|
|
||||||
ext = '.png'
|
|
||||||
|
|
||||||
filename = f"template_{uuid.uuid4()}{ext}"
|
filename = f"template_{uuid.uuid4()}{ext}"
|
||||||
filepath = os.path.join(preview_dir, filename)
|
filepath = os.path.join(preview_dir, filename)
|
||||||
file.save(filepath)
|
file.save(filepath)
|
||||||
|
|
||||||
preview_path = url_for('static', filename=f'preview/{filename}')
|
preview_path = url_for('static', filename=f'preview/{filename}')
|
||||||
|
|
||||||
# If no file uploaded, check if URL/path provided
|
|
||||||
if not preview_path:
|
if not preview_path:
|
||||||
preview_url = request.form.get('preview_path')
|
preview_url = request.form.get('preview_path')
|
||||||
if preview_url:
|
if preview_url:
|
||||||
|
|
@ -501,7 +597,48 @@ def update_template():
|
||||||
print(f"Error processing preview image URL: {e}")
|
print(f"Error processing preview image URL: {e}")
|
||||||
preview_path = preview_url
|
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_path = os.path.join(os.path.dirname(__file__), 'user_prompts.json')
|
||||||
user_prompts = []
|
user_prompts = []
|
||||||
|
|
||||||
|
|
@ -514,41 +651,34 @@ def update_template():
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check if index is valid
|
|
||||||
if template_index < 0 or template_index >= len(user_prompts):
|
if template_index < 0 or template_index >= len(user_prompts):
|
||||||
return jsonify({'error': 'Invalid template index'}), 400
|
return jsonify({'error': 'Invalid template index'}), 400
|
||||||
|
|
||||||
# Get old template to check for old preview image
|
|
||||||
old_template = user_prompts[template_index]
|
old_template = user_prompts[template_index]
|
||||||
old_preview = old_template.get('preview', '')
|
old_preview = old_template.get('preview', '')
|
||||||
|
if preview_path and old_preview and '/preview/' in old_preview:
|
||||||
# Delete old preview image if it exists in the preview folder
|
|
||||||
if old_preview and '/preview/' in old_preview:
|
|
||||||
try:
|
try:
|
||||||
# Extract filename from URL
|
|
||||||
old_filename = old_preview.split('/preview/')[-1]
|
old_filename = old_preview.split('/preview/')[-1]
|
||||||
old_filepath = os.path.join(preview_dir, old_filename)
|
old_filepath = os.path.join(preview_dir, old_filename)
|
||||||
|
|
||||||
# Delete old file if it exists
|
|
||||||
if os.path.exists(old_filepath):
|
if os.path.exists(old_filepath):
|
||||||
os.remove(old_filepath)
|
os.remove(old_filepath)
|
||||||
print(f"Deleted old preview image: {old_filepath}")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error deleting old preview image: {e}")
|
print(f"Error deleting old preview image: {e}")
|
||||||
|
|
||||||
# Update the template
|
|
||||||
user_prompts[template_index] = {
|
user_prompts[template_index] = {
|
||||||
'title': title,
|
'title': title,
|
||||||
'prompt': prompt,
|
'prompt': prompt,
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'category': category,
|
'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:
|
with open(user_prompts_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(user_prompts, f, indent=4, ensure_ascii=False)
|
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]})
|
return jsonify({'success': True, 'template': user_prompts[template_index]})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -10,30 +10,123 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
let currentCategory = 'all';
|
let currentCategory = 'all';
|
||||||
let currentMode = 'all';
|
let currentMode = 'all';
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
|
let favoriteTemplateKeys = new Set();
|
||||||
|
let favoriteFilterActive = false;
|
||||||
|
let userTemplateFilterActive = false;
|
||||||
|
|
||||||
/**
|
function setFavoriteKeys(keys) {
|
||||||
* Fetch templates from API
|
if (Array.isArray(keys)) {
|
||||||
*/
|
favoriteTemplateKeys = new Set(keys.filter(Boolean));
|
||||||
async function load() {
|
} else {
|
||||||
try {
|
favoriteTemplateKeys = new Set();
|
||||||
const response = await fetch('/prompts');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.prompts) {
|
|
||||||
allTemplates = data.prompts;
|
|
||||||
render();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load templates:', error);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getTemplateKey(template) {
|
||||||
* Get unique categories from templates
|
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/template_favorite', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key, favorite }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to update favorite');
|
||||||
|
}
|
||||||
|
setFavoriteKeys(data.favorites);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function getCategories() {
|
||||||
const categories = new Set();
|
const categories = new Set();
|
||||||
allTemplates.forEach(t => {
|
allTemplates.forEach(t => {
|
||||||
|
|
@ -45,53 +138,12 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
return Array.from(categories).sort();
|
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) {
|
function createTemplateCard(template) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'template-card';
|
card.className = 'template-card';
|
||||||
card.setAttribute('data-category', i18n.getText(template.category) || '');
|
card.setAttribute('data-category', i18n.getText(template.category) || '');
|
||||||
card.setAttribute('data-mode', template.mode || 'generate');
|
card.setAttribute('data-mode', template.mode || 'generate');
|
||||||
|
|
||||||
// Preview image
|
|
||||||
const preview = document.createElement('div');
|
const preview = document.createElement('div');
|
||||||
preview.className = 'template-card-preview';
|
preview.className = 'template-card-preview';
|
||||||
if (template.preview) {
|
if (template.preview) {
|
||||||
|
|
@ -99,38 +151,66 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
img.src = template.preview;
|
img.src = template.preview;
|
||||||
img.alt = i18n.getText(template.title) || 'Template preview';
|
img.alt = i18n.getText(template.title) || 'Template preview';
|
||||||
img.loading = 'lazy';
|
img.loading = 'lazy';
|
||||||
img.onerror = function() {
|
img.onerror = function () {
|
||||||
this.onerror = null;
|
this.onerror = null;
|
||||||
this.src = '/static/eror.png';
|
this.src = '/static/eror.png';
|
||||||
};
|
};
|
||||||
preview.appendChild(img);
|
preview.appendChild(img);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit button (show on all templates)
|
const previewActions = document.createElement('div');
|
||||||
const editBtn = document.createElement('button');
|
previewActions.className = 'template-card-preview-actions';
|
||||||
editBtn.className = 'template-edit-btn';
|
|
||||||
editBtn.innerHTML = `
|
const templateKey = getTemplateKey(template);
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
const isFavorite = isFavoriteKey(templateKey);
|
||||||
<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"/>
|
const favoriteBtn = document.createElement('button');
|
||||||
</svg>
|
favoriteBtn.type = 'button';
|
||||||
`;
|
favoriteBtn.className = `template-card-favorite-btn${isFavorite ? ' active' : ''}`;
|
||||||
editBtn.title = 'Edit Template';
|
favoriteBtn.setAttribute('aria-pressed', isFavorite ? 'true' : 'false');
|
||||||
editBtn.addEventListener('click', (e) => {
|
favoriteBtn.title = isFavorite ? 'Remove from favorites' : 'Mark as favorite';
|
||||||
e.stopPropagation();
|
favoriteBtn.innerHTML = `
|
||||||
if (window.openEditTemplateModal) {
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||||
window.openEditTemplateModal(template);
|
<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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
preview.appendChild(editBtn);
|
previewActions.appendChild(favoriteBtn);
|
||||||
|
|
||||||
|
if (template.isUserTemplate || template.builtinTemplateIndex !== undefined) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
previewActions.appendChild(editBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.appendChild(previewActions);
|
||||||
card.appendChild(preview);
|
card.appendChild(preview);
|
||||||
|
|
||||||
// Content
|
|
||||||
const content = document.createElement('div');
|
const content = document.createElement('div');
|
||||||
content.className = 'template-card-content';
|
content.className = 'template-card-content';
|
||||||
|
|
||||||
// Title
|
|
||||||
const title = document.createElement('h4');
|
const title = document.createElement('h4');
|
||||||
title.className = 'template-card-title';
|
title.className = 'template-card-title';
|
||||||
title.textContent = i18n.getText(template.title) || 'Untitled Template';
|
title.textContent = i18n.getText(template.title) || 'Untitled Template';
|
||||||
|
|
@ -138,7 +218,6 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
|
|
||||||
card.appendChild(content);
|
card.appendChild(content);
|
||||||
|
|
||||||
// Click handler
|
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
onSelectTemplate?.(template);
|
onSelectTemplate?.(template);
|
||||||
});
|
});
|
||||||
|
|
@ -146,9 +225,24 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function load() {
|
||||||
* Render the gallery
|
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() {
|
function render() {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -157,21 +251,17 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Create header with controls
|
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'template-gallery-header';
|
header.className = 'template-gallery-header';
|
||||||
|
|
||||||
// Title
|
|
||||||
const title = document.createElement('h2');
|
const title = document.createElement('h2');
|
||||||
title.className = 'template-gallery-title';
|
title.className = 'template-gallery-title';
|
||||||
title.textContent = i18n.t('promptTemplates');
|
title.textContent = i18n.t('promptTemplates');
|
||||||
header.appendChild(title);
|
header.appendChild(title);
|
||||||
|
|
||||||
// Controls container
|
|
||||||
const controls = document.createElement('div');
|
const controls = document.createElement('div');
|
||||||
controls.className = 'template-gallery-controls';
|
controls.className = 'template-gallery-controls';
|
||||||
|
|
||||||
// Search input
|
|
||||||
const searchContainer = document.createElement('div');
|
const searchContainer = document.createElement('div');
|
||||||
searchContainer.className = 'template-search-container';
|
searchContainer.className = 'template-search-container';
|
||||||
const searchInput = document.createElement('input');
|
const searchInput = document.createElement('input');
|
||||||
|
|
@ -180,7 +270,6 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
searchInput.placeholder = i18n.t('searchPlaceholder');
|
searchInput.placeholder = i18n.t('searchPlaceholder');
|
||||||
searchInput.value = searchQuery;
|
searchInput.value = searchQuery;
|
||||||
|
|
||||||
// Only search on Enter
|
|
||||||
searchInput.addEventListener('keydown', (e) => {
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
searchQuery = e.target.value;
|
searchQuery = e.target.value;
|
||||||
|
|
@ -188,34 +277,29 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also update on blur to ensure value is captured if user clicks away
|
|
||||||
searchInput.addEventListener('blur', (e) => {
|
searchInput.addEventListener('blur', (e) => {
|
||||||
if (searchQuery !== e.target.value) {
|
if (searchQuery !== e.target.value) {
|
||||||
searchQuery = e.target.value;
|
searchQuery = e.target.value;
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
searchContainer.appendChild(searchInput);
|
searchContainer.appendChild(searchInput);
|
||||||
controls.appendChild(searchContainer);
|
controls.appendChild(searchContainer);
|
||||||
|
|
||||||
// Mode filter
|
|
||||||
const modeSelect = document.createElement('select');
|
const modeSelect = document.createElement('select');
|
||||||
modeSelect.className = 'template-mode-select';
|
modeSelect.className = 'template-mode-select';
|
||||||
|
|
||||||
const modes = [
|
const modes = [
|
||||||
{ value: 'all', label: 'All Modes' },
|
{ value: 'all', label: 'All Modes' },
|
||||||
{ value: 'edit', label: 'Edit' },
|
{ value: 'edit', label: 'Edit' },
|
||||||
{ value: 'generate', label: 'Generate' }
|
{ value: 'generate', label: 'Generate' }
|
||||||
];
|
];
|
||||||
|
|
||||||
modes.forEach(mode => {
|
modes.forEach(mode => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = mode.value;
|
option.value = mode.value;
|
||||||
option.textContent = mode.label;
|
option.textContent = mode.label;
|
||||||
modeSelect.appendChild(option);
|
modeSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
modeSelect.value = currentMode;
|
modeSelect.value = currentMode;
|
||||||
modeSelect.addEventListener('change', (e) => {
|
modeSelect.addEventListener('change', (e) => {
|
||||||
currentMode = e.target.value;
|
currentMode = e.target.value;
|
||||||
|
|
@ -223,22 +307,18 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
});
|
});
|
||||||
controls.appendChild(modeSelect);
|
controls.appendChild(modeSelect);
|
||||||
|
|
||||||
// Category filter
|
|
||||||
const categorySelect = document.createElement('select');
|
const categorySelect = document.createElement('select');
|
||||||
categorySelect.className = 'template-category-select';
|
categorySelect.className = 'template-category-select';
|
||||||
|
|
||||||
const allOption = document.createElement('option');
|
const allOption = document.createElement('option');
|
||||||
allOption.value = 'all';
|
allOption.value = 'all';
|
||||||
allOption.textContent = i18n.t('allCategories');
|
allOption.textContent = i18n.t('allCategories');
|
||||||
categorySelect.appendChild(allOption);
|
categorySelect.appendChild(allOption);
|
||||||
|
|
||||||
categories.forEach(cat => {
|
categories.forEach(cat => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = cat;
|
option.value = cat;
|
||||||
option.textContent = cat;
|
option.textContent = cat;
|
||||||
categorySelect.appendChild(option);
|
categorySelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
categorySelect.value = currentCategory;
|
categorySelect.value = currentCategory;
|
||||||
categorySelect.addEventListener('change', (e) => {
|
categorySelect.addEventListener('change', (e) => {
|
||||||
currentCategory = e.target.value;
|
currentCategory = e.target.value;
|
||||||
|
|
@ -246,14 +326,48 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
});
|
});
|
||||||
controls.appendChild(categorySelect);
|
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');
|
const createTemplateBtn = document.createElement('button');
|
||||||
createTemplateBtn.className = 'template-create-btn';
|
createTemplateBtn.className = 'template-create-btn';
|
||||||
createTemplateBtn.innerHTML = `
|
createTemplateBtn.innerHTML = `
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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"/>
|
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Create Template</span>
|
|
||||||
`;
|
`;
|
||||||
createTemplateBtn.addEventListener('click', () => {
|
createTemplateBtn.addEventListener('click', () => {
|
||||||
if (window.openCreateTemplateModal) {
|
if (window.openCreateTemplateModal) {
|
||||||
|
|
@ -265,16 +379,13 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
header.appendChild(controls);
|
header.appendChild(controls);
|
||||||
container.appendChild(header);
|
container.appendChild(header);
|
||||||
|
|
||||||
// Results count
|
|
||||||
const count = document.createElement('div');
|
const count = document.createElement('div');
|
||||||
count.className = 'template-results-count';
|
count.className = 'template-results-count';
|
||||||
count.textContent = i18n.t('resultsCount', filtered.length);
|
count.textContent = i18n.t('resultsCount', filtered.length);
|
||||||
container.appendChild(count);
|
container.appendChild(count);
|
||||||
|
|
||||||
// Create grid
|
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'template-card-grid';
|
grid.className = 'template-card-grid';
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'template-empty-state';
|
empty.className = 'template-empty-state';
|
||||||
|
|
@ -289,28 +400,18 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
||||||
container.appendChild(grid);
|
container.appendChild(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the gallery
|
|
||||||
*/
|
|
||||||
function show() {
|
|
||||||
if (container) {
|
|
||||||
container.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide the gallery
|
|
||||||
*/
|
|
||||||
function hide() {
|
|
||||||
if (container) {
|
|
||||||
container.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
load,
|
load,
|
||||||
render,
|
render,
|
||||||
show,
|
show() {
|
||||||
hide
|
if (container) {
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hide() {
|
||||||
|
if (container) {
|
||||||
|
container.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
static/script.js
121
static/script.js
|
|
@ -333,14 +333,101 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const templatePreviewDropzone = document.getElementById('template-preview-dropzone');
|
const templatePreviewDropzone = document.getElementById('template-preview-dropzone');
|
||||||
const templatePreviewImg = document.getElementById('template-preview-img');
|
const templatePreviewImg = document.getElementById('template-preview-img');
|
||||||
const dropzonePlaceholder = document.querySelector('.dropzone-placeholder');
|
const dropzonePlaceholder = document.querySelector('.dropzone-placeholder');
|
||||||
|
const templateTagList = document.getElementById('template-tag-list');
|
||||||
|
const templateTagInput = document.getElementById('template-tags-input');
|
||||||
|
|
||||||
let currentPreviewFile = null;
|
let currentPreviewFile = null;
|
||||||
let currentPreviewUrl = null;
|
let currentPreviewUrl = null;
|
||||||
let editingTemplate = null; // Track if we're editing an existing template
|
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 = '×';
|
||||||
|
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)
|
// Global function for opening edit modal (called from templateGallery.js)
|
||||||
window.openEditTemplateModal = async function(template) {
|
window.openEditTemplateModal = async function(template) {
|
||||||
editingTemplate = template;
|
editingTemplate = template;
|
||||||
|
editingTemplateSource = template.isUserTemplate ? 'user' : 'builtin';
|
||||||
|
editingBuiltinIndex = editingTemplateSource === 'builtin' ? template.builtinTemplateIndex : null;
|
||||||
|
|
||||||
// Pre-fill with template data
|
// Pre-fill with template data
|
||||||
templateTitleInput.value = template.title || '';
|
templateTitleInput.value = template.title || '';
|
||||||
|
|
@ -403,6 +490,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
currentPreviewFile = null;
|
currentPreviewFile = null;
|
||||||
|
|
||||||
|
setTemplateTags(template.tags || []);
|
||||||
|
if (templateTagInput) {
|
||||||
|
templateTagInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Update button text
|
// Update button text
|
||||||
saveTemplateBtn.innerHTML = '<span>Update Template</span><div class="btn-shine"></div>';
|
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)
|
// Global function for opening create modal with empty values (called from templateGallery.js)
|
||||||
window.openCreateTemplateModal = async function() {
|
window.openCreateTemplateModal = async function() {
|
||||||
editingTemplate = null;
|
editingTemplate = null;
|
||||||
|
editingTemplateSource = 'user';
|
||||||
|
editingBuiltinIndex = null;
|
||||||
|
|
||||||
|
setTemplateTags([]);
|
||||||
|
if (templateTagInput) {
|
||||||
|
templateTagInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Clear all fields
|
// Clear all fields
|
||||||
templateTitleInput.value = '';
|
templateTitleInput.value = '';
|
||||||
|
|
@ -477,6 +576,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
createTemplateBtn.addEventListener('click', async () => {
|
createTemplateBtn.addEventListener('click', async () => {
|
||||||
// Reset editing state
|
// Reset editing state
|
||||||
editingTemplate = null;
|
editingTemplate = null;
|
||||||
|
editingTemplateSource = 'user';
|
||||||
|
editingBuiltinIndex = null;
|
||||||
|
|
||||||
// Pre-fill data
|
// Pre-fill data
|
||||||
templateTitleInput.value = '';
|
templateTitleInput.value = '';
|
||||||
|
|
@ -554,6 +655,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
currentPreviewFile = null;
|
currentPreviewFile = null;
|
||||||
|
|
||||||
|
setTemplateTags([]);
|
||||||
|
if (templateTagInput) {
|
||||||
|
templateTagInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Update button text
|
// Update button text
|
||||||
saveTemplateBtn.innerHTML = '<span>Save Template</span><div class="btn-shine"></div>';
|
saveTemplateBtn.innerHTML = '<span>Save Template</span><div class="btn-shine"></div>';
|
||||||
|
|
||||||
|
|
@ -564,6 +670,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (closeTemplateModalBtn) {
|
if (closeTemplateModalBtn) {
|
||||||
closeTemplateModalBtn.addEventListener('click', () => {
|
closeTemplateModalBtn.addEventListener('click', () => {
|
||||||
createTemplateModal.classList.add('hidden');
|
createTemplateModal.classList.add('hidden');
|
||||||
|
editingTemplate = null;
|
||||||
|
editingTemplateSource = null;
|
||||||
|
editingBuiltinIndex = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -708,6 +817,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
formData.append('prompt', prompt);
|
formData.append('prompt', prompt);
|
||||||
formData.append('mode', mode);
|
formData.append('mode', mode);
|
||||||
formData.append('category', category);
|
formData.append('category', category);
|
||||||
|
formData.append('tags', JSON.stringify(templateTags));
|
||||||
|
|
||||||
if (currentPreviewFile) {
|
if (currentPreviewFile) {
|
||||||
formData.append('preview', currentPreviewFile);
|
formData.append('preview', currentPreviewFile);
|
||||||
|
|
@ -718,7 +828,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
// If editing, add the template index
|
// If editing, add the template index
|
||||||
const endpoint = editingTemplate ? '/update_template' : '/save_template';
|
const endpoint = editingTemplate ? '/update_template' : '/save_template';
|
||||||
if (editingTemplate) {
|
if (editingTemplate) {
|
||||||
formData.append('template_index', editingTemplate.userTemplateIndex);
|
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, {
|
const response = await fetch(endpoint, {
|
||||||
|
|
@ -740,6 +854,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
// Reset editing state
|
// Reset editing state
|
||||||
editingTemplate = null;
|
editingTemplate = null;
|
||||||
|
editingTemplateSource = null;
|
||||||
|
editingBuiltinIndex = null;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(`Failed to ${editingTemplate ? 'update' : 'save'} template: ` + error.message);
|
alert(`Failed to ${editingTemplate ? 'update' : 'save'} template: ` + error.message);
|
||||||
|
|
@ -755,6 +871,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
createTemplateModal.addEventListener('click', (e) => {
|
createTemplateModal.addEventListener('click', (e) => {
|
||||||
if (e.target === createTemplateModal) {
|
if (e.target === createTemplateModal) {
|
||||||
createTemplateModal.classList.add('hidden');
|
createTemplateModal.classList.add('hidden');
|
||||||
|
editingTemplate = null;
|
||||||
|
editingTemplateSource = null;
|
||||||
|
editingBuiltinIndex = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
203
static/style.css
203
static/style.css
|
|
@ -270,7 +270,7 @@ select {
|
||||||
border-radius: 0.85rem;
|
border-radius: 0.85rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
background-color: rgba(6, 7, 20, 0.95);
|
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;
|
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1043,6 +1043,61 @@ button#generate-btn:disabled {
|
||||||
position: relative;
|
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 {
|
.template-card-preview img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -1281,6 +1336,76 @@ button#generate-btn:disabled {
|
||||||
margin-left: 0.25rem;
|
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 {
|
.template-preview-dropzone {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 220px;
|
height: 220px;
|
||||||
|
|
@ -1390,33 +1515,24 @@ button#generate-btn:disabled {
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-edit-btn {
|
.template-edit-btn {
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 0.5rem;
|
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.75);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
transition: transform 0.2s, border 0.2s, background 0.2s;
|
||||||
transition: all 0.2s;
|
|
||||||
z-index: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.template-card:hover .template-edit-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-edit-btn:hover {
|
.template-edit-btn:hover {
|
||||||
background: var(--accent-color);
|
background: var(--accent-color);
|
||||||
color: #000;
|
color: #000;
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
transform: scale(1.1);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-edit-btn svg {
|
.template-edit-btn svg {
|
||||||
|
|
@ -1425,11 +1541,13 @@ button#generate-btn:disabled {
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-create-btn {
|
.template-create-btn {
|
||||||
|
width:43.5px;
|
||||||
|
height: 43.5px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
|
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
|
||||||
color: #000;
|
color: #000;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.85rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -1448,3 +1566,56 @@ button#generate-btn:disabled {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="create-template-btn" class="canvas-btn icon-btn"
|
<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"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M10 14H12M12 14H14M12 14V16M12 14V12" stroke="currentColor"
|
<path d="M10 14H12M12 14H14M12 14V16M12 14V12" stroke="currentColor"
|
||||||
|
|
@ -289,6 +289,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="controls-footer" style="margin-top: 1.5rem; justify-content: flex-end;">
|
<div class="controls-footer" style="margin-top: 1.5rem; justify-content: flex-end;">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue