diff --git a/app/main.py b/app/main.py index 8be0383..ce83867 100644 --- a/app/main.py +++ b/app/main.py @@ -244,24 +244,34 @@ def search_tags(): Query params: - q: search query (matches tag name, case-insensitive) - limit: max results (default 10) + - exclude_kind: comma-separated list of tag kinds to exclude (e.g., 'archive') """ query = request.args.get('q', '').strip().lower() limit = request.args.get('limit', 10, type=int) + exclude_kind = request.args.get('exclude_kind', '').strip() + exclude_kinds = [k.strip() for k in exclude_kind.split(',') if k.strip()] if not query: # Return most-used tags when no query - tags = ( + base_query = ( Tag.query .join(image_tags) .group_by(Tag.id) + ) + if exclude_kinds: + base_query = base_query.filter(~Tag.kind.in_(exclude_kinds)) + tags = ( + base_query .order_by(func.count(image_tags.c.image_id).desc()) .limit(limit) .all() ) else: + base_query = Tag.query.filter(Tag.name.ilike(f'%{query}%')) + if exclude_kinds: + base_query = base_query.filter(~Tag.kind.in_(exclude_kinds)) tags = ( - Tag.query - .filter(Tag.name.ilike(f'%{query}%')) + base_query .order_by(Tag.name) .limit(limit) .all() @@ -571,3 +581,136 @@ def get_import_stats(): return jsonify(ok=True, stats=stats) except Exception as e: return jsonify(ok=False, error=str(e)), 500 + + +# ---------------------------- +# Image filter/cleanup endpoints +# ---------------------------- + +@main.get("/api/filter-scan") +def scan_filterable_images(): + """ + Scan existing images against current filter settings. + Returns counts and IDs of images that would be filtered. + Query params: + - filter_type: 'transparency' or 'dimensions' + - threshold: for transparency (0.0-1.0), or min_width/min_height for dimensions + - min_width: minimum width (for dimensions filter) + - min_height: minimum height (for dimensions filter) + - limit: max results to return (default 100) + """ + from app.utils.image_importer import is_mostly_transparent + + filter_type = request.args.get("filter_type", "transparency") + limit = request.args.get("limit", 100, type=int) + + results = [] + + if filter_type == "transparency": + threshold = request.args.get("threshold", 0.9, type=float) + + # Only check images that could have transparency + images = ImageRecord.query.filter( + ImageRecord.filepath.ilike("%.png") | + ImageRecord.filepath.ilike("%.gif") | + ImageRecord.filepath.ilike("%.webp") + ).limit(limit * 5).all() # Check more to find enough matches + + for img in images: + if len(results) >= limit: + break + if img.filepath and os.path.exists(img.filepath): + try: + if is_mostly_transparent(img.filepath, threshold): + results.append({ + "id": img.id, + "filename": img.filename, + "filepath": img.filepath, + "thumb_url": url_for('main.serve_image', + filename=(img.thumb_path or img.filepath).replace('/images/', '')) + }) + except Exception: + pass + + elif filter_type == "dimensions": + min_width = request.args.get("min_width", 0, type=int) + min_height = request.args.get("min_height", 0, type=int) + + if min_width <= 0 and min_height <= 0: + return jsonify(ok=True, count=0, images=[], message="No dimension filter set") + + query = ImageRecord.query + if min_width > 0: + query = query.filter(ImageRecord.width < min_width) + if min_height > 0: + query = query.filter(ImageRecord.height < min_height) + + images = query.limit(limit).all() + + for img in images: + results.append({ + "id": img.id, + "filename": img.filename, + "filepath": img.filepath, + "width": img.width, + "height": img.height, + "thumb_url": url_for('main.serve_image', + filename=(img.thumb_path or img.filepath).replace('/images/', '')) + }) + + # Get total count (without limit) for progress indication + total_count = len(results) + + return jsonify(ok=True, count=total_count, images=results, filter_type=filter_type) + + +@main.post("/api/filter-delete") +def delete_filtered_images(): + """ + Delete images by their IDs after filter review. + Query params: + - image_ids: comma-separated list of image IDs to delete + - confirm: must be 'true' to actually delete + """ + image_ids_str = request.form.get("image_ids", "") + confirm = request.form.get("confirm") == "true" + + if not confirm: + return jsonify(ok=False, error="Confirmation required"), 400 + + if not image_ids_str: + return jsonify(ok=False, error="No image IDs provided"), 400 + + try: + image_ids = [int(x) for x in image_ids_str.split(",") if x.strip()] + except ValueError: + return jsonify(ok=False, error="Invalid image IDs"), 400 + + deleted_count = 0 + errors = [] + + for img_id in image_ids: + img = ImageRecord.query.get(img_id) + if not img: + continue + + try: + # Remove image file from disk + if img.filepath and os.path.exists(img.filepath): + os.remove(img.filepath) + # Remove thumbnail if exists + if img.thumb_path and os.path.exists(img.thumb_path): + os.remove(img.thumb_path) + # Delete DB record + db.session.delete(img) + deleted_count += 1 + except Exception as e: + errors.append(f"Failed to delete {img.filename}: {e}") + + db.session.commit() + + return jsonify( + ok=True, + deleted_count=deleted_count, + errors=errors if errors else None + ) diff --git a/app/static/js/modal-pagination.js b/app/static/js/modal-pagination.js index 2d47a1c..2fba69b 100644 --- a/app/static/js/modal-pagination.js +++ b/app/static/js/modal-pagination.js @@ -159,8 +159,8 @@ document.addEventListener('DOMContentLoaded', () => { async function fetchAutocomplete(query) { try { const url = query - ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8` - : `/api/tags/search?limit=8`; + ? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=archive` + : `/api/tags/search?limit=8&exclude_kind=archive`; const r = await fetch(url); const j = await r.json(); renderAutocomplete(j.tags || []); @@ -296,6 +296,10 @@ document.addEventListener('DOMContentLoaded', () => { modal.classList.add('active'); updateImage(index); history.replaceState({ modalIndex: index }, '', window.location.href); + // Focus the tag input after modal opens + if (tagInput) { + setTimeout(() => tagInput.focus(), 100); + } } function closeModal() { diff --git a/app/static/js/showcase.js b/app/static/js/showcase.js index 6530896..731d74d 100644 --- a/app/static/js/showcase.js +++ b/app/static/js/showcase.js @@ -133,10 +133,12 @@ } item.appendChild(imgEl); - if (img.tags && img.tags.length > 0) { + // Filter out archive tags from display (they're still visible in modal) + const visibleTags = (img.tags || []).filter(t => t.kind !== 'archive'); + if (visibleTags.length > 0) { const overlay = document.createElement('div'); overlay.className = 'tag-overlay'; - img.tags.forEach(t => { + visibleTags.forEach(t => { const chip = document.createElement('a'); chip.className = 'tag-chip'; chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`; diff --git a/app/static/style.css b/app/static/style.css index 038f6f6..b8fcd12 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -890,17 +890,51 @@ header { /*------------------------------------------------------------------------------ Settings ------------------------------------------------------------------------------*/ +.settings-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + max-width: 1600px; + margin: 1.5rem auto; + padding: 0 1.5rem; + align-items: start; +} +.settings-column { + display: flex; + flex-direction: column; + gap: 1.5rem; +} +@media (max-width: 1200px) { + .settings-grid { + grid-template-columns: repeat(2, 1fr); + } + .settings-column:first-child { + grid-column: 1 / -1; + flex-direction: row; + } + .settings-column:first-child > .settings-container { + flex: 1; + } +} +@media (max-width: 768px) { + .settings-grid { + grid-template-columns: 1fr; + padding: 0 1rem; + } + .settings-column:first-child { + flex-direction: column; + } +} .settings-container { - max-width: 500px; - margin: 2rem auto; - padding: 2rem; + padding: 1.5rem; 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; +.settings-section h2, +.settings-info h2 { + margin-bottom: 1rem; text-align: center; color: var(--text); font-weight: 500; @@ -933,7 +967,6 @@ header { .danger-btn:hover { background-color: var(--btn-danger-hover); } .settings-info { - margin-top: 2rem; font-size: 0.9rem; color: var(--text-muted); text-align: center; @@ -1101,6 +1134,26 @@ header { border-color: var(--btn-primary); background: rgba(255, 255, 255, 0.12); } + +/* Select dropdowns - ensure dark theme for options */ +select.form-input { + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2.5rem; +} +select.form-input option, +select.form-input optgroup { + background: #1a1a1a; + color: #ffffff; +} +select.form-input optgroup { + font-weight: 600; + color: #a0a0a0; +} + .form-button { padding: 0.75rem; background-color: var(--btn-primary); diff --git a/app/templates/_gallery_grid.html b/app/templates/_gallery_grid.html index b5938f1..67b8b77 100644 --- a/app/templates/_gallery_grid.html +++ b/app/templates/_gallery_grid.html @@ -10,17 +10,16 @@
- {% if image.tags %} + {% set visible_tags = image.tags | selectattr('kind', 'ne', 'archive') | list %} + {% if visible_tags %}