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
|
||||
user_prompts.json
|
||||
/static/preview
|
||||
template_favorites.json
|
||||
|
|
|
|||
186
app.py
186
app.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
@ -106,7 +158,35 @@ export function createTemplateGallery({ container, onSelectTemplate }) {
|
|||
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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
119
static/script.js
119
static/script.js
|
|
@ -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 = '×';
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
203
static/style.css
203
static/style.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;">
|
||||
|
|
|
|||
Loading…
Reference in a new issue