From b2a25aa91da964ae8c0d6ea3415bf870cbbd40e6 Mon Sep 17 00:00:00 2001 From: Jerome Carbel <12296919+SirTerrific@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:49:47 -0400 Subject: [PATCH 1/2] feat: search the gallery, and choose its order The gallery showed whatever order the filesystem returned, which is neither alphabetical nor chronological, and it showed all of it. Finding one image among a few hundred meant scrolling and hoping. The listing is now newest first by default, and takes ?q= to filter by name and ?sort=oldest|name to change the order. The response is still a plain list of filenames, so nothing that consumes it has to change. Ordering uses the database's created_at where a row exists and the file's own mtime otherwise, so images that predate the database still land somewhere sensible instead of at an arbitrary position. The search box is debounced by 250ms: typing eight characters queries once, not eight times. Co-Authored-By: Claude Opus 5 --- app.py | 38 ++++++++++++++++- frontend/app/routes/gallery.tsx | 33 +++++++++++++-- frontend/app/utils/galleryApi.ts | 11 ++++- tests/test_gallery_listing.py | 72 ++++++++++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 tests/test_gallery_listing.py diff --git a/app.py b/app.py index 806e34d..5193f11 100644 --- a/app.py +++ b/app.py @@ -206,7 +206,43 @@ def load_media_provider(): # List all uploaded images (not album-specific) @app.route('/api/images', methods=['GET']) def api_list_images(): - files = [f for f in os.listdir(app.config['UPLOAD_FOLDER']) if os.path.isfile(os.path.join(app.config['UPLOAD_FOLDER'], f))] + """List the uploaded filenames, newest first. + + The directory listing has no meaningful order, so the database's created_at is + used where a row exists and the file's own mtime otherwise. Pass ?q= to filter + by name and ?sort=oldest|name to change the order. The response stays a plain + list of filenames, as before. + """ + upload_folder = app.config['UPLOAD_FOLDER'] + files = [ + f for f in os.listdir(upload_folder) + if os.path.isfile(os.path.join(upload_folder, f)) + ] + + query = (request.args.get('q') or '').strip().lower() + if query: + files = [f for f in files if query in f.lower()] + + added_at = { + img.filename: img.created_at + for img in Image.query.filter(Image.filename.in_(files)).all() + } if files else {} + + def sort_key(filename): + known = added_at.get(filename) + if known is not None: + return known.timestamp() + try: + return os.path.getmtime(os.path.join(upload_folder, filename)) + except OSError: + return 0.0 + + sort = request.args.get('sort') + if sort == 'name': + files.sort(key=str.lower) + else: + files.sort(key=sort_key, reverse=sort != 'oldest') + return {'images': files} diff --git a/frontend/app/routes/gallery.tsx b/frontend/app/routes/gallery.tsx index 4cb8677..614e4f7 100644 --- a/frontend/app/routes/gallery.tsx +++ b/frontend/app/routes/gallery.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; -import { deleteImage, fetchImages, fetchAlbums, uploadImage, createAlbum, addImagesToAlbum, fetchProviderAlbumImages, fetchProviderAlbums, getProviderImageStreamUrl } from "../utils/galleryApi"; +import { deleteImage, fetchImages, fetchAlbums, uploadImage, createAlbum, addImagesToAlbum, fetchProviderAlbumImages, fetchProviderAlbums, getProviderImageStreamUrl, type ImageSort } from "../utils/galleryApi"; import ImageCard from "../components/imageCard"; import AlbumCard from "~/components/AlbumCard"; import ImageGrid from "~/components/imageGrid"; @@ -52,11 +52,13 @@ export default function Gallery() { const [pendingFiles, setPendingFiles] = useState(null); const [dropAlbumId, setDropAlbumId] = useState(""); const [dropNewAlbumName, setDropNewAlbumName] = useState(""); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("newest"); async function loadLocalGallery() { setLoading(true); try { - const [imgs, als] = await Promise.all([fetchImages(), fetchAlbums()]); + const [imgs, als] = await Promise.all([fetchImages({ q: search, sort }), fetchAlbums()]); // Convert to GalleryImage objects setImages(imgs.map((img: string) => ({ id: img, @@ -86,8 +88,13 @@ export default function Gallery() { } } + // Re-query on search and sort, debounced so typing does not fire a request per key. + useEffect(() => { + const timer = setTimeout(loadLocalGallery, search ? 250 : 0); + return () => clearTimeout(timer); + }, [search, sort]); + useEffect(() => { - loadLocalGallery(); loadProviderGallery(); // Load TVs once and share with image cards to avoid per-card requests (async () => { @@ -415,6 +422,26 @@ export default function Gallery() { )} +
+ setSearch(e.target.value)} + placeholder="Search by name…" + aria-label="Search images by name" + className="border px-2 py-2 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 flex-1 min-w-48" + /> + +

Tick the boxes, or shift-click, to move or delete several images at once.

