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()
if user and check_password_hash(user.password_hash, form.password.data):
login_user(user)
return redirect(url_for('main.index'))
return redirect(url_for('main.gallery'))
else:
flash('Login failed. Check username/password.', 'danger')
return render_template('login.html', form=form)
+41 -9
View File
@@ -14,7 +14,18 @@ main = Blueprint('main', __name__)
@main.route('/')
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')
@login_required
@@ -25,11 +36,12 @@ def gallery():
).limit(recent_images).all()
return render_template('gallery.html', images=images)
@main.route('/static/images/<path:filename>')
@main.route('/images/<path:filename>')
def serve_image(filename):
return send_from_directory('/images', filename)
@main.route('/artist/<artist_name>')
@login_required
def artist_gallery(artist_name):
per_page=49
page = request.args.get('page', 1, type=int)
@@ -38,6 +50,30 @@ def artist_gallery(artist_name):
).paginate(page=page, per_page=per_page)
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')
@login_required
@@ -49,13 +85,9 @@ def settings():
@main.route('/import-images')
@login_required
def trigger_image_import():
if not current_user.is_admin:
abort(403)
source_dir = '/import'
dest_dir = '/images'
result = import_images_task(source_dir, dest_dir)
flash(f"Image import completed: {result}")
with open('/import/trigger.flag', 'w') as f:
f.write('start')
flash("Image import triggered.")
return redirect(url_for('main.settings'))
@main.route('/reset-db', methods=['POST'])
+204 -1
View File
@@ -223,4 +223,207 @@ body {
transform: none;
font-size: 1.5rem;
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 content %}
<h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h2>
<h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h1>
<!-- Pagination Controls -->
<div class="pagination-container">
@@ -21,23 +21,21 @@
</div>
<!-- Gallery Grid -->
<div class="gallery-grid">
{% for image in images %}
<div class="gallery-grid">
{% for image in images.items %}
<div class="gallery-item">
<div class="gallery-thumb img-clickable"
data-full="{{ url_for('static', filename=image.filepath) }}"
style="background-image: url('{{ url_for('static', filename=image.filepath) }}');"
data-full="{{ url_for('static', filename=image.filepath) }}"
data-filename="{{ image.filename }}">
</div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date">
{{ image.taken_at or image.imported_at | datetimeformat }}
</span>
<div class="gallery-thumb img-clickable"
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}"
style="background-image: url('{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}');"
data-filename="{{ image.filename }}">
</div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date">
{{ (image.taken_at or image.imported_at) | datetimeformat }}
</span>
</div>
{% endfor %}
</div>
</div>
<!-- Pagination Controls -->
<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 %}
<div class="gallery-item">
<div class="gallery-thumb img-clickable"
data-full="{{ url_for('static', filename=image.filepath) }}"
style="background-image: url('{{ url_for('static', filename=image.filepath) }}');"
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('main.serve_image', filename=image.filepath | replace('/images/', '')) }}');"
data-filename="{{ image.filename }}">
</div>
<a href="{{ url_for('main.artist_gallery', artist_name=image.artist) }}">{{ image.artist }}</a>
<span class="image-date">
{{ image.taken_at or image.imported_at | datetimeformat }}
{{ (image.taken_at or image.imported_at) | datetimeformat }}
</span>
</div>
{% endfor %}
</div>
{% endblock %}
+10 -4
View File
@@ -1,8 +1,14 @@
{% extends "layout.html" %}
{% block title %}Home{% endblock %}
{% block title %}Welcome{% endblock %}
{% block content %}
<h1>Welcome to the Flask Template App!</h1>
<p>This is the index page.</p>
<div class="index-hero" style="background-image: url('{{ background_url }}');">
<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 %}
+2 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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') }}">
</head>
<body>
@@ -18,6 +18,7 @@
{% endif %}
{% 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>
{% else %}
<a class="nav-button" href="{{ url_for('auth.login') }}">Login</a>
+26 -6
View File
@@ -1,10 +1,30 @@
{% extends "layout.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<h2>Login</h2>
<form method="POST">
<div class="form-container">
<h2>Login</h2>
<form method="POST" class="form-card">
{{ form.hidden_tag() }}
{{ form.email(size=32, placeholder=form.email.label.text) }}<br>
{{ form.password(size=32, placeholder=form.password.label.text) }}<br>
{{ form.submit() }}
</form>
<div class="form-group">
{{ form.email.label(class="form-label") }}
{{ 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 %}
+43 -30
View File
@@ -1,39 +1,52 @@
{% extends "layout.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<h2>Register</h2>
<form method="POST">
<div class="form-container">
<h2>Register</h2>
<form method="POST" class="form-card">
{{ form.hidden_tag() }}
<p>
{{ form.username(size=32, placeholder=form.username.label.text) }}
{% for error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<div class="form-group">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-input", placeholder=form.username.label.text) }}
{% for error in form.username.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<p>
{{ form.email(size=32, placeholder=form.email.label.text) }}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<div class="form-group">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-input", placeholder=form.email.label.text) }}
{% for error in form.email.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<p>
{{ form.password(size=32, placeholder=form.password.label.text) }}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<div class="form-group">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-input", placeholder=form.password.label.text) }}
{% for error in form.password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<p>
{{ form.confirm_password(size=32, placeholder=form.confirm_password.label.text) }}
{% for error in form.confirm_password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</p>
<div class="form-group">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-input", placeholder=form.confirm_password.label.text) }}
{% for error in form.confirm_password.errors %}
<span class="error">{{ error }}</span>
{% endfor %}
</div>
<p>
{{ form.submit() }}
</p>
</form>
<div class="form-actions">
{{ form.submit(class="form-button") }}
</div>
</form>
<p class="form-footer-text">
Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a>.
</p>
</div>
{% endblock %}
+15 -15
View File
@@ -1,25 +1,25 @@
{% extends "layout.html" %}
{% block title %}Settings{% endblock %}
{% block content %}
<div class="container mt-4">
<h2>Image Management</h2>
<p>Use the options below to manage image data.</p>
<h1 style="text-align:center; margin-top: 1rem;">Settings</h1>
<div class="mb-3">
<a href="{{ url_for('main.trigger_image_import') }}" class="btn btn-primary">
📥 Import Images
</a>
</div>
<div class="settings-container">
<div class="settings-section">
<h2>Admin Actions</h2>
<div class="settings-actions">
<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 method="POST" action="{{ url_for('main.reset_db') }}"
onsubmit="return confirm('Are you sure you want to delete all image records?');">
<button type="submit" class="btn btn-danger">
🗑️ Reset Database
</button>
<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.');">
<button type="submit" class="btn danger-btn">Reset Image Database</button>
</form>
</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 %}
+4 -1
View File
@@ -1,7 +1,7 @@
# app/utils/image_importer.py
from datetime import datetime
import os, shutil, hashlib
import os, shutil, hashlib, time
from PIL import Image
import exifread
import mimetypes
@@ -60,6 +60,9 @@ def import_images_task(source_dir, dest_dir):
db.session.add(record)
imported.append(filename)
# Add delay between each image
time.sleep(1)
db.session.commit()
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'
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_HOST = os.environ.get('DB_HOST', 'localhost')
DB_HOST = os.environ.get('DB_HOST', 'postgres')
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 = (
f'postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
)
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')
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(BASEDIR, DB_NAME)}'
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(DB_DIR, DB_NAME)}'
else:
raise ValueError(f"Unsupported DB_TYPE '{DB_TYPE}'. Use 'sqlite' or 'postgresql'.")
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:
- "5000:5000"
volumes:
- ./:/app
- ./import:/import
- ./images:/images
- ./imagerepo/db/:/db # SQLite db storage
- ./imagerepo/images:/images # where the app will store it's imported images
- ./import:/import # where it will import images from
+1 -1
View File
@@ -28,7 +28,7 @@ echo "Applying database migrations..."
flask db upgrade
# Start periodic image import in background
echo "Starting 2h import loop"
echo "import worker waiting for flag"
python image_import_worker.py &
# Start the Flask app
+28 -10
View File
@@ -1,20 +1,38 @@
# image_import_loop.py
import time
import os
from app import create_app
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()
def looped_import(source_dir='/import', dest_dir='/images', delay_seconds=7200):
def monitor_and_import():
with app.app_context():
print("[Image Importer] Starting import task...")
try:
result = import_images_task(source_dir, dest_dir)
print(f"[Image Importer] {result}")
except Exception as e:
print(f"[Image Importer] Error: {e}")
print(f"[Image Importer] Sleeping for {delay_seconds / 3600:.1f} hours...\n")
time.sleep(delay_seconds)
print("[Image Importer] Monitoring for trigger flag...")
while True:
if os.path.exists(TRIGGER_FLAG):
print("[Image Importer] Trigger flag found. Starting import...")
try:
result = import_images_task(SOURCE_DIR, DEST_DIR)
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__':
looped_import()
monitor_and_import()