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_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import event
from sqlalchemy.engine import Engine
@@ -11,7 +10,6 @@ import sqlite3
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
@event.listens_for(Engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
@@ -30,16 +28,11 @@ 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'
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.auth import auth
app.register_blueprint(main)
app.register_blueprint(auth)
@app.template_filter('datetimeformat')
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
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.orm import joinedload
from app.utils.image_importer import import_images_task
from app.models import ImageRecord, Tag, ArchiveRecord, image_tags
from app import db
import os
@@ -17,21 +15,67 @@ main = Blueprint('main', __name__)
@main.route('/')
def index():
return redirect(url_for("main.gallery"))
# from random import choice # (kept from your original)
# image = ImageRecord.query.order_by(func.random()).first()
# background_url = None
"""
Showcase view: full-bleed masonry collage of random images/videos.
"""
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')
# @login_required
def gallery():
"""
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')
# @login_required
def tag_list():
"""
Generic tag explorer:
@@ -80,19 +123,27 @@ def tag_list():
/tags?kind=user -> only user tags
/tags?kind=artist -> only artist 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.
"""
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:
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 = []
for t in tags:
for t, count in tags_with_counts:
imgs = (
ImageRecord.query
.join(ImageRecord.tags)
@@ -101,9 +152,16 @@ def tag_list():
.limit(images_per_tag)
.all()
)
# Display label: for artist/archive 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
tag_data.append({"name": label, "tag": t.name, "images": imgs, "kind": t.kind})
# Display label: for prefixed kinds use the part after ':', else full name
label = t.name.split(":", 1)[1] if (":" in t.name) else t.name
tag_data.append({
"id": t.id,
"name": label,
"tag": t.name,
"images": imgs,
"kind": t.kind,
"count": count
})
return render_template('tags_list.html',
tag_data=tag_data,
@@ -111,33 +169,23 @@ def tag_list():
active_kind=kind)
@main.route('/artists')
# @login_required
def artist_list():
# Backward-compatible route that now shows the Tag Explorer filtered to artist tags
return redirect(url_for('main.tag_list', kind='artist'))
@main.route('/artist/<artist_name>')
# @login_required
def artist_gallery(artist_name):
# Backward-compatible: go to gallery filtered by artist tag
return redirect(url_for('main.gallery', tag=f"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('/trigger-thumbnail-generation', methods=['POST'])
# @login_required
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:
with open('/import/thumbnail.flag', 'w') as f:
f.write("trigger")
@@ -149,7 +197,6 @@ def trigger_thumbnail_generation():
@main.route('/import-images')
# @login_required
def trigger_image_import():
with open('/import/trigger.flag', 'w') as f:
f.write('start')
@@ -158,15 +205,11 @@ def trigger_image_import():
@main.route('/reset-db', methods=['POST'])
# @login_required
def reset_db():
"""
Safe reset that works with Postgres (TRUNCATE ... CASCADE).
Falls back to ordered deletes if TRUNCATE isn't supported (e.g., SQLite).
"""
if not current_user.is_admin:
abort(403)
try:
# Postgres fast path
db.session.execute(text("""
@@ -194,16 +237,46 @@ def reset_db():
# 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")
# @login_required
def list_tags(image_id):
from app.models import ImageRecord
img = ImageRecord.query.get_or_404(image_id)
return jsonify(ok=True, tags=[{"name": t.name, "kind": t.kind} for t in img.tags])
@main.post("/image/<int:image_id>/tags/add")
# @login_required
def add_tag(image_id):
"""
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")
# @login_required
def remove_tag(image_id):
"""
Minimal JSON endpoint to remove a tag from an image.
@@ -246,3 +318,112 @@ def remove_tag(image_id):
img.tags.remove(tag)
db.session.commit()
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 flask_login import UserMixin
from . import login_manager
from datetime import datetime
# tag to object relationship table
image_tags = db.Table(
@@ -19,13 +16,6 @@ image_tags = db.Table(
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):
__tablename__ = "image_record"
id = db.Column(db.Integer, primary_key=True)
@@ -34,7 +24,6 @@ class ImageRecord(db.Model):
thumb_path = db.Column(db.String(512))
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)
@@ -47,11 +36,10 @@ class ImageRecord(db.Model):
"Tag",
secondary=image_tags,
back_populates="images",
passive_deletes=True, # <-- let DB handle deletes
passive_deletes=True,
)
class Tag(db.Model):
# __tablename__ = "tag" (implicit)
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False)
kind = db.Column(db.String(64), nullable=True)
@@ -69,18 +57,10 @@ class ArchiveRecord(db.Model):
"""
__tablename__ = "archive_record"
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)
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())
# optional: where this archive lived in the source tree (e.g., artist dir)
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 = 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 tagForm = document.getElementById('modalTagForm');
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 currentIndex = -1;
@@ -36,18 +42,31 @@ document.addEventListener('DOMContentLoaded', () => {
function getEditorImageId() {
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) {
if (!tagList) return;
tagList.innerHTML = '';
(tags || []).forEach(t => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
const label = (t.kind === 'artist')
? `🎨 ${t.name.split(':', 1)[0] === 'artist' ? t.name.split(':', 2)[1] : t.name}`
: (t.kind === 'archive')
? `🗜️ ${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>`;
const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name);
chip.innerHTML = `${icon} ${displayName} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
tagList.appendChild(chip);
});
}
@@ -84,12 +103,129 @@ document.addEventListener('DOMContentLoaded', () => {
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) {
tagForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = getEditorImageId();
const name = (tagInput.value || '').trim();
if (!id || !name) return;
hideAutocomplete();
await addTag(id, name);
tagInput.value = '';
});
@@ -306,8 +442,23 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.key === 'Escape') closeModal();
});
images.forEach((img, index) => {
img.addEventListener('click', () => openModal(index));
// Use event delegation on the document for .img-clickable clicks
// 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', () => {
+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');
}
});
});
+731 -184
View File
File diff suppressed because it is too large Load Diff
+43 -35
View File
@@ -1,39 +1,44 @@
<!-- /app/templates/_gallery_grid.html -->
<div class="gallery-grid">
{% for image in images.items %}
<div class="gallery-item">
<div class="gallery-thumb img-clickable"
data-id="{{ image.id }}"
data-full="{{ url_for('main.serve_image', filename=image.filepath | replace('/images/', '') ) }}"
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 }}">
<div class="gallery-item img-clickable"
data-id="{{ image.id }}"
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-filename="{{ image.filename }}">
{% if image.tags %}
<div class="tag-overlay">
{% for t in image.tags %}
<a class="tag-chip"
href="{{ url_for('main.gallery', tag=t.name) }}"
title="Filter by {{ t.name }}"
onclick="event.stopPropagation()">
{% if t.kind == 'artist' %}
🎨 {{ t.name.split(':', 1)[1] }}
{% elif t.kind == 'archive' %}
🗜️ {{ t.name.split(':', 1)[1] }}
{% else %}
#{{ t.name }}
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
<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.filename.lower().endswith(('.mp4', '.mov')) %}
<div class="play-overlay"></div>
{% endif %}
</div>
{% if image.tags %}
<div class="tag-overlay">
{% for t in image.tags %}
<a class="tag-chip"
href="{{ url_for('main.gallery', tag=t.name) }}"
title="Filter by {{ t.name }}"
onclick="event.stopPropagation()">
{% if t.kind == 'artist' %}
🎨 {{ t.name.split(':', 1)[1] if ':' in t.name else t.name }}
{% elif t.kind == 'archive' %}
🗜️ {{ 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 %}
#{{ t.name }}
{% endif %}
</a>
{% endfor %}
</div>
{% endif %}
{% if image.filename.lower().endswith(('.mp4', '.mov')) %}
<div class="play-overlay"></div>
{% endif %}
<!-- Removed the artist link; keep date only -->
<span class="image-date">{{ image.taken_at or image.imported_at | datetimeformat }}</span>
</div>
{% endfor %}
@@ -44,20 +49,23 @@
<!-- Modal -->
<div id="imageModal" class="modal">
<div class="modal-content">
<!-- Navigation buttons -->
<button id="modalPrev" class="modal-button modal-prev"></button>
<button id="modalNext" class="modal-button modal-next"></button>
<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>
<!-- Tag editor (inline, minimal UI) -->
<div id="modalTagEditor" class="tag-editor" data-image-id="">
<div id="modalTagList" class="tags"></div>
<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>
</form>
</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 %}
<h1 style="text-align:center; margin-top: 1rem;">Gallery</h1>
<!-- Pagination Controls (Top) -->
{% include '_pagination.html' %}
<!-- Gallery Grid -->
{% include '_gallery_grid.html' %}
<!-- Pagination Controls (Bottom) -->
{% include '_pagination.html' %}
<!-- Floating Pagination Bar -->
{% if images.pages > 1 %}
{% include '_pagination_floating.html' %}
{% endif %}
{% 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>
<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') }}">Tags</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>
</header>
@@ -44,15 +31,13 @@
{% endif %}
{% endwith %}
<hr>
<!-- MAIN CONTENT -->
<div class="content">
{% block content %}{% endblock %}
</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="nextPageUrl" value="{{ next_page_url }}">
<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>
+92 -16
View File
@@ -1,10 +1,25 @@
{% 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 %}
<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 -->
<div class="filter-bar" role="tablist" aria-label="Filter tags by kind">
<a class="filter-btn {{ 'is-active' if not active_kind }}"
@@ -15,6 +30,18 @@
href="{{ url_for('main.tag_list', kind='artist') }}"
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' }}"
href="{{ url_for('main.tag_list', kind='archive') }}"
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>
</div>
<div class="artist-grid">
{% if tag_data %}
<div class="tag-grid">
{% for tag in tag_data %}
<a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="artist-card-link">
<div class="artist-card">
<div class="card-title" title="{{ tag.name }}">
<h3 class="card-title-text">
{% if tag.kind == 'artist' %}🎨{% elif tag.kind == 'archive' %}🗜️{% else %}# {% endif %}
{{ tag.name }}
</h3>
</div>
<div class="artist-preview">
<div class="tag-card" data-tag-id="{{ tag.id }}" data-tag-name="{{ tag.tag }}" data-tag-kind="{{ tag.kind }}">
<a href="{{ url_for('main.gallery', tag=tag.tag) }}" class="tag-card-link">
<div class="tag-preview">
{% 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/', '') ) }}');">
</div>
{% endfor %}
{% if tag.images|length == 0 %}
<div class="tag-thumb tag-thumb-empty"></div>
{% endif %}
</div>
</div>
</a>
<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>
</a>
<button class="tag-edit-btn" title="Edit tag" data-tag-id="{{ tag.id }}">✏️</button>
</div>
{% endfor %}
</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 %}
+1 -4
View File
@@ -1,13 +1,10 @@
Flask
Flask-SQLAlchemy
Flask-Migrate
Flask-Login
Flask-WTF
psycopg2-binary
email-validator
pillow
exifread
imagehash
pyunpack
patool
gunicorn
gunicorn