diff --git a/app.py b/app.py index 806e34d..e237f69 100644 --- a/app.py +++ b/app.py @@ -206,7 +206,40 @@ 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. + Pass params "?q=" to search specific filenames or "?sort=" to change the default sorting + """ + 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"] == []