Compare commits

..

2 Commits
main ... 0.2.9

Author SHA1 Message Date
79c7e4d92c Bump version: 0.2.8 → 0.2.9 2025-07-09 12:54:23 +01:00
b072ec6c65 reworked favourites logic 2025-07-09 12:54:17 +01:00
15 changed files with 467 additions and 480 deletions

View File

@ -1,24 +1,19 @@
[tool.bumpversion]
current_version = "0.2.16"
current_version = "0.2.9"
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
serialize = ["{major}.{minor}.{patch}"]
search = "{current_version}"
replace = "{new_version}"
regex = false
ignore_missing_version = false
ignore_missing_files = false
tag = true
commit = true
message = "Bump version: {current_version} → {new_version}"
sign_tags = false
tag_name = "{new_version}"
tag_message = "Bump version: {current_version} → {new_version}"
[[tool.bumpversion.files]]
filename = ".bumpversion.toml"
search = 'current_version = "{current_version}"'
replace = 'current_version = "{new_version}"'
[[tool.bumpversion.files]]
filename = "Dockerfile"
search = 'ARG VERSION="{current_version}"'
replace = 'ARG VERSION="{new_version}"'
allow_dirty = false
commit = true
message = "Bump version: {current_version} → {new_version}"
moveable_tags = []
commit_args = ""
setup_hooks = []

View File

@ -3,13 +3,9 @@ FROM python:3.11-slim
# Set the working directory in the container
WORKDIR /app
# Set version label
ARG VERSION="0.2.16"
LABEL version=$VERSION
# Copy project files into the container
COPY . /app
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt

108
README.md
View File

