aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2023-02-12 13:05:25 +0100
committerJoris2023-02-12 13:05:25 +0100
commit25ecb1cb75b7b5b584ad223e12d74c3e3ee7da89 (patch)
tree54118b78bd783812725a6a6cd43f3b4d47ffe006
parent31acb6e43c07066e9a5ba404ea6c59201937c05a (diff)
Add textual search on title, subtitle and authors
-rw-r--r--public/main.css5
-rw-r--r--src/book.ts14
-rw-r--r--src/lib/functions.ts7
-rw-r--r--src/lib/i18n.ts9
-rw-r--r--src/lib/rx.ts9
-rw-r--r--src/lib/search.ts17
-rw-r--r--src/main.ts156
-rw-r--r--src/view/filters.ts90
8 files changed, 187 insertions, 120 deletions
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<string>
+ authorsSort: string
+ genres: Array<string>
+ date: string
+ read: ReadStatus,
+ cover: string
+}
+
+export type ReadStatus = 'Read' | 'Unread' | 'Reading' | 'Stopped'
+
+export const readStatuses: Array<ReadStatus> = ['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<string | undefined>): 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<string>
- authorsSort: string
- genres: Array<string>
- date: string
- read: ReadStatus,
- cover: string
-}
-
-type ReadStatus = 'Read' | 'Unread' | 'Reading' | 'Stopped'
-
-const readStatuses: Array<ReadStatus> = ['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<Book> = (bookLibrary as Array<Book>).sort((a, b) =>
+const sortedBookLibrary: Array<Book> = (bookLibrary as Array<Book.Book>).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>({}, (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.Model>({}, (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<Array<Book>>
- filters: Rx<Filters>
- 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<Array<Book>>
- readStatus: Rx<ReadStatus | undefined>
- 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<Array<Book.Book>>
+ filters: Rx<Model>
+ 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<Array<Book.Book>>
+ readStatus: Rx<Book.ReadStatus | undefined>
+ 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>): string {
+ return I18n.unit(n, labels[0], labels[1], (n, str) => `${str} (${n})`)
+}
+
+function readStatusLabels(status: Book.ReadStatus): Array<string> {
+ 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']
+ }
+}