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 <noreply@anthropic.com>
This commit is contained in:
@@ -314,6 +314,7 @@ pub struct SeriesItem {
|
|||||||
pub library_id: Uuid,
|
pub library_id: Uuid,
|
||||||
pub series_status: Option<String>,
|
pub series_status: Option<String>,
|
||||||
pub missing_count: Option<i64>,
|
pub missing_count: Option<i64>,
|
||||||
|
pub metadata_provider: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -336,6 +337,9 @@ pub struct ListSeriesQuery {
|
|||||||
/// Filter series with missing books: "true" to show only series with missing books
|
/// Filter series with missing books: "true" to show only series with missing books
|
||||||
#[schema(value_type = Option<String>, example = "true")]
|
#[schema(value_type = Option<String>, example = "true")]
|
||||||
pub has_missing: Option<String>,
|
pub has_missing: Option<String>,
|
||||||
|
/// Filter by metadata provider: a provider name (e.g. "google_books"), "linked" (any provider), or "unlinked" (no provider)
|
||||||
|
#[schema(value_type = Option<String>, example = "google_books")]
|
||||||
|
pub metadata_provider: Option<String>,
|
||||||
#[schema(value_type = Option<i64>, example = 1)]
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
@@ -351,6 +355,7 @@ pub struct ListSeriesQuery {
|
|||||||
("library_id" = String, Path, description = "Library UUID"),
|
("library_id" = String, Path, description = "Library UUID"),
|
||||||
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
||||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
||||||
|
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: a provider name (e.g. 'google_books'), 'linked' (any provider), or 'unlinked' (no provider)"),
|
||||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
("limit" = Option<i64>, 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()
|
"AND mc.missing_count > 0".to_string()
|
||||||
} else { String::new() };
|
} 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!(
|
let missing_cte = format!(
|
||||||
r#"
|
r#"
|
||||||
missing_counts AS (
|
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!(
|
let count_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH sorted_books AS (
|
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
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
GROUP BY sb.name
|
GROUP BY sb.name
|
||||||
),
|
),
|
||||||
{missing_cte}
|
{missing_cte},
|
||||||
|
{metadata_links_cte}
|
||||||
SELECT COUNT(*) FROM series_counts sc
|
SELECT COUNT(*) FROM series_counts sc
|
||||||
LEFT JOIN series_metadata sm ON sm.library_id = $1 AND sm.name = sc.name
|
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 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
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
GROUP BY sb.name
|
GROUP BY sb.name
|
||||||
),
|
),
|
||||||
{missing_cte}
|
{missing_cte},
|
||||||
|
{metadata_links_cte}
|
||||||
SELECT
|
SELECT
|
||||||
sc.name,
|
sc.name,
|
||||||
sc.book_count,
|
sc.book_count,
|
||||||
sc.books_read_count,
|
sc.books_read_count,
|
||||||
sb.id as first_book_id,
|
sb.id as first_book_id,
|
||||||
sm.status as series_status,
|
sm.status as series_status,
|
||||||
mc.missing_count
|
mc.missing_count,
|
||||||
|
ml.provider as metadata_provider
|
||||||
FROM series_counts sc
|
FROM series_counts sc
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
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 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 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
|
WHERE TRUE
|
||||||
{q_cond}
|
{q_cond}
|
||||||
{count_rs_cond}
|
{count_rs_cond}
|
||||||
{ss_cond}
|
{ss_cond}
|
||||||
{missing_cond}
|
{missing_cond}
|
||||||
|
{metadata_provider_cond}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''),
|
REGEXP_REPLACE(LOWER(sc.name), '[0-9].*$', ''),
|
||||||
COALESCE(
|
COALESCE(
|
||||||
@@ -509,6 +537,12 @@ pub async fn list_series(
|
|||||||
count_builder = count_builder.bind(ss);
|
count_builder = count_builder.bind(ss);
|
||||||
data_builder = data_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);
|
data_builder = data_builder.bind(limit).bind(offset);
|
||||||
|
|
||||||
@@ -528,6 +562,7 @@ pub async fn list_series(
|
|||||||
library_id,
|
library_id,
|
||||||
series_status: row.get("series_status"),
|
series_status: row.get("series_status"),
|
||||||
missing_count: row.get("missing_count"),
|
missing_count: row.get("missing_count"),
|
||||||
|
metadata_provider: row.get("metadata_provider"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -553,6 +588,9 @@ pub struct ListAllSeriesQuery {
|
|||||||
/// Filter series with missing books: "true" to show only series with missing books
|
/// Filter series with missing books: "true" to show only series with missing books
|
||||||
#[schema(value_type = Option<String>, example = "true")]
|
#[schema(value_type = Option<String>, example = "true")]
|
||||||
pub has_missing: Option<String>,
|
pub has_missing: Option<String>,
|
||||||
|
/// Filter by metadata provider: a provider name (e.g. "google_books"), "linked" (any provider), or "unlinked" (no provider)
|
||||||
|
#[schema(value_type = Option<String>, example = "google_books")]
|
||||||
|
pub metadata_provider: Option<String>,
|
||||||
#[schema(value_type = Option<i64>, example = 1)]
|
#[schema(value_type = Option<i64>, example = 1)]
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
#[schema(value_type = Option<i64>, example = 50)]
|
#[schema(value_type = Option<i64>, example = 50)]
|
||||||
@@ -571,6 +609,7 @@ pub struct ListAllSeriesQuery {
|
|||||||
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
("q" = Option<String>, Query, description = "Filter by series name (case-insensitive, partial match)"),
|
||||||
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
("library_id" = Option<String>, Query, description = "Filter by library ID"),
|
||||||
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
("reading_status" = Option<String>, Query, description = "Filter by reading status, comma-separated (e.g. 'unread,reading')"),
|
||||||
|
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: a provider name (e.g. 'google_books'), 'linked' (any provider), or 'unlinked' (no provider)"),
|
||||||
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
|
||||||
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
("limit" = Option<i64>, Query, description = "Items per page (max 200, default 50)"),
|
||||||
("sort" = Option<String>, Query, description = "Sort order: 'title' (default) or 'latest' (most recently added first)"),
|
("sort" = Option<String>, 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()
|
"AND mc.missing_count > 0".to_string()
|
||||||
} else { String::new() };
|
} 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
|
// Missing counts CTE — needs library_id filter when filtering by library
|
||||||
let missing_cte = if query.library_id.is_some() {
|
let missing_cte = if query.library_id.is_some() {
|
||||||
format!(
|
format!(
|
||||||
@@ -652,6 +698,16 @@ pub async fn list_all_series(
|
|||||||
"#.to_string()
|
"#.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!(
|
let count_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
WITH sorted_books AS (
|
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
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
GROUP BY sb.name, sb.library_id
|
GROUP BY sb.name, sb.library_id
|
||||||
),
|
),
|
||||||
{missing_cte}
|
{missing_cte},
|
||||||
|
{metadata_links_cte}
|
||||||
SELECT COUNT(*) FROM series_counts sc
|
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 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 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
|
LEFT JOIN book_reading_progress brp ON brp.book_id = sb.id
|
||||||
GROUP BY sb.name, sb.library_id
|
GROUP BY sb.name, sb.library_id
|
||||||
),
|
),
|
||||||
{missing_cte}
|
{missing_cte},
|
||||||
|
{metadata_links_cte}
|
||||||
SELECT
|
SELECT
|
||||||
sc.name,
|
sc.name,
|
||||||
sc.book_count,
|
sc.book_count,
|
||||||
@@ -721,16 +780,19 @@ pub async fn list_all_series(
|
|||||||
sb.id as first_book_id,
|
sb.id as first_book_id,
|
||||||
sb.library_id,
|
sb.library_id,
|
||||||
sm.status as series_status,
|
sm.status as series_status,
|
||||||
mc.missing_count
|
mc.missing_count,
|
||||||
|
ml.provider as metadata_provider
|
||||||
FROM series_counts sc
|
FROM series_counts sc
|
||||||
JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
|
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 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 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
|
WHERE TRUE
|
||||||
{q_cond}
|
{q_cond}
|
||||||
{rs_cond}
|
{rs_cond}
|
||||||
{ss_cond}
|
{ss_cond}
|
||||||
{missing_cond}
|
{missing_cond}
|
||||||
|
{metadata_provider_cond}
|
||||||
ORDER BY {series_order_clause}
|
ORDER BY {series_order_clause}
|
||||||
LIMIT ${limit_p} OFFSET ${offset_p}
|
LIMIT ${limit_p} OFFSET ${offset_p}
|
||||||
"#
|
"#
|
||||||
@@ -757,6 +819,12 @@ pub async fn list_all_series(
|
|||||||
count_builder = count_builder.bind(ss);
|
count_builder = count_builder.bind(ss);
|
||||||
data_builder = data_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);
|
data_builder = data_builder.bind(limit).bind(offset);
|
||||||
|
|
||||||
@@ -776,6 +844,7 @@ pub async fn list_all_series(
|
|||||||
library_id: row.get("library_id"),
|
library_id: row.get("library_id"),
|
||||||
series_status: row.get("series_status"),
|
series_status: row.get("series_status"),
|
||||||
missing_count: row.get("missing_count"),
|
missing_count: row.get("missing_count"),
|
||||||
|
metadata_provider: row.get("metadata_provider"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -887,6 +956,7 @@ pub async fn ongoing_series(
|
|||||||
library_id: row.get("library_id"),
|
library_id: row.get("library_id"),
|
||||||
series_status: None,
|
series_status: None,
|
||||||
missing_count: None,
|
missing_count: None,
|
||||||
|
metadata_provider: None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
|
|||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
router.replace(buildUrl() as any);
|
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) =>
|
{fields.map((field) =>
|
||||||
field.type === "text" ? (
|
field.type === "text" ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LiveSearchForm } from "../components/LiveSearchForm";
|
|||||||
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
import { Card, CardContent, OffsetPagination } from "../components/ui";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ProviderIcon } from "../components/ProviderIcon";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -21,12 +22,13 @@ export default async function SeriesPage({
|
|||||||
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
|
||||||
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
|
const seriesStatus = typeof searchParamsAwaited.series_status === "string" ? searchParamsAwaited.series_status : undefined;
|
||||||
const hasMissing = searchParamsAwaited.has_missing === "true";
|
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 page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
|
||||||
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
|
||||||
|
|
||||||
const [libraries, seriesPage, dbStatuses] = await Promise.all([
|
const [libraries, seriesPage, dbStatuses] = await Promise.all([
|
||||||
fetchLibraries().catch(() => [] as LibraryDto[]),
|
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
|
() => ({ items: [] as SeriesDto[], total: 0, page: 1, limit }) as SeriesPageDto
|
||||||
),
|
),
|
||||||
fetchSeriesStatuses().catch(() => [] as string[]),
|
fetchSeriesStatuses().catch(() => [] as string[]),
|
||||||
@@ -39,7 +41,7 @@ export default async function SeriesPage({
|
|||||||
{ value: "latest", label: t("books.sortLatest") },
|
{ 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 = [
|
const libraryOptions = [
|
||||||
{ value: "", label: t("books.allLibraries") },
|
{ value: "", label: t("books.allLibraries") },
|
||||||
@@ -70,6 +72,17 @@ export default async function SeriesPage({
|
|||||||
{ value: "true", label: t("series.missingBooks") },
|
{ 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -87,11 +100,12 @@ export default async function SeriesPage({
|
|||||||
basePath="/series"
|
basePath="/series"
|
||||||
fields={[
|
fields={[
|
||||||
{ name: "q", type: "text", label: t("common.search"), placeholder: t("series.searchPlaceholder"), className: "flex-1 w-full" },
|
{ 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: "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-36" },
|
{ 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: "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: "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" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -158,6 +172,11 @@ export default async function SeriesPage({
|
|||||||
{t("series.missingCount", { count: String(s.missing_count), plural: s.missing_count > 1 ? "s" : "" })}
|
{t("series.missingCount", { count: String(s.missing_count), plural: s.missing_count > 1 ? "s" : "" })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{s.metadata_provider && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium bg-purple-500/15 text-purple-600 inline-flex items-center gap-0.5">
|
||||||
|
<ProviderIcon provider={s.metadata_provider} size={10} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export type SeriesDto = {
|
|||||||
library_id: string;
|
library_id: string;
|
||||||
series_status: string | null;
|
series_status: string | null;
|
||||||
missing_count: number | null;
|
missing_count: number | null;
|
||||||
|
metadata_provider: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function config() {
|
export function config() {
|
||||||
@@ -322,6 +323,7 @@ export async function fetchAllSeries(
|
|||||||
sort?: string,
|
sort?: string,
|
||||||
seriesStatus?: string,
|
seriesStatus?: string,
|
||||||
hasMissing?: boolean,
|
hasMissing?: boolean,
|
||||||
|
metadataProvider?: string,
|
||||||
): Promise<SeriesPageDto> {
|
): Promise<SeriesPageDto> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (libraryId) params.set("library_id", libraryId);
|
if (libraryId) params.set("library_id", libraryId);
|
||||||
@@ -330,6 +332,7 @@ export async function fetchAllSeries(
|
|||||||
if (sort) params.set("sort", sort);
|
if (sort) params.set("sort", sort);
|
||||||
if (seriesStatus) params.set("series_status", seriesStatus);
|
if (seriesStatus) params.set("series_status", seriesStatus);
|
||||||
if (hasMissing) params.set("has_missing", "true");
|
if (hasMissing) params.set("has_missing", "true");
|
||||||
|
if (metadataProvider) params.set("metadata_provider", metadataProvider);
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
params.set("limit", limit.toString());
|
params.set("limit", limit.toString());
|
||||||
|
|
||||||
|
|||||||
@@ -550,6 +550,12 @@ const en: Record<TranslationKey, string> = {
|
|||||||
// Series filters
|
// Series filters
|
||||||
"seriesFilters.all": "All",
|
"seriesFilters.all": "All",
|
||||||
"seriesFilters.missingBooks": "Missing books",
|
"seriesFilters.missingBooks": "Missing books",
|
||||||
|
|
||||||
|
// Metadata filter
|
||||||
|
"series.metadata": "Metadata",
|
||||||
|
"series.metadataAll": "All",
|
||||||
|
"series.metadataLinked": "Linked",
|
||||||
|
"series.metadataUnlinked": "Not linked",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -548,6 +548,12 @@ const fr = {
|
|||||||
// Series filters
|
// Series filters
|
||||||
"seriesFilters.all": "Tous",
|
"seriesFilters.all": "Tous",
|
||||||
"seriesFilters.missingBooks": "Livres manquants",
|
"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;
|
} as const;
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof fr;
|
export type TranslationKey = keyof typeof fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user