template update

This commit is contained in:
phamhungd 2025-11-23 22:08:56 +07:00
parent be77213b65
commit 9b909dae9c
10 changed files with 3059 additions and 66 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View file

@ -5,3 +5,4 @@
.DS_Store .DS_Store
/.venv /.venv
/static/uploads /static/uploads
.DS_Store

59
app.py
View file

@ -248,5 +248,64 @@ def get_gallery():
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response return response
@app.route('/prompts')
def get_prompts():
category = request.args.get('category')
try:
# Read prompts.json file
prompts_path = os.path.join(os.path.dirname(__file__), 'prompts.json')
with open(prompts_path, 'r', encoding='utf-8') as f:
prompts = json.load(f)
# Filter by category if specified
if category:
prompts = [p for p in prompts if p.get('category') == category]
response = jsonify({'prompts': prompts})
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/refine_prompt', methods=['POST'])
def refine_prompt():
data = request.get_json()
current_prompt = data.get('current_prompt')
instruction = data.get('instruction')
api_key = data.get('api_key') or os.environ.get('GOOGLE_API_KEY')
if not api_key:
return jsonify({'error': 'API Key is required.'}), 401
if not instruction:
return jsonify({'error': 'Instruction is required'}), 400
try:
client = genai.Client(api_key=api_key)
system_instruction = "You are an expert prompt engineer for image generation AI. Rewrite the prompt to incorporate the user's instruction while maintaining the original intent and improving quality. Return ONLY the new prompt text, no explanations."
prompt_content = f"Current prompt: {current_prompt}\nUser instruction: {instruction}\nNew prompt:"
print(f"Refining prompt with instruction: {instruction}")
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[prompt_content],
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.7,
)
)
if response.text:
return jsonify({'refined_prompt': response.text.strip()})
else:
return jsonify({'error': 'No response from AI'}), 500
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True, port=8888) app.run(debug=True, port=8888)

1965
prompts.json Normal file

File diff suppressed because it is too large Load diff

BIN
static/eror.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

70
static/modules/i18n.js Normal file
View file

@ -0,0 +1,70 @@
/**
* Simple i18n utility for bilingual English-Vietnamese support
*/
export const i18n = {
currentLang: 'vi',
translations: {
searchPlaceholder: {
en: 'Search templates...',
vi: 'Tìm kiếm mẫu...'
},
allCategories: {
en: 'All Categories',
vi: 'Tất cả danh mục'
},
resultsCount: {
en: (count) => `${count} template${count !== 1 ? 's' : ''}`,
vi: (count) => `${count} mẫu`
},
noResults: {
en: 'No templates found',
vi: 'Không tìm thấy mẫu nào'
},
promptTemplates: {
en: 'Prompt Templates',
vi: 'Mẫu Prompt'
},
toggleLanguage: {
en: 'Switch to Vietnamese',
vi: 'Chuyển sang tiếng Anh'
},
languageCode: {
en: 'EN',
vi: 'VI'
}
},
/**
* Translate a key with optional arguments
*/
t(key, ...args) {
const translation = this.translations[key]?.[this.currentLang];
if (typeof translation === 'function') {
return translation(...args);
}
return translation || key;
},
/**
* Set current language
*/
setLanguage(lang) {
if (lang === 'en' || lang === 'vi') {
this.currentLang = lang;
}
},
/**
* Get language-aware text from object
* Supports both string and object formats
*/
getText(obj) {
if (typeof obj === 'string') return obj;
if (typeof obj === 'object' && obj !== null) {
return obj[this.currentLang] || obj['en'] || obj['vi'] || '';
}
return '';
}
};

View file

