refactor(ui): implement base template and shared CSS

Introduce a `base.html` template to establish a common structure for all pages using Jinja2 template inheritance. This eliminates redundant HTML boilerplate across multiple files.

Additionally, common CSS rules have been extracted from individual templates and consolidated into a single `static/css/main.css` file. All pages now link to this shared stylesheet.

This refactoring significantly reduces code duplication, improves maintainability, and ensures a consistent user interface.
This commit is contained in:
Karl 2025-07-18 13:56:38 +01:00
parent 31b373b34a
commit aa8e1d1701
8 changed files with 296 additions and 403 deletions

51
static/css/main.css Normal file
View File

@ -0,0 +1,51 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: black;
color: white;
font-family: sans-serif;
padding: 2rem;
}
.button-link {
background: #333;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
display: inline-block;
text-align: center;
border: none;
}
.button-link:hover {
background: #555;
}
.version {
position: fixed;
bottom: 8px;
right: 12px;
color: #666;
font-size: 12px;
font-family: monospace;
user-select: none;
opacity: 0.6;
}
.version a {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.version a:hover {
text-decoration: underline;
}

20
templates/base.html Normal file
View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}AI Image Frame{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
<!-- Version number at bottom right -->
<div class="version">
<a href="{{ url_for('settings_route.config_editor') }}">v{{ version }}</a>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -1,219 +1,199 @@
<!DOCTYPE html>
<html lang="en">
{% extends "base.html" %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Create An Image</title>
<style>
/* ---------- reset ---------- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
{% block title %}Create An Image{% endblock %}
{% block head %}
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
padding: 20px;
}
textarea {
width: 80vw;
height: 200px;
border-radius: 10px;
padding: 15px;
font-size: 16px;
font-family: monospace;
resize: none;
margin-bottom: 20px;
background: #111;
color: #eee;
border: 1px solid #333;
}
.button-group {
display: flex;
gap: 20px;
align-items: center;
}
button,
select {
background: #333;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
button:hover,
select:hover {
background: #555;
}
#spinner-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
visibility: hidden;
z-index: 1000;
}
.spinner {
width: 50px;
height: 50px;
border: 6px solid #555;
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- layout ---------- */
@media (max-width: 600px) {
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: black;
color: white;
font-family: Arial, sans-serif;
padding: 20px;
}
textarea {
width: 80vw;
height: 200px;
border-radius: 10px;
padding: 15px;
font-size: 16px;
font-family: monospace;
resize: none;
margin-bottom: 20px;
background: #111;
color: #eee;
border: 1px solid #333;
min-height: 100dvh;
height: auto;
justify-content: flex-start;
padding-top: 40px;
}
.button-group {
display: flex;
gap: 20px;
align-items: center;
flex-direction: column;
align-items: stretch;
width: 100%;
}
button,
select {
background: #333;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
width: 100%;
}
button:hover,
select:hover {
background: #555;
textarea {
height: 150px;
}
}
</style>
{% endblock %}
/* ---------- spinner ---------- */
#spinner-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
visibility: hidden;
z-index: 1000;
}
{% block content %}
<h1 style="margin-bottom: 20px;">Create An Image</h1>
.spinner {
width: 50px;
height: 50px;
border: 6px solid #555;
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
<textarea id="prompt-box" placeholder="Enter your custom prompt here..."></textarea>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
<div class="button-group">
<button onclick="showSpinner(); location.href='/'">Back</button>
@media (max-width: 600px) {
body {
min-height: 100dvh;
height: auto;
justify-content: flex-start;
padding-top: 40px;
}
<button onclick="sendPrompt()">Send Prompt</button>
.button-group {
flex-direction: column;
align-items: stretch;
width: 100%;
}
<button onclick="randomPrompt()">Random Prompt</button>
button,
select {
width: 100%;
}
<select id="model-select">
<option value="" selected>Random</option>
<optgroup label="FLUX">
{% for m in models if 'flux' in m|lower %}
<option value="{{ m }}">{{ m.rsplit('.', 1)[0] }}</option>
{% endfor %}
</optgroup>
<optgroup label="SDXL">
{% for m in models if 'flux' not in m|lower %}
<option value="{{ m }}">{{ m.rsplit('.', 1)[0] }}</option>
{% endfor %}
</optgroup>
</select>
textarea {
height: 150px;
}
}
</style>
</head>
<select id="topic-select">
<option value="">No Topic</option>
<option value="random">Random</option>
<optgroup label="Topics">
{% for t in topics %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</optgroup>
</select>
</div>
<body>
<h1 style="margin-bottom: 20px;">Create An Image</h1>
<div id="spinner-overlay">
<div class="spinner"></div>
</div>
{% endblock %}
<textarea id="prompt-box" placeholder="Enter your custom prompt here..."></textarea>
{% block scripts %}
<script>
const overlay = document.getElementById('spinner-overlay');
<div class="button-group">
<button onclick="showSpinner(); location.href='/'">Back</button>
function showSpinner() { overlay.style.visibility = 'visible'; }
<button onclick="sendPrompt()">Send Prompt</button>
function sendPrompt() {
showSpinner();
const prompt = document.getElementById('prompt-box').value;
const model = document.getElementById('model-select').value;
<button onclick="randomPrompt()">Random Prompt</button>
const formData = new URLSearchParams();
formData.append('prompt', prompt);
formData.append('model', model);
<select id="model-select">
<option value="" selected>Random</option>
<!-- Group: FLUX -->
<optgroup label="FLUX">
{% for m in models if 'flux' in m|lower %}
<option value="{{ m }}">{{ m.rsplit('.', 1)[0] }}</option>
{% endfor %}
</optgroup>
<!-- Group: SDXL -->
<optgroup label="SDXL">
{% for m in models if 'flux' not in m|lower %}
<option value="{{ m }}">{{ m.rsplit('.', 1)[0] }}</option>
{% endfor %}
</optgroup>
</select>
<select id="topic-select">
<option value="">No Topic</option>
<option value="random">Random</option>
<optgroup label="Topics">
{% for t in topics %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</optgroup>
</select>
</div>
<!-- waiting overlay -->
<div id="spinner-overlay">
<div class="spinner"></div>
</div>
<script>
const overlay = document.getElementById('spinner-overlay');
function showSpinner() { overlay.style.visibility = 'visible'; }
function sendPrompt() {
showSpinner();
const prompt = document.getElementById('prompt-box').value;
const model = document.getElementById('model-select').value;
const formData = new URLSearchParams();
formData.append('prompt', prompt);
formData.append('model', model);
fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
})
.then(response => {
window.location.href = response.redirected ? response.url : '/create';
})
.then(response => {
window.location.href = response.redirected ? response.url : '/create';
})
.catch(error => {
overlay.style.visibility = 'hidden';
alert("Error sending prompt: " + error);
});
}
.catch(error => {
overlay.style.visibility = 'hidden';
alert("Error sending prompt: " + error);
});
}
// wrapper for Random Prompt button so it also sends the model
function randomPrompt() {
showSpinner();
const model = document.getElementById('model-select').value;
const topic = document.getElementById('topic-select').value; // this line was missing
function randomPrompt() {
showSpinner();
const model = document.getElementById('model-select').value;
const topic = document.getElementById('topic-select').value;
const formData = new URLSearchParams();
formData.append('model', model);
formData.append('topic', topic); // include topic in request
const formData = new URLSearchParams();
formData.append('model', model);
formData.append('topic', topic);
fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
})
.then(response => {
window.location.href = response.redirected ? response.url : '/create';
})
.then(response => {
window.location.href = response.redirected ? response.url : '/create';
})
.catch(error => {
overlay.style.visibility = 'hidden';
alert("Error requesting random prompt: " + error);
});
}
</script>
</body>
</html>
.catch(error => {
overlay.style.visibility = 'hidden';
alert("Error requesting random prompt: " + error);
});
}
</script>
{% endblock %}

