major updates to theming, creation of showcase view and polish of existing systems including tagging editting.
This commit is contained in:
+221
-40
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user