mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1775770381607,
|
||||
"when": 1776021950831,
|
||||
"tag": "0000_baseline",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user