major updates to theming, creation of showcase view and polish of existing systems including tagging editting.
This commit is contained in:
@@ -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
@@ -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'))
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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">
|
||||
Don’t have an account? <a href="{{ url_for('auth.register') }}">Register here</a>.
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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
@@ -1,13 +1,10 @@
|
||||
Flask
|
||||
Flask-SQLAlchemy
|
||||
Flask-Migrate
|
||||
Flask-Login
|
||||
Flask-WTF
|
||||
psycopg2-binary
|
||||
email-validator
|
||||
pillow
|
||||
exifread
|
||||
imagehash
|
||||
pyunpack
|
||||
patool
|
||||
gunicorn
|
||||
gunicorn
|
||||
|
||||
Reference in New Issue
Block a user