initial functionality and styling pass

This commit is contained in:
Bryan Van Deusen
2025-07-29 00:34:03 -04:00
parent 1590ca72c1
commit c67f1afc1f
1763 changed files with 876 additions and 76 deletions
+6 -1
View File
@@ -16,7 +16,7 @@ def create_app(config_class='config.Config'):
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
login_manager.login_view = 'auth.login' # IMPORTANT: now uses auth Blueprint
login_manager.login_view = 'auth.login'
from app.main import main
from app.auth import auth
@@ -24,4 +24,9 @@ def create_app(config_class='config.Config'):
app.register_blueprint(main)
app.register_blueprint(auth)
@app.template_filter('datetimeformat')
def datetimeformat(value, format="%Y-%m-%d"):
return value.strftime(format) if value else ""
return app
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -2
View File
@@ -15,11 +15,27 @@ def register():
form = RegistrationForm()
if form.validate_on_submit():
hashed_password = generate_password_hash(form.password.data)
user = User(username=form.username.data, email=form.email.data, password_hash=hashed_password)
# Check if any users exist
is_first_user = User.query.count() == 0
user = User(
username=form.username.data,
email=form.email.data,
password_hash=hashed_password,
is_admin=is_first_user # Make first user an admin
)
db.session.add(user)
db.session.commit()
flash('Account created! Please log in.', 'success')
if is_first_user:
flash('Account created as administrator.', 'success')
else:
flash('Account created! Please log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('register.html', form=form)
@auth.route('/login', methods=['GET', 'POST'])
+62 -1
View File
@@ -1,9 +1,70 @@
# app/main.py
from flask import Blueprint, render_template
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request
from flask_login import login_required, current_user
from sqlalchemy import desc, func
from app.utils.image_importer import import_images_task
from app.models import ImageRecord
from app import db
import os
main = Blueprint('main', __name__)
@main.route('/')
def index():
return render_template('index.html')
@main.route('/gallery')
@login_required
def gallery():
recent_images = 21
images = ImageRecord.query.order_by(
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
).limit(recent_images).all()
return render_template('gallery.html', images=images)
@main.route('/static/images/<path:filename>')
def serve_image(filename):
return send_from_directory('/images', filename)
@main.route('/artist/<artist_name>')
def artist_gallery(artist_name):
per_page=49
page = request.args.get('page', 1, type=int)
images = ImageRecord.query.filter_by(artist=artist_name).order_by(
desc(func.coalesce(ImageRecord.taken_at, ImageRecord.imported_at))
).paginate(page=page, per_page=per_page)
return render_template('artist_gallery.html', images=images, artist=artist_name)
@main.route('/settings')
@login_required
def settings():
if not current_user.is_admin:
abort(403)
return render_template('settings.html')
@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}")
return redirect(url_for('main.settings'))
@main.route('/reset-db', methods=['POST'])
@login_required
def reset_db():
if not current_user.is_admin:
abort(403)
ImageRecord.query.delete()
db.session.commit()
flash("Database has been reset. All image records removed.", "info")
return redirect(url_for('main.settings'))
+20
View File
@@ -1,12 +1,32 @@
from . import db
from flask_login import UserMixin
from . import login_manager
from datetime import datetime
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
class ImageRecord(db.Model):
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(255), nullable=False)
filepath = db.Column(db.String(512), nullable=False)
hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256
perceptual_hash = db.Column(db.String(16), nullable=True) # hex of 64-bit hash
artist = db.Column(db.String(255), nullable=True)
file_size = db.Column(db.Integer, nullable=True)
width = db.Column(db.Integer, nullable=True)
height = db.Column(db.Integer, nullable=True)
format = db.Column(db.String(10), nullable=True)
camera_model = db.Column(db.String(255), nullable=True)
taken_at = db.Column(db.DateTime, nullable=True)
imported_at = db.Column(db.DateTime, default=db.func.now())
is_thumbnail = db.Column(db.Boolean, default=False)
@login_manager.user_loader
def load_user(user_id):
+225 -3
View File
@@ -1,4 +1,226 @@
.error {
color: red;
font-size: 0.9em;
/* ========== General Layout ========== */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0 1rem;
background-color: #5d6668;
}
.mainview {
max-width: 1600px;
margin: 0 auto;
background-color: rgba(0,0,0,.4);
}
/* Navbar container */
.navbar {
display: flex;
background-color: #1e1e1e;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
justify-content: flex-start;
flex-wrap: wrap;
justify-content: center;
}
/* Button-style nav links */
.nav-button {
display: inline-block;
padding: 0.5rem 1rem;
color: white;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
font-size: 1.5rem;
transition: background-color 0.2s ease, transform 0.1s ease;
}
.nav-button:hover {
background-color: #444;
transform: translateY(-1px);
}
.nav-button:active {
background-color: #1a1a1a;
transform: translateY(1px);
}
.flash {
padding: 0.5rem 1rem;
margin: 1rem 0;
border-radius: 4px;
}
.flash.success {
background-color: #d4edda;
color: #155724;
}
.flash.danger {
background-color: #f8d7da;
color: #721c24;
}
.content {
padding: 8px;
}
/* ========== Gallery Layout ========== */
.image-date {
display: block;
font-size: 0.8rem;
color: #e2e2e2;
margin-top: 0.25rem;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
}
.gallery-item {
display: flex;
flex-direction: column;
align-items: center;
height: 450px;
background-color: rgba(1,1,1,.1);
box-shadow: 0 3px 4px rgba(0,0,0,0.7);
padding: 8px;
border-radius: 8px;
transition: transform 0.2s ease;
}
.gallery-item:hover {
transform: scale(1.03);
}
.gallery-thumb {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
box-shadow: 0 4px 4px rgba(0,0,0,0.4);
border-radius: 12px;
cursor: pointer;
}
.gallery-item a {
color: #d4aeff;
font-weight: bold;
text-decoration: none;
font-size: 0.95rem;
}
.gallery-item a:hover {
text-decoration: underline;
color: #d4d4d4;
}
.pagination-container {
text-align: center;
margin: 2rem 0;
}
.pagination-link {
display: inline-block;
margin: 0 0.3rem;
padding: 0.5rem 1rem;
border: 1px solid #aaa;
border-radius: 4px;
color: #333;
text-decoration: none;
}
.pagination-link.active,
.pagination-link:hover {
background-color: #007BFF;
color: white;
border-color: #007BFF;
}
/* MODAL STYLES */
.modal {
display: none;
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.85);
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
position: relative;
max-width: 95vw;
max-height: 95vh;
display: flex;
flex-direction: column;
align-items: center;
}
.modal-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.modal-body img {
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 0 10px rgba(0,0,0,0.6);
}
.modal-filename {
color: #fff;
background: rgba(0, 0, 0, 0.6);
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.9rem;
text-align: center;
word-break: break-word;
}
/* Modal arrows and close */
.modal-button {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.4);
border: none;
color: white;
font-size: 2rem;
padding: 0.5rem 1rem;
cursor: pointer;
z-index: 1001;
opacity: 0;
transition: opacity 0.2s;
}
.modal:hover .modal-button {
opacity: 1;
}
.modal-prev {
left: -60px;
}
.modal-next {
right: -60px;
}
.modal-close {
top: 10px;
right: 10px;
transform: none;
font-size: 1.5rem;
opacity: 1;
}
+56
View File
@@ -0,0 +1,56 @@
{% extends "layout.html" %}
{% block title %}{{ artist }}'s Gallery{% endblock %}
{% block content %}
<h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h2>
<!-- Pagination Controls -->
<div class="pagination-container">
{% if images.has_prev %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.prev_num) }}">← Previous</a>
{% endif %}
{% for p in range(1, images.pages + 1) %}
<a class="pagination-link {% if p == images.page %}active{% endif %}" href="{{ url_for('main.artist_gallery', artist_name=artist, page=p) }}">{{ p }}</a>
{% endfor %}
{% if images.has_next %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.next_num) }}">Next →</a>
{% endif %}
</div>
<!-- Gallery Grid -->
<div class="gallery-grid">
{% 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-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>
<!-- Pagination Controls -->
<div class="pagination-container">
{% if images.has_prev %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.prev_num) }}">← Previous</a>
{% endif %}
{% for p in range(1, images.pages + 1) %}
<a class="pagination-link {% if p == images.page %}active{% endif %}" href="{{ url_for('main.artist_gallery', artist_name=artist, page=p) }}">{{ p }}</a>
{% endfor %}
{% if images.has_next %}
<a class="pagination-link" href="{{ url_for('main.artist_gallery', artist_name=artist, page=images.next_num) }}">Next →</a>
{% endif %}
</div>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "layout.html" %}
{% block title %}Gallery{% endblock %}
{% block content %}
<!-- Gallery Thumbnail Grid -->
<div class="gallery-grid">
{% 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-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>
{% endblock %}
+117 -21
View File
@@ -1,37 +1,133 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Flask Template App</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta charset="utf-8">
<title>Flask Template App</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="mainview">
<header>
{% if current_user.is_authenticated %}
Hello {{ current_user.username }} |
<a href="{{ url_for('auth.logout') }}">Logout</a>
{% else %}
<a href="{{ url_for('auth.login') }}">Login</a> |
<a href="{{ url_for('auth.register') }}">Register</a>
<header>
<nav class="navbar">
<a class="nav-button" href="{{ url_for('main.gallery') if current_user.is_authenticated else url_for('main.index') }}">Home</a>
{% if current_user.is_authenticated and current_user.is_admin %}
<a class="nav-button" href="{{ url_for('main.settings') }}">Settings</a>
{% endif %}
{% if current_user.is_authenticated %}
<a class="nav-button" href="{{ url_for('auth.logout') }}">Logout</a>
{% else %}
<a class="nav-button" href="{{ url_for('auth.login') }}">Login</a>
<a class="nav-button" href="{{ url_for('auth.register') }}">Register</a>
{% endif %}
</nav>
</header>
<!-- FLASH MESSAGES -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">
{{ message }}
</header>
<!-- FLASH MESSAGES -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<hr>
<!-- MAIN CONTENT -->
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
<!-- Modal -->
<div id="imageModal" class="modal">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content bg-transparent border-0">
<button id="modalClose" class="modal-button modal-close"></button>
<button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button>
<div class="modal-body">
<h5 id="modalFilename" class="modal-filename"></h5>
<img id="modalImage" src="" class="img-fluid">
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
</div>
</div>
<hr>
<!-- MAIN CONTENT -->
{% block content %}{% endblock %}
<script>
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('imageModal');
const modalImage = document.getElementById('modalImage');
const modalClose = document.getElementById('modalClose');
const modalPrev = document.getElementById('modalPrev');
const modalNext = document.getElementById('modalNext');
const modalFilename = document.getElementById('modalFilename');
const images = Array.from(document.querySelectorAll('.img-clickable'));
let currentIndex = -1;
function openModal(index) {
currentIndex = index;
const img = images[currentIndex];
modalImage.src = img.dataset.full;
modalFilename.textContent = img.dataset.filename || '';
modal.classList.add('active');
}
function closeModal() {
modal.classList.remove('active');
modalImage.src = '';
modalFilename.textContent = '';
currentIndex = -1;
}
function showNext() {
if (images.length === 0) return;
currentIndex = (currentIndex + 1) % images.length;
const img = images[currentIndex];
modalImage.src = img.dataset.full;
modalFilename.textContent = img.dataset.filename || '';
}
function showPrev() {
if (images.length === 0) return;
currentIndex = (currentIndex - 1 + images.length) % images.length;
const img = images[currentIndex];
modalImage.src = img.dataset.full;
modalFilename.textContent = img.dataset.filename || '';
}
images.forEach((img, index) => {
img.addEventListener('click', () => openModal(index));
});
modalClose.addEventListener('click', closeModal);
modalNext.addEventListener('click', showNext);
modalPrev.addEventListener('click', showPrev);
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (!modal.classList.contains('active')) return;
if (e.key === 'ArrowRight') showNext();
else if (e.key === 'ArrowLeft') showPrev();
else if (e.key === 'Escape') closeModal();
});
});
</script>
</body>
</html>
+25
View File
@@ -0,0 +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>
<div class="mb-3">
<a href="{{ url_for('main.trigger_image_import') }}" class="btn btn-primary">
📥 Import Images
</a>
</div>
<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>
</div>
</div>
{% endblock %}
Binary file not shown.
+129
View File
@@ -0,0 +1,129 @@
# app/utils/image_importer.py
from datetime import datetime
import os, shutil, hashlib
from PIL import Image
import exifread
import mimetypes
import imagehash
from app import db
from app.models import ImageRecord
def import_images_task(source_dir, dest_dir):
imported = []
for artist_dir in os.listdir(source_dir):
artist_path = os.path.join(source_dir, artist_dir)
if not os.path.isdir(artist_path):
continue
for root, _, files in os.walk(artist_path):
for file in files:
if file.lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff')):
full_path = os.path.join(root, file)
filename = os.path.basename(full_path)
file_hash = calculate_hash(full_path)
if ImageRecord.query.filter_by(hash=file_hash).first():
continue
metadata = extract_metadata(full_path)
if not metadata['width'] or not metadata['height']:
continue
phash = calculate_perceptual_hash(full_path)
if phash and is_similar_image(phash, metadata['width'], metadata['height']):
print(f"[SKIP] {filename} is visually similar to an existing larger image.")
continue
target_dir = os.path.join(dest_dir, artist_dir)
os.makedirs(target_dir, exist_ok=True)
dest_path = os.path.join(target_dir, filename)
shutil.copy2(full_path, dest_path)
record = ImageRecord(
filename=filename,
filepath=dest_path,
hash=file_hash,
perceptual_hash=str(phash) if phash else None,
artist=artist_dir,
file_size=metadata['file_size'],
width=metadata['width'],
height=metadata['height'],
format=metadata['format'],
camera_model=metadata['camera_model'],
taken_at=metadata['taken_at'],
imported_at=datetime.utcnow()
)
db.session.add(record)
imported.append(filename)
db.session.commit()
return f"Imported {len(imported)} images."
def calculate_hash(file_path):
hash_func = hashlib.sha256()
with open(file_path, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b''):
hash_func.update(chunk)
return hash_func.hexdigest()
def calculate_perceptual_hash(file_path):
try:
with Image.open(file_path) as img:
return imagehash.phash(img)
except Exception as e:
print(f"[WARN] Failed to compute perceptual hash for {file_path}: {e}")
return None
def is_similar_image(phash, width, height, threshold=2):
for img in ImageRecord.query.filter(ImageRecord.perceptual_hash != None).all():
try:
existing_phash = imagehash.hex_to_hash(img.perceptual_hash)
distance = phash - existing_phash
if distance <= threshold:
if img.width >= width and img.height >= height:
return True
except Exception as e:
print(f"[WARN] Failed pHash comparison: {e}")
return False
def extract_metadata(file_path):
metadata = {
'file_size': None,
'width': None,
'height': None,
'format': None,
'camera_model': None,
'taken_at': None
}
try:
metadata['file_size'] = os.path.getsize(file_path)
with Image.open(file_path) as img:
metadata['width'], metadata['height'] = img.size
metadata['format'] = img.format
except Exception as e:
ext = os.path.splitext(file_path)[1]
mime_type, _ = mimetypes.guess_type(file_path)
print(f"[WARN] Failed to read image metadata: {file_path}")
print(f"[WARN] Reason: {e}")
print(f"[INFO] File extension: {ext}, MIME type: {mime_type}")
try:
with open(file_path, 'rb') as f:
tags = exifread.process_file(f, stop_tag="UNDEF", details=False)
if 'EXIF DateTimeOriginal' in tags:
metadata['taken_at'] = datetime.strptime(str(tags['EXIF DateTimeOriginal']), "%Y:%m:%d %H:%M:%S")
if 'Image Model' in tags:
metadata['camera_model'] = str(tags['Image Model'])
except Exception as e:
print(f"[WARN] Failed to read EXIF data: {file_path} {e}")
return metadata