diff options
Diffstat (limited to 'src/view/client')
-rw-r--r-- | src/view/client/book.ts | 15 | ||||
-rw-r--r-- | src/view/client/lib/functions.ts | 7 | ||||
-rw-r--r-- | src/view/client/lib/i18n.ts | 14 | ||||
-rw-r--r-- | src/view/client/lib/rx.ts | 404 | ||||
-rw-r--r-- | src/view/client/lib/search.ts | 17 | ||||
-rw-r--r-- | src/view/client/main.ts | 46 | ||||
-rw-r--r-- | src/view/client/view/books.ts | 116 | ||||
-rw-r--r-- | src/view/client/view/components/modal.ts | 38 | ||||
-rw-r--r-- | src/view/client/view/filters.ts | 90 |
9 files changed, 0 insertions, 747 deletions
diff --git a/src/view/client/book.ts b/src/view/client/book.ts deleted file mode 100644 index 680cc11..0000000 --- a/src/view/client/book.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface Book { - title: string - subtitle?: string - authors: Array<string> - authorsSort: string - genres: Array<string> - date: number - summary?: 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/view/client/lib/functions.ts b/src/view/client/lib/functions.ts deleted file mode 100644 index 21fdad9..0000000 --- a/src/view/client/lib/functions.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function debounce(func: Function, timeout: number): any { - let timer: any - return (...args: any) => { - clearTimeout(timer) - timer = setTimeout(() => { func.apply(this, args) }, timeout) - } -} diff --git a/src/view/client/lib/i18n.ts b/src/view/client/lib/i18n.ts deleted file mode 100644 index 3716367..0000000 --- a/src/view/client/lib/i18n.ts +++ /dev/null @@ -1,14 +0,0 @@ -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/view/client/lib/rx.ts b/src/view/client/lib/rx.ts deleted file mode 100644 index bf01b6d..0000000 --- a/src/view/client/lib/rx.ts +++ /dev/null @@ -1,404 +0,0 @@ -// [1.0.1] 2023-02-13 - -// Html - -export type Html - = false - | undefined - | string - | number - | Tag - | WithVar<any> - | Array<Html> - | Rx<Html> - -interface Tag { - type: 'Tag' - tagName: string - attributes: Attributes - children?: Array<Html> - onmount?: (element: Element) => void - onunmount?: (element: Element) => void -} - -interface WithVar<A> { - type: 'WithVar' - init: A - getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Html -} - -interface Attributes { - [key: string]: Rx<AttributeValue> | AttributeValue -} - -type AttributeValue - = string - | number - | boolean - | ((event: Event) => void) - | ((element: Element) => void) - -function isHtml(x: any): x is Html { - return (typeof x === 'string' - || typeof x === 'number' - || isTag(x) - || isWithVar(x) - || isRx(x) - || Array.isArray(x)) -} - -type ValueOrArray<T> = T | Array<ValueOrArray<T>> - -export function h( - tagName: string, - x?: Attributes | Html, - ...children: Array<Html> -): Tag { - if (x === undefined || x === false) { - return { - type: 'Tag', - tagName, - attributes: {} - } - } else if (isHtml(x)) { - return { - type: 'Tag', - tagName, - attributes: {}, - children: [x, ...children], - } - } else { - let attributes = x as Attributes - let onmount, onunmount - if ('onmount' in attributes) { - onmount = attributes['onmount'] as (element: Element) => void - delete attributes['onmount'] - } - if ('onunmount' in attributes) { - onunmount = attributes['onunmount'] as (element: Element) => void - delete attributes['onunmount'] - } - return { - type: 'Tag', - tagName, - attributes, - children, - onmount, - onunmount - } - } -} - -export function withVar<A>(init: A, getChildren: (v: Var<A>, update: (f: (value: A) => A) => void) => Html): WithVar<A> { - return { - type: 'WithVar', - init, - getChildren - } -} - -// Rx - -export type RxAble<A> = Rx<A> | A - -export class Rx<A> { - map<B>(f: (value: A) => B): Rx<B> { - return new Map<A, B>(this, f) - } - - flatMap<B>(f: (value: A) => Rx<B>): Rx<B> { - return new FlatMap<A, B>(this, f) - } -} - -class Var<A> extends Rx<A> { - readonly type: 'Var' - readonly id: string - - constructor(id: string) { - super() - this.id = id - this.type = 'Var' - } -} - -class Map<A, B> extends Rx<B> { - readonly type: 'Map' - readonly rx: Rx<A> - readonly f: (value: A) => B - - constructor(rx: Rx<A>, f: (value: A) => B) { - super() - this.type = 'Map' - this.rx = rx - this.f = f - } -} - -class FlatMap<A, B> extends Rx<B> { - readonly type: 'FlatMap' - readonly rx: Rx<A> - readonly f: (value: A) => Rx<B> - - constructor(rx: Rx<A>, f: (value: A) => Rx<B>) { - super() - this.type = 'FlatMap' - this.rx = rx - this.f = f - } -} - -// Mount - -export function mount(html: Html): Cancelable { - const state = new State() - let appendRes = appendChild(state, document.body, html) - return appendRes.cancel -} - -interface StateEntry<A> { - value: A - subscribers: Array<(value: A) => void> -} - -class State { - readonly state: {[key: string]: StateEntry<any>} - varCounter: bigint - - constructor() { - this.state = {} - this.varCounter = BigInt(0) - } - - register<A>(initValue: A) { - const v = new Var(this.varCounter.toString()) - this.varCounter += BigInt(1) - this.state[v.id] = { - value: initValue, - subscribers: [] - } - return v - } - - unregister<A>(v: Var<A>) { - delete this.state[v.id] - } - - get<A>(v: Var<A>) { - return this.state[v.id].value - } - - update<A>(v: Var<A>, f: (value: A) => A) { - const value = f(this.state[v.id].value) - this.state[v.id].value = value - this.state[v.id].subscribers.forEach(notify => { - // Don’t notify if it has been removed from a precedent notifier - if (this.state[v.id].subscribers.indexOf(notify) !== -1) { - notify(value) - } - }) - } - - subscribe<A>(v: Var<A>, notify: (value: A) => void): Cancelable { - this.state[v.id].subscribers.push(notify) - return () => this.state[v.id].subscribers = this.state[v.id].subscribers.filter(n => n !== notify) - } -} - -// Cancelable - -type Cancelable = () => void - -const voidCancel = () => {} - -// Removable - -type Removable = () => void - -const voidRemove = () => {} - -// Rx run - -function rxRun<A>(state: State, rx: Rx<A>, effect: (value: A) => void): Cancelable { - if (isVar(rx)) { - const cancel = state.subscribe(rx, effect) - effect(state.get(rx)) - return cancel - } else if (isMap<A, any>(rx)) { - return rxRun(state, rx.rx, value => effect(rx.f(value))) - } else if (isFlatMap(rx)) { - let cancel1 = voidCancel - const cancel2 = rxRun(state, rx.rx, (value: A) => { - cancel1() - cancel1 = rxRun(state, rx.f(value), effect) - }) - return () => { - cancel2() - cancel1() - } - } else { - throw new Error(`Unrecognized rx: ${rx}`) - } -} - -function isRx<A>(x: any): x is Rx<A> { - return x !== undefined && x.type !== undefined && (x.type === "Var" || x.type === "Map" || x.type === "FlatMap") -} - -function isVar<A>(x: any): x is Var<A> { - return x.type === "Var" -} - -function isMap<A, B>(x: any): x is Map<A, B> { - return x.type === "Map" -} - -function isFlatMap<A, B>(x: any): x is FlatMap<A, B> { - return x.type === "FlatMap" -} - -// Append - -interface AppendResult { - cancel: Cancelable - remove: Removable - lastAdded?: Node -} - -function appendChild(state: State, element: Element, child: Html, lastAdded?: Node): AppendResult { - if (Array.isArray(child)) { - let cancels: Array<Cancelable> = [] - let removes: Array<Removable> = [] - child.forEach((o) => { - const appendResult = appendChild(state, element, o, lastAdded) - cancels.push(appendResult.cancel) - removes.push(appendResult.remove) - lastAdded = appendResult.lastAdded - }) - return { - cancel: () => cancels.forEach((o) => o()), - remove: () => removes.forEach((o) => o()), - lastAdded - } - } else if (typeof child == "string") { - const node = document.createTextNode(child) - appendNode(element, node, lastAdded) - return { - cancel: voidCancel, - remove: () => element.removeChild(node), - lastAdded: node - } - } else if (typeof child == "number") { - return appendChild(state, element, child.toString(), lastAdded) - } else if (isTag(child)) { - const { tagName, attributes, children, onmount, onunmount } = child - - const childElement = document.createElement(tagName) - const cancelAttributes = Object.entries(attributes).map(([key, value]) => { - if (isRx<AttributeValue>(value)) { - return rxRun(state, value, newValue => setAttribute(state, childElement, key, newValue)) - } else { - setAttribute(state, childElement, key, value) - } - }) - - const appendChildrenRes = appendChild(state, childElement, children) - - appendNode(element, childElement, lastAdded) - - if (onmount !== undefined) { - onmount(childElement) - } - - return { - cancel: () => { - cancelAttributes.forEach(cancel => cancel !== undefined ? cancel() : {}) - appendChildrenRes.cancel() - if (onunmount !== undefined) { - onunmount(childElement) - } - }, - remove: () => element.removeChild(childElement), - lastAdded: childElement, - } - } else if (isWithVar(child)) { - const { init, getChildren } = child - const v = state.register(init) - const children = getChildren(v, f => state.update(v, f)) - const appendRes = appendChild(state, element, children) - return { - cancel: () => { - appendRes.cancel() - state.unregister(v) - }, - remove: () => appendRes.remove(), - lastAdded: appendRes.lastAdded - } - } else if (isRx(child)) { - const rxBase = document.createTextNode('') - appendNode(element, rxBase, lastAdded) - let appendRes: AppendResult = { - cancel: voidCancel, - remove: voidRemove, - lastAdded: rxBase - } - const cancelRx = rxRun(state, child, (value: Html) => { - appendRes.cancel() - appendRes.remove() - appendRes = appendChild(state, element, value, rxBase) - }) - return { - cancel: () => { - appendRes.cancel() - cancelRx() - }, - remove: () => { - appendRes.remove() - element.removeChild(rxBase) - }, - lastAdded: appendRes.lastAdded, - } - } else if (child === undefined || child === false) { - return { - cancel: voidCancel, - remove: voidRemove, - lastAdded - } - } else { - throw new Error(`Unrecognized child: ${child}`) - } -} - -function isTag<A>(x: any): x is Tag { - return x !== undefined && x.type === "Tag" -} - -function isWithVar<A>(x: any): x is WithVar<A> { - return x !== undefined && x.type === "WithVar" -} - -function appendNode(base: Element, node: Node, lastAdded?: Node) { - if (lastAdded !== undefined) { - base.insertBefore(node, lastAdded.nextSibling) - } else { - base.append(node) - } -} - -function setAttribute(state: State, element: Element, key: string, attribute: AttributeValue) { - if (typeof attribute == "boolean") { - if (attribute) { - // @ts-ignore - element[key] = "true" - } - } else if (typeof attribute == "number") { - // @ts-ignore - element[key] = attribute.toString() - } else if (typeof attribute == "string") { - // @ts-ignore - element[key] = attribute - } else { - // @ts-ignore - element[key] = (event: Event) => attribute(event) - } -} diff --git a/src/view/client/lib/search.ts b/src/view/client/lib/search.ts deleted file mode 100644 index 026cb94..0000000 --- a/src/view/client/lib/search.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/view/client/main.ts b/src/view/client/main.ts deleted file mode 100644 index 5885871..0000000 --- a/src/view/client/main.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { h, withVar, mount, Html } 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 Books from 'view/books' -import * as Book from 'book' - -// @ts-ignore -const sortedBookLibrary: Array<Book> = (bookLibrary as Array<Book.Book>).sort((a, b) => - a.authorsSort == b.authorsSort - ? a.date > b.date - : a.authorsSort > b.authorsSort) - -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)) - ) - )) - - 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')) - ), - Books.view(filteredBooks) - ) - ] - }) -)) - -function matchSearch(book: Book.Book, search: string): boolean { - return Search.match(search, book.title, book.subtitle, ...book.authors) -} diff --git a/src/view/client/view/books.ts b/src/view/client/view/books.ts deleted file mode 100644 index aba55c1..0000000 --- a/src/view/client/view/books.ts +++ /dev/null @@ -1,116 +0,0 @@ -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/src/view/client/view/components/modal.ts b/src/view/client/view/components/modal.ts deleted file mode 100644 index 5e845e1..0000000 --- a/src/view/client/view/components/modal.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/src/view/client/view/filters.ts b/src/view/client/view/filters.ts deleted file mode 100644 index efe4115..0000000 --- a/src/view/client/view/filters.ts +++ /dev/null @@ -1,90 +0,0 @@ -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'] - } -} |