{loading ? (
Loading...
diff --git a/frontend/app/utils/galleryApi.ts b/frontend/app/utils/galleryApi.ts index 87f1740..a4c9bf8 100644 --- a/frontend/app/utils/galleryApi.ts +++ b/frontend/app/utils/galleryApi.ts @@ -19,8 +19,15 @@ export async function fetchProviderAlbumImages(albumId: string) { export function getProviderImageStreamUrl(imageId: string, size: string = "fullsize") { return `${API_BASE}/api/provider/images/${encodeURIComponent(imageId)}/stream?size=${encodeURIComponent(size)}`; } -export async function fetchImages() { - const res = await fetch(`${API_BASE}/api/images`); +export type ImageSort = 'newest' | 'oldest' | 'name'; + +export async function fetchImages(options: { q?: string; sort?: ImageSort } = {}) { + const params = new URLSearchParams(); + if (options.q) params.set('q', options.q); + if (options.sort && options.sort !== 'newest') params.set('sort', options.sort); + const query = params.toString(); + + const res = await fetch(`${API_BASE}/api/images${query ? `?${query}` : ''}`); if (!res.ok) throw new Error('Failed to fetch images'); return (await res.json()).images; } diff --git a/tests/test_gallery_listing.py b/tests/test_gallery_listing.py new file mode 100644 index 0000000..c597e2b --- /dev/null +++ b/tests/test_gallery_listing.py @@ -0,0 +1,72 @@ +"""Covers the order of the gallery listing and its name filter. + +Run with: pytest tests/test_gallery_listing.py +""" + +import io +import os +from datetime import datetime, timedelta + +import pytest +from PIL import Image as PILImage + +import app as backend + + +@pytest.fixture +def client(): + backend.app.config["TESTING"] = True + with backend.app.app_context(): + backend.db.drop_all() + backend.db.create_all() + for name in os.listdir(backend.app.config["UPLOAD_FOLDER"]): + path = os.path.join(backend.app.config["UPLOAD_FOLDER"], name) + if os.path.isfile(path): + os.remove(path) + return backend.app.test_client() + + +def upload(client, name): + buf = io.BytesIO() + PILImage.new("RGB", (60, 40), "red").save(buf, format="PNG") + buf.seek(0) + return client.post( + "/api/upload", + data={"file": (buf, name)}, + content_type="multipart/form-data", + ) + + +def test_images_are_listed_newest_first(client): + for name in ("first.png", "second.png", "third.png"): + upload(client, name) + with backend.app.app_context(): + rows = {img.filename: img for img in backend.Image.query.all()} + base = datetime(2026, 1, 1) + rows["first.png"].created_at = base + rows["second.png"].created_at = base + timedelta(days=1) + rows["third.png"].created_at = base + timedelta(days=2) + backend.db.session.commit() + + assert client.get("/api/images").get_json()["images"] == ["third.png", "second.png", "first.png"] + assert client.get("/api/images?sort=oldest").get_json()["images"] == ["first.png", "second.png", "third.png"] + assert client.get("/api/images?sort=name").get_json()["images"] == ["first.png", "second.png", "third.png"] + + +def test_a_file_without_a_row_is_ordered_by_its_own_timestamp(client): + """Images that predate the database still have to land somewhere sensible.""" + upload(client, "known.png") + orphan = os.path.join(backend.app.config["UPLOAD_FOLDER"], "orphan.png") + PILImage.new("RGB", (60, 40), "blue").save(orphan) + os.utime(orphan, (0, 0)) # as old as it gets + + listing = client.get("/api/images").get_json()["images"] + assert set(listing) == {"known.png", "orphan.png"} + assert listing[-1] == "orphan.png", "the oldest file should come last" + + +def test_the_listing_can_be_filtered_by_name(client): + upload(client, "sunset.png") + upload(client, "portrait.png") + assert client.get("/api/images?q=SUN").get_json()["images"] == ["sunset.png"] + assert client.get("/api/images?q=nothing").get_json()["images"] == [] From 5e47335ff30db376ee3394c4e29a394065f95d92 Mon Sep 17 00:00:00 2001 From: mrtncode <145050460+mrtncode@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:02:17 +0200 Subject: [PATCH 2/2] Adjust docstring --- app.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app.py b/app.py index 5193f11..e237f69 100644 --- a/app.py +++ b/app.py @@ -206,12 +206,9 @@ def load_media_provider(): # List all uploaded images (not album-specific) @app.route('/api/images', methods=['GET']) def api_list_images(): - """List the uploaded filenames, newest first. - - The directory listing has no meaningful order, so the database's created_at is - used where a row exists and the file's own mtime otherwise. Pass ?q= to filter - by name and ?sort=oldest|name to change the order. The response stays a plain - list of filenames, as before. + """ + List the uploaded filenames, newest first. + Pass params "?q=" to search specific filenames or "?sort=" to change the default sorting """ upload_folder = app.config['UPLOAD_FOLDER'] files = [