diff options
Diffstat (limited to 'library/client/view')
-rw-r--r-- | library/client/view/books.ts | 116 | ||||
-rw-r--r-- | library/client/view/components/modal.ts | 38 | ||||
-rw-r--r-- | library/client/view/filters.ts | 90 |
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'] + } +} |