From 06a245d90a6d7926fe5329b1b78b4152aab535b5 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Wed, 18 Mar 2026 21:35:38 +0100 Subject: [PATCH] feat: add metadata provider filter to series page - Add `metadata_provider` query param to series API endpoints (linked/unlinked/specific provider) - Return `metadata_provider` field in series response - Add metadata filter dropdown on series page with all provider options - Show small provider icon badge on linked series cards - LiveSearchForm now wraps filters on two rows when needed Co-Authored-By: Claude Opus 4.6 --- apps/api/src/books.rs | 86 +++++++++++++++++-- .../app/components/LiveSearchForm.tsx | 2 +- apps/backoffice/app/series/page.tsx | 29 +++++-- apps/backoffice/lib/api.ts | 3 + apps/backoffice/lib/i18n/en.ts | 6 ++ apps/backoffice/lib/i18n/fr.ts | 6 ++ 6 files changed, 118 insertions(+), 14 deletions(-) diff --git a/apps/api/src/books.rs b/apps/api/src/books.rs index c1d8d97..e2e4c34 100644 --- a/apps/api/src/books.rs +++ b/apps/api/src/books.rs @@ -314,6 +314,7 @@ pub struct SeriesItem { pub library_id: Uuid, pub series_status: Option, pub missing_count: Option, + pub metadata_provider: Option, } #[derive(Serialize, ToSchema)] @@ -336,6 +337,9 @@ pub struct ListSeriesQuery { /// Filter series with missing books: "true" to show only series with missing books #[schema(value_type = Option, example = "true")] pub has_missing: Option, + /// Filter by metadata provider: a provider name (e.g. "google_books"), "linked" (any provider), or "unlinked" (no provider) + #[schema(value_type = Option, example = "google_books")] + pub metadata_provider: Option, #[schema(value_type = Option, example = 1)] pub page: Option, #[schema(value_type = Option, example = 50)] @@ -351,6 +355,7 @@ pub struct ListSeriesQuery { ("library_id" = String, Path, description = "Library UUID"), ("q" = Option, Query, description = "Filter by series name (case-insensitive, partial match)"), ("reading_status" = Option, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"), + ("metadata_provider" = Option, Query, description = "Filter by metadata provider: a provider name (e.g. 'google_books'), 'linked' (any provider), or 'unlinked' (no provider)"), ("page" = Option, Query, description = "Page number (1-indexed, default 1)"), ("limit" = Option, Query, description = "Items per page (max 200, default 50)"), ), @@ -400,6 +405,13 @@ pub async fn list_series( "AND mc.missing_count > 0".to_string() } else { String::new() }; + let metadata_provider_cond = match query.metadata_provider.as_deref() { + Some("unlinked") => "AND ml.provider IS NULL".to_string(), + Some("linked") => "AND ml.provider IS NOT NULL".to_string(), + Some(_) => { p += 1; format!("AND ml.provider = ${p}") }, + None => String::new(), + }; + let missing_cte = format!( r#" missing_counts AS ( @@ -413,6 +425,16 @@ pub async fn list_series( "# ); + let metadata_links_cte = r#" + metadata_links AS ( + SELECT DISTINCT ON (eml.series_name, eml.library_id) + eml.series_name, eml.library_id, eml.provider + FROM external_metadata_links eml + WHERE eml.status = 'approved' + ORDER BY eml.series_name, eml.library_id, eml.created_at DESC + ) + "#; + let count_sql = format!( r#" WITH sorted_books AS ( @@ -427,11 +449,13 @@ pub async fn list_series( LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id GROUP BY sb.name ), - {missing_cte} + {missing_cte}, + {metadata_links_cte} SELECT COUNT(*) FROM series_counts sc LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.name = sc.name LEFT JOIN missing_counts mc ON mc.series_name = sc.name - WHERE TRUE {q_cond} {count_rs_cond} {ss_cond} {missing_cond} + LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = $1 + WHERE TRUE {q_cond} {count_rs_cond} {ss_cond} {missing_cond} {metadata_provider_cond} "# ); @@ -464,23 +488,27 @@ pub async fn list_series( LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id GROUP BY sb.name ), - {missing_cte} + {missing_cte}, + {metadata_links_cte} SELECT sc.name, sc.book_count, sc.books_read_count, sb.id as first_book_id, sm.status as series_status, - mc.missing_count + mc.missing_count, + ml.provider as metadata_provider FROM series_counts sc JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1 LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.name = sc.name LEFT JOIN missing_counts mc ON mc.series_name = sc.name + LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = $1 WHERE TRUE {q_cond} {count_rs_cond} {ss_cond} {missing_cond} + {metadata_provider_cond} ORDER BY REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''), COALESCE( @@ -509,6 +537,12 @@ pub async fn list_series( count_builder = count_builder.bind(ss); data_builder = data_builder.bind(ss); } + if let Some(ref mp) = query.metadata_provider { + if mp != "linked" && mp != "unlinked" { + count_builder = count_builder.bind(mp); + data_builder = data_builder.bind(mp); + } + } data_builder = data_builder.bind(limit).bind(offset); @@ -528,6 +562,7 @@ pub async fn list_series( library_id, series_status: row.get("series_status"), missing_count: row.get("missing_count"), + metadata_provider: row.get("metadata_provider"), }) .collect(); @@ -553,6 +588,9 @@ pub struct ListAllSeriesQuery { /// Filter series with missing books: "true" to show only series with missing books #[schema(value_type = Option, example = "true")] pub has_missing: Option, + /// Filter by metadata provider: a provider name (e.g. "google_books"), "linked" (any provider), or "unlinked" (no provider) + #[schema(value_type = Option, example = "google_books")] + pub metadata_provider: Option, #[schema(value_type = Option, example = 1)] pub page: Option, #[schema(value_type = Option, example = 50)] @@ -571,6 +609,7 @@ pub struct ListAllSeriesQuery { ("q" = Option, Query, description = "Filter by series name (case-insensitive, partial match)"), ("library_id" = Option, Query, description = "Filter by library ID"), ("reading_status" = Option, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"), + ("metadata_provider" = Option, Query, description = "Filter by metadata provider: a provider name (e.g. 'google_books'), 'linked' (any provider), or 'unlinked' (no provider)"), ("page" = Option, Query, description = "Page number (1-indexed, default 1)"), ("limit" = Option, Query, description = "Items per page (max 200, default 50)"), ("sort" = Option, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"), @@ -625,6 +664,13 @@ pub async fn list_all_series( "AND mc.missing_count > 0".to_string() } else { String::new() }; + let metadata_provider_cond = match query.metadata_provider.as_deref() { + Some("unlinked") => "AND ml.provider IS NULL".to_string(), + Some("linked") => "AND ml.provider IS NOT NULL".to_string(), + Some(_) => { p += 1; format!("AND ml.provider = ${p}") }, + None => String::new(), + }; + // Missing counts CTE — needs library_id filter when filtering by library let missing_cte = if query.library_id.is_some() { format!( @@ -652,6 +698,16 @@ pub async fn list_all_series( "#.to_string() }; + let metadata_links_cte = r#" + metadata_links AS ( + SELECT DISTINCT ON (eml.series_name, eml.library_id) + eml.series_name, eml.library_id, eml.provider + FROM external_metadata_links eml + WHERE eml.status = 'approved' + ORDER BY eml.series_name, eml.library_id, eml.created_at DESC + ) + "#; + let count_sql = format!( r#" WITH sorted_books AS ( @@ -666,11 +722,13 @@ pub async fn list_all_series( LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id GROUP BY sb.name, sb.library_id ), - {missing_cte} + {missing_cte}, + {metadata_links_cte} SELECT COUNT(*) FROM series_counts sc LEFT JOIN series_metadata sm ON sm.library_id = sc.library_id AND sm.name = sc.name LEFT JOIN missing_counts mc ON mc.series_name = sc.name AND mc.library_id = sc.library_id - WHERE TRUE {q_cond} {rs_cond} {ss_cond} {missing_cond} + LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = sc.library_id + WHERE TRUE {q_cond} {rs_cond} {ss_cond} {missing_cond} {metadata_provider_cond} "# ); @@ -713,7 +771,8 @@ pub async fn list_all_series( LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id GROUP BY sb.name, sb.library_id ), - {missing_cte} + {missing_cte}, + {metadata_links_cte} SELECT sc.name, sc.book_count, @@ -721,16 +780,19 @@ pub async fn list_all_series( sb.id as first_book_id, sb.library_id, sm.status as series_status, - mc.missing_count + mc.missing_count, + ml.provider as metadata_provider FROM series_counts sc JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1 LEFT JOIN series_metadata sm ON sm.library_id = sc.library_id AND sm.name = sc.name LEFT JOIN missing_counts mc ON mc.series_name = sc.name AND mc.library_id = sc.library_id + LEFT JOIN metadata_links ml ON ml.series_name = sc.name AND ml.library_id = sc.library_id WHERE TRUE {q_cond} {rs_cond} {ss_cond} {missing_cond} + {metadata_provider_cond} ORDER BY {series_order_clause} LIMIT ${limit_p} OFFSET ${offset_p} "# @@ -757,6 +819,12 @@ pub async fn list_all_series( count_builder = count_builder.bind(ss); data_builder = data_builder.bind(ss); } + if let Some(ref mp) = query.metadata_provider { + if mp != "linked" && mp != "unlinked" { + count_builder = count_builder.bind(mp); + data_builder = data_builder.bind(mp); + } + } data_builder = data_builder.bind(limit).bind(offset); @@ -776,6 +844,7 @@ pub async fn list_all_series( library_id: row.get("library_id"), series_status: row.get("series_status"), missing_count: row.get("missing_count"), + metadata_provider: row.get("metadata_provider"), }) .collect(); @@ -887,6 +956,7 @@ pub async fn ongoing_series( library_id: row.get("library_id"), series_status: None, missing_count: None, + metadata_provider: None, }) .collect(); diff --git a/apps/backoffice/app/components/LiveSearchForm.tsx b/apps/backoffice/app/components/LiveSearchForm.tsx index 8568dfc..cb78356 100644 --- a/apps/backoffice/app/components/LiveSearchForm.tsx +++ b/apps/backoffice/app/components/LiveSearchForm.tsx @@ -68,7 +68,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc if (timerRef.current) clearTimeout(timerRef.current); router.replace(buildUrl() as any); }} - className="flex flex-col sm:flex-row gap-3 items-start sm:items-end" + className="flex flex-col sm:flex-row sm:flex-wrap gap-3 items-start sm:items-end" > {fields.map((field) => field.type === "text" ? ( diff --git a/apps/backoffice/app/series/page.tsx b/apps/backoffice/app/series/page.tsx index 26bb413..a9dbfac 100644 --- a/apps/backoffice/app/series/page.tsx +++ b/apps/backoffice/app/series/page.tsx @@ -5,6 +5,7 @@ import { LiveSearchForm } from "../components/LiveSearchForm"; import { Card, CardContent, OffsetPagination } from "../components/ui"; import Image from "next/image"; import Link from "next/link"; +import { ProviderIcon } from "../components/ProviderIcon"; export const dynamic = "force-dynamic"; @@ -21,12 +22,13 @@ export default async function SeriesPage({ const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined; const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined; const hasMissing = searchParamsAwaited.has_missing === "true"; + const metadataProvider = typeof searchParamsAwaited.metadata_provider === "string" ? searchParamsAwaited.metadata_provider : undefined; const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1; const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20; const [libraries, seriesPage, dbStatuses] = await Promise.all([ fetchLibraries().catch(() => [] as LibraryDto[]), - fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit, sort, seriesStatus, hasMissing).catch( + fetchAllSeries(libraryId, searchQuery || undefined, readingStatus, page, limit, sort, seriesStatus, hasMissing, metadataProvider).catch( () => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto ), fetchSeriesStatuses().catch(() => [] as string[]), @@ -39,7 +41,7 @@ export default async function SeriesPage({ { value: "latest", label: t("books.sortLatest") }, ]; - const hasFilters = searchQuery || libraryId || readingStatus || sort || seriesStatus || hasMissing; + const hasFilters = searchQuery || libraryId || readingStatus || sort || seriesStatus || hasMissing || metadataProvider; const libraryOptions = [ { value: "", label: t("books.allLibraries") }, @@ -70,6 +72,17 @@ export default async function SeriesPage({ { value: "true", label: t("series.missingBooks") }, ]; + const metadataOptions = [ + { value: "", label: t("series.metadataAll") }, + { value: "linked", label: t("series.metadataLinked") }, + { value: "unlinked", label: t("series.metadataUnlinked") }, + { value: "google_books", label: "Google Books" }, + { value: "open_library", label: "Open Library" }, + { value: "comicvine", label: "ComicVine" }, + { value: "anilist", label: "AniList" }, + { value: "bedetheque", label: "Bédéthèque" }, + ]; + return ( <>
@@ -87,11 +100,12 @@ export default async function SeriesPage({ basePath="/series" fields={[ { name: "q", type: "text", label: t("common.search"), placeholder: t("series.searchPlaceholder"), className: "flex-1 w-full" }, - { name: "library", type: "select", label: t("books.library"), options: libraryOptions, className: "w-full sm:w-48" }, - { name: "status", type: "select", label: t("series.reading"), options: statusOptions, className: "w-full sm:w-36" }, + { name: "library", type: "select", label: t("books.library"), options: libraryOptions, className: "w-full sm:w-44" }, + { name: "status", type: "select", label: t("series.reading"), options: statusOptions, className: "w-full sm:w-32" }, { name: "series_status", type: "select", label: t("editSeries.status"), options: seriesStatusOptions, className: "w-full sm:w-36" }, { name: "has_missing", type: "select", label: t("series.missing"), options: missingOptions, className: "w-full sm:w-36" }, - { name: "sort", type: "select", label: t("books.sort"), options: sortOptions, className: "w-full sm:w-36" }, + { name: "metadata_provider", type: "select", label: t("series.metadata"), options: metadataOptions, className: "w-full sm:w-36" }, + { name: "sort", type: "select", label: t("books.sort"), options: sortOptions, className: "w-full sm:w-32" }, ]} /> @@ -158,6 +172,11 @@ export default async function SeriesPage({ {t("series.missingCount", { count: String(s.missing_count), plural: s.missing_count > 1 ? "s" : "" })} )} + {s.metadata_provider && ( + + + + )}
diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 1e93fcc..43d2719 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -123,6 +123,7 @@ export type SeriesDto = { library_id: string; series_status: string | null; missing_count: number | null; + metadata_provider: string | null; }; export function config() { @@ -322,6 +323,7 @@ export async function fetchAllSeries( sort?: string, seriesStatus?: string, hasMissing?: boolean, + metadataProvider?: string, ): Promise { const params = new URLSearchParams(); if (libraryId) params.set("library_id", libraryId); @@ -330,6 +332,7 @@ export async function fetchAllSeries( if (sort) params.set("sort", sort); if (seriesStatus) params.set("series_status", seriesStatus); if (hasMissing) params.set("has_missing", "true"); + if (metadataProvider) params.set("metadata_provider", metadataProvider); params.set("page", page.toString()); params.set("limit", limit.toString()); diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 3d9df9d..2b1ad08 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -550,6 +550,12 @@ const en: Record = { // Series filters "seriesFilters.all": "All", "seriesFilters.missingBooks": "Missing books", + + // Metadata filter + "series.metadata": "Metadata", + "series.metadataAll": "All", + "series.metadataLinked": "Linked", + "series.metadataUnlinked": "Not linked", }; export default en; diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index 6155d49..a61ecf9 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -548,6 +548,12 @@ const fr = { // Series filters "seriesFilters.all": "Tous", "seriesFilters.missingBooks": "Livres manquants", + + // Metadata filter + "series.metadata": "Métadonnées", + "series.metadataAll": "Toutes", + "series.metadataLinked": "Associée", + "series.metadataUnlinked": "Non associée", } as const; export type TranslationKey = keyof typeof fr;