diff --git a/.gitignore b/.gitignore index 9168098..f7072ff 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ .DS_Store user_prompts.json /static/preview +template_favorites.json diff --git a/app.py b/app.py index 6967c0d..8bc1511 100644 --- a/app.py +++ b/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 @@ -415,43 +505,49 @@ def update_template(): try: import requests from urllib.parse import urlparse - - # Get template index + template_index = request.form.get('template_index') - if template_index is None: - return jsonify({'error': 'Template index is required'}), 400 - - template_index = int(template_index) - - # Handle multipart form data + 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 + 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: @@ -459,7 +555,7 @@ def update_template(): if preview_url.startswith('http://') or preview_url.startswith('https://'): response = requests.get(preview_url, timeout=10) response.raise_for_status() - + content_type = response.headers.get('content-type', '') if 'image/png' in content_type: ext = '.png' @@ -470,41 +566,82 @@ def update_template(): else: parsed = urlparse(preview_url) ext = os.path.splitext(parsed.path)[1] or '.png' - + filename = f"template_{uuid.uuid4()}{ext}" filepath = os.path.join(preview_dir, filename) - + with open(filepath, 'wb') as f: f.write(response.content) - + preview_path = url_for('static', filename=f'preview/{filename}') - + elif preview_url.startswith('/static/'): rel_path = preview_url.split('/static/')[1] source_path = os.path.join(app.static_folder, rel_path) - + if os.path.exists(source_path): ext = os.path.splitext(source_path)[1] or '.png' filename = f"template_{uuid.uuid4()}{ext}" dest_path = os.path.join(preview_dir, filename) - + import shutil shutil.copy2(source_path, dest_path) - + preview_path = url_for('static', filename=f'preview/{filename}') else: preview_path = preview_url else: preview_path = preview_url - + except Exception as e: print(f"Error processing preview image URL: {e}") preview_path = preview_url - - # Read existing user templates + + 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 = [] - + if os.path.exists(user_prompts_path): try: with open(user_prompts_path, 'r', encoding='utf-8') as f: @@ -513,44 +650,37 @@ def update_template(): user_prompts = json.loads(content) except json.JSONDecodeError: pass - - # Check if index is valid + if template_index < 0 or template_index >= len(user_prompts): return jsonify({'error': 'Invalid template index'}), 400 - - # Get old template to check for old preview image + old_template = user_prompts[template_index] old_preview = old_template.get('preview', '') - - # Delete old preview image if it exists in the preview folder - if old_preview and '/preview/' in old_preview: + 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: print(f"Error updating template: {e}") return jsonify({'error': str(e)}), 500 diff --git a/static/modules/templateGallery.js b/static/modules/templateGallery.js index 18004b2..8f93bba 100644 --- a/static/modules/templateGallery.js +++ b/static/modules/templateGallery.js @@ -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; - /** - * Fetch templates from API - */ - async function load() { - try { - 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 setFavoriteKeys(keys) { + if (Array.isArray(keys)) { + favoriteTemplateKeys = new Set(keys.filter(Boolean)); + } else { + favoriteTemplateKeys = new Set(); } } - /** - * Get unique categories from templates - */ + 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; + + 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() { const categories = new Set(); allTemplates.forEach(t => { @@ -45,53 +138,12 @@ export function createTemplateGallery({ container, onSelectTemplate }) { return Array.from(categories).sort(); } - /** - * Filter templates based on category and search - */ - function filterTemplates() { - let filtered = allTemplates; - - // Filter by category - if (currentCategory !== 'all') { - filtered = filtered.filter(t => { - const categoryText = i18n.getText(t.category); - return categoryText === currentCategory; - }); - } - - // Filter by mode - if (currentMode !== 'all') { - filtered = filtered.filter(t => { - return (t.mode || 'generate') === currentMode; - }); - } - - // Filter by search query - if (searchQuery) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter(t => { - const title = i18n.getText(t.title).toLowerCase(); - const prompt = i18n.getText(t.prompt).toLowerCase(); - const category = i18n.getText(t.category).toLowerCase(); - return title.includes(query) || - prompt.includes(query) || - category.includes(query); - }); - } - - return filtered; - } - - /** - * Create a template card element - */ function createTemplateCard(template) { const card = document.createElement('div'); card.className = 'template-card'; card.setAttribute('data-category', i18n.getText(template.category) || ''); card.setAttribute('data-mode', template.mode || 'generate'); - // Preview image const preview = document.createElement('div'); preview.className = 'template-card-preview'; if (template.preview) { @@ -99,38 +151,66 @@ export function createTemplateGallery({ container, onSelectTemplate }) { img.src = template.preview; img.alt = i18n.getText(template.title) || 'Template preview'; img.loading = 'lazy'; - img.onerror = function() { + img.onerror = function () { this.onerror = null; this.src = '/static/eror.png'; }; preview.appendChild(img); } - - // Edit button (show on all templates) - const editBtn = document.createElement('button'); - editBtn.className = 'template-edit-btn'; - editBtn.innerHTML = ` - - `; - editBtn.title = 'Edit Template'; - editBtn.addEventListener('click', (e) => { - e.stopPropagation(); - if (window.openEditTemplateModal) { - window.openEditTemplateModal(template); + + 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 = ` + `; + 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 = ` + + `; + 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); - // 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'); @@ -179,43 +269,37 @@ export function createTemplateGallery({ container, onSelectTemplate }) { searchInput.className = 'template-search-input'; searchInput.placeholder = i18n.t('searchPlaceholder'); searchInput.value = searchQuery; - - // Only search on Enter + searchInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { searchQuery = e.target.value; render(); } }); - - // Also update on blur to ensure value is captured if user clicks away + searchInput.addEventListener('blur', (e) => { - if (searchQuery !== e.target.value) { + if (searchQuery !== e.target.value) { searchQuery = e.target.value; render(); - } + } }); 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 = ` + + `; + 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 = ` + + `; + userToggle.addEventListener('click', () => { + userTemplateFilterActive = !userTemplateFilterActive; + render(); + }); + controls.appendChild(userToggle); + const createTemplateBtn = document.createElement('button'); createTemplateBtn.className = 'template-create-btn'; createTemplateBtn.innerHTML = ` - Create Template `; 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() { - if (container) { - container.classList.remove('hidden'); - } - } - - /** - * Hide the gallery - */ - function hide() { - if (container) { - container.classList.add('hidden'); - } - } - return { load, render, - show, - hide + show() { + if (container) { + container.classList.remove('hidden'); + } + }, + hide() { + if (container) { + container.classList.add('hidden'); + } + } }; } diff --git a/static/script.js b/static/script.js index 2b77549..b2e539a 100644 --- a/static/script.js +++ b/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 = 'Update Template
'; @@ -412,7 +504,14 @@ 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 = ''; templatePromptInput.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 = 'Save Template'; @@ -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) { - 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, { @@ -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; } }); } diff --git a/static/style.css b/static/style.css index e52cd5a..2cfc34d 100644 --- a/static/style.css +++ b/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; +} diff --git a/templates/index.html b/templates/index.html index d5403f5..aab91fe 100644 --- a/templates/index.html +++ b/templates/index.html @@ -157,7 +157,7 @@