Compare commits

...

3 Commits

Author SHA1 Message Date
fc8856c83f chore: bump version to 1.19.0
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m19s
2026-03-21 08:12:19 +01:00
bd09f3d943 feat: persist filter state in localStorage across pages
Save/restore filter values in LiveSearchForm using localStorage keyed
by basePath (e.g. filters:/books, filters:/series). Filters are restored
on mount when the URL has no active filters, and cleared when the user
clicks the Clear button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:12:10 +01:00
1f434c3d67 feat: add format and metadata filters to books page
Add two new filters to the books listing page:
- Format filter (CBZ/CBR/PDF/EPUB) using existing API support
- Metadata linked/unlinked filter with new API support via
  LEFT JOIN on external_metadata_links (using DISTINCT ON CTE
  matching the series endpoint pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 08:09:37 +01:00
9 changed files with 125 additions and 12 deletions

8
Cargo.lock generated
View File

@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "api"
version = "1.18.0"
version = "1.19.0"
dependencies = [
"anyhow",
"argon2",
@@ -1232,7 +1232,7 @@ dependencies = [
[[package]]
name = "indexer"
version = "1.18.0"
version = "1.19.0"
dependencies = [
"anyhow",
"axum",
@@ -1771,7 +1771,7 @@ dependencies = [
[[package]]
name = "parsers"
version = "1.18.0"
version = "1.19.0"
dependencies = [
"anyhow",
"flate2",
@@ -2906,7 +2906,7 @@ dependencies = [
[[package]]
name = "stripstream-core"
version = "1.18.0"
version = "1.19.0"
dependencies = [
"anyhow",
"serde",

View File

@@ -9,7 +9,7 @@ resolver = "2"
[workspace.package]
edition = "2021"
version = "1.18.0"
version = "1.19.0"
license = "MIT"
[workspace.dependencies]

View File

@@ -29,6 +29,9 @@ pub struct ListBooksQuery {
/// Sort order: "title" (default) or "latest" (most recently added first)
#[schema(value_type = Option<String>, example = "latest")]
pub sort: Option<String>,
/// Filter by metadata provider: "linked" (any provider), "unlinked" (no provider), or a specific provider name
#[schema(value_type = Option<String>, example = "linked")]
pub metadata_provider: Option<String>,
}
#[derive(Serialize, ToSchema)]
@@ -108,6 +111,7 @@ pub struct BookDetails {
("page" = Option<i64>, Query, description = "Page number (1-indexed, default 1)"),
("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)"),
("metadata_provider" = Option<String>, Query, description = "Filter by metadata provider: 'linked' (any provider), 'unlinked' (no provider), or a specific provider name"),
),
responses(
(status = 200, body = BooksPage),
@@ -141,16 +145,34 @@ pub async fn list_books(
let author_cond = if query.author.is_some() {
p += 1; format!("AND (${p} = ANY(COALESCE(NULLIF(b.authors, '{{}}'), CASE WHEN b.author IS NOT NULL AND b.author != '' THEN ARRAY[b.author] ELSE ARRAY[]::text[] END)))")
} else { String::new() };
let metadata_cond = match query.metadata_provider.as_deref() {
Some("unlinked") => "AND eml.id IS NULL".to_string(),
Some("linked") => "AND eml.id IS NOT NULL".to_string(),
Some(_) => { p += 1; format!("AND eml.provider = ${p}") },
None => String::new(),
};
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, eml.id
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#"SELECT COUNT(*) FROM books b
r#"WITH {metadata_links_cte}
SELECT COUNT(*) FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
AND ($3::text IS NULL OR b.format = $3)
{series_cond}
{rs_cond}
{author_cond}"#
{author_cond}
{metadata_cond}"#
);
let order_clause = if query.sort.as_deref() == Some("latest") {
@@ -164,18 +186,21 @@ pub async fn list_books(
let offset_p = p + 2;
let data_sql = format!(
r#"
WITH {metadata_links_cte}
SELECT b.id, b.library_id, b.kind, b.format, b.title, b.author, b.authors, b.series, b.volume, b.language, b.page_count, b.thumbnail_path, b.updated_at,
COALESCE(brp.status, 'unread') AS reading_status,
brp.current_page AS reading_current_page,
brp.last_read_at AS reading_last_read_at
FROM books b
LEFT JOIN book_reading_progress brp ON brp.book_id = b.id
LEFT JOIN metadata_links eml ON eml.series_name = b.series AND eml.library_id = b.library_id
WHERE ($1::uuid IS NULL OR b.library_id = $1)
AND ($2::text IS NULL OR b.kind = $2)
AND ($3::text IS NULL OR b.format = $3)
{series_cond}
{rs_cond}
{author_cond}
{metadata_cond}
ORDER BY {order_clause}
LIMIT ${limit_p} OFFSET ${offset_p}
"#
@@ -204,6 +229,12 @@ pub async fn list_books(
count_builder = count_builder.bind(author.clone());
data_builder = data_builder.bind(author.clone());
}
if let Some(ref mp) = query.metadata_provider {
if mp != "linked" && mp != "unlinked" {
count_builder = count_builder.bind(mp.clone());
data_builder = data_builder.bind(mp.clone());
}
}
data_builder = data_builder.bind(limit).bind(offset);

View File

@@ -18,6 +18,8 @@ export default async function BooksPage({
const libraryId = typeof searchParamsAwaited.library === "string" ? searchParamsAwaited.library : undefined;
const searchQuery = typeof searchParamsAwaited.q === "string" ? searchParamsAwaited.q : "";
const readingStatus = typeof searchParamsAwaited.status === "string" ? searchParamsAwaited.status : undefined;
const format = typeof searchParamsAwaited.format === "string" ? searchParamsAwaited.format : undefined;
const metadataProvider = typeof searchParamsAwaited.metadata === "string" ? searchParamsAwaited.metadata : undefined;
const sort = typeof searchParamsAwaited.sort === "string" ? searchParamsAwaited.sort : undefined;
const page = typeof searchParamsAwaited.page === "string" ? parseInt(searchParamsAwaited.page) : 1;
const limit = typeof searchParamsAwaited.limit === "string" ? parseInt(searchParamsAwaited.limit) : 20;
@@ -62,7 +64,7 @@ export default async function BooksPage({
totalHits = searchResponse.estimated_total_hits;
}
} else {
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort).catch(() => ({
const booksPage = await fetchBooks(libraryId, undefined, page, limit, readingStatus, sort, undefined, format, metadataProvider).catch(() => ({
items: [] as BookDto[],
total: 0,
page: 1,
@@ -91,12 +93,26 @@ export default async function BooksPage({
{ value: "read", label: t("status.read") },
];
const formatOptions = [
{ value: "", label: t("books.allFormats") },
{ value: "cbz", label: "CBZ" },
{ value: "cbr", label: "CBR" },
{ value: "pdf", label: "PDF" },
{ value: "epub", label: "EPUB" },
];
const metadataOptions = [
{ value: "", label: t("series.metadataAll") },
{ value: "linked", label: t("series.metadataLinked") },
{ value: "unlinked", label: t("series.metadataUnlinked") },
];
const sortOptions = [
{ value: "", label: t("books.sortTitle") },
{ value: "latest", label: t("books.sortLatest") },
];
const hasFilters = searchQuery || libraryId || readingStatus || sort;
const hasFilters = searchQuery || libraryId || readingStatus || format || metadataProvider || sort;
return (
<>
@@ -117,6 +133,8 @@ export default async function BooksPage({
{ name: "q", type: "text", label: t("common.search"), placeholder: t("books.searchPlaceholder") },
{ name: "library", type: "select", label: t("books.library"), options: libraryOptions },
{ name: "status", type: "select", label: t("books.status"), options: statusOptions },
{ name: "format", type: "select", label: t("books.format"), options: formatOptions },
{ name: "metadata", type: "select", label: t("series.metadata"), options: metadataOptions },
{ name: "sort", type: "select", label: t("books.sort"), options: sortOptions },
]}
/>

View File

@@ -18,6 +18,10 @@ const FILTER_ICONS: Record<string, string> = {
metadata_provider: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
// Sort - arrows up/down
sort: "M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12",
// Format - document/file
format: "M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z",
// Metadata - link/chain
metadata: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
};
interface FieldDef {
@@ -35,12 +39,17 @@ interface LiveSearchFormProps {
debounceMs?: number;
}
const STORAGE_KEY_PREFIX = "filters:";
export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearchFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useTranslation();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const restoredRef = useRef(false);
const storageKey = `${STORAGE_KEY_PREFIX}${basePath}`;
const buildUrl = useCallback((): string => {
if (!formRef.current) return basePath;
@@ -54,16 +63,58 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
return qs ? `${basePath}?${qs}` : basePath;
}, [basePath]);
const saveFilters = useCallback(() => {
if (!formRef.current) return;
const formData = new FormData(formRef.current);
const filters: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
const str = value.toString().trim();
if (str) filters[key] = str;
}
try {
localStorage.setItem(storageKey, JSON.stringify(filters));
} catch {}
}, [storageKey]);
const navigate = useCallback((immediate: boolean) => {
if (timerRef.current) clearTimeout(timerRef.current);
if (immediate) {
saveFilters();
router.replace(buildUrl() as any);
} else {
timerRef.current = setTimeout(() => {
saveFilters();
router.replace(buildUrl() as any);
}, debounceMs);
}
}, [router, buildUrl, debounceMs]);
}, [router, buildUrl, debounceMs, saveFilters]);
// Restore filters from localStorage on mount if URL has no filters
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
const hasUrlFilters = fields.some((f) => {
const val = searchParams.get(f.name);
return val && val.trim() !== "";
});
if (hasUrlFilters) return;
try {
const saved = localStorage.getItem(storageKey);
if (!saved) return;
const filters: Record<string, string> = JSON.parse(saved);
const fieldNames = new Set(fields.map((f) => f.name));
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (fieldNames.has(key) && value) params.set(key, value);
}
const qs = params.toString();
if (qs) {
router.replace(`${basePath}?${qs}` as any);
}
} catch {}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
return () => {
@@ -85,6 +136,7 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
onSubmit={(e) => {
e.preventDefault();
if (timerRef.current) clearTimeout(timerRef.current);
saveFilters();
router.replace(buildUrl() as any);
}}
className="space-y-4"
@@ -145,7 +197,11 @@ export function LiveSearchForm({ fields, basePath, debounceMs = 300 }: LiveSearc
{hasFilters && (
<button
type="button"
onClick={() => router.replace(basePath as any)}
onClick={() => {
formRef.current?.reset();
try { localStorage.removeItem(storageKey); } catch {}
router.replace(basePath as any);
}}
className="
inline-flex items-center gap-1
h-8 px-2.5

View File

@@ -286,6 +286,8 @@ export async function fetchBooks(
readingStatus?: string,
sort?: string,
author?: string,
format?: string,
metadataProvider?: string,
): Promise<BooksPageDto> {
const params = new URLSearchParams();
if (libraryId) params.set("library_id", libraryId);
@@ -293,6 +295,8 @@ export async function fetchBooks(
if (readingStatus) params.set("reading_status", readingStatus);
if (sort) params.set("sort", sort);
if (author) params.set("author", author);
if (format) params.set("format", format);
if (metadataProvider) params.set("metadata_provider", metadataProvider);
params.set("page", page.toString());
params.set("limit", limit.toString());

View File

@@ -100,6 +100,8 @@ const en: Record<TranslationKey, string> = {
"books.noResults": "No books found for \"{{query}}\"",
"books.noBooks": "No books available",
"books.coverOf": "Cover of {{name}}",
"books.format": "Format",
"books.allFormats": "All formats",
// Series page
"series.title": "Series",

View File

@@ -98,6 +98,8 @@ const fr = {
"books.noResults": "Aucun livre trouvé pour \"{{query}}\"",
"books.noBooks": "Aucun livre disponible",
"books.coverOf": "Couverture de {{name}}",
"books.format": "Format",
"books.allFormats": "Tous les formats",
// Series page
"series.title": "Séries",

View File

@@ -1,6 +1,6 @@
{
"name": "stripstream-backoffice",
"version": "1.18.0",
"version": "1.19.0",
"private": true,
"scripts": {
"dev": "next dev -p 7082",