diff options
author | Joris | 2023-09-17 12:23:47 +0200 |
---|---|---|
committer | Joris | 2023-09-17 12:23:47 +0200 |
commit | 1ebc55c72a1a17293bbf4ad86e0177a10a794750 (patch) | |
tree | 5fce0ea3a011ccbae85b0d3927f8ac33099585fb /library | |
parent | c236facb4d4c277773c83f1a4ee85b48833d7e67 (diff) |
Make app packageable
Diffstat (limited to 'library')
-rw-r--r-- | library/client/book.ts | 15 | ||||
-rw-r--r-- | library/client/lib/functions.ts | 7 | ||||
-rw-r--r-- | library/client/lib/i18n.ts | 14 | ||||
-rw-r--r-- | library/client/lib/rx.ts | 404 | ||||
-rw-r--r-- | library/client/lib/search.ts | 17 | ||||
-rw-r--r-- | library/client/main.ts | 46 | ||||
-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 | ||||
-rw-r--r-- | library/public/index.html | 7 | ||||
-rw-r--r-- | library/public/main.css | 235 | ||||
-rw-r--r-- | library/tsconfig.json | 13 |
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/**/*"] +} |