Add chat-like generate UI with sidebar navigation

- Sidebar with Generate and Gallery icons
- Chat-style generate interface with prompt history
- Images tile in rows as they generate
- Spinner while generating
- Steps input (default 20)
- Mobile responsive
- Lightbox for all images

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adam Ladachowski
2026-02-14 03:08:44 +01:00
parent 030af651bd
commit 19541f610e
+272 -32
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>tsr Gallery</title>
<title>tsr</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/glightbox/dist/css/glightbox.min.css">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -12,29 +12,59 @@
background: #1a1a1a;
color: #fff;
min-height: 100vh;
display: flex;
}
header {
padding: 1rem;
background: #222;
text-align: center;
position: sticky;
top: 0;
z-index: 100;
/* Sidebar */
.sidebar {
width: 60px;
background: #111;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 0;
gap: 0.5rem;
border-right: 1px solid #333;
}
header h1 { font-size: 1.5rem; font-weight: 300; }
.sidebar button {
width: 44px;
height: 44px;
border: none;
background: transparent;
color: #888;
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.sidebar button:hover { background: #222; color: #fff; }
.sidebar button.active { background: #333; color: #fff; }
.sidebar svg { width: 24px; height: 24px; }
/* Main content */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* Views */
.view { display: none; flex: 1; flex-direction: column; overflow: hidden; }
.view.active { display: flex; }
/* Gallery view */
#gallery-view { overflow-y: auto; }
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
padding: 8px;
padding: 12px;
}
@media (min-width: 768px) {
.gallery { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; padding: 12px; }
.gallery { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
}
.gallery a {
aspect-ratio: 1;
overflow: hidden;
border-radius: 4px;
border-radius: 6px;
background: #333;
}
.gallery img {
@@ -44,53 +74,263 @@
transition: transform 0.2s;
}
.gallery a:hover img { transform: scale(1.05); }
.empty {
text-align: center;
padding: 4rem 1rem;
color: #666;
/* Generate view */
#generate-view {
display: flex;
flex-direction: column;
}
.loading {
text-align: center;
padding: 4rem 1rem;
color: #666;
.chat-area {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.chat-message {
margin-bottom: 1rem;
}
.chat-prompt {
color: #888;
font-size: 0.85rem;
margin-bottom: 0.5rem;
padding-left: 4px;
}
.chat-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chat-images a {
width: 150px;
height: 150px;
border-radius: 6px;
overflow: hidden;
background: #333;
}
@media (min-width: 768px) {
.chat-images a { width: 200px; height: 200px; }
}
.chat-images img {
width: 100%;
height: 100%;
object-fit: cover;
}
.chat-images .generating {
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 0.8rem;
}
.chat-images .generating::after {
content: '';
width: 20px;
height: 20px;
border: 2px solid #444;
border-top-color: #888;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 8px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Input area */
.input-area {
padding: 12px;
background: #222;
border-top: 1px solid #333;
}
.input-row {
display: flex;
gap: 8px;
max-width: 800px;
margin: 0 auto;
}
.input-row input[type="text"] {
flex: 1;
padding: 12px 16px;
border: 1px solid #444;
border-radius: 8px;
background: #1a1a1a;
color: #fff;
font-size: 1rem;
}
.input-row input[type="text"]:focus {
outline: none;
border-color: #666;
}
.input-row input[type="number"] {
width: 60px;
padding: 12px 8px;
border: 1px solid #444;
border-radius: 8px;
background: #1a1a1a;
color: #fff;
font-size: 1rem;
text-align: center;
}
.input-row button {
padding: 12px 20px;
border: none;
border-radius: 8px;
background: #0066cc;
color: #fff;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.input-row button:hover { background: #0077ee; }
.input-row button:disabled { background: #444; cursor: not-allowed; }
.empty { text-align: center; padding: 4rem 1rem; color: #666; }
</style>
</head>
<body>
<header>
<h1>tsr Gallery</h1>
</header>
<main>
<div id="gallery" class="gallery">
<div class="loading">Loading...</div>
<nav class="sidebar">
<button id="nav-generate" title="Generate" class="active">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.5 19.5h3m-1.5-1.5v3" />
</svg>
</button>
<button id="nav-gallery" title="Gallery">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
</button>
</nav>
<main class="main">
<!-- Generate View -->
<div id="generate-view" class="view active">
<div class="chat-area" id="chat-area"></div>
<div class="input-area">
<div class="input-row">
<input type="text" id="prompt-input" placeholder="Describe what you want to generate..." autofocus>
<input type="number" id="steps-input" value="20" min="1" max="50" title="Steps">
<button id="generate-btn">Generate</button>
</div>
</div>
</div>
<!-- Gallery View -->
<div id="gallery-view" class="view">
<div id="gallery" class="gallery"></div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/glightbox/dist/js/glightbox.min.js"></script>
<script>
let lightbox = null;
// Navigation
document.getElementById('nav-generate').onclick = () => switchView('generate');
document.getElementById('nav-gallery').onclick = () => switchView('gallery');
function switchView(view) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.querySelectorAll('.sidebar button').forEach(b => b.classList.remove('active'));
document.getElementById(view + '-view').classList.add('active');
document.getElementById('nav-' + view).classList.add('active');
if (view === 'gallery') loadGallery();
}
// Gallery
async function loadGallery() {
const container = document.getElementById('gallery');
try {
const res = await fetch('/api/images?limit=100');
const data = await res.json();
if (!data.images || data.images.length === 0) {
container.innerHTML = '<div class="empty">No images yet</div>';
return;
}
container.innerHTML = data.images.map(img => `
<a href="javascript:void(0)" class="glightbox" data-gallery="gallery" data-type="image" data-href="/api/images/${img.id}" data-description="${img.prompt || ''}">
<a href="javascript:void(0)" class="glightbox" data-type="image" data-href="/api/images/${img.id}">
<img src="/api/images/${img.id}" alt="${img.id}" loading="lazy">
</a>
`).join('');
GLightbox({ selector: '.glightbox' });
initLightbox();
} catch (e) {
container.innerHTML = '<div class="empty">Failed to load gallery</div>';
}
}
loadGallery();
function initLightbox() {
if (lightbox) lightbox.destroy();
lightbox = GLightbox({ selector: '.glightbox' });
}
// Generate
const chatArea = document.getElementById('chat-area');
const promptInput = document.getElementById('prompt-input');
const stepsInput = document.getElementById('steps-input');
const generateBtn = document.getElementById('generate-btn');
promptInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
generate();
}
});
generateBtn.onclick = generate;
async function generate() {
const prompt = promptInput.value.trim();
if (!prompt) return;
const steps = parseInt(stepsInput.value) || 20;
promptInput.value = '';
generateBtn.disabled = true;
// Add message
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-message';
msgDiv.innerHTML = `
<div class="chat-prompt">${escapeHtml(prompt)}</div>
<div class="chat-images">
<div class="generating">Generating</div>
</div>
`;
chatArea.appendChild(msgDiv);
chatArea.scrollTop = chatArea.scrollHeight;
try {
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
steps: steps,
width: 512,
height: 512,
save_to_gallery: true
})
});
const data = await res.json();
const imagesDiv = msgDiv.querySelector('.chat-images');
if (data.images && data.images.length > 0) {
imagesDiv.innerHTML = data.images.map(id => `
<a href="javascript:void(0)" class="glightbox" data-type="image" data-href="/api/images/${id}">
<img src="/api/images/${id}" alt="${id}">
</a>
`).join('');
initLightbox();
} else {
imagesDiv.innerHTML = '<div class="empty">Generation failed</div>';
}
} catch (e) {
msgDiv.querySelector('.chat-images').innerHTML = '<div class="empty">Error: ' + e.message + '</div>';
}
generateBtn.disabled = false;
chatArea.scrollTop = chatArea.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>
</body>
</html>