diff --git a/app/__init__.py b/app/__init__.py index 0430d32..e69b946 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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"): diff --git a/app/auth.py b/app/auth.py deleted file mode 100644 index 7f2f1b4..0000000 --- a/app/auth.py +++ /dev/null @@ -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')) diff --git a/app/forms.py b/app/forms.py deleted file mode 100644 index 56c8752..0000000 --- a/app/forms.py +++ /dev/null @@ -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') diff --git a/app/main.py b/app/main.py index d4c8775..f9f8eac 100644 --- a/app/main.py +++ b/app/main.py @@ -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/') -# @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//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//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//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/") +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//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//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) diff --git a/app/models.py b/app/models.py index 83b134f..2e28ce6 100644 --- a/app/models.py +++ b/app/models.py @@ -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)) diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js index f6a48ac..317e4e8 100644 --- a/app/static/js/modal-pagination.js +++ b/app/static/js/modal-pagination.js @@ -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} `; + const icon = getTagIcon(t.kind); + const displayName = getTagDisplayName(t.name); + chip.innerHTML = `${icon} ${displayName} `; 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 = '
No matching tags
'; + tagAutocomplete.classList.add('active'); + return; + } + + tagAutocomplete.innerHTML = tags.map((t, i) => { + const icon = getTagIcon(t.kind); + const displayName = getTagDisplayName(t.name); + return `
+ ${icon} ${displayName} + ${t.kind || 'user'} +
`; + }).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', () => { diff --git a/app/static/js/showcase.js b/app/static/js/showcase.js new file mode 100644 index 0000000..3b50429 --- /dev/null +++ b/app/static/js/showcase.js @@ -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(); +})(); diff --git a/app/static/js/tag-editor.js b/app/static/js/tag-editor.js new file mode 100644 index 0000000..7590442 --- /dev/null +++ b/app/static/js/tag-editor.js @@ -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'); + } + }); +}); diff --git a/app/static/style.css b/app/static/style.css index 8658519..a45e8d6 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -16,62 +16,52 @@ ==============================================================================*/ /*------------------------------------------------------------------------------ - Theme tokens + Theme tokens - Dark theme matching showcase ------------------------------------------------------------------------------*/ :root { /* surfaces */ - --bg: #5d6668; - --panel: #1c1c1c; - --panel-alt: rgba(0, 0, 0, .4); + --bg: #0a0a0a; + --bg-elevated: #141414; + --panel: #1a1a1a; + --panel-alt: rgba(20, 20, 20, 0.95); /* nav */ - --nav-bg: rgba(30, 30, 30, 0.92); - --nav-border: rgba(255, 255, 255, 0.10); + --nav-bg: rgba(0, 0, 0, 0.8); + --nav-border: rgba(255, 255, 255, 0.08); /* text */ --text: #ffffff; - --text-dim: #e2e2e2; + --text-dim: rgba(255, 255, 255, 0.7); + --text-muted: rgba(255, 255, 255, 0.5); /* accents */ - --link: #d4aeff; - --link-hover: #d4d4d4; + --link: #a78bfa; + --link-hover: #c4b5fd; /* shadows */ - --shadow-1: 0 2px 6px rgba(0, 0, 0, 0.1); - --shadow-2: 0 3px 4px rgba(0, 0, 0, 0.7); - --shadow-3: 0 4px 10px rgba(0, 0, 0, 0.4); + --shadow-1: 0 2px 8px rgba(0, 0, 0, 0.4); + --shadow-2: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-3: 0 8px 24px rgba(0, 0, 0, 0.6); /* buttons */ - --btn-primary: #0066cc; - --btn-primary-hover: #004d99; - --btn-danger: #cc0033; - --btn-danger-hover: #990024; + --btn-primary: #6366f1; + --btn-primary-hover: #4f46e5; + --btn-danger: #dc2626; + --btn-danger-hover: #b91c1c; /* flash */ - --flash-info: #3182ce; - --flash-success: #38a169; - --flash-warning: #dd6b20; - --flash-danger: #e53e3e; + --flash-info: #3b82f6; + --flash-success: #22c55e; + --flash-warning: #f59e0b; + --flash-danger: #ef4444; - /* filter pills (tags_list) */ - --filter-bg: #f8fafc; /* slate-50 */ - --filter-fg: #0f172a; /* slate-900 */ - --filter-border: #e2e8f0; /* slate-200 */ - --filter-active-bg: #4f46e5; /* indigo-600 */ + /* filter pills */ + --filter-bg: rgba(255, 255, 255, 0.08); + --filter-fg: #e2e8f0; + --filter-border: rgba(255, 255, 255, 0.15); + --filter-active-bg: #6366f1; --filter-active-fg: #ffffff; - --filter-active-border: #4338ca; /* indigo-700 */ -} - -/* Optional dark-scheme tuning for filter pills */ -@media (prefers-color-scheme: dark) { - :root { - --filter-bg: rgba(255, 255, 255, 0.06); - --filter-fg: #e2e8f0; /* slate-200 */ - --filter-border: rgba(255, 255, 255, 0.12); - --filter-active-bg: #6366f1; /* indigo-500 */ - --filter-active-fg: #0b1020; /* deeper contrast if desired */ - --filter-active-border: #818cf8; - } + --filter-active-border: #818cf8; } /*------------------------------------------------------------------------------ @@ -80,39 +70,57 @@ * { box-sizing: border-box; } body { - font-family: Arial, sans-serif; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; - padding: 0 1rem; + padding: 0; color: var(--text); background-color: var(--bg); + min-height: 100vh; } -.content { padding: 8px; } +.content { + padding: 1rem; + max-width: 1800px; + margin: 0 auto; +} .mainview { - max-width: 1600px; + max-width: 100%; margin: 0 auto; - background-color: var(--panel-alt); + background-color: transparent; + min-height: 100vh; +} + +h1 { + font-weight: 500; + letter-spacing: -0.02em; +} + +hr { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin: 0; } /*------------------------------------------------------------------------------ - Header / Navbar (sticky) + Header / Navbar (sticky with gradient fade) ------------------------------------------------------------------------------*/ header { position: sticky; top: 0; z-index: 1000; + background: linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.6) 70%, rgba(0,0,0,0) 100%); + padding-bottom: 1rem; } .navbar { display: flex; flex-wrap: wrap; justify-content: center; - gap: .25rem .5rem; - background-color: var(--nav-bg); - border-bottom: 1px solid var(--nav-border); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); + gap: 0.25rem; + padding: 0.75rem 1rem; + background: transparent; + border: none; } .nav-button { @@ -120,13 +128,20 @@ header { padding: 0.5rem 1rem; color: var(--text); text-decoration: none; - border-radius: 5px; - font-weight: bold; - font-size: 1.5rem; - transition: background-color 0.2s ease, transform 0.1s ease; + border-radius: 6px; + font-weight: 500; + font-size: 1rem; + transition: background 0.2s ease, transform 0.1s ease; + text-shadow: 0 1px 3px rgba(0,0,0,0.5); +} +.nav-button:hover { + background: rgba(255,255,255,0.15); + transform: translateY(-1px); +} +.nav-button:active { + background: rgba(255,255,255,0.1); + transform: translateY(0); } -.nav-button:hover { background-color: #444; transform: translateY(-1px); } -.nav-button:active { background-color: #1a1a1a; transform: translateY(1px); } /*------------------------------------------------------------------------------ Flash messages @@ -153,75 +168,147 @@ header { .flash-error { background-color: var(--flash-danger); } /*------------------------------------------------------------------------------ - Pagination + Pagination - Floating minimal bar ------------------------------------------------------------------------------*/ +.floating-pagination { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(20, 20, 20, 0.9); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + box-shadow: var(--shadow-3); + z-index: 100; +} + +.fp-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.08); + color: var(--text); + text-decoration: none; + font-size: 1.25rem; + transition: all 0.2s ease; +} +.fp-btn:hover:not(.disabled) { + background: rgba(255, 255, 255, 0.15); + color: white; +} +.fp-btn.disabled { + opacity: 0.3; + cursor: not-allowed; + pointer-events: none; +} + +.fp-indicator { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.5rem; + font-size: 0.9rem; + color: var(--text-dim); +} +.fp-current { + color: var(--text); + font-weight: 500; +} +.fp-sep { + color: var(--text-muted); +} +.fp-total { + color: var(--text-muted); +} + +/* Legacy pagination (kept for other pages if needed) */ .pagination-container { text-align: center; margin: 2rem 0; } .pagination-link { display: inline-block; - margin: 0 0.3rem; + margin: 0 0.2rem; padding: 0.5rem 1rem; - border: 1px solid #aaa; - border-radius: 4px; - color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: var(--text-dim); text-decoration: none; + background: rgba(255, 255, 255, 0.05); + transition: all 0.2s ease; } -.pagination-link.active, .pagination-link:hover { - background-color: #007BFF; - color: white; - border-color: #007BFF; + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.25); + color: var(--text); } -.pagination-ellipsis { padding: 0 0.5rem; color: #999; } +.pagination-link.active { + background: var(--btn-primary); + color: white; + border-color: var(--btn-primary); +} +.pagination-ellipsis { padding: 0 0.5rem; color: var(--text-muted); } /*------------------------------------------------------------------------------ - Gallery + Gallery - Seamless grid with overlaid metadata ------------------------------------------------------------------------------*/ -.image-date { - display: block; - font-size: 0.8rem; - color: var(--text-dim); - margin-top: 0.25rem; -} - .gallery-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1rem; - padding: 1rem; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 4px; + padding: 4px; } .gallery-item { - display: flex; - flex-direction: column; - align-items: center; - height: 450px; + position: relative; + height: 320px; background-color: var(--panel); - box-shadow: var(--shadow-2); - padding: 8px; - border-radius: 8px; - transition: transform 0.2s ease; + overflow: hidden; + cursor: pointer; } -.gallery-item:hover { transform: scale(1.03); } .gallery-thumb { - position: relative; - width: 100%; - height: 100%; + position: absolute; + inset: 0; background-size: cover; background-position: center; - box-shadow: 0 4px 4px rgba(0,0,0,0.4); - border-radius: 12px; - cursor: pointer; + transition: transform 0.3s ease, filter 0.3s ease; +} + +.gallery-item:hover .gallery-thumb { + transform: scale(1.03); + filter: brightness(1.1); +} + +/* Date overlay at bottom */ +.image-date { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 6px 8px; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.9); + background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%); + text-shadow: 0 1px 2px rgba(0,0,0,0.5); + z-index: 2; + pointer-events: none; } .gallery-item a { color: var(--link); - font-weight: bold; + font-weight: 500; text-decoration: none; - font-size: 0.95rem; + font-size: 0.9rem; } .gallery-item a:hover { text-decoration: underline; color: var(--link-hover); } @@ -232,10 +319,11 @@ header { display: grid; place-items: center; color: white; - font-size: 2rem; - text-shadow: 0 1px 4px rgba(0,0,0,.6); + font-size: 3rem; + text-shadow: 0 2px 8px rgba(0,0,0,0.8); z-index: 1; pointer-events: none; + opacity: 0.9; } /*------------------------------------------------------------------------------ @@ -246,20 +334,23 @@ header { position: fixed; inset: 0; z-index: 1000; - background: rgba(0, 0, 0, 0.85); + background: rgba(0, 0, 0, 0.92); justify-content: center; align-items: center; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); } .modal.active { display: flex; } .modal-content { position: relative; width: 100%; - max-width: 1600px; - max-height: 95vh; + height: 100%; display: flex; flex-direction: column; align-items: center; + justify-content: center; + padding: 1rem; } /* Modal body */ @@ -267,14 +358,20 @@ header { display: flex; flex-direction: column; align-items: center; - gap: 0.75rem; + gap: 1rem; width: 100%; + max-width: 1400px; + max-height: 100%; } /* Image wrapper & zoom */ .modal-image-wrapper { + flex: 1; + display: flex; + align-items: center; + justify-content: center; max-width: 100%; - max-height: 80vh; + max-height: calc(100vh - 140px); overflow: hidden; cursor: default; } @@ -284,10 +381,14 @@ header { } .modal-image-wrapper img { max-width: 100%; - max-height: 80vh; + max-height: calc(100vh - 140px); object-fit: contain; - border-radius: 6px; - box-shadow: 0 0 10px rgba(0,0,0,0.6); + border-radius: 4px; +} +.modal-image-wrapper video { + max-width: 100%; + max-height: calc(100vh - 140px); + border-radius: 4px; } /* Tall images */ .modal-image-wrapper.too-tall { overflow-y: auto; } @@ -295,28 +396,73 @@ header { width: 90vw; height: auto; max-height: none; } -/* Modal navigation buttons */ +/* Modal navigation buttons - cleaner pill style */ .modal-button { position: absolute; - top: 10%; - bottom: 10%; - width: 60px; - background: rgba(0, 0, 0, 0.3); - border: none; - color: white; - font-size: 2.5rem; + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 48px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.8); + font-size: 1.25rem; cursor: pointer; z-index: 1001; display: flex; align-items: center; justify-content: center; opacity: 0; - transition: opacity 0.2s; - border-radius: 4px; + transition: all 0.2s ease; + border-radius: 50%; +} +.modal-button:hover { + background: rgba(255, 255, 255, 0.2); + color: white; + border-color: rgba(255, 255, 255, 0.3); } .modal-content:hover .modal-button { opacity: 1; } -.modal-prev { left: 0; } -.modal-next { right: 0; } +.modal-prev { left: 1rem; } +.modal-next { right: 1rem; } + +/* Close button */ +.modal-close-btn { + position: absolute; + top: 1rem; + right: 1rem; + width: 40px; + height: 40px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 50%; + color: rgba(255, 255, 255, 0.8); + font-size: 1.5rem; + cursor: pointer; + z-index: 1002; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + line-height: 1; +} +.modal-close-btn:hover { + background: rgba(255, 255, 255, 0.2); + color: white; + border-color: rgba(255, 255, 255, 0.3); +} + +/* Close hint - hidden on mobile since we have a button */ +.modal-close-hint { + position: absolute; + top: 1.5rem; + right: 4rem; + color: rgba(255, 255, 255, 0.4); + font-size: 0.8rem; + pointer-events: none; +} +@media (max-width: 640px) { + .modal-close-hint { display: none; } +} /*------------------------------------------------------------------------------ Tag overlays & editor @@ -348,9 +494,137 @@ header { .tag-chip:hover { background: rgba(255,255,255,1); } -.tag-editor { margin-top: .5rem; } -.tag-form { margin-top: .4rem; display: flex; gap: .5rem; } -.tag-form input { flex: 1; } +/* Modal tag editor - refined layout */ +.tag-editor { + width: 100%; + max-width: 500px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.tag-editor .tags { + margin: 0 0 0.5rem 0; + min-height: 28px; +} + +/* Tag chips in modal - with remove button */ +.tag-editor .tag-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.15); + color: var(--text); +} +.tag-editor .tag-chip:hover { + background: rgba(255, 255, 255, 0.18); +} +.tag-editor .tag-chip .x { + background: none; + border: none; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + padding: 0; + margin-left: 2px; + font-size: 1rem; + line-height: 1; +} +.tag-editor .tag-chip .x:hover { + color: #ef4444; +} + +/* Tag form with autocomplete */ +.tag-form { + display: flex; + gap: 0.5rem; + position: relative; +} +.tag-form-wrapper { + flex: 1; + position: relative; +} +.tag-form input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + color: var(--text); + font-size: 0.9rem; +} +.tag-form input::placeholder { + color: var(--text-muted); +} +.tag-form input:focus { + outline: none; + border-color: var(--btn-primary); + background: rgba(255, 255, 255, 0.12); +} +.tag-form button { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + background: var(--btn-primary); + color: white; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.2s ease; + white-space: nowrap; +} +.tag-form button:hover { + background: var(--btn-primary-hover); +} + +/* Autocomplete dropdown */ +.tag-autocomplete { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--panel); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + margin-top: 4px; + max-height: 200px; + overflow-y: auto; + z-index: 1010; + display: none; + box-shadow: var(--shadow-3); +} +.tag-autocomplete.active { + display: block; +} +.tag-autocomplete-item { + padding: 0.5rem 0.75rem; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; + color: var(--text); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.tag-autocomplete-item:last-child { + border-bottom: none; +} +.tag-autocomplete-item:hover, +.tag-autocomplete-item.selected { + background: rgba(255, 255, 255, 0.1); +} +.tag-autocomplete-item .tag-kind { + font-size: 0.75rem; + color: var(--text-muted); + margin-left: auto; +} +.tag-autocomplete-empty { + padding: 0.5rem 0.75rem; + color: var(--text-muted); + font-size: 0.85rem; + font-style: italic; +} /* Tag overlay bar at the top of the image */ .tag-overlay { @@ -365,20 +639,159 @@ header { } /*------------------------------------------------------------------------------ - Tag/Artist cards (tags_list.html) + Tag cards (tags_list.html) - New design ------------------------------------------------------------------------------*/ +.tag-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; + padding: 0.5rem; +} + +.tag-card { + position: relative; + background: var(--panel); + border-radius: 8px; + overflow: hidden; + box-shadow: var(--shadow-2); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.tag-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-3); +} + +.tag-card-link { + text-decoration: none; + color: inherit; + display: block; +} + +.tag-preview { + display: flex; + gap: 2px; + height: 140px; + overflow: hidden; +} + +.tag-thumb { + flex: 1; + background-size: cover; + background-position: center; + background-color: var(--bg-elevated); + transition: filter 0.2s ease; +} +.tag-card:hover .tag-thumb { + filter: brightness(1.08); +} +.tag-thumb-empty { + background: linear-gradient(135deg, var(--bg-elevated) 0%, var(--panel) 100%); +} + +.tag-card-info { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: linear-gradient(to top, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.2) 100%); +} + +.tag-icon { + font-size: 1.1rem; + flex-shrink: 0; +} + +.tag-name { + flex: 1; + font-weight: 500; + font-size: 0.95rem; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tag-count { + font-size: 0.8rem; + color: var(--text-muted); + background: rgba(255,255,255,0.1); + padding: 0.2rem 0.5rem; + border-radius: 999px; + flex-shrink: 0; +} + +.tag-edit-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + width: 32px; + height: 32px; + border-radius: 50%; + background: rgba(0,0,0,0.6); + border: 1px solid rgba(255,255,255,0.1); + color: var(--text-dim); + cursor: pointer; + opacity: 0; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; +} +.tag-card:hover .tag-edit-btn { + opacity: 1; +} +.tag-edit-btn:hover { + background: rgba(0,0,0,0.8); + color: var(--text); + border-color: rgba(255,255,255,0.2); +} + +.empty-state { + text-align: center; + color: var(--text-muted); + padding: 3rem 1rem; + font-size: 1.1rem; +} + +/* Tag edit modal specific */ +.tag-edit-modal-content { + max-width: 400px; + padding: 2rem; + background: var(--panel); + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.1); +} +.tag-edit-modal-content h2 { + margin: 0 0 1.5rem 0; + text-align: center; + color: var(--text); +} +.tag-edit-form .form-group { + margin-bottom: 1rem; +} +.tag-edit-form .form-actions { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; +} +.tag-edit-form .form-actions .btn { + flex: 1; +} + +/* Legacy artist grid (kept for backward compatibility) */ .artist-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1.5rem; - padding: 1rem; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 6px; + padding: 0.5rem; } .artist-card-link { text-decoration: none; color: inherit; display: block; - transition: transform 0.2s ease; + transition: transform 0.3s ease; } .artist-card-link:hover .artist-card { transform: scale(1.02); @@ -387,57 +800,64 @@ header { .artist-card { background-color: var(--panel); - padding: 1rem; - border-radius: 8px; - box-shadow: 0 2px 6px rgba(0,0,0,0.2); - transition: transform 0.2s ease; + padding: 0; + border-radius: 4px; + overflow: hidden; + box-shadow: var(--shadow-2); + transition: transform 0.3s ease, box-shadow 0.3s ease; } .artist-card h3 { - margin-bottom: 0.5rem; + margin: 0; + padding: 0.75rem; text-align: center; color: var(--text); + font-weight: 500; + font-size: 0.95rem; + background: rgba(0,0,0,0.3); } .artist-preview { display: flex; - gap: 0; + gap: 2px; justify-content: center; overflow: hidden; - border-radius: 6px; } .artist-thumb { flex: 1; - height: 300px; + height: 280px; background-size: cover; background-position: center; background-repeat: no-repeat; - transition: transform 0.3s ease; + transition: transform 0.3s ease, filter 0.3s ease; +} +.artist-card-link:hover .artist-thumb { + filter: brightness(1.1); } -.artist-thumb:hover { transform: scale(1.05); } /* Filter pills (top of tags_list) */ .filter-bar { display: flex; flex-wrap: wrap; - gap: .5rem; - margin: .75rem 0 1rem; + gap: 0.5rem; + margin: 0.75rem 0 1.5rem; + justify-content: center; } .filter-btn { display: inline-flex; align-items: center; - gap: .4rem; - padding: .4rem .8rem; + gap: 0.4rem; + padding: 0.5rem 1rem; border-radius: 999px; border: 1px solid var(--filter-border); background: var(--filter-bg); color: var(--filter-fg); text-decoration: none; font: 14px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; - transition: transform .15s ease, box-shadow .15s ease, background .15s ease; + transition: all 0.2s ease; } .filter-btn:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0,0,0,.08); + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); } .filter-btn.is-active { background: var(--filter-active-bg); @@ -453,14 +873,20 @@ header { Settings ------------------------------------------------------------------------------*/ .settings-container { - max-width: 600px; + max-width: 500px; margin: 2rem auto; - padding: 1.5rem; - background: #f8f8f8; - border-radius: 12px; - box-shadow: var(--shadow-1); + padding: 2rem; + background: var(--panel); + border-radius: 8px; + box-shadow: var(--shadow-2); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.settings-section h2 { + margin-bottom: 1.5rem; + text-align: center; + color: var(--text); + font-weight: 500; } -.settings-section h2 { margin-bottom: 1rem; text-align: center; } .settings-actions { display: flex; flex-direction: column; @@ -469,55 +895,165 @@ header { } .btn { - padding: 0.75rem 1.5rem; + padding: 0.75rem 2rem; font-size: 1rem; border: none; - border-radius: 8px; + border-radius: 6px; cursor: pointer; - transition: background 0.3s ease; + transition: all 0.2s ease; + font-weight: 500; + min-width: 200px; +} +.btn:hover { + transform: translateY(-1px); } .primary-btn { background-color: var(--btn-primary); color: #fff; } .primary-btn:hover { background-color: var(--btn-primary-hover); } +.warning-btn { background-color: var(--flash-warning); color: #fff; } +.warning-btn:hover { background-color: #d97706; } .danger-btn { background-color: var(--btn-danger); color: #fff; } .danger-btn:hover { background-color: var(--btn-danger-hover); } .settings-info { margin-top: 2rem; - font-size: 0.95rem; - color: #444; + font-size: 0.9rem; + color: var(--text-muted); text-align: center; + line-height: 1.5; } /*------------------------------------------------------------------------------ - Index / Landing + Showcase - Full-bleed Masonry Collage ------------------------------------------------------------------------------*/ -.index-hero { - position: relative; - height: 100vh; - background-size: cover; - background-position: center; - filter: blur(0px); /* initialize */ - overflow: hidden; +.showcase-body { + margin: 0; + padding: 0; + background: #0a0a0a; + min-height: 100vh; + overflow-x: hidden; } -.index-overlay { + +/* Floating navbar overlay */ +.showcase-nav { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 1rem; + background: linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.6) 70%, rgba(0,0,0,0) 100%); + pointer-events: none; + padding-bottom: 1rem; +} + +.showcase-nav-links { + display: flex; + justify-content: center; + gap: 0.25rem; + pointer-events: auto; +} + +.showcase-nav .nav-link { + pointer-events: auto; + color: #fff; + text-decoration: none; + padding: 0.5rem 1rem; + font-size: 1rem; + font-weight: 500; + border-radius: 6px; + transition: background 0.2s ease; + text-shadow: 0 1px 3px rgba(0,0,0,0.5); +} + +.showcase-nav .nav-link:hover { + background: rgba(255,255,255,0.15); +} + +.showcase-nav .nav-hint { + color: rgba(255,255,255,0.4); + font-size: 0.8rem; + margin-top: 0.25rem; + pointer-events: none; +} + +/* Masonry container - JS-managed columns */ +.masonry-container { + display: flex; + gap: 4px; + padding: 60px 4px 4px 4px; /* top padding for navbar */ + min-height: 100vh; + align-items: flex-start; + max-width: 2200px; /* prevent columns from getting too wide on ultrawide */ + margin: 0 auto; +} + +.masonry-column { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Masonry item */ +.masonry-item { + position: relative; + cursor: pointer; + overflow: hidden; + background: #1a1a1a; +} + +.masonry-item img { + display: block; + width: 100%; + height: auto; + transition: transform 0.3s ease, filter 0.3s ease; +} + +.masonry-item:hover img { + transform: scale(1.03); + filter: brightness(1.1); +} + +/* Tag overlay on masonry items */ +.masonry-item .tag-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 6px; + background: linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%); + display: flex; + flex-wrap: wrap; + gap: 4px; + z-index: 2; +} + +/* Play overlay for videos */ +.masonry-item .play-overlay { position: absolute; inset: 0; - background-color: rgba(20, 20, 20, 0.5); - backdrop-filter: blur(16px); - display: flex; - align-items: center; - justify-content: center; + display: grid; + place-items: center; + color: white; + font-size: 3rem; + text-shadow: 0 2px 8px rgba(0,0,0,0.8); z-index: 1; + pointer-events: none; + opacity: 0.9; } -.index-message { - text-align: center; - color: #fff; - padding: 2rem; - max-width: 600px; + +/* Loading state for shuffle */ +.masonry-container.loading { + opacity: 0.5; + pointer-events: none; +} + +.masonry-container { + transition: opacity 0.3s ease; } -.index-message h1 { font-size: 2.5rem; margin-bottom: 1rem; } -.index-message p { font-size: 1.2rem; margin-bottom: 2rem; } -.index-message .btn { font-size: 1.1rem; } /*------------------------------------------------------------------------------ Forms @@ -526,28 +1062,39 @@ header { max-width: 400px; margin: 2rem auto; padding: 2rem; - background-color: #ffffffdd; - border-radius: 10px; - box-shadow: var(--shadow-1); + background-color: var(--panel); + border-radius: 8px; + box-shadow: var(--shadow-2); + border: 1px solid rgba(255, 255, 255, 0.08); } .form-card { display: flex; flex-direction: column; gap: 1rem; } .form-group { display: flex; flex-direction: column; } -.form-label { margin-bottom: 0.5rem; font-weight: 600; } +.form-label { margin-bottom: 0.5rem; font-weight: 500; color: var(--text); } .form-input { - padding: 0.6rem; - border: 1px solid #ccc; - border-radius: 5px; + padding: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + color: var(--text); + font-size: 1rem; +} +.form-input:focus { + outline: none; + border-color: var(--btn-primary); + background: rgba(255, 255, 255, 0.12); } .form-button { padding: 0.75rem; - background-color: #5a67d8; + background-color: var(--btn-primary); color: white; border: none; - border-radius: 5px; + border-radius: 6px; cursor: pointer; + font-weight: 500; + transition: background 0.2s ease; } -.form-button:hover { background-color: #434190; } -.form-footer-text { margin-top: 1rem; text-align: center; } +.form-button:hover { background-color: var(--btn-primary-hover); } +.form-footer-text { margin-top: 1rem; text-align: center; color: var(--text-dim); } /* Fixed-height, 2-line title area so all cards match height */ .card-title { diff --git a/app/templates/_gallery_grid.html b/app/templates/_gallery_grid.html index 0efea6a..b5938f1 100644 --- a/app/templates/_gallery_grid.html +++ b/app/templates/_gallery_grid.html @@ -1,39 +1,44 @@