@ -0,0 +1,281 @@
/**
* Template Gallery Module
* Displays prompt templates from prompts.json as selectable cards
*/
import { i18n } from './i18n.js';
export function createTemplateGallery({ container, onSelectTemplate }) {
let allTemplates = [];
let currentCategory = 'all';
let currentMode = 'all';
let searchQuery = '';
/**
* 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;
}
}
/**
* Get unique categories from templates
*/
function getCategories() {
const categories = new Set();
allTemplates.forEach(t => {
if (t.category) {
const categoryText = i18n.getText(t.category);
if (categoryText) categories.add(categoryText);
}
});
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) {
const img = document.createElement('img');
img.src = template.preview;
img.alt = i18n.getText(template.title) || 'Template preview';
img.loading = 'lazy';
img.onerror = function() {
this.onerror = null;
this.src = '/static/eror.png';
};
preview.appendChild(img);
}
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';
content.appendChild(title);
card.appendChild(content);
// Click handler
card.addEventListener('click', () => {
onSelectTemplate?.(template);
});
return card;
}
/**
* Render the gallery
*/
function render() {
if (!container) return;
const filtered = filterTemplates();
const categories = getCategories();
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');
searchInput.type = 'text';
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) {
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;
render();
});
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;
render();
});
controls.appendChild(categorySelect);
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';
empty.textContent = i18n.t('noResults');
grid.appendChild(empty);
} else {
filtered.forEach(template => {
grid.appendChild(createTemplateCard(template));
});
}
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
};
}

View file

