This commit is contained in:
phamhungd 2025-11-21 22:01:45 +07:00
parent 16c71242d2
commit c456468b31
7 changed files with 342 additions and 81 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
.DS_Store
.DS_Store
.DS_Store
/.venv

17
run_app.command Executable file
View file

@ -0,0 +1,17 @@
# /opt/miniconda3/bin/python is required by the user
#!/bin/zsh
cd "$(dirname "$0")"
PYTHON_BIN="/opt/miniconda3/bin/python"
# Create a virtual environment if missing, then activate it
if [[ ! -d ".venv" ]]; then
"$PYTHON_BIN" -m venv .venv
fi
source .venv/bin/activate
# Ensure dependencies are available (skip reinstall if up-to-date)
pip install -r requirements.txt
# Start the Flask app on port 8888
exec .venv/bin/python app.py

View file

@ -23,6 +23,11 @@ document.addEventListener('DOMContentLoaded', () => {
let cachedReferenceImages = [];
const imageDisplayArea = document.querySelector('.image-display-area');
const canvasToolbar = document.querySelector('.canvas-toolbar');
const popupOverlay = document.getElementById('popup-overlay');
const popupTitleEl = document.getElementById('popup-title');
const popupBodyEl = document.getElementById('popup-body');
const popupCloseBtn = document.getElementById('popup-close');
const popupButtons = document.querySelectorAll('[data-popup-target]');
const ZOOM_STEP = 0.1;
const MIN_ZOOM = 0.4;
const MAX_ZOOM = 4;
@ -31,6 +36,48 @@ document.addEventListener('DOMContentLoaded', () => {
let isPanning = false;
let lastPointer = { x: 0, y: 0 };
const infoContent = {
title: 'Thông tin',
sections: [
{
heading: 'Liên hệ',
items: [
'Người tạo: Phạm Hưng',
'Group: <a href="https://www.facebook.com/groups/stablediffusion.vn/" target="_blank" rel="noreferrer">SDVN - Cộng đồng AI Art</a>',
'Website: <a href="https://sdvn.vn" target="_blank" rel="noreferrer">sdvn.vn</a>',
],
},
],
};
const docsContent = {
title: 'Phím tắt và mẹo',
sections: [
{
heading: 'Phím tắt',
items: [
'Ctrl/Cmd + Enter → tạo ảnh mới',
'D → tải ảnh hiện tại',
'R → reset zoom/pan vùng hiển thị ảnh',
'Esc → đóng popup thông tin/docs',
],
},
{
heading: 'Thao tác nhanh',
items: [
'Kéo ảnh từ lịch sử vào ô tham chiếu để tái sử dụng',
'Tùy chỉnh tỉ lệ và độ phân giải trước khi nhấn Generate',
'API key và prompt được lưu để lần sau không phải nhập lại',
],
},
],
};
const POPUP_CONTENT = {
info: infoContent,
docs: docsContent,
};
// Load gallery on start
loadSettings();
initializeImageInputs();
@ -193,6 +240,15 @@ document.addEventListener('DOMContentLoaded', () => {
const img = document.createElement('img');
img.src = withCacheBuster(imageUrl);
img.loading = 'lazy';
img.draggable = true;
img.dataset.source = imageUrl;
img.addEventListener('dragstart', event => {
event.dataTransfer?.setData('text/uri-list', imageUrl);
event.dataTransfer?.setData('text/plain', imageUrl);
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'copy';
}
});
div.appendChild(img);
galleryGrid.appendChild(div);
@ -456,12 +512,19 @@ document.addEventListener('DOMContentLoaded', () => {
slot.classList.remove('drag-over');
});
slot.addEventListener('drop', event => {
slot.addEventListener('drop', async event => {
event.preventDefault();
slot.classList.remove('drag-over');
const file = event.dataTransfer?.files?.[0];
if (file) {
handleSlotFile(index, file);
return;
}
const imageUrl = event.dataTransfer?.getData('text/uri-list')
|| event.dataTransfer?.getData('text/plain');
if (imageUrl) {
await handleSlotDropFromHistory(index, imageUrl);
}
});
@ -493,6 +556,24 @@ document.addEventListener('DOMContentLoaded', () => {
reader.readAsDataURL(file);
}
async function handleSlotDropFromHistory(index, imageUrl) {
try {
const response = await fetch(withCacheBuster(imageUrl));
if (!response.ok) {
console.warn('Failed to fetch history image', response.statusText);
return;
}
const blob = await response.blob();
const name = imageUrl.split('/').pop()?.split('?')[0] || `history-${index + 1}.png`;
const type = blob.type || 'image/png';
const file = new File([blob], name, { type });
handleSlotFile(index, file);
} catch (error) {
console.error('Unable to import history image', error);
}
}
function updateSlotVisual(index) {
const slotRecord = imageSlotState[index];
if (!slotRecord) return;
@ -580,4 +661,57 @@ document.addEventListener('DOMContentLoaded', () => {
return null;
}
}
popupButtons.forEach(button => {
button.addEventListener('click', () => {
const target = button.dataset.popupTarget;
if (target) {
showPopup(target);
}
});
});
if (popupCloseBtn) {
popupCloseBtn.addEventListener('click', closePopup);
}
if (popupOverlay) {
popupOverlay.addEventListener('click', event => {
if (event.target === popupOverlay) {
closePopup();
}
});
}
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && popupOverlay && !popupOverlay.classList.contains('hidden')) {
event.preventDefault();
closePopup();
}
});
function showPopup(type) {
const content = POPUP_CONTENT[type];
if (!content || !popupOverlay || !popupBodyEl || !popupTitleEl) return;
popupTitleEl.textContent = content.title;
popupBodyEl.innerHTML = content.sections
.map(section => {
const items = (section.items || []).map(item => `<li>${item}</li>`).join('');
return `
<section class="popup-section">
<h3>${section.heading}</h3>
<ul>${items}</ul>
</section>
`;
})
.join('');
popupOverlay.classList.remove('hidden');
}
function closePopup() {
if (!popupOverlay) return;
popupOverlay.classList.add('hidden');
}
});

BIN
static/sdvn-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -63,7 +63,7 @@ body {
border: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: row;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
gap: 0.75rem;
padding: 1rem;
@ -111,28 +111,6 @@ body {
cursor: not-allowed;
}
.toolbar-generate {
display: flex;
align-items: center;
gap: 0.35rem;
}
.toolbar-generate #generate-btn {
padding: 0.4rem 1rem;
font-size: 0.95rem;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.45rem;
min-width: 90px;
}
.toolbar-generate #generate-btn {
padding: 0.35rem 0.9rem;
font-size: 0.85rem;
line-height: 1.1;
}
.brand {
margin-bottom: 2rem;
@ -148,18 +126,51 @@ body {
}
.badge {
font-size: 0.75rem;
background-color: rgba(245, 197, 24, 0.15);
font-size: 0.5rem;
/* background-color: rgba(245, 197, 24, 0.15); */
color: var(--accent-color);
padding: 0.25rem 0.5rem;
border-radius: 999px;
padding: 0.2rem 0.3rem;
/* border-radius: 999px; */
font-weight: 600;
}
.controls-section {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
min-height: 0;
}
.controls-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.controls-footer {
display: flex;
justify-content: flex-end;
}
.controls-footer #generate-btn {
padding: 0.4rem 1rem;
font-size: 0.95rem;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.45rem;
min-width: 90px;
}
.controls-footer #generate-btn {
padding: 0.35rem 0.9rem;
font-size: 0.85rem;
line-height: 1.1;
}
.input-group {
@ -517,6 +528,95 @@ button#generate-btn:disabled {
color: inherit;
}
.popup-overlay {
position: fixed;
inset: 0;
background: rgba(3, 3, 10, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.popup-card {
width: min(520px, 100%);
background: rgba(10, 11, 22, 0.96);
border: 1px solid var(--border-color);
border-radius: 1rem;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.8);
padding: 1.25rem;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.popup-header h2 {
font-size: 1rem;
letter-spacing: 0.3px;
color: var(--text-primary);
}
.popup-close {
border: none;
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
width: 32px;
height: 32px;
border-radius: 999px;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.popup-close:hover {
background: rgba(255, 255, 255, 0.18);
}
.popup-body {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.popup-section h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 0.35rem;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.popup-section ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
padding-left: 0;
}
.popup-section li {
font-size: 0.8rem;
color: var(--text-primary);
padding-left: 1rem;
position: relative;
}
.popup-section li::before {
content: '•';
position: absolute;
left: 0;
color: var(--accent-color);
}
/* Gallery Section */
.history-section {
border-radius: 1.25rem;

View file

@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>aPix Image Workspace</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='sdvn-logo.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -15,11 +16,11 @@
<div class="app-container">
<aside class="sidebar">
<div class="brand">
<h1>aPix</h1>
<span class="badge">Creative Studio</span>
<h1>aPix <span class="badge">by SDVN</span></h1>
</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">
@ -63,21 +64,20 @@
<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="content-area">
<div class="top-toolbar">
<div class="toolbar-info">
<button type="button" class="toolbar-info-btn">Info</button>
<button type="button" class="toolbar-info-btn">Docs</button>
<button type="button" class="toolbar-info-btn" disabled>Preview</button>
</div>
<div class="toolbar-generate">
<button id="generate-btn">
<span>Generate</span>
<div class="btn-shine"></div>
</button>
<button type="button" class="toolbar-info-btn" data-popup-target="docs">Docs</button>
<button type="button" class="toolbar-info-btn" data-popup-target="info">Info</button>
</div>
</div>
<main class="main-content">
@ -120,6 +120,15 @@
</section>
</div>
</div>
<div id="popup-overlay" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="popup-title">
<div class="popup-card">
<header class="popup-header">
<h2 id="popup-title"></h2>
<button id="popup-close" type="button" class="popup-close" aria-label="Close">&times;</button>
</header>
<div id="popup-body" class="popup-body"></div>
</div>
</div>
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>