aboutsummaryrefslogtreecommitdiff
path: root/library/client/view
diff options
context:
space:
mode:
authorJoris2023-09-17 12:23:47 +0200
committerJoris2023-09-17 12:23:47 +0200
commit1ebc55c72a1a17293bbf4ad86e0177a10a794750 (patch)
tree5fce0ea3a011ccbae85b0d3927f8ac33099585fb /library/client/view
parentc236facb4d4c277773c83f1a4ee85b48833d7e67 (diff)
Make app packageable
Diffstat (limited to 'library/client/view')
-rw-r--r--library/client/view/books.ts116
-rw-r--r--library/client/view/components/modal.ts38
-rw-r--r--library/client/view/filters.ts90
3 files changed, 244 insertions, 0 deletions
diff --git a/library/client/view/books.ts b/library/client/view/books.ts
new file mode 100644
index 0000000..aba55c1
--- /dev/null
+++ b/library/client/view/books.ts
@@ -0,0 +1,116 @@
+import { h, withVar, mount, Html, Rx } from 'lib/rx'
+import * as Book from 'book'
+import * as Modal from 'view/components/modal'
+
+export function view(books: Rx<Array<Book.Book>>): Html {
+ return h('div',
+ { className: 'g-Books' },
+ withVar<Book.Book | undefined>(undefined, (focusBook, updateFocusBook) => [
+ books.map(bs => [
+ focusBook.map(book => {
+ if (book !== undefined) {
+ let onKeyup = keyupHandler({
+ books: bs,
+ book,
+ onUpdate: (book: Book.Book) => updateFocusBook(_ => book)
+ })
+
+ return bookDetailModal({
+ book,
+ onClose: () => updateFocusBook(_ => undefined),
+ onmount: () => addEventListener('keyup', onKeyup),
+ onunmount: () => removeEventListener('keyup', onKeyup)
+ })
+ }
+ }),
+ bs.map(book => viewBook({
+ book,
+ onSelect: (book) => updateFocusBook(_ => book)
+ }))
+ ])
+ ])
+ )
+}
+
+interface KeyupHandlerParams {
+ books: Array<Book.Book>
+ book: Book.Book
+ onUpdate: (book: Book.Book) => void
+}
+
+function keyupHandler({ books, book, onUpdate }: KeyupHandlerParams): ((e: KeyboardEvent) => void) {
+ return (e: KeyboardEvent) => {
+ if (e.key === 'ArrowLeft') {
+ const indexedBooks = books.map((b, i) => ({ b, i }))
+ const focus = indexedBooks.find(({ b }) => b == book)
+ if (focus !== undefined && focus.i > 0) {
+ onUpdate(books[focus.i - 1])
+ }
+ } else if (e.key === 'ArrowRight') {
+ const indexedBooks = books.map((b, i) => ({ b, i }))
+ const focus = indexedBooks.find(({ b }) => b == book)
+ if (focus !== undefined && focus.i < books.length - 1) {
+ onUpdate(books[focus.i + 1])
+ }
+ }
+ }
+}
+
+interface ViewBookParams {
+ book: Book.Book
+ onSelect: (book: Book.Book) => void
+}
+
+function viewBook({ book, onSelect }: ViewBookParams): Html {
+ return h('button',
+ { className: 'g-Book' },
+ h('img',
+ { src: book.cover,
+ alt: book.title,
+ className: 'g-Book__Image',
+ onclick: () => onSelect(book)
+ }
+ )
+ )
+}
+
+interface BookDetailModalParams {
+ book: Book.Book
+ onClose: () => void
+ onmount: () => void
+ onunmount: () => void
+}
+
+function bookDetailModal({ book, onClose, onmount, onunmount }: BookDetailModalParams): Html {
+ return Modal.view({
+ header: h('div',
+ h('div', { className: 'g-BookDetail__Title' }, `${book.title}, ${book.date}`),
+ book.subtitle && h('div', { className: 'g-BookDetail__Subtitle' }, book.subtitle)
+ ),
+ body: h('div',
+ { className: 'g-BookDetail' },
+ h('img', { src: book.cover }),
+ h('div',
+ h('dl',
+ metadata('Auteur', book.authors),
+ metadata('Genre', book.genres)
+ ),
+ book.summary && book.summary
+ .split('\n')
+ .map(str => str.trim())
+ .filter(str => str != '')
+ .map(str => h('p', str))
+ )
+ ),
+ onClose,
+ onmount,
+ onunmount
+ })
+}
+
+function metadata(term: string, descriptions: Array<string>): Html {
+ return h('div',
+ h('dt', term, descriptions.length > 1 && 's', ' :'),
+ h('dd', ' ', descriptions.join(', '))
+ )
+}
diff --git a/library/client/view/components/modal.ts b/library/client/view/components/modal.ts
new file mode 100644
index 0000000..5e845e1
--- /dev/null
+++ b/library/client/view/components/modal.ts
@@ -0,0 +1,38 @@
+import { h, Html } from 'lib/rx'
+
+interface Params {
+ header: Html
+ body: Html
+ onClose: () => void
+ onmount?: (element: Element) => void
+ onunmount?: (element: Element) => void
+}
+
+export function view({ header, body, onClose, onmount, onunmount }: Params): Html {
+ return h('div',
+ { className: 'g-Modal',
+ onclick: () => onClose(),
+ onmount: (element: Element) => onmount && onmount(element),
+ onunmount: (element: Element) => onunmount && onunmount(element)
+ },
+ h('div',
+ { className: 'g-Modal__Content',
+ onclick: (e: Event) => e.stopPropagation()
+ },
+ h('div',
+ { className: 'g-Modal__Header' },
+ header,
+ h('button',
+ { className: 'g-Modal__Close',
+ onclick: () => onClose()
+ },
+ '✕'
+ )
+ ),
+ h('div',
+ { className: 'g-Modal__Body' },
+ body
+ )
+ )
+ )
+}
diff --git a/library/client/view/filters.ts b/library/client/view/filters.ts
new file mode 100644
index 0000000..efe4115
--- /dev/null
+++ b/library/client/view/filters.ts
@@ -0,0 +1,90 @@
+import { h, Rx, Html } 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): Html {
+ 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): Html {
+ 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']
+ }
+}