From 25ecb1cb75b7b5b584ad223e12d74c3e3ee7da89 Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 12 Feb 2023 13:05:25 +0100 Subject: Add textual search on title, subtitle and authors --- public/main.css | 5 ++ src/book.ts | 14 +++++ src/lib/functions.ts | 7 +++ src/lib/i18n.ts | 9 +++ src/lib/rx.ts | 9 ++- src/lib/search.ts | 17 ++++++ src/main.ts | 156 +++++++++++++-------------------------------------- src/view/filters.ts | 90 +++++++++++++++++++++++++++++ 8 files changed, 187 insertions(+), 120 deletions(-) create mode 100644 src/book.ts create mode 100644 src/lib/functions.ts create mode 100644 src/lib/i18n.ts create mode 100644 src/lib/search.ts create mode 100644 src/view/filters.ts diff --git a/public/main.css b/public/main.css index 1beec21..09c344f 100644 --- a/public/main.css +++ b/public/main.css @@ -62,10 +62,15 @@ main { } header { + display: flex; font-size: 120%; margin-bottom: 1rem; } +.g-Search { + margin-right: 1rem; +} + .g-Books { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); diff --git a/src/book.ts b/src/book.ts new file mode 100644 index 0000000..a5ddb75 --- /dev/null +++ b/src/book.ts @@ -0,0 +1,14 @@ +export interface Book { + title: string + subtitle: string + authors: Array + authorsSort: string + genres: Array + date: string + read: ReadStatus, + cover: string +} + +export type ReadStatus = 'Read' | 'Unread' | 'Reading' | 'Stopped' + +export const readStatuses: Array = ['Read', 'Unread', 'Reading', 'Stopped' ] diff --git a/src/lib/functions.ts b/src/lib/functions.ts new file mode 100644 index 0000000..0ec9f00 --- /dev/null +++ b/src/lib/functions.ts @@ -0,0 +1,7 @@ +export function debounce(func: Function, timeout: number) { + let timer: any + return (...args: any) => { + clearTimeout(timer) + timer = setTimeout(() => { func.apply(this, args) }, timeout) + } +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..cd5b3de --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,9 @@ +export function unit(n: number, singular: string, plural: string, f: (n: number, unit: string) => string = format): string { + return n > 1 + ? f(n, plural) + : f(n, singular) +} + +function format(n: number, unit: string): string { + return `${n} ${unit}` +} diff --git a/src/lib/rx.ts b/src/lib/rx.ts index dbdd3ad..1b41c27 100644 --- a/src/lib/rx.ts +++ b/src/lib/rx.ts @@ -328,8 +328,8 @@ function appendChild(state: State, element: Element, child: Child, lastAdded?: N lastAdded: appendRes.lastAdded } } else if (isRx(child)) { - const rxBase = document.createTextNode("") - element.append(rxBase) + const rxBase = document.createTextNode('') + appendNode(element, rxBase, lastAdded) let appendRes: AppendResult = { cancel: voidCancel, remove: voidRemove, @@ -345,7 +345,10 @@ function appendChild(state: State, element: Element, child: Child, lastAdded?: N appendRes.cancel() cancelRx() }, - remove: appendRes.remove, + remove: () => { + appendRes.remove() + element.removeChild(rxBase) + }, lastAdded: appendRes.lastAdded, } } else if (child === undefined || child === false) { diff --git a/src/lib/search.ts b/src/lib/search.ts new file mode 100644 index 0000000..026cb94 --- /dev/null +++ b/src/lib/search.ts @@ -0,0 +1,17 @@ +export function match(search: string, ...targets: Array): boolean { + const formattedTargets = targets + .filter(t => t !== undefined) + .map(format) + + return search.split(/\s+/).every(subSearch => + formattedTargets.some(target => target.includes(format(subSearch))) + ) +} + +export function format(str: string): string { + return unaccent(str.toLowerCase()) +} + +function unaccent(str: string): string { + return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "") +} diff --git a/src/main.ts b/src/main.ts index 0a3fd30..21bf215 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,128 +1,50 @@ -import { h, withVar, mount, Rx } from 'lib/rx' - -// Model - -interface Book { - title: string - authors: Array - authorsSort: string - genres: Array - date: string - read: ReadStatus, - cover: string -} - -type ReadStatus = 'Read' | 'Unread' | 'Reading' | 'Stopped' - -const readStatuses: Array = ['Read', 'Unread', 'Reading', 'Stopped' ] - -// Books +import { h, withVar, mount } from 'lib/rx' +import * as Search from 'lib/search' +import * as Functions from 'lib/functions' +import * as I18n from 'lib/i18n' +import * as Filters from 'view/filters' +import * as Book from 'book' // @ts-ignore -const sortedBookLibrary: Array = (bookLibrary as Array).sort((a, b) => +const sortedBookLibrary: Array = (bookLibrary as Array).sort((a, b) => a.authorsSort == b.authorsSort ? a.date > b.date : a.authorsSort > b.authorsSort) -// Filters - -interface Filters { - read?: ReadStatus -} - -// View - -const view = withVar({}, (filters, updateFilters) => { - const filtredBooks = filters.map(f => - sortedBookLibrary.filter(book => - f.read === undefined || book.read === f.read - ) - ) - - return [ - h('aside', viewFilters({ filtredBooks, filters, updateFilters })), - h('main', - h('header', filtredBooks.map(fb => `${fb.length} livres`)), - h('div', - { className: 'g-Books' }, - filtredBooks.map(fb => - fb.map(book => h('img', { className: 'g-Book', src: book.cover })) - ) +mount(withVar({}, (filters, updateFilters) => + withVar('', (search, updateSearch) => { + const filteredBooks = filters.flatMap(f => search.map(s => + sortedBookLibrary.filter(book => + (f.read === undefined || book.read === f.read) + && (s === '' || matchSearch(book, s)) ) - ) - ] -}) - -// Filters view - -interface ViewFiltersParams { - filtredBooks: Rx> - filters: Rx - updateFilters: (f: (filters: Filters) => Filters) => void -} - -function viewFilters({ filtredBooks, filters, updateFilters }: ViewFiltersParams) { - return h('ul', - {}, - h('li', [ - h('div', { className: 'g-FilterTitle' }, 'Lecture'), - readFilter({ - filtredBooks, - readStatus: filters.map(fs => fs.read), - update: (status?: ReadStatus) => updateFilters(fs => { - fs.read = status - return fs - }) - }) - ]) - ) -} - -interface ReadFilterParams { - filtredBooks: Rx> - readStatus: Rx - update: (status?: ReadStatus) => void -} - -function readFilter({ filtredBooks, readStatus, update }: ReadFilterParams) { - return h('ul', - { className: 'g-Filters' }, - readStatus.map(currentStatus => { - if (currentStatus !== undefined) { - return h('li', - { className: 'g-Filter g-Filter--Selected' }, - h('button', - { onclick: () => update(undefined) }, - readStatusLabel(currentStatus) + )) + + return [ + h('aside', Filters.view({ filteredBooks, filters, updateFilters })), + h('main', + h('header', + h('input', + { className: 'g-Search', + oninput: Functions.debounce( + (event: Event) => updateSearch(_ => (event.target as HTMLInputElement).value), + 500 + ) + } + ), + filteredBooks.map(fb => I18n.unit(fb.length, 'livre', 'livres')) + ), + h('div', + { className: 'g-Books' }, + filteredBooks.map(fb => + fb.map(book => h('img', { className: 'g-Book', src: book.cover })) ) ) - } else { - return readStatuses.map(status => { - const count = filtredBooks.map(xs => xs.filter(b => b.read === status).length) - - return h('li', - { className: 'g-Filter g-Filter--Unselected' }, - h('button', - { onclick: () => update(status) }, - count.map(c => `${readStatusLabel(status)} (${c})`) - ) - ) - }) - } - }) - ) -} + ) + ] + }) +)) -function readStatusLabel(status: ReadStatus): string { - if (status === 'Read') { - return 'lus' - } else if (status === 'Unread') { - return 'non lus' - } else if (status === 'Reading') { - return 'en cours' - } else { - return 'arrêtés' - } +function matchSearch(book: Book.Book, search: string): boolean { + return Search.match(search, book.title, book.subtitle, ...book.authors) } - -mount(view) diff --git a/src/view/filters.ts b/src/view/filters.ts new file mode 100644 index 0000000..9ce277b --- /dev/null +++ b/src/view/filters.ts @@ -0,0 +1,90 @@ +import { h, Rx } from 'lib/rx' +import * as Book from 'book' +import * as I18n from 'lib/i18n' + +// Model + +export interface Model { + read?: Book.ReadStatus +} + +const init: Model = {} + +// View + +interface ViewFiltersParams { + filteredBooks: Rx> + filters: Rx + updateFilters: (f: (filters: Model) => Model) => void +} + +export function view({ filteredBooks, filters, updateFilters }: ViewFiltersParams) { + return h('ul', + h('li', [ + h('div', { className: 'g-FilterTitle' }, 'Lecture'), + readFilter({ + filteredBooks, + readStatus: filters.map(fs => fs.read), + update: (status?: Book.ReadStatus) => updateFilters(fs => { + fs.read = status + return fs + }) + }) + ]) + ) +} + +interface ReadFilterParams { + filteredBooks: Rx> + readStatus: Rx + update: (status?: Book.ReadStatus) => void +} + +function readFilter({ filteredBooks, readStatus, update }: ReadFilterParams) { + return h('ul', + { className: 'g-Filters' }, + readStatus.map(currentStatus => { + if (currentStatus !== undefined) { + return h('li', + { className: 'g-Filter g-Filter--Selected' }, + h('button', + { onclick: () => update(undefined) }, + filteredBooks.map(xs => unit(xs.length, readStatusLabels(currentStatus))) + ) + ) + } else { + return Book.readStatuses.map(status => + filteredBooks.map(xs => { + const count = xs.filter(b => b.read === status).length + + return count !== 0 + ? h('li', + { className: 'g-Filter g-Filter--Unselected' }, + h('button', + { onclick: () => update(status) }, + unit(count, readStatusLabels(status)) + ) + ) + : undefined + }) + ) + } + }) + ) +} + +function unit(n: number, labels: Array): string { + return I18n.unit(n, labels[0], labels[1], (n, str) => `${str} (${n})`) +} + +function readStatusLabels(status: Book.ReadStatus): Array { + if (status === 'Read') { + return ['lu', 'lus'] + } else if (status === 'Unread') { + return ['non lu', 'non lus'] + } else if (status === 'Reading') { + return ['en cours', 'en cours'] + } else { + return ['arrêté', 'arrêtés'] + } +} -- cgit v1.2.3