major updates to theming, creation of showcase view and polish of existing systems including tagging editting.

This commit is contained in:
Bryan Van Deusen
2026-01-18 11:32:21 -05:00
parent 5f568f43bc
commit 46144ccc76
21 changed files with 1787 additions and 557 deletions
-7
View File
@@ -3,7 +3,6 @@
from flask import Flask from flask import Flask
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate from flask_migrate import Migrate
from flask_login import LoginManager
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
@@ -11,7 +10,6 @@ import sqlite3
db = SQLAlchemy() db = SQLAlchemy()
migrate = Migrate() migrate = Migrate()
login_manager = LoginManager()
@event.listens_for(Engine, "connect") @event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record): def set_sqlite_pragma(dbapi_connection, connection_record):
@@ -30,16 +28,11 @@ def create_app(config_class='config.Config'):
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
from app.main import main from app.main import main
from app.auth import auth
app.register_blueprint(main) app.register_blueprint(main)
app.register_blueprint(auth)
@app.template_filter('datetimeformat') @app.template_filter('datetimeformat')
def datetimeformat(value, format="%Y-%m-%d"): def datetimeformat(value, format="%Y-%m-%d"):
-57
View File
@@ -1,57 +0,0 @@
# app/auth.py
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required
from werkzeug.security import generate_password_hash, check_password_hash
from app import db
from app.models import User
from app.forms import RegistrationForm, LoginForm
auth = Blueprint('auth', __name__, url_prefix='/auth')
@auth.route('/register', methods=['GET', 'POST'])
def register():
form = RegistrationForm()
if form.validate_on_submit():
hashed_password = generate_password_hash(form.password.data)
# 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()
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'])
def login():
form = LoginForm()
if form.validate_on_submit():
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.gallery'))
else:
flash('Login failed. Check username/password.', 'danger')
return render_template('login.html', form=form)
@auth.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('main.index'))
-26
View File
@@ -1,26 +0,0 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, ValidationError, Length
from .models import User
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=64)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8, max=64)])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('Username already exists.')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('Email already registered.')
class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
submit = SubmitField('Login')
+221 -40
View File
@@ -1,12 +1,10 @@
# app/main.py # app/main.py
from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify from flask import Blueprint, render_template, flash, redirect, url_for, abort, send_from_directory, request, jsonify
from flask_login import login_required, current_user
from sqlalchemy import desc, func, text from sqlalchemy import desc, func, text
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from app.utils.image_importer import import_images_task
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags from app.models import ImageRecord, Tag, ArchiveRecord, image_tags
from app import db from app import db
import os import os
@@ -17,21 +15,67 @@ main = Blueprint('main', __name__)
@main.route('/') @main.route('/')
def index(): def index():
return redirect(url_for("main.gallery")) """
# from random import choice # (kept from your original) Showcase view: full-bleed masonry collage of random images/videos.
# image = ImageRecord.query.order_by(func.random()).first() """
# background_url = None images = (
ImageRecord.query
.options(joinedload(ImageRecord.tags))
.order_by(func.random())
.limit(20)
.all()
)
return render_template('showcase.html', images=images)
# if image:
# path = image.thumb_path or image.filepath
# filename = path.replace('/images/', '')
# background_url = url_for('main.serve_image', filename=filename)
# return render_template('index.html', background_url=background_url) @main.route('/api/random-images')
def random_images_api():
"""
JSON endpoint returning random images for shuffle/infinite scroll.
Query params:
- count: number of images to return (default 12)
- exclude: comma-separated list of image IDs to exclude (for infinite scroll)
"""
count = request.args.get('count', 12, type=int)
exclude_str = request.args.get('exclude', '')
# Parse exclusion list
exclude_ids = []
if exclude_str:
try:
exclude_ids = [int(x) for x in exclude_str.split(',') if x.strip()]
except ValueError:
pass
# Build query
q = ImageRecord.query.options(joinedload(ImageRecord.tags))
if exclude_ids:
q = q.filter(~ImageRecord.id.in_(exclude_ids))
images = q.order_by(func.random()).limit(count).all()
result = []
for img in images:
is_video = img.filename.lower().endswith(('.mp4', '.mov'))
thumb_path = (img.thumb_path or img.filepath).replace('/images/', '')
full_path = img.filepath.replace('/images/', '')
result.append({
'id': img.id,
'filename': img.filename,
'thumb_url': url_for('main.serve_image', filename=thumb_path),
'full_url': url_for('main.serve_image', filename=full_path),
'is_video': is_video,
'date': (img.taken_at or img.imported_at).strftime('%Y-%m-%d') if (img.taken_at or img.imported_at) else '',
'tags': [{'name': t.name, 'kind': t.kind} for t in img.tags]
})
return jsonify(images=result)
@main.route('/gallery') @main.route('/gallery')
# @login_required
def gallery(): def gallery():
""" """
Gallery with pagination and optional tag filter (?tag=artist:NAME or any tag name). Gallery with pagination and optional tag filter (?tag=artist:NAME or any tag name).
@@ -72,7 +116,6 @@ def serve_image(filename):
@main.route('/tags') @main.route('/tags')
# @login_required
def tag_list(): def tag_list():
""" """
Generic tag explorer: Generic tag explorer:
@@ -80,19 +123,27 @@ def tag_list():
/tags?kind=user -> only user tags /tags?kind=user -> only user tags
/tags?kind=artist -> only artist tags /tags?kind=artist -> only artist tags
/tags?kind=archive -> only archive tags /tags?kind=archive -> only archive tags
/tags?kind=character -> only character tags
/tags?kind=series -> only series tags
/tags?kind=rating -> only rating tags
Shows a few preview images per tag. Shows a few preview images per tag.
""" """
images_per_tag = 3 images_per_tag = 3
kind = request.args.get('kind') # 'artist' | 'archive' | 'user' | None kind = request.args.get('kind') # 'artist' | 'archive' | 'user' | 'character' | 'series' | 'rating' | None
# Get tags with image counts
q = db.session.query(
Tag,
func.count(image_tags.c.image_id).label('img_count')
).outerjoin(image_tags).group_by(Tag.id)
q = Tag.query
if kind: if kind:
q = q.filter_by(kind=kind) q = q.filter(Tag.kind == kind)
tags = q.order_by(Tag.name.asc()).all() tags_with_counts = q.order_by(Tag.name.asc()).all()
tag_data = [] tag_data = []
for t in tags: for t, count in tags_with_counts:
imgs = ( imgs = (
ImageRecord.query ImageRecord.query
.join(ImageRecord.tags) .join(ImageRecord.tags)
@@ -101,9 +152,16 @@ def tag_list():
.limit(images_per_tag) .limit(images_per_tag)
.all() .all()
) )
# Display label: for artist/archive use the part after ':', else full name # Display label: for prefixed kinds use the part after ':', else full name
label = t.name.split(":", 1)[1] if (t.kind in ("artist", "archive") and ":" in t.name) else t.name label = t.name.split(":", 1)[1] if (":" in t.name) else t.name
tag_data.append({"name": label, "tag": t.name, "images": imgs, "kind": t.kind}) tag_data.append({
"id": t.id,
"name": label,
"tag": t.name,
"images": imgs,
"kind": t.kind,
"count": count
})
return render_template('tags_list.html', return render_template('tags_list.html',
tag_data=tag_data, tag_data=tag_data,
@@ -111,33 +169,23 @@ def tag_list():
active_kind=kind) active_kind=kind)
@main.route('/artists') @main.route('/artists')
# @login_required
def artist_list(): def artist_list():
# Backward-compatible route that now shows the Tag Explorer filtered to artist tags # Backward-compatible route that now shows the Tag Explorer filtered to artist tags
return redirect(url_for('main.tag_list', kind='artist')) return redirect(url_for('main.tag_list', kind='artist'))
@main.route('/artist/<artist_name>') @main.route('/artist/<artist_name>')
# @login_required
def artist_gallery(artist_name): def artist_gallery(artist_name):
# Backward-compatible: go to gallery filtered by artist tag # Backward-compatible: go to gallery filtered by artist tag
return redirect(url_for('main.gallery', tag=f"artist:{artist_name}")) return redirect(url_for('main.gallery', tag=f"artist:{artist_name}"))
@main.route('/settings') @main.route('/settings')
# @login_required
def settings(): def settings():
# if not current_user.is_admin:
# abort(403)
return render_template('settings.html') return render_template('settings.html')
@main.route('/trigger-thumbnail-generation', methods=['POST']) @main.route('/trigger-thumbnail-generation', methods=['POST'])
# @login_required
def trigger_thumbnail_generation(): def trigger_thumbnail_generation():
if not current_user.is_admin:
flash("You do not have permission to perform this action.", "danger")
return redirect(url_for('main.settings'))
try: try:
with open('/import/thumbnail.flag', 'w') as f: with open('/import/thumbnail.flag', 'w') as f:
f.write("trigger") f.write("trigger")
@@ -149,7 +197,6 @@ def trigger_thumbnail_generation():
@main.route('/import-images') @main.route('/import-images')
# @login_required
def trigger_image_import(): def trigger_image_import():
with open('/import/trigger.flag', 'w') as f: with open('/import/trigger.flag', 'w') as f:
f.write('start') f.write('start')
@@ -158,15 +205,11 @@ def trigger_image_import():
@main.route('/reset-db', methods=['POST']) @main.route('/reset-db', methods=['POST'])
# @login_required
def reset_db(): def reset_db():
""" """
Safe reset that works with Postgres (TRUNCATE ... CASCADE). Safe reset that works with Postgres (TRUNCATE ... CASCADE).
Falls back to ordered deletes if TRUNCATE isn't supported (e.g., SQLite). Falls back to ordered deletes if TRUNCATE isn't supported (e.g., SQLite).
""" """
if not current_user.is_admin:
abort(403)
try: try:
# Postgres fast path # Postgres fast path
db.session.execute(text(""" db.session.execute(text("""
@@ -194,16 +237,46 @@ def reset_db():
# Tag add/remove endpoints # Tag add/remove endpoints
# ---------------------------- # ----------------------------
@main.get("/api/tags/search")
def search_tags():
"""
Search existing tags for autocomplete.
Query params:
- q: search query (matches tag name, case-insensitive)
- limit: max results (default 10)
"""
query = request.args.get('q', '').strip().lower()
limit = request.args.get('limit', 10, type=int)
if not query:
# Return most-used tags when no query
tags = (
Tag.query
.join(image_tags)
.group_by(Tag.id)
.order_by(func.count(image_tags.c.image_id).desc())
.limit(limit)
.all()
)
else:
tags = (
Tag.query
.filter(Tag.name.ilike(f'%{query}%'))
.order_by(Tag.name)
.limit(limit)
.all()
)
return jsonify(tags=[{"name": t.name, "kind": t.kind} for t in tags])
@main.get("/image/<int:image_id>/tags") @main.get("/image/<int:image_id>/tags")
# @login_required
def list_tags(image_id): def list_tags(image_id):
from app.models import ImageRecord
img = ImageRecord.query.get_or_404(image_id) img = ImageRecord.query.get_or_404(image_id)
return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags]) return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags])
@main.post("/image/<int:image_id>/tags/add") @main.post("/image/<int:image_id>/tags/add")
# @login_required
def add_tag(image_id): def add_tag(image_id):
""" """
Minimal JSON endpoint to add a tag to an image. Minimal JSON endpoint to add a tag to an image.
@@ -234,7 +307,6 @@ def add_tag(image_id):
@main.post("/image/<int:image_id>/tags/remove") @main.post("/image/<int:image_id>/tags/remove")
# @login_required
def remove_tag(image_id): def remove_tag(image_id):
""" """
Minimal JSON endpoint to remove a tag from an image. Minimal JSON endpoint to remove a tag from an image.
@@ -246,3 +318,112 @@ def remove_tag(image_id):
img.tags.remove(tag) img.tags.remove(tag)
db.session.commit() db.session.commit()
return jsonify(ok=True) return jsonify(ok=True)
# ----------------------------
# Tag management endpoints
# ----------------------------
@main.get("/api/tag/<int:tag_id>")
def get_tag(tag_id):
"""Get a single tag's details."""
tag = Tag.query.get_or_404(tag_id)
# Get display name (without prefix)
display_name = tag.name.split(":", 1)[1] if ":" in tag.name else tag.name
return jsonify(ok=True, tag={
"id": tag.id,
"name": tag.name,
"display_name": display_name,
"kind": tag.kind
})
@main.post("/api/tag/<int:tag_id>/update")
def update_tag(tag_id):
"""
Update a tag's name and/or kind.
For prefixed kinds (artist, archive, character, series, rating),
the name will be stored as 'kind:name'.
If the new name matches an existing tag, merge them:
- Move all images from source tag to target tag
- Delete the source tag
- Return the target tag info with merged=True
"""
tag = Tag.query.get_or_404(tag_id)
new_name = (request.form.get("name") or "").strip()
new_kind = (request.form.get("kind") or "user").strip()
force_merge = request.form.get("merge") == "true"
if not new_name:
return jsonify(ok=False, error="Name is required"), 400
# Build the full tag name based on kind
prefixed_kinds = ("artist", "archive", "character", "series", "rating")
if new_kind in prefixed_kinds:
# Remove any existing prefix from name if present
if ":" in new_name:
new_name = new_name.split(":", 1)[1]
full_name = f"{new_kind}:{new_name}"
else:
# User tags don't have prefixes
full_name = new_name
# Check for duplicates (excluding current tag)
existing = Tag.query.filter(Tag.name == full_name, Tag.id != tag_id).first()
if existing:
if not force_merge:
# Ask user to confirm merge
return jsonify(
ok=False,
error="A tag with this name already exists",
can_merge=True,
target_tag={
"id": existing.id,
"name": existing.name,
"kind": existing.kind
}
), 409 # Conflict status
# Merge: move all images from source tag to target tag
source_images = tag.images[:] # Copy list to avoid mutation during iteration
merged_count = 0
for img in source_images:
if existing not in img.tags:
img.tags.append(existing)
merged_count += 1
img.tags.remove(tag)
# Delete the source tag
db.session.delete(tag)
db.session.commit()
return jsonify(ok=True, merged=True, merged_count=merged_count, tag={
"id": existing.id,
"name": existing.name,
"kind": existing.kind
})
# No conflict - just update the tag
tag.name = full_name
tag.kind = new_kind
db.session.commit()
return jsonify(ok=True, tag={
"id": tag.id,
"name": tag.name,
"kind": tag.kind
})
@main.post("/api/tag/<int:tag_id>/delete")
def delete_tag(tag_id):
"""Delete a tag entirely."""
tag = Tag.query.get_or_404(tag_id)
# Remove from all images first (handled by cascade, but explicit is clearer)
db.session.delete(tag)
db.session.commit()
return jsonify(ok=True)
+2 -22
View File
@@ -1,7 +1,4 @@
from . import db from . import db
from flask_login import UserMixin
from . import login_manager
from datetime import datetime
# tag to object relationship table # tag to object relationship table
image_tags = db.Table( image_tags = db.Table(
@@ -19,13 +16,6 @@ image_tags = db.Table(
db.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"), db.UniqueConstraint("image_id", "tag_id", name="uq_image_tag"),
) )
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): class ImageRecord(db.Model):
__tablename__ = "image_record" __tablename__ = "image_record"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
@@ -34,7 +24,6 @@ class ImageRecord(db.Model):
thumb_path = db.Column(db.String(512)) thumb_path = db.Column(db.String(512))
hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256 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 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) file_size = db.Column(db.Integer, nullable=True)
width = db.Column(db.Integer, nullable=True) width = db.Column(db.Integer, nullable=True)
height = db.Column(db.Integer, nullable=True) height = db.Column(db.Integer, nullable=True)
@@ -47,11 +36,10 @@ class ImageRecord(db.Model):
"Tag", "Tag",
secondary=image_tags, secondary=image_tags,
back_populates="images", back_populates="images",
passive_deletes=True, # <-- let DB handle deletes passive_deletes=True,
) )
class Tag(db.Model): class Tag(db.Model):
# __tablename__ = "tag" (implicit)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False) name = db.Column(db.String(255), unique=True, nullable=False)
kind = db.Column(db.String(64), nullable=True) kind = db.Column(db.String(64), nullable=True)
@@ -69,18 +57,10 @@ class ArchiveRecord(db.Model):
""" """
__tablename__ = "archive_record" __tablename__ = "archive_record"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(512), nullable=False) # full path or logical name filename = db.Column(db.String(512), nullable=False)
file_size = db.Column(db.BigInteger, nullable=False) file_size = db.Column(db.BigInteger, nullable=False)
hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256 of the archive file hash = db.Column(db.String(64), unique=True, nullable=False) # SHA256 of the archive file
imported_at = db.Column(db.DateTime, default=db.func.now()) imported_at = db.Column(db.DateTime, default=db.func.now())
# optional: where this archive lived in the source tree (e.g., artist dir)
artist = db.Column(db.String(255), nullable=True) artist = db.Column(db.String(255), nullable=True)
# Link to the tag we created for this archive so we can reuse it if the same archive returns
tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True) tag_id = db.Column(db.Integer, db.ForeignKey("tag.id", ondelete="SET NULL"), nullable=True)
tag = db.relationship("Tag") tag = db.relationship("Tag")
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
+159 -8
View File
@@ -12,6 +12,12 @@ document.addEventListener('DOMContentLoaded', () => {
const tagList = document.getElementById('modalTagList'); const tagList = document.getElementById('modalTagList');
const tagForm = document.getElementById('modalTagForm'); const tagForm = document.getElementById('modalTagForm');
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null; const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
const tagAutocomplete = document.getElementById('tagAutocomplete');
// Autocomplete state
let autocompleteItems = [];
let autocompleteSelectedIndex = -1;
let autocompleteDebounce = null;
let images = Array.from(document.querySelectorAll('.img-clickable')); let images = Array.from(document.querySelectorAll('.img-clickable'));
let currentIndex = -1; let currentIndex = -1;
@@ -36,18 +42,31 @@ document.addEventListener('DOMContentLoaded', () => {
function getEditorImageId() { function getEditorImageId() {
return tagEditor ? tagEditor.dataset.imageId : ''; return tagEditor ? tagEditor.dataset.imageId : '';
} }
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
rating: '⚠️'
};
return icons[kind] || '#';
}
function getTagDisplayName(name) {
// Remove prefix if present (e.g., "artist:Name" -> "Name")
return name.includes(':') ? name.split(':', 2)[1] : name;
}
function renderTags(tags) { function renderTags(tags) {
if (!tagList) return; if (!tagList) return;
tagList.innerHTML = ''; tagList.innerHTML = '';
(tags || []).forEach(t => { (tags || []).forEach(t => {
const chip = document.createElement('span'); const chip = document.createElement('span');
chip.className = 'tag-chip'; chip.className = 'tag-chip';
const label = (t.kind === 'artist') const icon = getTagIcon(t.kind);
? `🎨 ${t.name.split(':', 1)[0] === 'artist' ? t.name.split(':', 2)[1] : t.name}` const displayName = getTagDisplayName(t.name);
: (t.kind === 'archive') chip.innerHTML = `${icon} ${displayName} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
? `🗜️ ${t.name.split(':', 1)[0] === 'archive' ? t.name.split(':', 2)[1] : t.name}`
: `#${t.name}`;
chip.innerHTML = `${label} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
tagList.appendChild(chip); tagList.appendChild(chip);
}); });
} }
@@ -84,12 +103,129 @@ document.addEventListener('DOMContentLoaded', () => {
return false; return false;
} }
// ---------------------------
// Autocomplete helpers
// ---------------------------
function hideAutocomplete() {
if (tagAutocomplete) {
tagAutocomplete.classList.remove('active');
tagAutocomplete.innerHTML = '';
}
autocompleteItems = [];
autocompleteSelectedIndex = -1;
}
function renderAutocomplete(tags) {
if (!tagAutocomplete) return;
autocompleteItems = tags;
autocompleteSelectedIndex = -1;
if (tags.length === 0) {
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
tagAutocomplete.classList.add('active');
return;
}
tagAutocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name);
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${t.name}">
<span>${icon} ${displayName}</span>
<span class="tag-kind">${t.kind || 'user'}</span>
</div>`;
}).join('');
tagAutocomplete.classList.add('active');
}
function selectAutocompleteItem(index) {
const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item');
if (!items) return;
items.forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
autocompleteSelectedIndex = index;
}
async function fetchAutocomplete(query) {
try {
const url = query
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8`
: `/api/tags/search?limit=8`;
const r = await fetch(url);
const j = await r.json();
renderAutocomplete(j.tags || []);
} catch {
hideAutocomplete();
}
}
if (tagInput) {
// Show autocomplete on focus
tagInput.addEventListener('focus', () => {
const val = tagInput.value.trim();
fetchAutocomplete(val);
});
// Hide autocomplete on blur (with delay for click)
tagInput.addEventListener('blur', () => {
setTimeout(hideAutocomplete, 200);
});
// Search as user types
tagInput.addEventListener('input', () => {
clearTimeout(autocompleteDebounce);
autocompleteDebounce = setTimeout(() => {
fetchAutocomplete(tagInput.value.trim());
}, 150);
});
// Keyboard navigation
tagInput.addEventListener('keydown', (e) => {
if (!tagAutocomplete?.classList.contains('active')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
selectAutocompleteItem(nextIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
selectAutocompleteItem(prevIndex);
} else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
e.preventDefault();
const selected = autocompleteItems[autocompleteSelectedIndex];
if (selected) {
tagInput.value = selected.name;
hideAutocomplete();
tagForm.dispatchEvent(new Event('submit'));
}
} else if (e.key === 'Escape') {
hideAutocomplete();
}
});
}
// Click on autocomplete item
if (tagAutocomplete) {
tagAutocomplete.addEventListener('click', (e) => {
const item = e.target.closest('.tag-autocomplete-item');
if (!item) return;
const name = item.dataset.name;
if (tagInput && name) {
tagInput.value = name;
hideAutocomplete();
tagForm.dispatchEvent(new Event('submit'));
}
});
}
if (tagForm) { if (tagForm) {
tagForm.addEventListener('submit', async (e) => { tagForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const id = getEditorImageId(); const id = getEditorImageId();
const name = (tagInput.value || '').trim(); const name = (tagInput.value || '').trim();
if (!id || !name) return; if (!id || !name) return;
hideAutocomplete();
await addTag(id, name); await addTag(id, name);
tagInput.value = ''; tagInput.value = '';
}); });
@@ -306,8 +442,23 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.key === 'Escape') closeModal(); if (e.key === 'Escape') closeModal();
}); });
images.forEach((img, index) => { // Use event delegation on the document for .img-clickable clicks
img.addEventListener('click', () => openModal(index)); // This allows dynamically added elements (e.g., from shuffle) to work
document.addEventListener('click', (e) => {
const clickable = e.target.closest('.img-clickable');
if (!clickable) return;
// Re-query images to get current state (may have changed from shuffle)
images = Array.from(document.querySelectorAll('.img-clickable'));
const index = images.indexOf(clickable);
if (index !== -1) {
openModal(index);
}
});
// Listen for showcase updates to refresh the images array
document.addEventListener('showcaseUpdated', () => {
images = Array.from(document.querySelectorAll('.img-clickable'));
}); });
window.addEventListener('popstate', () => { window.addEventListener('popstate', () => {
+283
View File
@@ -0,0 +1,283 @@
// /app/static/js/showcase.js
// JS-managed masonry with column distribution for infinite scroll
(function() {
const container = document.getElementById('showcaseGrid');
if (!container) return;
// Configuration
const DESKTOP_COLUMNS = 4;
const MOBILE_COLUMNS = 2;
const MOBILE_BREAKPOINT = 768;
const SCROLL_THRESHOLD = 1500; // Load more when 1500px from bottom (about 2-3 rows ahead)
const LOAD_BATCH_SIZE = 12;
// State
let columns = [];
let columnHeights = [];
let isLoading = false;
let currentColumnCount = 0;
// Initialize
function init() {
setupColumns();
loadInitialImages();
setupEventListeners();
}
function getColumnCount() {
return window.innerWidth < MOBILE_BREAKPOINT ? MOBILE_COLUMNS : DESKTOP_COLUMNS;
}
function setupColumns() {
const count = getColumnCount();
if (count === currentColumnCount && columns.length > 0) return;
currentColumnCount = count;
container.innerHTML = '';
columns = [];
columnHeights = [];
for (let i = 0; i < count; i++) {
const col = document.createElement('div');
col.className = 'masonry-column';
col.dataset.column = i;
container.appendChild(col);
columns.push(col);
columnHeights.push(0);
}
}
function loadInitialImages() {
const dataScript = document.getElementById('initialImages');
if (!dataScript) return;
try {
const images = JSON.parse(dataScript.textContent);
distributeImages(images, true); // Use lazy loading for initial images (some may be below fold)
} catch (e) {
console.error('Failed to parse initial images:', e);
}
}
function setupEventListeners() {
// Keyboard shuffle
document.addEventListener('keydown', (e) => {
const modal = document.getElementById('imageModal');
if (modal && modal.classList.contains('active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'r' || e.key === 'R' || e.key === ' ') {
e.preventDefault();
shuffle();
}
});
// Infinite scroll
let scrollTimeout = null;
window.addEventListener('scroll', () => {
if (scrollTimeout) return;
scrollTimeout = setTimeout(() => {
scrollTimeout = null;
checkScrollPosition();
}, 100);
});
// Window resize - redistribute if column count changes
let resizeTimeout = null;
window.addEventListener('resize', () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
const newCount = getColumnCount();
if (newCount !== currentColumnCount) {
redistributeAllImages();
}
}, 200);
});
}
function checkScrollPosition() {
const scrollBottom = window.innerHeight + window.scrollY;
const docHeight = document.documentElement.scrollHeight;
if (docHeight - scrollBottom < SCROLL_THRESHOLD) {
loadMore();
}
}
function getShortestColumnIndex() {
let minHeight = Infinity;
let minIndex = 0;
for (let i = 0; i < columnHeights.length; i++) {
if (columnHeights[i] < minHeight) {
minHeight = columnHeights[i];
minIndex = i;
}
}
return minIndex;
}
function createImageElement(img, useLazyLoading = false) {
const item = document.createElement('div');
item.className = 'masonry-item img-clickable';
item.dataset.id = img.id;
item.dataset.full = img.full_url;
item.dataset.type = img.is_video ? 'video' : 'image';
item.dataset.filename = img.filename;
const imgEl = document.createElement('img');
imgEl.src = img.thumb_url;
imgEl.alt = img.filename;
if (useLazyLoading) {
imgEl.loading = 'lazy';
}
item.appendChild(imgEl);
if (img.tags && img.tags.length > 0) {
const overlay = document.createElement('div');
overlay.className = 'tag-overlay';
img.tags.forEach(t => {
const chip = document.createElement('a');
chip.className = 'tag-chip';
chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`;
chip.title = `Filter by ${t.name}`;
chip.onclick = (e) => e.stopPropagation();
chip.innerHTML = formatTag(t);
overlay.appendChild(chip);
});
item.appendChild(overlay);
}
if (img.is_video) {
const play = document.createElement('div');
play.className = 'play-overlay';
play.textContent = '▶';
item.appendChild(play);
}
return item;
}
function distributeImages(images, useLazyLoading = false) {
images.forEach(img => {
const colIndex = getShortestColumnIndex();
const item = createImageElement(img, useLazyLoading);
columns[colIndex].appendChild(item);
// Update height estimate (will be corrected after image loads)
columnHeights[colIndex] += 300;
// Correct height after image loads
const imgEl = item.querySelector('img');
imgEl.onload = () => {
updateColumnHeights();
};
});
document.dispatchEvent(new CustomEvent('showcaseUpdated'));
}
function updateColumnHeights() {
columns.forEach((col, i) => {
columnHeights[i] = col.offsetHeight;
});
}
function redistributeAllImages() {
// Collect all current items
const allItems = [];
columns.forEach(col => {
Array.from(col.children).forEach(item => {
allItems.push(item);
});
});
// Reset columns
setupColumns();
// Re-add items maintaining order
allItems.forEach(element => {
const colIndex = getShortestColumnIndex();
columns[colIndex].appendChild(element);
columnHeights[colIndex] += element.offsetHeight || 300;
});
document.dispatchEvent(new CustomEvent('showcaseUpdated'));
}
async function shuffle() {
if (isLoading) return;
isLoading = true;
container.classList.add('loading');
try {
const response = await fetch(`/api/random-images?count=20`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
// Clear everything
columns.forEach(col => col.innerHTML = '');
columnHeights = columnHeights.map(() => 0);
distributeImages(data.images);
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
console.error('Shuffle error:', err);
} finally {
container.classList.remove('loading');
isLoading = false;
}
}
async function loadMore() {
if (isLoading) return;
isLoading = true;
try {
const response = await fetch(`/api/random-images?count=${LOAD_BATCH_SIZE}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (data.images.length === 0) {
return;
}
distributeImages(data.images);
} catch (err) {
console.error('Load more error:', err);
} finally {
isLoading = false;
}
}
function formatTag(tag) {
const escape = (s) => {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
};
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
rating: '⚠️'
};
const icon = icons[tag.kind];
if (icon) {
const label = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
return `${icon} ${escape(label)}`;
} else {
return `#${escape(tag.name)}`;
}
}
// Start
init();
})();
+141
View File
@@ -0,0 +1,141 @@
// /app/static/js/tag-editor.js
// Tag management UI for the tags list page
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('tagEditModal');
const closeBtn = document.getElementById('tagEditClose');
const form = document.getElementById('tagEditForm');
const deleteBtn = document.getElementById('tagDeleteBtn');
const tagIdInput = document.getElementById('editTagId');
const nameInput = document.getElementById('editTagName');
const kindSelect = document.getElementById('editTagKind');
let currentTagCard = null;
// Open modal when edit button clicked
document.querySelectorAll('.tag-edit-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const tagId = btn.dataset.tagId;
currentTagCard = btn.closest('.tag-card');
// Fetch tag details
try {
const res = await fetch(`/api/tag/${tagId}`);
const data = await res.json();
if (data.ok) {
tagIdInput.value = data.tag.id;
nameInput.value = data.tag.display_name;
kindSelect.value = data.tag.kind || 'user';
modal.classList.add('active');
}
} catch (err) {
console.error('Failed to load tag:', err);
}
});
});
// Close modal
function closeModal() {
modal.classList.remove('active');
currentTagCard = null;
}
closeBtn?.addEventListener('click', closeModal);
modal?.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal?.classList.contains('active')) {
closeModal();
}
});
// Handle form submission
async function submitUpdate(forceMerge = false) {
const tagId = tagIdInput.value;
const formData = new FormData();
formData.append('name', nameInput.value);
formData.append('kind', kindSelect.value);
if (forceMerge) {
formData.append('merge', 'true');
}
try {
const res = await fetch(`/api/tag/${tagId}/update`, {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.ok) {
if (data.merged) {
// Tag was merged
alert(`Tags merged successfully! ${data.merged_count} images were updated.`);
}
// Reload page to show updated tag
window.location.reload();
} else if (data.can_merge) {
// Conflict - ask user if they want to merge
const targetName = data.target_tag.name;
const confirmMerge = confirm(
`A tag "${targetName}" already exists.\n\n` +
`Do you want to merge these tags? This will:\n` +
`• Move all images from the current tag to "${targetName}"\n` +
`• Delete the current tag\n\n` +
`This action cannot be undone.`
);
if (confirmMerge) {
await submitUpdate(true);
}
} else {
alert(data.error || 'Failed to update tag');
}
} catch (err) {
console.error('Failed to update tag:', err);
alert('Failed to update tag');
}
}
form?.addEventListener('submit', async (e) => {
e.preventDefault();
await submitUpdate(false);
});
// Handle delete
deleteBtn?.addEventListener('click', async () => {
const tagId = tagIdInput.value;
const tagName = nameInput.value;
if (!confirm(`Are you sure you want to delete the tag "${tagName}"? This will remove it from all images.`)) {
return;
}
try {
const res = await fetch(`/api/tag/${tagId}/delete`, {
method: 'POST'
});
const data = await res.json();
if (data.ok) {
// Remove the card from DOM and close modal
if (currentTagCard) {
currentTagCard.remove();
}
closeModal();
} else {
alert(data.error || 'Failed to delete tag');
}
} catch (err) {
console.error('Failed to delete tag:', err);
alert('Failed to delete tag');
}
});
});
+730 -183
View File
File diff suppressed because it is too large Load Diff
+20 -12
View File
@@ -1,14 +1,15 @@
<!-- /app/templates/_gallery_grid.html --> <!-- /app/templates/_gallery_grid.html -->
<div class="gallery-grid"> <div class="gallery-grid">
{% for image in images.items %} {% for image in images.items %}
<div class="gallery-item"> <div class="gallery-item img-clickable"
<div class="gallery-thumb img-clickable"
data-id="{{ image.id }}" data-id="{{ image.id }}"
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}" data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}"
data-type="{{ 'video' if image.filename.lower().endswith(('.mp4', '.mov')) else 'image' }}" data-type="{{ 'video' if image.filename.lower().endswith(('.mp4', '.mov')) else 'image' }}"
style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');"
data-filename="{{ image.filename }}"> data-filename="{{ image.filename }}">
<div class="gallery-thumb"
style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');"></div>
{% if image.tags %} {% if image.tags %}
<div class="tag-overlay"> <div class="tag-overlay">
{% for t in image.tags %} {% for t in image.tags %}
@@ -17,9 +18,15 @@
title="Filter by {{ t.name }}" title="Filter by {{ t.name }}"
onclick="event.stopPropagation()"> onclick="event.stopPropagation()">
{% if t.kind == 'artist' %} {% if t.kind == 'artist' %}
🎨 {{ t.name.split(':', 1)[1] }} 🎨 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'archive' %} {% elif t.kind == 'archive' %}
🗜️ {{ t.name.split(':', 1)[1] }} 🗜️ {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'character' %}
👤 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'series' %}
📺 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'rating' %}
⚠️ {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% else %} {% else %}
#{{ t.name }} #{{ t.name }}
{% endif %} {% endif %}
@@ -31,9 +38,7 @@
{% if image.filename.lower().endswith(('.mp4', '.mov')) %} {% if image.filename.lower().endswith(('.mp4', '.mov')) %}
<div class="play-overlay"></div> <div class="play-overlay"></div>
{% endif %} {% endif %}
</div>
<!-- Removed the artist link; keep date only -->
<span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span> <span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span>
</div> </div>
{% endfor %} {% endfor %}
@@ -44,20 +49,23 @@
<!-- Modal --> <!-- Modal -->
<div id="imageModal" class="modal"> <div id="imageModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<!-- Navigation buttons --> <button id="modalClose" class="modal-close-btn" title="Close (ESC)">×</button>
<button id="modalPrev" class="modal-button modal-prev"></button> <button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button> <button id="modalNext" class="modal-button modal-next"></button>
<span class="modal-close-hint">ESC to close</span>
<div class="modal-body"> <div class="modal-body">
<div id="modalImageWrapper" class="modal-image-wrapper"> <div id="modalImageWrapper" class="modal-image-wrapper">
<img id="modalImage" src="" alt="Full View"> <img id="modalImage" src="" alt="Full View">
</div> </div>
<!-- Tag editor (inline, minimal UI) -->
<div id="modalTagEditor" class="tag-editor" data-image-id=""> <div id="modalTagEditor" class="tag-editor" data-image-id="">
<div id="modalTagList" class="tags"></div> <div id="modalTagList" class="tags"></div>
<form id="modalTagForm" class="tag-form" autocomplete="off"> <form id="modalTagForm" class="tag-form" autocomplete="off">
<input type="text" name="name" placeholder="Add tag… (artist:foo, archive:bar, or freeform)"> <div class="tag-form-wrapper">
<input type="text" id="tagInput" name="name" placeholder="Add tag..." autocomplete="off">
<div id="tagAutocomplete" class="tag-autocomplete"></div>
</div>
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
</div> </div>
+37
View File
@@ -0,0 +1,37 @@
<!-- /app/templates/_pagination_floating.html -->
{% set total_pages = images.pages %}
{% set current_page = images.page %}
{% set endpoint = request.endpoint %}
{# Build a merged param dict: query args + path args #}
{% set params = request.args.to_dict(flat=True) %}
{% for k, v in request.view_args.items() %}
{% set _ = params.update({k: v}) %}
{% endfor %}
{# Helper to make a page URL while preserving all other params #}
{% macro page_url(p) -%}
{%- set pmap = params.copy() -%}
{%- set _ = pmap.update({'page': p}) -%}
{{ url_for(endpoint, **pmap) }}
{%- endmacro %}
<div class="floating-pagination">
<a class="fp-btn fp-prev {% if not images.has_prev %}disabled{% endif %}"
{% if images.has_prev %}href="{{ page_url(images.prev_num) }}"{% endif %}
title="Previous page">
</a>
<span class="fp-indicator">
<span class="fp-current">{{ current_page }}</span>
<span class="fp-sep">/</span>
<span class="fp-total">{{ total_pages }}</span>
</span>
<a class="fp-btn fp-next {% if not images.has_next %}disabled{% endif %}"
{% if images.has_next %}href="{{ page_url(images.next_num) }}"{% endif %}
title="Next page">
</a>
</div>
-17
View File
@@ -1,17 +0,0 @@
<!-- /app/templates/artist_gallery.html -->
{% extends "layout.html" %}
{% block title %}{{ artist }}'s Gallery{% endblock %}
{% block content %}
<h1 style="text-align:center; margin-top: 1rem;">{{ artist }}</h1>
<!-- Pagination Controls -->
{% include '_pagination.html' %}
<!-- Gallery Grid -->
{% include '_gallery_grid.html' %}
<!-- Pagination Controls -->
{% include '_pagination.html' %}
{% endblock %}
-22
View File
@@ -1,22 +0,0 @@
{% extends "layout.html" %}
{% block title %}Artists{% endblock %}
{% block content %}
<h1>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.thumb_path or image.filepath) | replace('/images/', '') ) }}');">
</div>
{% endfor %}
</div>
</div>
</a>
{% endfor %}
</div>
{% endblock %}
+4 -5
View File
@@ -5,13 +5,12 @@
{% block content %} {% block content %}
<h1 style="text-align:center; margin-top: 1rem;">Gallery</h1> <h1 style="text-align:center; margin-top: 1rem;">Gallery</h1>
<!-- Pagination Controls (Top) -->
{% include '_pagination.html' %}
<!-- Gallery Grid --> <!-- Gallery Grid -->
{% include '_gallery_grid.html' %} {% include '_gallery_grid.html' %}
<!-- Pagination Controls (Bottom) --> <!-- Floating Pagination Bar -->
{% include '_pagination.html' %} {% if images.pages > 1 %}
{% include '_pagination_floating.html' %}
{% endif %}
{% endblock %} {% endblock %}
-14
View File
@@ -1,14 +0,0 @@
{% extends "layout.html" %}
{% block title %}Welcome{% endblock %}
{% block content %}
<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 %}
+3 -18
View File
@@ -11,24 +11,11 @@
<header> <header>
<nav class="navbar"> <nav class="navbar">
<a class="nav-button" href="{{ url_for('main.gallery') }}">Home</a> <a class="nav-button" href="{{ url_for('main.index') }}">Showcase</a>
<a class="nav-button" href="{{ url_for('main.gallery') }}">Gallery</a>
<a class="nav-button" href="{{ url_for('main.tag_list', kind='artist') }}">Artists</a> <a class="nav-button" href="{{ url_for('main.tag_list', kind='artist') }}">Artists</a>
<a class="nav-button" href="{{ url_for('main.tag_list') }}">Tags</a> <a class="nav-button" href="{{ url_for('main.tag_list') }}">Tags</a>
<a class="nav-button" href="{{ url_for('main.settings') }}">Settings</a> <a class="nav-button" href="{{ url_for('main.settings') }}">Settings</a>
<!-- <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('main.tag_list', kind='artist') }}">Artists</a>
<a class="nav-button" href="{{ url_for('main.tag_list') }}">Tags</a>
<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> </nav>
</header> </header>
@@ -44,15 +31,13 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<hr>
<!-- MAIN CONTENT --> <!-- MAIN CONTENT -->
<div class="content"> <div class="content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</div> </div>
{% if request.endpoint == 'main.gallery' or request.endpoint == 'main.artist_gallery' %} {% if request.endpoint == 'main.gallery' %}
<input type="hidden" id="hasNextPage" value="{{ has_next|lower }}"> <input type="hidden" id="hasNextPage" value="{{ has_next|lower }}">
<input type="hidden" id="nextPageUrl" value="{{ next_page_url }}"> <input type="hidden" id="nextPageUrl" value="{{ next_page_url }}">
<input type="hidden" id="hasPrevPage" value="{{ has_prev|lower }}"> <input type="hidden" id="hasPrevPage" value="{{ has_prev|lower }}">
-30
View File
@@ -1,30 +0,0 @@
{% extends "layout.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<div class="form-container">
<h2>Login</h2>
<form method="POST" class="form-card">
{{ form.hidden_tag() }}
<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 %}
-52
View File
@@ -1,52 +0,0 @@
{% extends "layout.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<div class="form-container">
<h2>Register</h2>
<form method="POST" class="form-card">
{{ form.hidden_tag() }}
<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>
<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>
<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>
<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>
<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 %}
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ImageRepo - Showcase</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="showcase-body">
<!-- Floating navbar overlay -->
<nav class="showcase-nav">
<div class="showcase-nav-links">
<a class="nav-link" href="{{ url_for('main.index') }}">Showcase</a>
<a class="nav-link" href="{{ url_for('main.gallery') }}">Gallery</a>
<a class="nav-link" href="{{ url_for('main.tag_list', kind='artist') }}">Artists</a>
<a class="nav-link" href="{{ url_for('main.tag_list') }}">Tags</a>
<a class="nav-link" href="{{ url_for('main.settings') }}">Settings</a>
</div>
<span class="nav-hint">Press R to shuffle</span>
</nav>
<!-- Masonry grid container - columns created by JS -->
<div id="showcaseGrid" class="masonry-container"></div>
<!-- Initial image data for JS to distribute -->
<script id="initialImages" type="application/json">
[{% for image in images %}
{
"id": {{ image.id }},
"filename": "{{ image.filename | e }}",
"thumb_url": "{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '')) }}",
"full_url": "{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '')) }}",
"is_video": {{ 'true' if image.filename.lower().endswith(('.mp4', '.mov')) else 'false' }},
"tags": [{% for t in image.tags %}{"name": "{{ t.name | e }}", "kind": "{{ t.kind }}"}{% if not loop.last %}, {% endif %}{% endfor %}]
}{% if not loop.last %},{% endif %}
{% endfor %}]
</script>
<!-- Modal viewer -->
<div id="imageModal" class="modal">
<div class="modal-content">
<button id="modalClose" class="modal-close-btn" title="Close (ESC)">×</button>
<button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button>
<span class="modal-close-hint">ESC to close</span>
<div class="modal-body">
<div id="modalImageWrapper" class="modal-image-wrapper">
<img id="modalImage" src="" alt="Full View">
</div>
<div id="modalTagEditor" class="tag-editor" data-image-id="">
<div id="modalTagList" class="tags"></div>
<form id="modalTagForm" class="tag-form" autocomplete="off">
<div class="tag-form-wrapper">
<input type="text" id="tagInput" name="name" placeholder="Add tag..." autocomplete="off">
<div id="tagAutocomplete" class="tag-autocomplete"></div>
</div>
<button type="submit">Add</button>
</form>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/modal-pagination.js') }}"></script>
<script src="{{ url_for('static', filename='js/showcase.js') }}"></script>
</body>
</html>
+90 -14
View File
@@ -1,10 +1,25 @@
{% extends "layout.html" %} {% extends "layout.html" %}
{% block title %}{{ 'Artists' if active_kind == 'artist' else 'Tags' }}{% endblock %} {% block title %}
{%- if active_kind == 'artist' -%}Artists
{%- elif active_kind == 'character' -%}Characters
{%- elif active_kind == 'series' -%}Series
{%- elif active_kind == 'rating' -%}Ratings
{%- elif active_kind == 'archive' -%}Archives
{%- elif active_kind == 'user' -%}User Tags
{%- else -%}Tags{%- endif -%}
{% endblock %}
{% block content %} {% block content %}
<h1>{{ 'Artists' if active_kind == 'artist' else 'Tags' }}</h1> <h1>
{%- if active_kind == 'artist' -%}Artists
{%- elif active_kind == 'character' -%}Characters
{%- elif active_kind == 'series' -%}Series
{%- elif active_kind == 'rating' -%}Ratings
{%- elif active_kind == 'archive' -%}Archives
{%- elif active_kind == 'user' -%}User Tags
{%- else -%}All Tags{%- endif -%}
</h1>
<!-- Small kind filter nav (optional) -->
<!-- Tag kind filter --> <!-- Tag kind filter -->
<div class="filter-bar" role="tablist" aria-label="Filter tags by kind"> <div class="filter-bar" role="tablist" aria-label="Filter tags by kind">
<a class="filter-btn {{ 'is-active' if not active_kind }}" <a class="filter-btn {{ 'is-active' if not active_kind }}"
@@ -15,6 +30,18 @@
href="{{ url_for('main.tag_list', kind='artist') }}" href="{{ url_for('main.tag_list', kind='artist') }}"
aria-current="{{ 'page' if active_kind=='artist' else 'false' }}">🎨 Artists</a> aria-current="{{ 'page' if active_kind=='artist' else 'false' }}">🎨 Artists</a>
<a class="filter-btn {{ 'is-active' if active_kind=='character' }}"
href="{{ url_for('main.tag_list', kind='character') }}"
aria-current="{{ 'page' if active_kind=='character' else 'false' }}">👤 Characters</a>
<a class="filter-btn {{ 'is-active' if active_kind=='series' }}"
href="{{ url_for('main.tag_list', kind='series') }}"
aria-current="{{ 'page' if active_kind=='series' else 'false' }}">📺 Series</a>
<a class="filter-btn {{ 'is-active' if active_kind=='rating' }}"
href="{{ url_for('main.tag_list', kind='rating') }}"
aria-current="{{ 'page' if active_kind=='rating' else 'false' }}">⚠️ Ratings</a>
<a class="filter-btn {{ 'is-active' if active_kind=='archive' }}" <a class="filter-btn {{ 'is-active' if active_kind=='archive' }}"
href="{{ url_for('main.tag_list', kind='archive') }}" href="{{ url_for('main.tag_list', kind='archive') }}"
aria-current="{{ 'page' if active_kind=='archive' else 'false' }}">🗜️ Archives</a> aria-current="{{ 'page' if active_kind=='archive' else 'false' }}">🗜️ Archives</a>
@@ -24,25 +51,74 @@
aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User</a> aria-current="{{ 'page' if active_kind=='user' else 'false' }}"># User</a>
</div> </div>
<div class="artist-grid"> {% if tag_data %}
<div class="tag-grid">
{% for tag in tag_data %} {% for tag in tag_data %}
<a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="artist-card-link"> <div class="tag-card" data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.tag }}" data-tag-kind="{{ tag.kind }}">
<div class="artist-card"> <a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="tag-card-link">
<div class="card-title" title="{{ tag.name }}"> <div class="tag-preview">
<h3 class="card-title-text">
{% if tag.kind == 'artist' %}🎨{% elif tag.kind == 'archive' %}🗜️{% else %}# {% endif %}
{{ tag.name }}
</h3>
</div>
<div class="artist-preview">
{% for image in tag.images %} {% for image in tag.images %}
<div class="artist-thumb" <div class="tag-thumb"
style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');"> style="background-image: url('{{ url_for('main.serve_image', filename=(image.thumb_path or image.filepath) | replace('/images/', '') ) }}');">
</div> </div>
{% endfor %} {% endfor %}
{% if tag.images|length == 0 %}
<div class="tag-thumb tag-thumb-empty"></div>
{% endif %}
</div> </div>
<div class="tag-card-info">
<span class="tag-icon">
{%- if tag.kind == 'artist' -%}🎨
{%- elif tag.kind == 'character' -%}👤
{%- elif tag.kind == 'series' -%}📺
{%- elif tag.kind == 'rating' -%}⚠️
{%- elif tag.kind == 'archive' -%}🗜️
{%- else -%}#{%- endif -%}
</span>
<span class="tag-name" title="{{ tag.name }}">{{ tag.name }}</span>
<span class="tag-count">{{ tag.count }}</span>
</div> </div>
</a> </a>
<button class="tag-edit-btn" title="Edit tag" data-tag-id="{{ tag.id }}">✏️</button>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% else %}
<p class="empty-state">No tags found in this category.</p>
{% endif %}
<!-- Tag Edit Modal -->
<div id="tagEditModal" class="modal">
<div class="modal-content tag-edit-modal-content">
<button id="tagEditClose" class="modal-close-btn" title="Close">×</button>
<h2>Edit Tag</h2>
<form id="tagEditForm" class="tag-edit-form">
<input type="hidden" id="editTagId" name="tag_id">
<div class="form-group">
<label class="form-label" for="editTagName">Tag Name</label>
<input type="text" id="editTagName" name="name" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label" for="editTagKind">Category</label>
<select id="editTagKind" name="kind" class="form-input">
<option value="user"># User Tag</option>
<option value="artist">🎨 Artist</option>
<option value="character">👤 Character</option>
<option value="series">📺 Series</option>
<option value="rating">⚠️ Rating</option>
<option value="archive">🗜️ Archive</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn primary-btn">Save Changes</button>
<button type="button" id="tagDeleteBtn" class="btn danger-btn">Delete Tag</button>
</div>
</form>
</div>
</div>
<script src="{{ url_for('static', filename='js/tag-editor.js') }}"></script>
{% endblock %} {% endblock %}
-3
View File
@@ -1,10 +1,7 @@
Flask Flask
Flask-SQLAlchemy Flask-SQLAlchemy
Flask-Migrate Flask-Migrate
Flask-Login
Flask-WTF
psycopg2-binary psycopg2-binary
email-validator
pillow pillow
exifread exifread
imagehash imagehash