View File

@ -1,24 +1,9 @@
<!DOCTYPE html>
<html lang="en">
{% extends "base.html" %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image Archive</title>
{% block title %}Image Archive{% endblock %}
{% block head %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: black;
color: white;
font-family: sans-serif;
padding: 2rem;
}
h1 {
text-align: center;
margin-bottom: 2rem;
@ -102,18 +87,14 @@
text-align: left;
margin-top: 20px;
max-height: 25vh;
/* NEW: restrict height */
overflow-y: auto;
/* NEW: allow vertical scroll */
}
/* Back button fixed top right */
.home-button {
position: fixed;
top: 20px;
right: 20px;
z-index: 500;
/* lower than lightbox (999) */
}
.favourites-button {
@ -132,24 +113,6 @@
cursor: pointer;
}
.button-link {
background: #333;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
display: inline-block;
text-align: center;
border: none;
}
.button-link:hover {
background: #555;
}
@media (max-width: 600px) {
body {
padding: 1rem;
@ -181,9 +144,7 @@
max-width: 90%;
padding: 8px 16px;
max-height: 20vh;
/* smaller height for mobile */
overflow-y: auto;
/* keep scroll on mobile too */
}
.button-link {
@ -192,18 +153,16 @@
}
}
</style>
</head>
{% endblock %}
<body>
{% block content %}
<a href="/" class="button-link home-button">Home</a>
<button class="button-link favourites-button" id="favourites-button" onclick="toggleFavouritesView()">Show Favourites</button>
<h1 id="page-title">Image Archive</h1>
<!-- Empty gallery container; images will be loaded incrementally -->
<div class="gallery" id="gallery"></div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox" tabindex="-1" onkeyup="handleLightboxKeys(event)">
<span class="close" onclick="closeLightbox()">&times;</span>
<span class="favourite-heart" id="favourite-heart" onclick="toggleFavourite()">&#9825;</span>
@ -212,8 +171,9 @@
<p id="lightbox-prompt"></p>
<span class="arrow right" onclick="nextImage()">&#10095;</span>
</div>
{% endblock %}
<!-- Pass image filenames from Flask to JS -->
{% block scripts %}
<script>
let allImages = JSON.parse(`[
{% for image in images %}
@ -224,10 +184,10 @@
<script>
const gallery = document.getElementById('gallery');
const batchSize = 9; // images to load per batch
const batchSize = 9;
let loadedCount = 0;
let currentIndex = 0;
const detailsCache = {}; // Cache for image details
const detailsCache = {};
let showingFavourites = false;
let filteredImages = allImages;
@ -276,19 +236,16 @@
renderGallery();
}
// Load initial batch
renderGallery();
// Load more images when scrolling near bottom
window.addEventListener('scroll', () => {
const imagesToLoad = showingFavourites ? filteredImages : allImages;
if (loadedCount >= imagesToLoad.length) return; // all loaded
if (loadedCount >= imagesToLoad.length) return;
if ((window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 100)) {
loadNextBatch();
}
});
// Get current images in gallery for lightbox navigation
function getGalleryImages() {
return Array.from(gallery.querySelectorAll('img'));
}
@ -304,7 +261,7 @@
function updateFavouriteHeart(isFavourited) {
const heart = document.getElementById('favourite-heart');
heart.innerHTML = isFavourited ? '&#9829;' : '&#9825;'; // solid vs outline heart
heart.innerHTML = isFavourited ? '&#9829;' : '&#9825;';
heart.style.color = isFavourited ? 'red' : 'white';
}
@ -361,7 +318,7 @@
return response.json();
})
.then(data => {
detailsCache[filename] = data; // Cache the data
detailsCache[filename] = data;
document.getElementById("lightbox-prompt").textContent =
`Model:${data.model} - Created:${data.date}\n\n${data.prompt}`;
})
@ -376,7 +333,6 @@
const imagesToLoad = showingFavourites ? filteredImages : allImages;
if (currentIndex + 1 >= images.length && loadedCount < imagesToLoad.length) {
loadNextBatch();
// Wait briefly to ensure DOM updates
setTimeout(() => {
const updatedImages = getGalleryImages();
if (currentIndex + 1 < updatedImages.length) {
@ -396,11 +352,9 @@
showImageAndLoadDetails(currentIndex);
}
function closeLightbox() {
document.getElementById("lightbox").style.display = "none";
if (showingFavourites) {
// Refresh the gallery if a favourite was removed
const currentImage = getGalleryImages()[currentIndex];
const wasFavourited = currentImage.dataset.favourited === 'true';
const originalImage = allImages.find(img => img.filename === currentImage.dataset.filename);
@ -422,6 +376,4 @@
}
}
</script>
</body>
</html>
{% endblock %}

View File

@ -1,25 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Created</title>
{% extends "base.html" %}
{% block title %}Image Created{% endblock %}
{% block head %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: black;
color: white;
font-family: Arial, sans-serif;
padding: 20px;
text-align: center;
}
.message {
@ -50,13 +40,13 @@
background: #555;
}
</style>
</head>
<body>
{% endblock %}
{% block content %}
<div class="message">Image will be made with <i>{{ model }}</i> using prompt:</div>
<div class="prompt-text">
{{ prompt }}
</div>
<button onclick="location.href='/'">Home</button>
</body>
</html>
{% endblock %}

View File

@ -1,32 +1,20 @@
<!DOCTYPE html>
<html lang="en">
{% extends "base.html" %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Image of the Day</title>
{% block title %}AI Image of the Day{% endblock %}
{% block head %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: black;
color: white;
font-family: Arial, sans-serif;
position: relative;
padding-top: 20px;
padding-bottom: 20px;
}
.image-container {
max-width: 90vw;
max-height: 80vh;
@ -54,9 +42,7 @@
max-width: 80vw;
text-align: left;
max-height: 30vh;
/* NEW: limit height */
overflow-y: auto;
/* NEW: allow scrolling */
}
.button-group {
@ -66,46 +52,6 @@
justify-content: center;
}
.button-link {
background: #333;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
display: inline-block;
text-align: center;
}
.button-link:hover {
background: #555;
}
/* New style for version number */
.version {
position: fixed;
bottom: 8px;
right: 12px;
color: #666;
font-size: 12px;
font-family: monospace;
user-select: none;
opacity: 0.6;
}
.version a {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.version a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.image-container {
max-width: 100vw;
@ -134,14 +80,9 @@
}
}
</style>
<script>
setInterval(() => {
location.reload();
}, {{ reload_interval }}); // Refresh every X ms
</script>
</head>
{% endblock %}
<body>
{% block content %}
{% if image %}
<div class="image-container">
<img src="{{ url_for('image_routes.serve_image', filename=image) }}" alt="Latest Image" />
@ -156,11 +97,12 @@
{% else %}
<p>No images found</p>
{% endif %}
{% endblock %}
<!-- Version number at bottom right -->
<div class="version">
<a href="{{ url_for('settings_route.config_editor') }}">v{{ version }}</a>
</div>
</body>
</html>
{% block scripts %}
<script>
setInterval(() => {
location.reload();
}, {{ reload_interval }}); // Refresh every X ms
</script>
{% endblock %}

View File

@ -1,25 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block head %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: black;
color: white;
font-family: Arial, sans-serif;
padding: 20px;
text-align: center;
}
.message {
@ -59,8 +49,9 @@
background: #555;
}
</style>
</head>
<body>
{% endblock %}
{% block content %}
<div class="message">Please enter the password to continue:</div>
<form method="post">
<div class="prompt-text">
@ -69,5 +60,4 @@
</div>
<button type="submit">Login</button>
</form>
</body>
</html>
{% endblock %}

View File

@ -1,21 +1,10 @@
<!DOCTYPE html>
<html lang="en">
{% extends "base.html" %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Config Editor</title>
{% block title %}Config Editor{% endblock %}
{% block head %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: black;
color: white;
font-family: Arial, sans-serif;
min-height: 100vh;
padding: 40px 20px;
display: flex;
@ -74,21 +63,11 @@
margin-top: 20px;
}
.button-link {
background: #333;
height: 40px;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
transition: background 0.3s;
display: inline-block;
text-align: center;
}
.button-link:hover {
background: #555;
.back-button-wrapper {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
}
@media (max-width: 768px) {
@ -102,18 +81,10 @@
margin-bottom: 30px;
}
}
.back-button-wrapper {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
</head>
<body>
{% endblock %}
{% block content %}
<div class="box">
<h2>Topics</h2>
<form method="post">
@ -186,7 +157,4 @@
<div class="back-button-wrapper">
<a href="/" class="button-link">Back to Home</a>
</div>
</body>
</html>
{% endblock %}