aboutsummaryrefslogtreecommitdiff
path: root/library
diff options
context:
space:
mode:
authorJoris2023-09-17 12:23:47 +0200
committerJoris2023-09-17 12:23:47 +0200
commit1ebc55c72a1a17293bbf4ad86e0177a10a794750 (patch)
tree5fce0ea3a011ccbae85b0d3927f8ac33099585fb /library
parentc236facb4d4c277773c83f1a4ee85b48833d7e67 (diff)
Make app packageable
Diffstat (limited to 'library')
-rw-r--r--library/client/book.ts15
-rw-r--r--library/client/lib/functions.ts7
-rw-r--r--library/client/lib/i18n.ts14
-rw-r--r--library/client/lib/rx.ts404
-rw-r--r--library/client/lib/search.ts17
-rw-r--r--library/client/main.ts46
-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
-rw-r--r--library/public/index.html7
-rw-r--r--library/public/main.css235
-rw-r--r--library/tsconfig.json13
12 files changed, 1002 insertions, 0 deletions
diff --git a/library/client/book.ts b/library/client/book.ts
new file mode 100644
index 0000000..680cc11
--- /dev/null
+++ b/library/client/book.ts
@@ -0,0 +1,15 @@
+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/library/client/lib/functions.ts b/library/client/lib/functions.ts
new file mode 100644
index 0000000..21fdad9
--- /dev/null
+++ b/library/client/lib/functions.ts
@@ -0,0 +1,7 @@
+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/library/client/lib/i18n.ts b/library/client/lib/i18n.ts
new file mode 100644
index 0000000..3716367
--- /dev/null
+++ b/library/client/lib/i18n.ts
@@ -0,0 +1,14 @@
+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/library/client/lib/rx.ts b/library/client/lib/rx.ts
new file mode 100644
index 0000000..bf01b6d
--- /dev/null
+++ b/library/client/lib/rx.ts
@@ -0,0 +1,404 @@
+// [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/library/client/lib/search.ts b/library/client/lib/search.ts
new file mode 100644
index 0000000..026cb94
--- /dev/null
+++ b/library/client/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/library/client/main.ts b/library/client/main.ts
new file mode 100644
index 0000000..5885871
--- /dev/null
+++ b/library/client/main.ts
@@ -0,0 +1,46 @@
+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/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']
+ }
+}
diff --git a/library/public/index.html b/library/public/index.html
new file mode 100644
index 0000000..ce4d568
--- /dev/null
+++ b/library/public/index.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<meta charset="UTF-8">
+<link rel="stylesheet" href="main.css">
+<title>Bibliothèque</title>
+<body></body>
+<script src="books.js"></script>
+<script src="main.js"></script>
diff --git a/library/public/main.css b/library/public/main.css
new file mode 100644
index 0000000..f361cbe
--- /dev/null
+++ b/library/public/main.css
@@ -0,0 +1,235 @@
+/* Variables */
+
+:root {
+ --color-focus: #888833;
+
+ --font-size-dog: 1.5rem;
+
+ --spacing-mouse: 0.25rem;
+ --spacing-cat: 0.5rem;
+ --spacing-dog: 1rem;
+ --spacing-horse: 2rem;
+
+ --width-close-button: 3rem;
+ --width-book: 13rem;
+}
+
+/* Top level */
+
+html {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ height: 100%;
+ font-family: sans-serif;
+}
+
+dl {
+ margin: 0;
+}
+
+dd {
+ margin-left: 0;
+}
+
+p {
+ margin: 0;
+}
+
+/* Modal */
+
+.g-Modal {
+ width: 100vw;
+ height: 100vh;
+ position: fixed;
+ top: 0;
+ left: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 1;
+}
+
+.g-Modal__Content {
+ position: relative;
+ background-color: white;
+ width: 50%;
+ border-radius: 0.2rem;
+ border: 1px solid #EEE;
+}
+
+.g-Modal__Header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-dog) var(--spacing-horse);
+ border-bottom: 1px solid #EEE;
+}
+
+.g-Modal__Body {
+ padding: var(--spacing-horse);
+ max-height: 50vh;
+ overflow-y: scroll;
+}
+
+.g-Modal__Close {
+ cursor: pointer;
+ font-weight: bold;
+ border-radius: 50%;
+ border: 1px solid #EEE;
+ background-color: transparent;
+ width: var(--width-close-button);
+ height: var(--width-close-button);
+ font-size: 1.7rem;
+ flex-shrink: 0;
+}
+
+.g-Modal__Close:hover, .g-Modal__Close:focus {
+ background-color: #EEE;
+}
+
+/* Filters */
+
+aside {
+ background-color: #333;
+ color: white;
+ width: 200px;
+ overflow-y: auto;
+}
+
+ul {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+
+.g-FilterTitle {
+ padding: 0.5rem 1rem;
+ background-color: #555;
+ border-left: 8px solid transparent;
+ font-weight: bold;
+}
+
+.g-Filter--Unselected {
+ border-left: 8px solid #555;
+}
+
+.g-Filter--Selected {
+ border-left: 8px solid #883333;
+}
+
+.g-Filter button {
+ border: none;
+ background-color: transparent;
+ color: inherit;
+ cursor: pointer;
+ padding: 0.5rem 1rem;
+ width: 100%;
+ text-align: left;
+}
+
+.g-Filter button:hover {
+ background-color: var(--color-focus);
+}
+
+/* Books */
+
+main {
+ width: 100%;
+ padding: 1rem;
+ overflow-y: auto;
+}
+
+header {
+ display: flex;
+ font-size: 120%;
+ margin-bottom: 1rem;
+}
+
+.g-Search {
+ margin-right: 1rem;
+}
+
+.g-Books {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(var(--width-book), 1fr));
+ grid-gap: 1rem;
+}
+
+.g-Book {
+ align-self: center;
+ border: 1px solid #DDDDDD;
+ padding: 0;
+ width: fit-content;
+}
+
+.g-Book:hover {
+ cursor: pointer;
+ outline: none;
+}
+
+.g-Book:hover .g-Book__Image {
+ transform: scale(105%);
+ opacity: 0.9;
+}
+
+.g-Book__Image {
+ display: flex;
+ width: var(--width-book);
+ transition: all 0.2s ease-in-out;
+}
+
+/* Book detail */
+
+.g-BookDetail {
+ display: flex;
+ align-items: flex-start;
+}
+
+.g-BookDetail img {
+ width: var(--width-book);
+ margin-right: var(--spacing-horse);
+ border: 1px solid #EEE;
+}
+
+.g-BookDetail__Title {
+ font-size: var(--font-size-dog);
+ margin-bottom: var(--spacing-mouse);
+}
+
+.g-BookDetail__Subtitle {
+ font-style: italic;
+ margin-bottom: var(--spacing-mouse);
+}
+
+.g-BookDetail dl {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-cat);
+ margin-bottom: var(--spacing-dog);
+}
+
+.g-BookDetail dt {
+ display: inline;
+ text-decoration: underline;
+}
+
+.g-BookDetail dd {
+ display: inline;
+}
+
+.g-BookDetail p {
+ font-family: serif;
+ text-align: justify;
+ line-height: 1.4rem;
+ font-size: 1.1rem;
+ text-indent: 1.5rem;
+}
+
+.g-BookDetail p:not(:last-child) {
+ margin-bottom: var(--spacing-dog);
+}
diff --git a/library/tsconfig.json b/library/tsconfig.json
new file mode 100644
index 0000000..1e07c37
--- /dev/null
+++ b/library/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "module": "amd",
+ "target": "es2020",
+ "baseUrl": "client",
+ "outFile": "public/main.js",
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "removeComments": true,
+ "preserveConstEnums": true
+ },
+ "include": ["client/**/*"]
+}