feat: consolidate mobile UX and filtering updates

- improve responsive layouts and controls across dashboard, home, lens, collection, authors, and book detail surfaces

- modernize settings and metadata provider UIs, including the floating dashboard settings action and cleaner section composition

- add synced or per-view cover sizing and spacing behavior, plus expanded mobile filter and sort control patterns

- extend backend filtering with library rules and increase hardcover ID limits across schema, DTO validation, and migration metadata
This commit is contained in:
neon
2026-04-12 09:45:00 -06:00
parent 32359cebbf
commit 0aad8c0d7d
78 changed files with 4535 additions and 1830 deletions
+1 -1
View File
@@ -358,7 +358,7 @@ CREATE TABLE "book_metadata" (
"google_books_id" varchar(50),
"goodreads_id" varchar(50),
"amazon_id" varchar(20),
"hardcover_id" varchar(50),
"hardcover_id" varchar(255),
"open_library_id" varchar(50),
"itunes_id" varchar(50),
"metadata_score" integer,
@@ -1,5 +1,5 @@
{
"id": "3d7638fa-4c77-4988-a03b-15e20345198d",
"id": "5e7847b4-0c76-43ea-8030-9d4b4e1cb352",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -3284,7 +3284,7 @@
},
"hardcover_id": {
"name": "hardcover_id",
"type": "varchar(50)",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1775770381607,
"when": 1776021950831,
"tag": "0000_baseline",
"breakpoints": true
}
+1 -1
View File
@@ -53,7 +53,7 @@ export const bookMetadata = pgTable(
googleBooksId: varchar('google_books_id', { length: 50 }),
goodreadsId: varchar('goodreads_id', { length: 50 }),
amazonId: varchar('amazon_id', { length: 20 }),
hardcoverId: varchar('hardcover_id', { length: 50 }),
hardcoverId: varchar('hardcover_id', { length: 255 }),
openLibraryId: varchar('open_library_id', { length: 50 }),
itunesId: varchar('itunes_id', { length: 50 }),
metadataScore: integer('metadata_score'),
@@ -588,6 +588,35 @@ describe('collectionRuleToSql', () => {
});
});
describe('libraryRuleToSql', () => {
it('includesAny produces an EXISTS subquery scoped by library name', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'library', operator: 'includesAny', value: ['Main'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql' });
});
it('excludesAll with values produces not(EXISTS)', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(
wrapRule({ type: 'rule', field: 'library', operator: 'excludesAll', value: ['Archive'] }) as never,
USER_CTX,
) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'not', value: { type: 'sql' } });
});
it('includesAny with empty array produces always-false branch', () => {
const { builder } = makeBuilder();
const where = builder.buildWhere(wrapRule({ type: 'rule', field: 'library', operator: 'includesAny', value: [] }) as never, USER_CTX) as any;
expect(getRuleSql(where)).toMatchObject({ type: 'sql', text: '1 = 0' });
});
});
describe('formatRuleToSql', () => {
it('includesAny produces EXISTS subquery with format filter', () => {
const { builder } = makeBuilder();
@@ -17,6 +17,7 @@ import {
collections,
readingProgress,
genres,
libraries,
tags,
} from '../../db/schema';
@@ -150,6 +151,8 @@ export class BookQueryBuilder {
return this.tagRuleToSql(operator, value as string[]);
case 'collection':
return this.collectionRuleToSql(operator, value as string[], userId);
case 'library':
return this.libraryRuleToSql(operator, value as string[]);
case 'format':
return this.formatRuleToSql(operator, value as string[]);
case 'addedAt':
@@ -459,6 +462,29 @@ export class BookQueryBuilder {
}
}
private libraryRuleToSql(operator: string, values?: string[]): SQL {
const existsLibrary = (whereClause?: SQL) => {
const predicates: SQL[] = [eq(libraries.id, books.libraryId)];
if (whereClause) predicates.push(whereClause);
const sq = this.db
.select({ one: sql`1` })
.from(libraries)
.where(and(...predicates)!);
return sql`exists (${sq})`;
};
switch (operator) {
case 'includesAny':
if (!values?.length) return sql`1 = 0`;
return existsLibrary(or(...values.map((v) => eq(libraries.name, v)))!);
case 'excludesAll':
if (!values?.length) return sql`1 = 1`;
return not(existsLibrary(or(...values.map((v) => eq(libraries.name, v)))!));
default:
throw new BadRequestException(`Invalid operator '${operator}' for library field`);
}
}
private isbnRuleToSql(operator: string, value?: string): SQL {
switch (operator) {
case 'isEmpty':
@@ -47,7 +47,7 @@ export class UpdateBookMetadataDto {
@IsOptional() @IsString() @MaxLength(50) googleBooksId?: string | null;
@IsOptional() @IsString() @MaxLength(50) goodreadsId?: string | null;
@IsOptional() @IsString() @MaxLength(20) amazonId?: string | null;
@IsOptional() @IsString() @MaxLength(50) hardcoverId?: string | null;
@IsOptional() @IsString() @MaxLength(255) hardcoverId?: string | null;
@IsOptional() @IsString() @MaxLength(50) openLibraryId?: string | null;
@IsOptional() @IsString() @MaxLength(50) itunesId?: string | null;
@IsOptional() @IsString() @MaxLength(20) audibleId?: string | null;
@@ -17,6 +17,7 @@ const RULE_FIELDS = [
'genre',
'tag',
'collection',
'library',
'format',
'addedAt',
'fileAvailability',
@@ -62,6 +63,7 @@ const VALID_OPERATORS_BY_FIELD: Record<string, string[]> = {
genre: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
tag: ['includesAny', 'includesAll', 'excludesAll', 'isEmpty', 'isNotEmpty'],
collection: ['includesAny', 'excludesAll', 'isEmpty', 'isNotEmpty'],
library: ['includesAny', 'excludesAll'],
format: ['includesAny', 'excludesAll'],
publishedYear: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
seriesIndex: ['eq', 'notEq', 'gt', 'gte', 'lt', 'lte', 'between', 'isEmpty', 'isNotEmpty'],
+2 -2
View File
@@ -84,11 +84,11 @@ export class CoverService {
async proxyImage(url: string): Promise<{ buffer: Buffer; contentType: string }> {
const startedAt = Date.now();
this.logger.log(`[cover.proxy_image] [start] urlHost=${hostForLog(url)} - cover image proxy started`);
this.logger.debug(`[cover.proxy_image] [start] urlHost=${hostForLog(url)} - cover image proxy started`);
try {
const image = await this.fetchRemoteImage(url);
this.logger.log(
this.logger.debug(
`[cover.proxy_image] [end] urlHost=${hostForLog(url)} durationMs=${Date.now() - startedAt} bytes=${image.buffer.length} contentType=${image.contentType} - cover image proxy completed`,
);
return image;
@@ -108,7 +108,7 @@ export function buildMetadataPatch(sourceBook: {
googleBooksId: field('googleBooksId', truncateNullableText(sourceBook.googleBooksId, 50)),
goodreadsId: field('goodreadsId', truncateNullableText(sourceBook.goodreadsId, 50)),
amazonId: field('amazonId', truncateNullableText(sourceBook.amazonId, 20)),
hardcoverId: field('hardcoverId', truncateNullableText(sourceBook.hardcoverId, 50)),
hardcoverId: field('hardcoverId', truncateNullableText(sourceBook.hardcoverId, 255)),
audibleId: field('audibleId', truncateNullableText(sourceBook.audibleId, 20)),
comicvineId: field('comicvineId', truncateNullableText(sourceBook.comicvineId, 50)),
durationSeconds: field('durationSeconds', sanitizeNonNegativeInteger(sourceBook.durationSeconds)),