@ -1,108 +0,0 @@
# AI Image Frame Server
This project is a Flask-based web server designed to generate and display images from various AI models, primarily interacting with ComfyUI. It can be configured to automatically generate new images at scheduled times and provides a gallery to view the generated images.
## Features
* **Web Interface:** A simple web interface to view generated images.
* **Image Generation:** Integrates with ComfyUI to generate images based on given prompts and models.
* **Scheduled Generation:** Automatically generates new images at a configurable time.
* **Docker Support:** Comes with a `Dockerfile` and `docker-compose.yml` for easy setup and deployment.
* **Configurable:** Most options can be configured through a `user_config.cfg` file.
* **Authentication:** Optional password protection for image creation.
## Prerequisites
* Python 3.11
* Docker (for containerized deployment)
* An instance of ComfyUI running and accessible from the server.
## Installation and Setup
### Manual Setup
1. **Clone the repository:**
```bash
git clone <repository_url>
cd ai_image_frame_server
```
2. **Install dependencies:**
```bash
pip install -r requirements.txt
```
3. **Configure the application:**
* Copy the `user_config.cfg.sample` to `user_config.cfg`.
* Edit `user_config.cfg` with your settings. See the [Configuration](#configuration) section for more details.
4. **Run the application:**
```bash
export SECRET_KEY='a_very_secret_key'
python ai_frame_image_server.py
```
### Docker Setup
1. **Clone the repository:**
```bash
git clone <repository_url>
cd ai_image_frame_server
```
2. **Configure the application:**
* Copy the `user_config.cfg.sample` to `user_config.cfg`.
* Edit `user_config.cfg` with your settings. The `comfyui_url` should be the address of your ComfyUI instance, accessible from within the Docker network (e.g., `http://host.docker.internal:8188` or your server's IP).
3. **Build and run with Docker Compose:**
```bash
docker-compose up --build
```
The application will be available at `http://localhost:8088`.
## Configuration
The application is configured via the `user_config.cfg` file.
| Section | Key | Description | Default (from sample) |
| :-------- | :------------------- | :-------------------------------------------------------------------------- | :-------------------- |
| `[frame]` | `reload_interval` | How often the gallery page reloads in milliseconds. | `30000` |
| `[frame]` | `auto_regen` | Enable or disable automatic image generation (`True`/`False`). | `True` |
| `[frame]` | `regen_time` | The time to automatically generate a new image (HH:MM). | `03:00` |
| `[frame]` | `port` | The port the Flask application will run on. | `5000` |
| `[frame]` | `create_requires_auth` | Require a password to create images (`True`/`False`). | `False` |
| `[frame]` | `password_for_auth` | The password to use for image creation if authentication is enabled. | `create` |
| `[comfyui]` | `comfyui_url` | The URL of your ComfyUI instance. | `http://comfyui` |
| `[comfyui]` | `models` | A comma-separated list of models to use for generation. | `zavychromaxl_v100.safetensors,ponyDiffusionV6XL_v6StartWithThisOne.safetensors` |
| `[comfyui]` | `output_dir` | The directory to save generated images to. | `./output/` |
| `[comfyui]` | `prompt` | The prompt to use for generating a random prompt for stable diffusion. | `"Generate a random detailed prompt for stable diffusion."` |
| `[comfyui]` | `width` | The width of the generated image. | `1568` |
| `[comfyui]` | `height` | The height of the generated image. | `672` |
| `[comfyui]` | `topics` | A comma-separated list of topics to generate prompts from. | |
| `[comfyui]` | `FLUX` | Enable FLUX models (`True`/`False`). | `False` |
| `[comfyui]` | `ONLY_FLUX` | Only use FLUX models (`True`/`False`). | `False` |
| `[comfyui:flux]` | `models` | A comma-separated list of FLUX models. | `flux1-dev-Q4_0.gguf,flux1-schnell-Q4_0.gguf` |
| `[openwebui]` | `base_url` | The base URL for OpenWebUI. | `https://openwebui` |
| `[openwebui]` | `api_key` | The API key for OpenWebUI. | `sk-` |
| `[openwebui]` | `models` | A comma-separated list of models for OpenWebUI. | `llama3:latest,cogito:14b,gemma3:12b` |
## Usage
* **Gallery:** Open your browser to `http://<server_ip>:<port>` to see the gallery of generated images.
* **Create Image:** Navigate to `/create` to manually trigger image generation.
## Dependencies
* Flask
* comfy_api_simplified
* APScheduler
* Pillow
* And others, see `requirements.txt`.
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
## License
This project is unlicensed.

View File

@ -1,18 +0,0 @@
#!/bin/bash
set -ex
# Check if an argument is provided
if [ -z "$1" ]; then
echo "Usage: $0 <part>"
echo "Example: $0 patch"
exit 1
fi
PART=$1
# Bump the version
bump-my-version bump $PART
# Push the changes
git push
git push origin --tags

View File

@ -103,8 +103,7 @@ def generate_image(
# Generate image
logging.debug(f"Generating image: {file_name}")
results = api.queue_and_wait_images(wf, save_node)
rename_image()
for _, image_data in results.items():
output_path = os.path.join(
user_config["comfyui"]["output_dir"], f"{file_name}.png"
@ -112,6 +111,7 @@ def generate_image(
with open(output_path, "wb+") as f:
f.write(image_data)
generate_thumbnail(output_path)
rename_image(f"{file_name}.png")
logging.debug(f"Image generated successfully for UID: {file_name}")
@ -143,7 +143,7 @@ def select_model(model: str) -> tuple[str, str]:
def create_image(prompt: str | None = None, model: str = "Random") -> None:
"""Generate an image with a chosen workflow (Random, FLUX*, or SDXL*)."""
from datetime import datetime
if prompt is None:
prompt = create_prompt_on_openwebui(user_config["comfyui"]["prompt"])
@ -153,10 +153,14 @@ def create_image(prompt: str | None = None, model: str = "Random") -> None:
save_prompt(prompt)
selected_workflow, model = select_model(model)
# Generate a unique filename using a timestamp
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
file_name = f"{timestamp}_{random.getrandbits(32)}"
if selected_workflow == "FLUX":
generate_image(
file_name="image",
file_name=file_name,
comfy_prompt=prompt,
workflow_path="./workflow_flux.json",
prompt_node="Positive Prompt T5",
@ -169,6 +173,6 @@ def create_image(prompt: str | None = None, model: str = "Random") -> None:
model=model
)
else: # SDXL
generate_image("image", comfy_prompt=prompt, model=model)
generate_image(file_name, comfy_prompt=prompt, model=model)
logging.info(f"{selected_workflow} generation started with prompt: {prompt}")

View File

@ -48,33 +48,41 @@ def load_config() -> configparser.ConfigParser:
sys.exit(1)
def rename_image() -> str | None:
"""Renames 'image.png' in the output folder to a timestamped filename if it exists."""
old_path = os.path.join(user_config["comfyui"]["output_dir"], "image.png")
favourites_file = "./favourites.json"
def rename_image(new_filename: str) -> str | None:
"""Renames the latest image in the output folder to a timestamped filename."""
output_dir = user_config["comfyui"]["output_dir"]
image_png_path = os.path.join(output_dir, "image.png")
# Check if image.png exists and is a favourite
if os.path.exists(image_png_path):
favourites = get_favourites()
if "image.png" in favourites:
# It's a favourite, so rename it to preserve it
timestamped_filename = f"{datetime.now().strftime('%Y%m%d-%H%M%S')}.png"
timestamped_path = os.path.join(output_dir, timestamped_filename)
os.rename(image_png_path, timestamped_path)
# Update favourites list
favourites.remove("image.png")
favourites.append(timestamped_filename)
save_favourites(favourites)
print(f"Preserved favourite 'image.png' as '{timestamped_filename}'")
if os.path.exists(old_path):
new_filename = f"{str(time.time())}.png"
new_path = os.path.join(user_config["comfyui"]["output_dir"], new_filename)
# Check if image.png is a favourite
if os.path.exists(favourites_file):
with open(favourites_file, 'r') as f:
favourites = json.load(f)
if "image.png" in favourites:
favourites.remove("image.png")
favourites.append(new_filename)
with open(favourites_file, 'w') as f:
json.dump(favourites, f)
os.rename(old_path, new_path)
generate_thumbnail(new_path)
print(f"Renamed 'image.png' to '{new_filename}'")
return new_filename
else:
print("No image.png found.")
# Find the latest generated image (which is the new_filename)
latest_image_path = os.path.join(output_dir, new_filename)
if not os.path.exists(latest_image_path):
print(f"Error: Newly generated image '{new_filename}' not found.")
return None
# Rename the latest image to "image.png"
if os.path.exists(image_png_path):
os.remove(image_png_path) # remove if it wasn't a favourite
os.rename(latest_image_path, image_png_path)
generate_thumbnail(image_png_path)
print(f"Renamed '{new_filename}' to 'image.png'")
return "image.png"
def get_details_from_png(path):
try:
@ -124,5 +132,18 @@ def load_topics_from_config():
sorted_topics = sorted(topics, key=str.lower)
return sorted_topics
favourites_file = "./favourites.json"
def get_favourites():
if not os.path.exists(favourites_file):
return []
with open(favourites_file, 'r') as f:
return json.load(f)
def save_favourites(favourites):
with open(favourites_file, 'w') as f:
json.dump(favourites, f)
user_config = load_config()
output_folder = user_config["comfyui"]["output_dir"]

View File

@ -4,17 +4,7 @@ import json
bp = Blueprint("gallery_routes", __name__)
image_folder = "./output"
favourites_file = "./favourites.json"
def get_favourites():
if not os.path.exists(favourites_file):
return []
with open(favourites_file, 'r') as f:
return json.load(f)
def save_favourites(favourites):
with open(favourites_file, 'w') as f:
json.dump(favourites, f)
from libs.generic import get_favourites, save_favourites
@bp.route("/images", methods=["GET"])
def gallery():

View File

@ -1,51 +0,0 @@
* {
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;
}

View File

@ -1,20 +0,0 @@
<!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,199 +1,219 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
{% 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);
<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;
}
}
@media (max-width: 600px) {
/* ---------- layout ---------- */
body {
min-height: 100dvh;
height: auto;
justify-content: flex-start;
padding-top: 40px;
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;
}
.button-group {
flex-direction: column;
align-items: stretch;
width: 100%;
display: flex;
gap: 20px;
align-items: center;
}
button,
select {
width: 100%;
background: #333;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: background 0.3s;
}
textarea {
height: 150px;
button:hover,
select:hover {
background: #555;
}
}
</style>
{% endblock %}
{% block content %}
<h1 style="margin-bottom: 20px;">Create An Image</h1>
/* ---------- 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;
}
<textarea id="prompt-box" placeholder="Enter your custom prompt here..."></textarea>
.spinner {
width: 50px;
height: 50px;
border: 6px solid #555;
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
<div class="button-group">
<button onclick="showSpinner(); location.href='/'">Back</button>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
<button onclick="sendPrompt()">Send Prompt</button>
@media (max-width: 600px) {
body {
min-height: 100dvh;
height: auto;
justify-content: flex-start;
padding-top: 40px;
}
<button onclick="randomPrompt()">Random Prompt</button>
.button-group {
flex-direction: column;
align-items: stretch;
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>
button,
select {
width: 100%;
}
<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>
textarea {
height: 150px;
}
}
</style>
</head>
<div id="spinner-overlay">
<div class="spinner"></div>
</div>
{% endblock %}
<body>
<h1 style="margin-bottom: 20px;">Create An Image</h1>
{% block scripts %}
<script>
const overlay = document.getElementById('spinner-overlay');
<textarea id="prompt-box" placeholder="Enter your custom prompt here..."></textarea>
function showSpinner() { overlay.style.visibility = 'visible'; }
<div class="button-group">
<button onclick="showSpinner(); location.href='/'">Back</button>
function sendPrompt() {
showSpinner();
const prompt = document.getElementById('prompt-box').value;
const model = document.getElementById('model-select').value;
<button onclick="sendPrompt()">Send Prompt</button>
const formData = new URLSearchParams();
formData.append('prompt', prompt);
formData.append('model', model);
<button onclick="randomPrompt()">Random Prompt</button>
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';
<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()
})
.catch(error => {
overlay.style.visibility = 'hidden';
alert("Error sending prompt: " + error);
});
}
.then(response => {
window.location.href = response.redirected ? response.url : '/create';
})
.catch(error => {
overlay.style.visibility = 'hidden';
alert("Error sending prompt: " + error);
});
}
function randomPrompt() {
showSpinner();
const model = document.getElementById('model-select').value;
const topic = document.getElementById('topic-select').value;
// 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
const formData = new URLSearchParams();
formData.append('model', model);
formData.append('topic', topic);
const formData = new URLSearchParams();
formData.append('model', model);
formData.append('topic', topic); // include topic in request
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';
fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
})
.catch(error => {
overlay.style.visibility = 'hidden';
alert("Error requesting random prompt: " + error);
});
}
</script>
{% endblock %}
.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>

View File

@ -1,9 +1,24 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
{% block title %}Image Archive{% endblock %}
{% block head %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Image Archive</title>
<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;
@ -87,14 +102,18 @@
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 {
@ -113,6 +132,24 @@
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;
@ -144,7 +181,9 @@
max-width: 90%;
padding: 8px 16px;
max-height: 20vh;
/* smaller height for mobile */
overflow-y: auto;
/* keep scroll on mobile too */
}
.button-link {
@ -153,16 +192,18 @@
}
}
</style>
{% endblock %}
</head>
{% block content %}
<body>
<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>
@ -171,9 +212,8 @@
<p id="lightbox-prompt"></p>
<span class="arrow right" onclick="nextImage()">&#10095;</span>
</div>
{% endblock %}
{% block scripts %}
<!-- Pass image filenames from Flask to JS -->
<script>
let allImages = JSON.parse(`[
{% for image in images %}
@ -184,10 +224,10 @@
<script>
const gallery = document.getElementById('gallery');
const batchSize = 9;
const batchSize = 9; // images to load per batch
let loadedCount = 0;
let currentIndex = 0;
const detailsCache = {};
const detailsCache = {}; // Cache for image details
let showingFavourites = false;
let filteredImages = allImages;
@ -236,16 +276,19 @@
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;
if (loadedCount >= imagesToLoad.length) return; // all loaded
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'));
}
@ -261,7 +304,7 @@
function updateFavouriteHeart(isFavourited) {
const heart = document.getElementById('favourite-heart');
heart.innerHTML = isFavourited ? '&#9829;' : '&#9825;';
heart.innerHTML = isFavourited ? '&#9829;' : '&#9825;'; // solid vs outline heart
heart.style.color = isFavourited ? 'red' : 'white';
}
@ -318,7 +361,7 @@
return response.json();
})
.then(data => {
detailsCache[filename] = data;
detailsCache[filename] = data; // Cache the data
document.getElementById("lightbox-prompt").textContent =
`Model:${data.model} - Created:${data.date}\n\n${data.prompt}`;
})
@ -333,6 +376,7 @@
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) {
@ -352,9 +396,11 @@
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);
@ -376,4 +422,6 @@
}
}
</script>
{% endblock %}
</body>
</html>

View File

@ -1,15 +1,25 @@
{% extends "base.html" %}
{% block title %}Image Created{% endblock %}
{% block head %}
<!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>
<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 {
@ -40,13 +50,13 @@
background: #555;
}
</style>
{% endblock %}
{% block content %}
</head>
<body>
<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>
{% endblock %}
</body>
</html>

View File

@ -1,20 +1,32 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
{% block title %}AI Image of the Day{% endblock %}
{% block head %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI Image of the Day</title>
<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;
@ -42,7 +54,9 @@
max-width: 80vw;
text-align: left;
max-height: 30vh;
/* NEW: limit height */
overflow-y: auto;
/* NEW: allow scrolling */
}
.button-group {
@ -52,6 +66,46 @@
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;
@ -80,9 +134,14 @@
}
}
</style>
{% endblock %}
<script>
setInterval(() => {
location.reload();
}, {{ reload_interval }}); // Refresh every X ms
</script>
</head>
{% block content %}
<body>
{% if image %}
<div class="image-container">
<img src="{{ url_for('image_routes.serve_image', filename=image) }}" alt="Latest Image" />
@ -97,12 +156,11 @@
{% else %}
<p>No images found</p>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
setInterval(() => {
location.reload();
}, {{ reload_interval }}); // Refresh every X ms
</script>
{% endblock %}
<!-- Version number at bottom right -->
<div class="version">
<a href="{{ url_for('settings_route.config_editor') }}">v{{ version }}</a>
</div>
</body>
</html>

View File

@ -1,15 +1,25 @@
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block head %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<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 {
@ -49,9 +59,8 @@
background: #555;
}
</style>
{% endblock %}
{% block content %}
</head>
<body>
<div class="message">Please enter the password to continue:</div>
<form method="post">
<div class="prompt-text">
@ -60,4 +69,5 @@
</div>
<button type="submit">Login</button>
</form>
{% endblock %}
</body>
</html>

View File

@ -1,10 +1,21 @@
{% extends "base.html" %}
<!DOCTYPE html>
<html lang="en">
{% block title %}Config Editor{% endblock %}
{% block head %}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Config Editor</title>
<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;
@ -63,11 +74,21 @@
margin-top: 20px;
}
.back-button-wrapper {
width: 100%;
display: flex;
justify-content: center;
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;
}
@media (max-width: 768px) {
@ -81,10 +102,18 @@
margin-bottom: 30px;
}
}
</style>
{% endblock %}
{% block content %}
.back-button-wrapper {
width: 100%;
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="box">
<h2>Topics</h2>
<form method="post">
@ -157,4 +186,7 @@
<div class="back-button-wrapper">
<a href="/" class="button-link">Back to Home</a>
</div>
{% endblock %}
</body>
</html>