major styling pass, artist list, and import processing improvements

This commit is contained in:
Bryan Van Deusen
2025-07-31 23:48:37 -04:00
parent 48aa60b9ea
commit e3a4c348f1
17 changed files with 454 additions and 111 deletions
+28
View File
@@ -0,0 +1,28 @@
# Ignore test data
images/
import/
# Ignore Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
# Ignore environment files
.env
.env.*
# Ignore virtual environments
env/
venv/
# Ignore Git files
.git
.gitignore
# Ignore DB files
app.db
# Ignore Docker related files
.dockerignore
docker-compose.yml
+1 -1
View File
@@ -45,7 +45,7 @@ def login():
user = User.query.filter_by(email=form.email.data).first() user = User.query.filter_by(email=form.email.data).first()
if user and check_password_hash(user.password_hash, form.password.data): if user and check_password_hash(user.password_hash, form.password.data):
login_user(user) login_user(user)
return redirect(url_for('main.index')) return redirect(url_for('main.gallery'))
else: else:
flash('Login failed. Check username/password.', 'danger') flash('Login failed. Check username/password.', 'danger')
return render_template('login.html', form=form) return render_template('login.html', form=form)
+41 -9
View File
@@ -14,7 +14,18 @@ main = Blueprint('main', __name__)
@main.route('/') @main.route('/')
def index(): def index():
return render_template('index.html') from random import choice
from app.models import ImageRecord
image = ImageRecord.query.order_by(func.random()).first()
background_url = None
if image:
filename = image.filepath.replace('/images/', '')
background_url = url_for('main.serve_image', filename=filename)
return render_template('index.html', background_url=background_url)
@main.route('/gallery') @main.route('/gallery')
@login_required @login_required
@@ -25,11 +36,12 @@ def gallery():
).limit(recent_images).all() ).limit(recent_images).all()
return render_template('gallery.html', images=images) return render_template('gallery.html', images=images)
@main.route('/static/images/<path:filename>') @main.route('/images/<path:filename>')
def serve_image(filename): def serve_image(filename):
return send_from_directory('/images', filename) return send_from_directory('/images', filename)
@main.route('/artist/<artist_name>') @main.route('/artist/<artist_name>')
@login_required
def artist_gallery(artist_name): def artist_gallery(artist_name):
per_page=49 per_page=49
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
@@ -38,6 +50,30 @@ def artist_gallery(artist_name):
).paginate(page=page, per_page=per_page) ).paginate(page=page, per_page=per_page)
return render_template('artist_gallery.html', images=images, artist=artist_name) return render_template('artist_gallery.html', images=images, artist=artist_name)
@main.route('/artists')
@login_required
def artist_list():
from app.models import ImageRecord
from sqlalchemy import func
# Define how many images to show per artist card
images_per_artist = 3 # Change this value to tweak
# Get all unique artist names
artists = db.session.query(ImageRecord.artist).distinct().all()
artist_data = []
for (artist,) in artists:
images = (
ImageRecord.query
.filter_by(artist=artist)
.order_by(ImageRecord.taken_at.desc().nullslast(), ImageRecord.imported_at.desc())
.limit(images_per_artist)
.all()
)
artist_data.append({'name': artist, 'images': images})
return render_template('artist_list.html', artist_data=artist_data, images_per_artist=images_per_artist)
@main.route('/settings') @main.route('/settings')
@login_required @login_required
@@ -49,13 +85,9 @@ def settings():
@main.route('/import-images') @main.route('/import-images')
@login_required @login_required
def trigger_image_import(): def trigger_image_import():
if not current_user.is_admin: with open('/import/trigger.flag', 'w') as f:
abort(403) f.write('start')
source_dir = '/import' flash("Image import triggered.")
dest_dir = '/images'
result = import_images_task(source_dir, dest_dir)
flash(f"Image import completed: {result}")
return redirect(url_for('main.settings')) return redirect(url_for('main.settings'))
@main.route('/reset-db', methods=['POST']) @main.route('/reset-db', methods=['POST'])
+203
View File
@@ -224,3 +224,206 @@ body {
font-size: 1.5rem; font-size: 1.5rem;
opacity: 1; opacity: 1;
} }
/* Artist Styling */
.artist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
padding: 1rem;
}
.artist-card {
background-color: #1c1c1c;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.artist-card h3 {
margin-bottom: 0.5rem;
text-align: center;
color: white;
}
.artist-preview {
display: flex;
gap: 0;
justify-content: center;
overflow: hidden;
border-radius: 6px;
}
.artist-thumb {
flex: 1;
height: 300px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transition: transform 0.3s ease;
}
.artist-thumb:hover {
transform: scale(1.05);
}
.artist-card-link {
text-decoration: none;
color: inherit;
display: block;
transition: transform 0.2s ease;
}
.artist-card-link:hover .artist-card {
transform: scale(1.02);
box-shadow: 0 4px 10px rgba(0,0,0,0.4);
}
.settings-container {
max-width: 600px;
margin: 2rem auto;
padding: 1.5rem;
background: #f8f8f8;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.settings-section h2 {
margin-bottom: 1rem;
text-align: center;
}
.settings-actions {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.3s ease;
}
.primary-btn {
background-color: #0066cc;
color: white;
}
.primary-btn:hover {
background-color: #004d99;
}
.danger-btn {
background-color: #cc0033;
color: white;
}
.danger-btn:hover {
background-color: #990024;
}
.settings-info {
margin-top: 2rem;
font-size: 0.95rem;
color: #444;
text-align: center;
}
.index-hero {
position: relative;
height: 100vh;
background-size: cover;
background-position: center;
filter: blur(0px); /* Required to initialize */
overflow: hidden;
}
.index-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(20, 20, 20, 0.5);
backdrop-filter: blur(16px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.index-message {
text-align: center;
color: #fff;
padding: 2rem;
max-width: 600px;
}
.index-message h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.index-message p {
font-size: 1.2rem;
margin-bottom: 2rem;
}
.index-message .btn {
font-size: 1.1rem;
}
/* Form Styling */
.form-container {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
background-color: #ffffffdd;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.form-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
margin-bottom: 0.5rem;
font-weight: 600;
}
.form-input {
padding: 0.6rem;
border: 1px solid #ccc;
border-radius: 5px;
}
.form-button {
padding: 0.75rem;
background-color: #5a67d8;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
.form-button:hover {
background-color: #434190;
}
.form-footer-text {
margin-top: 1rem;
text-align: center;
}
+13 -15
View File
@@ -3,7 +3,7 @@
{% block title %}{{ artist }}'s Gallery{% endblock %} {% block title %}{{ artist }}'s Gallery{% endblock %}
{% block content %} {% block content %}
<h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h2> <h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h1>
<!-- Pagination Controls --> <!-- Pagination Controls -->
<div class="pagination-container"> <div class="pagination-container">
@@ -21,23 +21,21 @@
</div> </div>
<!-- Gallery Grid --> <!-- Gallery Grid -->
<div class="gallery-grid"> <div class="gallery-grid">
{% for image in images %} {% for image in images.items %}
<div class="gallery-item"> <div class="gallery-item">
<div class="gallery-thumb img-clickable" <div class="gallery-thumb img-clickable"
data-full="{{ url_for('static', filename=image.filepath) }}" data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}"
style="background-image: url('{{ url_for('static', filename=image.filepath) }}');" style="background-image: url('{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}');"
data-full="{{ url_for('static', filename=image.filepath) }}" data-filename="{{ image.filename }}">
data-filename="{{ image.filename }}"> </div>
</div> <a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a> <span class="image-date">
<span class="image-date"> {{ (image.taken_at or image.imported_at) | datetimeformat }}
{{ image.taken_at or image.imported_at | datetimeformat }} </span>
</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Pagination Controls --> <!-- Pagination Controls -->
<div class="pagination-container"> <div class="pagination-container">
+23
View File
@@ -0,0 +1,23 @@
{% extends "layout.html" %}
{% block title %}Artists{% endblock %}
{% block content %}
<h1 style="text-align:center; margin-top: 1rem;">Artists</h1>
<div class="artist-grid">
{% for artist in artist_data %}
<a href="{{ url_for('main.artist_gallery', artist_name=artist.name) }}" class="artist-card-link">
<div class="artist-card">
<h3>{{ artist.name }}</h3>
<div class="artist-preview">
{% for image in artist.images %}
<div class="artist-thumb"
style="background-image: url('{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}');">
</div>
{% endfor %}
</div>
</div>
</a>
{% endfor %}
</div>
{% endblock %}
+3 -6
View File
@@ -8,18 +8,15 @@
{% for image in images %} {% for image in images %}
<div class="gallery-item"> <div class="gallery-item">
<div class="gallery-thumb img-clickable" <div class="gallery-thumb img-clickable"
data-full="{{ url_for('static', filename=image.filepath) }}" data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}"
style="background-image: url('{{ url_for('static', filename=image.filepath) }}');" style="background-image: url('{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}');"
data-full="{{ url_for('static', filename=image.filepath) }}"
data-filename="{{ image.filename }}"> data-filename="{{ image.filename }}">
</div> </div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a> <a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date"> <span class="image-date">
{{ image.taken_at or image.imported_at | datetimeformat }} {{ (image.taken_at or image.imported_at) | datetimeformat }}
</span> </span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endblock %} {% endblock %}
+10 -4
View File
@@ -1,8 +1,14 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Welcome{% endblock %}
{% block title %}Home{% endblock %}
{% block content %} {% block content %}
<h1>Welcome to the Flask Template App!</h1> <div class="index-hero" style="background-image: url('{{ background_url }}');">
<p>This is the index page.</p> <div class="index-overlay">
<div class="index-message">
<h1>Private Image Repository</h1>
<p>This is a private image collection. You must register for access to browse and contribute.</p>
<a href="{{ url_for('auth.register') }}" class="btn primary-btn">Register</a>
</div>
</div>
</div>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Flask Template App</title> <title>ImageRepo - {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
@@ -18,6 +18,7 @@
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<a class="nav-button" href="{{ url_for('main.artist_list') }}">Artists</a>
<a class="nav-button" href="{{ url_for('auth.logout') }}">Logout</a> <a class="nav-button" href="{{ url_for('auth.logout') }}">Logout</a>
{% else %} {% else %}
<a class="nav-button" href="{{ url_for('auth.login') }}">Login</a> <a class="nav-button" href="{{ url_for('auth.login') }}">Login</a>
+26 -6
View File
@@ -1,10 +1,30 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Login{% endblock %}
{% block content %} {% block content %}
<h2>Login</h2> <div class="form-container">
<form method="POST"> <h2>Login</h2>
<form method="POST" class="form-card">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ form.email(size=32, placeholder=form.email.label.text) }}<br>
{{ form.password(size=32, placeholder=form.password.label.text) }}<br> <div class="form-group">
{{ form.submit() }} {{ form.email.label(class="form-label") }}
</form> {{ form.email(class="form-input", placeholder=form.email.label.text) }}
</div>
<div class="form-group">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-input", placeholder=form.password.label.text) }}
</div>
<div class="form-actions">
{{ form.submit(class="form-button") }}
</div>
</form>
<p class="form-footer-text">
Dont have an account? <a href="{{ url_for('auth.register') }}">Register here</a>.
</p>
</div>
{% endblock %} {% endblock %}
+43 -30
View File
@@ -1,39 +1,52 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Register{% endblock %}
{% block content %} {% block content %}
<h2>Register</h2> <div class="form-container">
<form method="POST"> <h2>Register</h2>
<form method="POST" class="form-card">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p> <div class="form-group">
{{ form.username(size=32, placeholder=form.username.label.text) }} {{ form.username.label(class="form-label") }}
{% for error in form.username.errors %} {{ form.username(class="form-input", placeholder=form.username.label.text) }}
<span class="error">{{ error }}</span> {% for error in form.username.errors %}
{% endfor %} <span class="error">{{ error }}</span>
</p> {% endfor %}
</div>
<p> <div class="form-group">
{{ form.email(size=32, placeholder=form.email.label.text) }} {{ form.email.label(class="form-label") }}
{% for error in form.email.errors %} {{ form.email(class="form-input", placeholder=form.email.label.text) }}
<span class="error">{{ error }}</span> {% for error in form.email.errors %}
{% endfor %} <span class="error">{{ error }}</span>
</p> {% endfor %}
</div>
<p> <div class="form-group">
{{ form.password(size=32, placeholder=form.password.label.text) }} {{ form.password.label(class="form-label") }}
{% for error in form.password.errors %} {{ form.password(class="form-input", placeholder=form.password.label.text) }}
<span class="error">{{ error }}</span> {% for error in form.password.errors %}
{% endfor %} <span class="error">{{ error }}</span>
</p> {% endfor %}
</div>
<p> <div class="form-group">
{{ form.confirm_password(size=32, placeholder=form.confirm_password.label.text) }} {{ form.confirm_password.label(class="form-label") }}
{% for error in form.confirm_password.errors %} {{ form.confirm_password(class="form-input", placeholder=form.confirm_password.label.text) }}
<span class="error">{{ error }}</span> {% for error in form.confirm_password.errors %}
{% endfor %} <span class="error">{{ error }}</span>
</p> {% endfor %}
</div>
<p> <div class="form-actions">
{{ form.submit() }} {{ form.submit(class="form-button") }}
</p> </div>
</form> </form>
<p class="form-footer-text">
Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a>.
</p>
</div>
{% endblock %} {% endblock %}
+15 -15
View File
@@ -1,25 +1,25 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}Settings{% endblock %} {% block title %}Settings{% endblock %}
{% block content %} {% block content %}
<div class="container mt-4"> <h1 style="text-align:center; margin-top: 1rem;">Settings</h1>
<h2>Image Management</h2>
<p>Use the options below to manage image data.</p>
<div class="mb-3"> <div class="settings-container">
<a href="{{ url_for('main.trigger_image_import') }}" class="btn btn-primary"> <div class="settings-section">
📥 Import Images <h2>Admin Actions</h2>
</a> <div class="settings-actions">
</div> <form action="{{ url_for('main.trigger_image_import') }}" method="get">
<button type="submit" class="btn primary-btn">Trigger Image Import</button>
</form>
<div class="mb-3"> <form action="{{ url_for('main.reset_db') }}" method="post" onsubmit="return confirm('Are you sure you want to delete all image records? This action cannot be undone.');">
<form method="POST" action="{{ url_for('main.reset_db') }}" <button type="submit" class="btn danger-btn">Reset Image Database</button>
onsubmit="return confirm('Are you sure you want to delete all image records?');">
<button type="submit" class="btn btn-danger">
🗑️ Reset Database
</button>
</form> </form>
</div> </div>
</div> </div>
<div class="settings-info">
<p>Use these tools to manually trigger an import or reset the image database. Resetting the database does not delete the actual image files.</p>
</div>
</div>
{% endblock %} {% endblock %}
+4 -1
View File
@@ -1,7 +1,7 @@
# app/utils/image_importer.py # app/utils/image_importer.py
from datetime import datetime from datetime import datetime
import os, shutil, hashlib import os, shutil, hashlib, time
from PIL import Image from PIL import Image
import exifread import exifread
import mimetypes import mimetypes
@@ -60,6 +60,9 @@ def import_images_task(source_dir, dest_dir):
db.session.add(record) db.session.add(record)
imported.append(filename) imported.append(filename)
# Add delay between each image
time.sleep(1)
db.session.commit() db.session.commit()
return f"Imported {len(imported)} images." return f"Imported {len(imported)} images."
+8 -8
View File
@@ -5,26 +5,26 @@ class Config:
DB_TYPE = os.environ.get('DB_TYPE', 'sqlite').lower() # 'sqlite' or 'postgresql' DB_TYPE = os.environ.get('DB_TYPE', 'sqlite').lower() # 'sqlite' or 'postgresql'
if DB_TYPE == 'postgresql': if DB_TYPE == 'postgresql':
DB_USER = os.environ.get('DB_USER', 'postgres') DB_USER = os.environ.get('DB_USER', 'imagerepo')
DB_PASS = os.environ.get('DB_PASS', 'postgres') DB_PASS = os.environ.get('DB_PASS', 'postgres')
DB_HOST = os.environ.get('DB_HOST', 'localhost') DB_HOST = os.environ.get('DB_HOST', 'postgres')
DB_PORT = os.environ.get('DB_PORT', '5432') DB_PORT = os.environ.get('DB_PORT', '5432')
DB_NAME = os.environ.get('DB_NAME', 'postgres') DB_NAME = os.environ.get('DB_NAME', 'imagerepo')
SQLALCHEMY_DATABASE_URI = ( SQLALCHEMY_DATABASE_URI = (
f'postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}' f'postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
) )
elif DB_TYPE == 'sqlite': elif DB_TYPE == 'sqlite':
BASEDIR = os.path.abspath(os.path.dirname(__file__)) DB_DIR = '/db'
os.makedirs(DB_DIR, exist_ok=True) # only works if /db is writable
DB_NAME = os.environ.get('DB_NAME', 'app.db') DB_NAME = os.environ.get('DB_NAME', 'app.db')
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASEDIR, DB_NAME)}' SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(DB_DIR, DB_NAME)}'
else: else:
raise ValueError(f"Unsupported DB_TYPE '{DB_TYPE}'. Use 'sqlite' or 'postgresql'.") raise ValueError(f"Unsupported DB_TYPE '{DB_TYPE}'. Use 'sqlite' or 'postgresql'.")
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
+4 -3
View File
@@ -4,7 +4,8 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
volumes: volumes:
- ./:/app - ./imagerepo/db/:/db # SQLite db storage
- ./import:/import - ./imagerepo/images:/images # where the app will store it's imported images
- ./images:/images - ./import:/import # where it will import images from
+1 -1
View File
@@ -28,7 +28,7 @@ echo "Applying database migrations..."
flask db upgrade flask db upgrade
# Start periodic image import in background # Start periodic image import in background
echo "Starting 2h import loop" echo "import worker waiting for flag"
python image_import_worker.py & python image_import_worker.py &
# Start the Flask app # Start the Flask app
+28 -10
View File
@@ -1,20 +1,38 @@
# image_import_loop.py # image_import_loop.py
import time import time
import os
from app import create_app from app import create_app
from app.utils.image_importer import import_images_task from app.utils.image_importer import import_images_task
TRIGGER_FLAG = "/import/trigger.flag"
SOURCE_DIR = "/import"
DEST_DIR = "/images"
CHECK_INTERVAL = 10 # seconds
app = create_app() app = create_app()
def looped_import(source_dir='/import', dest_dir='/images', delay_seconds=7200): def monitor_and_import():
with app.app_context(): with app.app_context():
print("[Image Importer] Starting import task...") print("[Image Importer] Monitoring for trigger flag...")
try:
result = import_images_task(source_dir, dest_dir) while True:
print(f"[Image Importer] {result}") if os.path.exists(TRIGGER_FLAG):
except Exception as e: print("[Image Importer] Trigger flag found. Starting import...")
print(f"[Image Importer] Error: {e}") try:
print(f"[Image Importer] Sleeping for {delay_seconds / 3600:.1f} hours...\n") result = import_images_task(SOURCE_DIR, DEST_DIR)
time.sleep(delay_seconds) print(f"[Image Importer] {result}")
except Exception as e:
print(f"[Image Importer] Error during import: {e}")
finally:
try:
os.remove(TRIGGER_FLAG)
print("[Image Importer] Trigger flag cleared.")
except Exception as e:
print(f"[Image Importer] Failed to remove trigger flag: {e}")
else:
print("[Image Importer] No trigger flag. Sleeping...")
time.sleep(CHECK_INTERVAL)
if __name__ == '__main__': if __name__ == '__main__':
looped_import() monitor_and_import()