@ -3,6 +3,8 @@ import { createGallery } from './modules/gallery.js';
import { createReferenceSlotManager } from './modules/referenceSlots.js'; import { createReferenceSlotManager } from './modules/referenceSlots.js';
import { setupHelpPopups } from './modules/popup.js'; import { setupHelpPopups } from './modules/popup.js';
import { extractMetadataFromBlob } from './modules/metadata.js'; import { extractMetadataFromBlob } from './modules/metadata.js';
import { createTemplateGallery } from './modules/templateGallery.js';
import { i18n } from './modules/i18n.js';
const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings'; const SETTINGS_STORAGE_KEY = 'gemini-image-app-settings';
const ZOOM_STEP = 0.1; const ZOOM_STEP = 0.1;
@ -68,6 +70,8 @@ document.addEventListener('DOMContentLoaded', () => {
const placeholderState = document.getElementById('placeholder-state'); const placeholderState = document.getElementById('placeholder-state');
const loadingState = document.getElementById('loading-state'); const loadingState = document.getElementById('loading-state');
const errorState = document.getElementById('error-state'); const errorState = document.getElementById('error-state');
const templateGalleryState = document.getElementById('template-gallery-state');
const templateGalleryContainer = document.getElementById('template-gallery-container');
const resultState = document.getElementById('result-state'); const resultState = document.getElementById('result-state');
const errorText = document.getElementById('error-text'); const errorText = document.getElementById('error-text');
const generatedImage = document.getElementById('generated-image'); const generatedImage = document.getElementById('generated-image');
@ -79,15 +83,36 @@ document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.querySelector('.sidebar'); const sidebar = document.querySelector('.sidebar');
const resizeHandle = document.querySelector('.sidebar-resize-handle'); const resizeHandle = document.querySelector('.sidebar-resize-handle');
// Refine Prompt Elements
const refinePromptBtn = document.getElementById('refine-prompt-btn');
const refineModal = document.getElementById('refine-modal');
const closeRefineModalBtn = document.getElementById('close-refine-modal');
const refineInstructionInput = document.getElementById('refine-instruction');
const confirmRefineBtn = document.getElementById('confirm-refine-btn');
let zoomLevel = 1; let zoomLevel = 1;
let panOffset = { x: 0, y: 0 }; let panOffset = { x: 0, y: 0 };
let isPanning = false; let isPanning = false;
let lastPointer = { x: 0, y: 0 }; let lastPointer = { x: 0, y: 0 };
let hasGeneratedImage = false; // Track if image exists
const slotManager = createReferenceSlotManager(imageInputGrid, { const slotManager = createReferenceSlotManager(imageInputGrid, {
onChange: persistSettings, onChange: persistSettings,
}); });
const templateGallery = createTemplateGallery({
container: templateGalleryContainer,
onSelectTemplate: (template) => {
// Fill prompt field with template prompt (language-aware)
if (template.prompt) {
promptInput.value = i18n.getText(template.prompt);
persistSettings();
}
// Stay in template gallery view - don't auto-switch
// User will switch view by selecting image from history or generating
}
});
const gallery = createGallery({ const gallery = createGallery({
galleryGrid, galleryGrid,
onSelect: async ({ imageUrl, metadata }) => { onSelect: async ({ imageUrl, metadata }) => {
@ -95,7 +120,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (metadata) { if (metadata) {
applyMetadata(metadata); applyMetadata(metadata);
} }
}, }
}); });
setupHelpPopups({ setupHelpPopups({
@ -164,6 +189,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('keydown', handleGenerateShortcut); document.addEventListener('keydown', handleGenerateShortcut);
document.addEventListener('keydown', handleResetShortcut); document.addEventListener('keydown', handleResetShortcut);
document.addEventListener('keydown', handleDownloadShortcut); document.addEventListener('keydown', handleDownloadShortcut);
document.addEventListener('keydown', handleTemplateShortcut);
if (imageDisplayArea) { if (imageDisplayArea) {
imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false }); imageDisplayArea.addEventListener('wheel', handleCanvasWheel, { passive: false });
@ -217,6 +243,82 @@ document.addEventListener('DOMContentLoaded', () => {
canvasToolbar.addEventListener('click', handleCanvasToolbarClick); canvasToolbar.addEventListener('click', handleCanvasToolbarClick);
} }
// Refine Prompt Logic
if (refinePromptBtn) {
refinePromptBtn.addEventListener('click', () => {
refineInstructionInput.value = ''; // Clear previous instruction
refineModal.classList.remove('hidden');
refineInstructionInput.focus();
});
}
if (closeRefineModalBtn) {
closeRefineModalBtn.addEventListener('click', () => {
refineModal.classList.add('hidden');
});
}
// Close modal when clicking outside
if (refineModal) {
refineModal.addEventListener('click', (e) => {
if (e.target === refineModal) {
refineModal.classList.add('hidden');
}
});
}
const refineLoading = document.getElementById('refine-loading');
if (confirmRefineBtn && refineLoading) {
confirmRefineBtn.addEventListener('click', async () => {
const instruction = refineInstructionInput.value.trim();
const currentPrompt = promptInput.value.trim();
const apiKey = apiKeyInput.value.trim();
if (!instruction) return;
if (!apiKey) {
alert('Please enter your API Key first.');
return;
}
// Show loading state
confirmRefineBtn.classList.add('hidden');
refineLoading.classList.remove('hidden');
try {
const response = await fetch('/refine_prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
current_prompt: currentPrompt,
instruction: instruction,
api_key: apiKey
}),
});
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
if (data.refined_prompt) {
promptInput.value = data.refined_prompt;
persistSettings(); // Save the new prompt
refineModal.classList.add('hidden');
}
} catch (error) {
alert('Failed to refine prompt: ' + error.message);
} finally {
// Reset state
confirmRefineBtn.classList.remove('hidden');
refineLoading.classList.add('hidden');
}
});
}
document.addEventListener('pointermove', handleCanvasPointerMove); document.addEventListener('pointermove', handleCanvasPointerMove);
document.addEventListener('pointerup', () => { document.addEventListener('pointerup', () => {
if (isPanning && imageDisplayArea) { if (isPanning && imageDisplayArea) {
@ -232,12 +334,33 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
loadGallery(); loadGallery();
loadTemplateGallery();
initializeSidebarResizer(sidebar, resizeHandle); initializeSidebarResizer(sidebar, resizeHandle);
// Setup canvas language toggle
const canvasLangInput = document.getElementById('canvas-lang-input');
if (canvasLangInput) {
// Set initial state
canvasLangInput.checked = i18n.currentLang === 'en';
canvasLangInput.addEventListener('change', (e) => {
i18n.setLanguage(e.target.checked ? 'en' : 'vi');
// Update visual state
const options = document.querySelectorAll('.canvas-lang-option');
options.forEach(opt => {
const isActive = opt.dataset.lang === i18n.currentLang;
opt.classList.toggle('active', isActive);
});
// Reload template gallery with new language
templateGallery.render();
});
}
function setViewState(state) { function setViewState(state) {
placeholderState.classList.add('hidden'); placeholderState.classList.add('hidden');
loadingState.classList.add('hidden'); loadingState.classList.add('hidden');
errorState.classList.add('hidden'); errorState.classList.add('hidden');
templateGalleryState.classList.add('hidden');
resultState.classList.add('hidden'); resultState.classList.add('hidden');
switch (state) { switch (state) {
@ -250,6 +373,9 @@ document.addEventListener('DOMContentLoaded', () => {
case 'error': case 'error':
errorState.classList.remove('hidden'); errorState.classList.remove('hidden');
break; break;
case 'template-gallery':
templateGalleryState.classList.remove('hidden');
break;
case 'result': case 'result':
resultState.classList.remove('hidden'); resultState.classList.remove('hidden');
break; break;
@ -281,6 +407,7 @@ document.addEventListener('DOMContentLoaded', () => {
resetView(); resetView();
}; };
hasGeneratedImage = true; // Mark that we have an image
setViewState('result'); setViewState('result');
} }
@ -320,6 +447,19 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
async function loadTemplateGallery() {
try {
await templateGallery.load();
// Don't auto-show template gallery - let user trigger it
// Default view will be placeholder or template gallery based on state
if (!hasGeneratedImage) {
setViewState('template-gallery');
}
} catch (error) {
console.error('Unable to load template gallery', error);
}
}
function initializeSidebarResizer(sidebar, handle) { function initializeSidebarResizer(sidebar, handle) {
if (!sidebar || !handle) return; if (!sidebar || !handle) return;
const resizerQuery = window.matchMedia('(min-width: 1025px)'); const resizerQuery = window.matchMedia('(min-width: 1025px)');
@ -432,6 +572,29 @@ document.addEventListener('DOMContentLoaded', () => {
downloadLink.click(); downloadLink.click();
} }
function handleTemplateShortcut(event) {
if (event.key.toLowerCase() !== 't') return;
if (event.ctrlKey || event.metaKey || event.altKey) return;
const targetTag = event.target?.tagName;
if (targetTag && ['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) return;
if (event.target?.isContentEditable) return;
event.preventDefault();
// Toggle template gallery
if (templateGalleryState.classList.contains('hidden')) {
setViewState('template-gallery');
} else {
// If we have a generated image, go back to result
if (hasGeneratedImage) {
setViewState('result');
} else {
// Otherwise go to placeholder
setViewState('placeholder');
}
}
}
function handleCanvasWheel(event) { function handleCanvasWheel(event) {
if (resultState.classList.contains('hidden')) return; if (resultState.classList.contains('hidden')) return;
event.preventDefault(); event.preventDefault();
@ -475,6 +638,14 @@ document.addEventListener('DOMContentLoaded', () => {
case 'zoom-reset': case 'zoom-reset':
resetView(); resetView();
break; break;
case 'toggle-template':
// Toggle between result and template gallery
if (resultState.classList.contains('hidden')) {
setViewState('result');
} else {
setViewState('template-gallery');
}
break;
} }
} }

View file

@ -311,6 +311,80 @@ textarea {
min-height: 100px; min-height: 100px;
} }
.prompt-wrapper {
position: relative;
width: 100%;
}
.prompt-wrapper textarea {
width: 100%;
padding-bottom: 2.5rem; /* Make space for the button */
}
.prompt-refine-btn {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
z-index: 5;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.prompt-refine-btn:hover {
background: rgba(251, 191, 36, 0.15);
color: var(--accent-color);
border-color: var(--accent-color);
transform: scale(1.05);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
.prompt-refine-btn svg {
width: 16px;
height: 16px;
fill: currentColor;
}
#confirm-refine-btn {
padding: 0.4rem 1rem;
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
color: #111;
border: none;
border-radius: 0.75rem;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 4px 12px rgba(245, 197, 24, 0.3);
transition: transform 0.1s, box-shadow 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
}
#confirm-refine-btn:hover {
box-shadow: 0 6px 16px rgba(245, 197, 24, 0.4);
transform: translateY(-1px);
}
#confirm-refine-btn:active {
transform: translateY(1px);
}
.rotating-icon {
animation: spin 1s linear infinite;
}
/* Reference image input grid */
/* Reference image input grid */ /* Reference image input grid */
.image-inputs { .image-inputs {
display: flex; display: flex;
@ -769,6 +843,285 @@ button#generate-btn:disabled {
background: rgba(255, 68, 68, 0.9); background: rgba(255, 68, 68, 0.9);
} }
/* Template Browser Box */
.template-browser-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: linear-gradient(135deg, rgba(251, 191, 36, 0.15), rgba(251, 191, 36, 0.05));
border: 2px dashed rgba(251, 191, 36, 0.6);
position: relative;
overflow: visible;
}
.template-browser-box:hover {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.25), rgba(251, 191, 36, 0.1));
border-color: var(--accent-color);
}
.template-browser-box.active {
background: linear-gradient(135deg, rgba(251, 191, 36, 0.3), rgba(251, 191, 36, 0.15));
border-color: var(--accent-color);
border-style: solid;
}
.template-box-icon {
color: var(--accent-color);
display: flex;
align-items: center;
justify-content: center;
}
.template-box-icon svg {
width: 48px;
height: 48px;
}
.template-box-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--accent-color);
text-align: center;
letter-spacing: 0.5px;
}
/* Template Gallery */
.template-gallery-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden;
}
.template-gallery-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.template-gallery-title {
font-size: 1.5rem;
font-weight: 600;
color: white;
margin: 0;
}
.template-gallery-controls {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.template-search-container {
flex: 1;
min-width: 200px;
}
.template-search-input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 0.875rem;
transition: border-color 0.2s;
}
.template-search-input:focus {
outline: none;
border-color: var(--accent-color);
}
.template-category-select {
min-width: 150px;
}
/* Canvas Language Toggle (Top-Left Corner) */
.canvas-lang-toggle {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 20;
}
.canvas-lang-switch {
display: inline-block;
cursor: pointer;
}
.canvas-lang-switch input {
display: none;
}
.canvas-lang-slider {
display: flex;
align-items: center;
gap: 0;
background: rgba(10, 15, 50, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 18px;
padding: 3px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
}
.canvas-lang-option {
padding: 4px 12px;
font-size: 0.75rem;
font-weight: 600;
border-radius: 15px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
user-select: none;
color: rgba(255, 255, 255, 0.5);
}
.canvas-lang-option.active {
background: linear-gradient(135deg, #FBC02D, #F9A825);
color: #0A0F32;
box-shadow: 0 2px 8px rgba(251, 192, 45, 0.4);
}
.canvas-lang-switch:hover .canvas-lang-option:not(.active) {
color: rgba(255, 255, 255, 0.8);
}
.template-results-count {
font-size: 0.75rem;
color: var(--text-secondary);
padding: 0.25rem 0;
}
.template-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
overflow-y: auto;
padding: 0.5rem;
flex: 1;
}
.template-empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
.template-card {
background: rgba(15, 23, 42, 0.8);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0.75rem;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
height: 220px;
}
.template-card:hover {
transform: translateY(-4px);
border-color: var(--accent-color);
box-shadow: 0 10px 30px rgba(251, 191, 36, 0.3);
}
.template-card-preview {
width: 100%;
height: 160px;
overflow: hidden;
background: rgba(0, 0, 0, 0.3);
position: relative;
}
.template-card-preview img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.template-card:hover .template-card-preview img {
transform: scale(1.05);
}
.template-card-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.template-card-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: left;
}
.template-category-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background: rgba(251, 191, 36, 0.15);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 0.25rem;
font-size: 0.7rem;
font-weight: 600;
color: var(--accent-color);
text-transform: uppercase;
letter-spacing: 0.5px;
align-self: flex-start;
}
.template-card-author {
font-size: 0.7rem;
color: var(--text-secondary);
margin-top: auto;
}
@media (max-width: 768px) {
.template-card-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.template-card {
height: 180px;
}
.template-card-preview {
height: 120px;
}
.template-gallery-controls {
flex-direction: column;
align-items: stretch;
}
.template-search-container,
.template-category-select {
width: 100%;
min-width: unset;
}
}
/* Spinner */ /* Spinner */
.spinner { .spinner {
width: 50px; width: 50px;

View file

@ -15,73 +15,100 @@
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="brand"> <div class="brand">
<h1>aPix <span class="badge">by SDVN</span></h1> <h1>aPix <span class="badge">by SDVN</span></h1>
</div>
<button type="button" class="toolbar-info-btn info-icon-btn" data-popup-target="help"
aria-label="Thông tin và hướng dẫn">
<span aria-hidden="true">i</span>
</button>
</div>
<div class="controls-section">
<div class="controls-content">
<div class="input-group">
<label for="api-key">API Key</label>
<input type="password" id="api-key" placeholder="Google Cloud API Key">
</div> </div>
<button type="button" class="toolbar-info-btn info-icon-btn" data-popup-target="help"
aria-label="Thông tin và hướng dẫn"> <div class="input-group">
<span aria-hidden="true">i</span> <label for="prompt">Prompt</label>
<div class="prompt-wrapper">
<textarea id="prompt" placeholder="Describe your imagination..." rows="6"></textarea>
<button id="refine-prompt-btn" class="prompt-refine-btn" title="Refine with AI">
<svg fill="currentColor" height="16px" width="16px" version="1.1" id="Layer_1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512.422 512.422" xml:space="preserve">
<g>
<g>
<g>
<path d="M41.053,223.464c2.667,1.067,5.76,1.067,8.427-0.213l83.307-37.867c5.333-2.56,7.573-8.96,5.013-14.293
c-2.453-5.12-8.533-7.467-13.76-5.12l-58.347,26.56c27.84-83.307,105.387-138.987,194.667-138.987
c93.547,0,175.36,62.507,198.933,152c1.493,5.653,7.36,9.067,13.013,7.573c5.653-1.493,9.067-7.36,7.573-13.013
c-26.027-98.773-116.267-167.893-219.52-167.893c-98.453,0-184.107,61.44-215.04,153.387l-24.533-61.333
c-1.813-5.547-7.893-8.64-13.44-6.827c-5.547,1.813-8.64,7.893-6.827,13.44c0.107,0.427,0.32,0.853,0.533,1.28l34.027,85.333
C36.146,220.158,38.279,222.398,41.053,223.464z" />
<path
d="M511.773,380.904c-0.107-0.213-0.213-0.427-0.213-0.64l-34.027-85.333c-1.067-2.667-3.2-4.907-5.973-5.973
c-2.667-1.067-5.76-0.96-8.427,0.213l-83.307,37.867c-5.44,2.24-8,8.533-5.76,13.973c2.24,5.44,8.533,8,13.973,5.76
c0.213-0.107,0.427-0.213,0.64-0.32l58.347-26.56c-28.053,83.307-105.707,138.987-194.88,138.987
c-93.547,0-175.36-62.507-198.933-152c-1.493-5.653-7.36-9.067-13.013-7.573c-5.653,1.493-9.067,7.36-7.573,13.013
c25.92,98.88,116.267,167.893,219.52,167.893c98.453,0,184-61.44,215.04-153.387l24.533,61.333
c2.027,5.547,8.107,8.427,13.653,6.4C510.919,392.531,513.799,386.451,511.773,380.904z" />
</g>
</g>
</g>
</svg>
</button>
</div>
</div>
<div class="input-group image-inputs">
<div class="image-input-header">
<label>Reference Images</label>
</div>
<div id="image-input-grid" class="image-input-grid" aria-live="polite"></div>
</div>
<div class="input-group">
<label for="aspect-ratio">Aspect Ratio</label>
<select id="aspect-ratio">
<option value="Auto">Auto (Default)</option>
<option value="1:1">1:1 (Square)</option>
<option value="16:9">16:9 (Widescreen)</option>
<option value="4:3">4:3 (Standard)</option>
<option value="3:4">3:4 (Portrait)</option>
<option value="9:16">9:16 (Mobile)</option>
<option value="2:3">2:3</option>
<option value="3:2">3:2</option>
<option value="4:5">4:5</option>
<option value="5:4">5:4</option>
<option value="21:9">21:9 (Cinema)</option>
</select>
</div>
<div class="input-group">
<label for="resolution">Resolution</label>
<select id="resolution">
<option value="1K" selected>1K</option>
<option value="2K">2K</option>
<option value="4K">4K</option>
</select>
</div>
</div>
<div class="controls-footer">
<button id="generate-btn">
<span>Generate</span>
<div class="btn-shine"></div>
</button> </button>
</div> </div>
</div>
<div class="controls-section"> </aside>
<div class="controls-content"> <div class="sidebar-resize-handle" aria-hidden="true"></div>
<div class="input-group"> <div class="content-area">
<label for="api-key">API Key</label>
<input type="password" id="api-key" placeholder="Google Cloud API Key">
</div>
<div class="input-group">
<label for="prompt">Prompt</label>
<textarea id="prompt" placeholder="Describe your imagination..." rows="6"></textarea>
</div>
<div class="input-group image-inputs">
<div class="image-input-header">
<label>Reference Images</label>
</div>
<div id="image-input-grid" class="image-input-grid" aria-live="polite"></div>
</div>
<div class="input-group">
<label for="aspect-ratio">Aspect Ratio</label>
<select id="aspect-ratio">
<option value="Auto">Auto (Default)</option>
<option value="1:1">1:1 (Square)</option>
<option value="16:9">16:9 (Widescreen)</option>
<option value="4:3">4:3 (Standard)</option>
<option value="3:4">3:4 (Portrait)</option>
<option value="9:16">9:16 (Mobile)</option>
<option value="2:3">2:3</option>
<option value="3:2">3:2</option>
<option value="4:5">4:5</option>
<option value="5:4">5:4</option>
<option value="21:9">21:9 (Cinema)</option>
</select>
</div>
<div class="input-group">
<label for="resolution">Resolution</label>
<select id="resolution">
<option value="1K" selected>1K</option>
<option value="2K">2K</option>
<option value="4K">4K</option>
</select>
</div>
</div>
<div class="controls-footer">
<button id="generate-btn">
<span>Generate</span>
<div class="btn-shine"></div>
</button>
</div>
</div>
</aside>
<div class="sidebar-resize-handle" aria-hidden="true"></div>
<div class="content-area">
<main class="main-content"> <main class="main-content">
<div class="image-display-area"> <div class="image-display-area">
<div id="placeholder-state" class="state-view"> <div id="placeholder-state" class="state-view">
@ -98,9 +125,37 @@
<p id="error-text"></p> <p id="error-text"></p>
</div> </div>
<div id="template-gallery-state" class="state-view hidden">
<!-- Language Toggle in top-left corner -->
<div class="canvas-lang-toggle">
<label class="canvas-lang-switch">
<input type="checkbox" id="canvas-lang-input">
<span class="canvas-lang-slider">
<span class="canvas-lang-option active" data-lang="vi">VI</span>
<span class="canvas-lang-option" data-lang="en">EN</span>
</span>
</label>
</div>
<div id="template-gallery-container" class="template-gallery-container"></div>
</div>
<div id="result-state" class="state-view hidden"> <div id="result-state" class="state-view hidden">
<img id="generated-image" src="" alt="Generated Image"> <img id="generated-image" src="" alt="Generated Image">
<div class="canvas-toolbar" role="toolbar"> <div class="canvas-toolbar" role="toolbar">
<button type="button" class="canvas-btn" data-action="toggle-template"
aria-label="Toggle template view" title="Chọn mẫu prompt">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M19 3H5C3.89543 3 3 3.89543 3 5V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V5C21 3.89543 20.1046 3 19 3Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M3 9H21" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M9 21V9" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="canvas-btn" data-action="zoom-out"></button> <button type="button" class="canvas-btn" data-action="zoom-out"></button>
<button type="button" class="canvas-btn" data-action="zoom-in"></button> <button type="button" class="canvas-btn" data-action="zoom-in"></button>
<button type="button" class="canvas-btn icon-btn" data-action="zoom-reset" <button type="button" class="canvas-btn icon-btn" data-action="zoom-reset"
@ -129,6 +184,44 @@
</section> </section>
</div> </div>
</div> </div>
<div id="refine-modal" class="popup-overlay hidden">
<div class="popup-card">
<header class="popup-header">
<h2>Refine Prompt</h2>
<button id="close-refine-modal" type="button" class="popup-close" aria-label="Close">&times;</button>
</header>
<div class="popup-body">
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.5rem;">How would you like to
change the current prompt?</p>
<textarea id="refine-instruction" placeholder="e.g. Make it more realistic, add dramatic lighting..."
rows="3"></textarea>
<div class="controls-footer" style="margin-top: 1rem; justify-content: flex-end; align-items: center;">
<button id="confirm-refine-btn">
<span>Refine</span>
<div class="btn-shine"></div>
</button>
<div id="refine-loading" class="hidden" style="color: var(--accent-color);">
<svg class="rotating-icon" fill="currentColor" height="32px" width="32px" version="1.1"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512.422 512.422">
<path d="M41.053,223.464c2.667,1.067,5.76,1.067,8.427-0.213l83.307-37.867c5.333-2.56,7.573-8.96,5.013-14.293
c-2.453-5.12-8.533-7.467-13.76-5.12l-58.347,26.56c27.84-83.307,105.387-138.987,194.667-138.987
c93.547,0,175.36,62.507,198.933,152c1.493,5.653,7.36,9.067,13.013,7.573c5.653-1.493,9.067-7.36,7.573-13.013
c-26.027-98.773-116.267-167.893-219.52-167.893c-98.453,0-184.107,61.44-215.04,153.387l-24.533-61.333
c-1.813-5.547-7.893-8.64-13.44-6.827c-5.547,1.813-8.64,7.893-6.827,13.44c0.107,0.427,0.32,0.853,0.533,1.28l34.027,85.333
C36.146,220.158,38.279,222.398,41.053,223.464z" />
<path
d="M511.773,380.904c-0.107-0.213-0.213-0.427-0.213-0.64l-34.027-85.333c-1.067-2.667-3.2-4.907-5.973-5.973
c-2.667-1.067-5.76-0.96-8.427,0.213l-83.307,37.867c-5.44,2.24-8,8.533-5.76,13.973c2.24,5.44,8.533,8,13.973,5.76
c0.213-0.107,0.427-0.213,0.64-0.32l58.347-26.56c-28.053,83.307-105.707,138.987-194.88,138.987
c-93.547,0-175.36-62.507-198.933-152c-1.493-5.653-7.36-9.067-13.013-7.573c-5.653,1.493-9.067,7.36-7.573,13.013
c25.92,98.88,116.267,167.893,219.52,167.893c98.453,0,184-61.44,215.04-153.387l24.533,61.333
c2.027,5.547,8.107,8.427,13.653,6.4C510.919,392.531,513.799,386.451,511.773,380.904z" />
</svg>
</div>
</div>
</div>
</div>
</div>
<div id="popup-overlay" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="popup-title"> <div id="popup-overlay" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="popup-title">
<div class="popup-card"> <div class="popup-card">
<header class="popup-header"> <header class="popup-header">