diff --git a/openlibrary/components/lit/OlBookActions.js b/openlibrary/components/lit/OlBookActions.js new file mode 100644 index 00000000000..5ba72f6dd8f --- /dev/null +++ b/openlibrary/components/lit/OlBookActions.js @@ -0,0 +1,1309 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; +import './OlIcon.js'; +import { SHELF, SHELF_LABEL, SHELF_EVENT, setShelf, setRating, setCheckIn, redirectToLogin } from './utils/books-api.js'; +import { getLists, subscribeToLists, loadLists, toggleListSeed, createUserList } from './utils/lists-store.js'; +import { MONTHS, formatReadDate, quickYears, partialDate } from './utils/dates.js'; +import { showToast } from './OlToastRegion.js'; +import { trackEvent } from '../../plugins/openlibrary/js/ol.analytics.js'; +import { translate } from './utils/labels.js'; +import './OlPopover.js'; +import './OLButton.js'; + +export const DEFAULT_LABELS = { + actionsFor: 'Actions for %(title)s', + wantToRead: 'Want to Read', + currentlyReading: 'Currently Reading', + alreadyRead: 'Already Read', + stoppedReading: 'Stopped Reading', + removeFromShelf: 'Remove from shelf', + rateThisBook: 'Rate this book', + rateStar: 'Rate %(rating)s of 5', + clearRating: 'Clear rating', + addToList: 'Add to list', + back: 'Back', + createList: 'Create a list', + listName: 'List name', + create: 'Create', + filterLists: 'Filter lists…', + noLists: 'You have no lists yet.', + noMatchingLists: 'No lists match.', + loadingLists: 'Loading lists…', + itemsInList: '%(count)s items', + inLists: 'In %(count)s of your lists', + errorGeneric: 'Something went wrong. Please try again.', + whenFinished: 'When did you finish this book?', + today: 'Today', + inYear: 'In %(year)s', + otherDate: 'Other date', + year: 'Year', + month: 'Month', + day: 'Day', + saveDate: 'Save', +}; + +const SHELF_ICON = { + [SHELF.WANT_TO_READ]: 'bookmark', + [SHELF.CURRENTLY_READING]: 'book-open', + [SHELF.ALREADY_READ]: 'circle-check', + [SHELF.STOPPED_READING]: 'circle-pause', +}; + +const SHELF_ROWS = Object.values(SHELF).map((id) => ({ id, icon: SHELF_ICON[id], label: SHELF_LABEL[id] })); + +/** + * The panes in the track, in order. The track's width and slide are both + * derived from this, so a new pane is an entry here plus a `_render*`. + */ +const PANES = ['main', 'lists', 'checkIn']; + +/** + * Per-book action popover: reading-log shelves, a star rating, and an + * "Add to list" pane that slides in from the right. Composes `` + * for the shell; the caller supplies the trigger. + * + * Only for logged-in users — the caller sends logged-out visitors to login + * instead of rendering this. State is optimistic: the UI updates first and + * an error toast rolls back. + * + * @element ol-book-actions + * + * @prop {Object} book - `{ key, title, firstPublishYear?, editionKey? }` + * @prop {Number} shelf - Current shelf id (1–4) or null + * @prop {Number} rating - Current rating (1–5) or null + * @prop {String} readDate - The check-in date, whole or partial ("2026", + * "2026-08", "2026-08-22"), or null when the reader has not given one + * @prop {Number} eventId - Id of that check-in, so changing the date edits it + * rather than recording a second finish + * @prop {String} userKey - "/people/", needed to create lists + * @prop {Object} labels - Translated strings (see DEFAULT_LABELS) + * @prop {String} placement - ol-popover placement; unset uses its default + * + * @fires ol-book-state-change - After a shelf or rating change is accepted by + * the server. detail: { key, shelf, rating } + * @fires ol-book-check-in - After a finish date is accepted by the server. The + * component keeps its own copy (`readDate`/`eventId`); the event is for the + * surface to persist it across renders. detail: { key, date, eventId } — + * `date` is whole or partial, as stored. + * @fires ol-list-created - After the inline form creates a list. Sibling + * popovers share the lists store and need no event; this is for surfaces + * outside the components. detail: { key, name, seedKey } + * + * @slot trigger - The button that opens the popover. + */ +export class OlBookActions extends LitElement { + static properties = { + book: { type: Object }, + shelf: { type: Number }, + rating: { type: Number }, + readDate: { type: String, attribute: 'read-date' }, + eventId: { type: Number, attribute: 'event-id' }, + userKey: { type: String, attribute: 'user-key' }, + labels: { type: Object }, + placement: { type: String }, + hideRating: { type: Boolean, attribute: 'hide-rating' }, + _pane: { state: true }, + _snap: { state: true }, + _trackHeight: { state: true }, + _listsLoading: { state: true }, + _listsFailed: { state: true }, + _listFilter: { state: true }, + _creating: { state: true }, + _createBusy: { state: true }, + _hoverRating: { state: true }, + _busy: { state: true }, + _pickingDate: { state: true }, + _dateBusy: { state: true }, + _date: { state: true }, + }; + + static styles = css` + :host { + display: inline-flex; + font-family: var(--font-family-body); + } + + .panel { + /* A fixed measure: the popover shrink-wraps its content, and the + title would otherwise size the panel per book. */ + width: 300px; + /* One height for every row, so the panel never shifts as rows + re-render (the rating caption swaps between a span and a button). */ + --_row-height: calc(var(--font-size-body-medium) * var(--line-height-body) + 2 * var(--spacing-inset-sm)); + /* Keeps the first and last rows off the rounded corners. */ + padding-block: var(--spacing-inset-xs); + color: var(--color-text); + font-size: var(--font-size-body-medium); + /* clip, not hidden: focusing the off-screen pane must not scroll + the panel (that would double up with the track's translate). */ + overflow: clip; + border-radius: var(--border-radius-overlay); + } + + /* ol-popover becomes a full-bleed bottom tray here (keep in sync with + its 767px breakpoint), so fill it instead of leaving a dead strip. */ + @media (max-width: 767px) { + .panel { + width: 100%; + } + } + + /* Metadata, not a title bar: quiet enough that the rows below it read + as the actionable part of the panel. */ + .header { + position: relative; + padding: var(--spacing-inset-sm) var(--spacing-inset-md); + color: var(--color-text-secondary); + font-size: var(--font-size-label-medium); + } + + /* Clamped as one block so the year wraps with the title. */ + .header .heading { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + } + + /* Inset rules read as separators inside the surface; a full-bleed one + reads as a panel edge. */ + .header::after, + .lists-header::after, + .pane-header::after, + .group.rating::before, + .group.lists-entry::before { + content: ""; + position: absolute; + inset-inline: var(--spacing-inset-md); + height: 1px; + background: var(--color-border-subtle); + } + + .header::after, + .lists-header::after, + .pane-header::after { + bottom: 0; + } + + /* Panes side by side in a track --pane-count panels wide; the track + slides to bring one into view. Its height is set inline to the active + pane's height (measured), so the panel doesn't stretch to the tallest + one. Both the width and the slide come from --pane-count, so adding a + pane to PANES is the whole change. */ + .track { + display: flex; + align-items: flex-start; + width: calc(100% * var(--pane-count)); + transition: + transform 220ms cubic-bezier(0.165, 0.84, 0.44, 1), + height 220ms cubic-bezier(0.165, 0.84, 0.44, 1); + } + + /* Reset to the main pane on close without a visible slide. */ + .track.snap { + transition: none; + } + + @media (prefers-reduced-motion: reduce) { + .track { + transition: none; + } + } + + .pane { + width: calc(100% / var(--pane-count)); + flex: 0 0 calc(100% / var(--pane-count)); + box-sizing: border-box; + } + + /* Off-screen pane must not be reachable */ + .pane[inert] { + visibility: hidden; + } + + .group { + display: flex; + flex-direction: column; + padding: var(--spacing-inset-xs) 0; + } + + .group.rating, + .group.lists-entry { + position: relative; + } + + .group.rating::before, + .group.lists-entry::before { + top: 0; + } + + /* Inset so the hover fill reads as a pill inside the panel rather + than a band running to its edges; the padding gives back what the + margin takes, so the icon column stays put. */ + .row { + display: flex; + align-items: center; + gap: var(--spacing-inline-md); + box-sizing: border-box; + min-height: var(--_row-height); + margin: 0; + margin-inline: var(--spacing-inset-xs); + padding-block: var(--spacing-inset-sm); + padding-inline: calc(var(--spacing-inset-md) - var(--spacing-inset-xs)); + border-radius: var(--border-radius-button); + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + text-decoration: none; + } + + .row .obd-icon { + width: 20px; + height: 20px; + flex: 0 0 20px; + color: var(--color-icon-muted); + } + + .row .label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .row .trail { + width: 16px; + height: 16px; + color: var(--color-icon-muted); + } + + @media (hover: hover) and (pointer: fine) { + .row:hover { + background: var(--color-hover-overlay); + } + } + + /* Press feedback, the same tactile squeeze gives: colour + changes are instant, only the scale animates. A row has no resting + fill, so the press paints one — on touch, where :hover never runs, + there would otherwise be nothing to squeeze. */ + .row, + .list-row { + transition: transform 0.08s; + } + + .row:active, + .list-row:active { + background: var(--color-hover-overlay); + transform: scale(0.97); + } + + /* Except the shelf rows: clicking one re-renders it — label weight and + colour change, a check mark appears — and re-laying out mid-scale + reads as a flicker. They keep the press fill, not the squeeze. */ + .group.shelves .row { + transition: none; + } + + .group.shelves .row:active { + transform: none; + } + + .row:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: -2px; + } + + .row[aria-checked="true"] { + color: var(--color-link); + font-weight: 600; + } + + .row[aria-checked="true"] .obd-icon { + color: var(--color-link); + } + + .row[disabled] { + cursor: default; + opacity: 0.6; + } + + /* Stars */ + .stars { + display: flex; + align-items: center; + gap: var(--spacing-inline-md); + box-sizing: border-box; + height: var(--_row-height); + padding: 0 var(--spacing-inset-md); + } + + .star-buttons, + .stars .caption { + line-height: 1; + } + + .star-buttons { + display: inline-flex; + } + + .star { + padding: 0; + border: 0; + background: none; + cursor: pointer; + line-height: 0; + } + + /* Sized to the row icons, and only the filled stars carry the gold — + an all-gold band outweighs the shelves above it. */ + .star .obd-icon { + width: 20px; + height: 20px; + color: var(--color-icon-muted); + --ol-icon-stroke-width: 1.5; + } + + .star .obd-icon[filled] { + color: var(--gold); + } + + /* Icon-only, so 3% would be sub-pixel — presses its icon + shapes harder for the same reason. */ + .star { + transition: transform 0.08s; + } + + .star:active { + transform: scale(0.93); + } + + .star:focus-visible { + outline: 2px solid var(--color-focus-ring); + border-radius: var(--border-radius-sm); + } + + .stars .caption { + /* The check-in pane's .caption padding would push this 16px + further from the stars than the Clear-rating button sits. */ + padding: 0; + color: var(--color-text-secondary); + font-size: var(--font-size-label-medium); + } + + .stars .clear { + padding: 0; + border: 0; + background: none; + color: var(--color-text-secondary); + font: inherit; + font-size: var(--font-size-label-medium); + cursor: pointer; + } + + .stars .clear:hover { + color: var(--color-text); + text-decoration: underline; + } + + .stars .clear:focus-visible { + outline: 2px solid var(--color-focus-ring); + border-radius: var(--border-radius-sm); + } + + /* Check-in pane */ + + /* The question, not a heading: the rows under it are the answer. */ + .caption { + padding: var(--spacing-inset-sm) var(--spacing-inset-md); + color: var(--color-text-secondary); + font-size: var(--font-size-label-medium); + } + + /* A disclosure, not a link onwards: the chevron points down at the + fields the row opens and flips once they are showing. */ + .date-toggle .trail { + transition: transform 180ms cubic-bezier(0.165, 0.84, 0.44, 1); + } + + .date-toggle[aria-expanded='true'] .trail { + transform: rotate(180deg); + } + + @media (prefers-reduced-motion: reduce) { + .date-toggle .trail { + transition: none; + } + } + + /* Sits directly under the row that opened it, so the gap reads as a + seam between row and fields rather than a new section. */ + .date-form { + padding-top: var(--spacing-inset-xs); + } + + /* Three selects on one line only fit at small size — the same height + and radius the small web-component controls use. */ + .date-fields { + display: flex; + gap: var(--spacing-inline-sm); + padding: 0 var(--spacing-inset-md) var(--spacing-inset-sm); + } + + .select { + flex: 1; + min-width: 0; + height: var(--control-height-small); + box-sizing: border-box; + padding: 0 var(--spacing-inset-xs); + border: var(--border-input); + border-radius: var(--border-radius-input); + background: var(--color-surface); + color: inherit; + font: inherit; + font-size: var(--font-size-label-medium); + } + + /* Year is the only one that is always meaningful, so it gets the room. */ + .select.year { + flex: 0 0 84px; + } + + .select:focus { + outline: 2px solid var(--color-focus-ring); + outline-offset: -1px; + } + + .select:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .date-actions { + display: flex; + justify-content: flex-end; + padding: 0 var(--spacing-inset-md) var(--spacing-inset-sm); + } + + /* Lists pane */ + + /* Header and field each hold a small control at most, and swap what + they show when creating a list; a fixed height keeps the list below + from jumping when they do. */ + .lists-header, + .pane-header, + .field { + box-sizing: border-box; + height: calc(var(--control-height-small) + 2 * var(--spacing-inset-sm)); + } + + .lists-header, + .pane-header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-inline-sm); + padding: var(--spacing-inset-sm) var(--spacing-inset-md); + } + + .back { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-weight: 600; + cursor: pointer; + } + + .back .obd-icon { + width: 16px; + height: 16px; + } + + .field { + display: flex; + align-items: center; + gap: var(--spacing-inline-sm); + padding: 0 var(--spacing-inset-md); + margin-bottom: var(--spacing-stack-xs); + } + + .input { + flex: 1; + min-width: 0; + height: var(--control-height-small); + box-sizing: border-box; + padding: 0 var(--spacing-inset-sm); + border: var(--border-input); + border-radius: var(--border-radius-input); + background: var(--color-surface); + color: inherit; + font: inherit; + } + + .input:focus { + outline: 2px solid var(--color-focus-ring); + outline-offset: -1px; + } + + .list-items { + max-height: 240px; + overflow-y: auto; + padding-bottom: var(--spacing-inset-xs); + } + + .list-row { + display: flex; + align-items: center; + gap: var(--spacing-inline-md); + margin-inline: var(--spacing-inset-xs); + padding-block: var(--spacing-inset-sm); + padding-inline: calc(var(--spacing-inset-md) - var(--spacing-inset-xs)); + border-radius: var(--border-radius-button); + cursor: pointer; + } + + @media (hover: hover) and (pointer: fine) { + .list-row:hover { + background: var(--color-hover-overlay); + } + } + + /* 16px like the other popover controls, but sitting in a 20px slot so + it lines up with the main pane's row icons — one row height, one + label column across both panes. */ + .list-row input { + width: 16px; + height: 16px; + margin-inline: 2px; + accent-color: var(--color-primary); + flex: 0 0 auto; + } + + .list-row .name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .count { + color: var(--color-text-secondary); + font-size: var(--font-size-label-medium); + } + + .empty, + .loading { + display: flex; + align-items: center; + gap: var(--spacing-inline-sm); + padding: var(--spacing-inset-md); + color: var(--color-text-secondary); + font-size: var(--font-size-label-medium); + } + + .spinner { + width: 18px; + height: 18px; + animation: spin 0.9s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + @media (prefers-reduced-motion: reduce) { + .spinner { + animation-duration: 3s; + } + } + `; + + constructor() { + super(); + this.book = null; + this.shelf = null; + this.rating = null; + this.userKey = ''; + this.labels = {}; + this.hideRating = false; + this._warm = false; + // Capture-phase, so the panes exist before ol-popover's own trigger + // handling measures the panel. + this.addEventListener('click', this._warmUp, true); + this._pane = 'main'; + this._snap = false; + this._trackHeight = 0; + this._listsLoading = false; + this._listsFailed = false; + this._listFilter = ''; + this._creating = false; + this._createBusy = false; + this._hoverRating = 0; + this._busy = false; + this._pickingDate = false; + this._dateBusy = false; + this._date = { year: '', month: '', day: '' }; + } + + get _paneIndex() { + return PANES.indexOf(this._pane); + } + + /** + * Build the pane DOM on the first click that could open the popover. + * Every book on a page carries one of these, so the three panes and their + * dozens of nested elements are only built for popovers someone opens. + * The update must be synchronous: ol-popover positions itself from the + * panel's measured size before it fires `ol-popover-open`. + */ + _warmUp = () => { + if (this._warm) return; + this._warm = true; + this.requestUpdate(); + this.performUpdate(); + }; + + /** @param {string} name */ + _renderPane(name) { + if (name === 'lists') return this._renderLists(); + if (name === 'checkIn') return this._renderCheckIn(); + return this._renderMain(); + } + + /** @param {string} key */ + t(key, vars) { + return translate(this.labels, DEFAULT_LABELS, key, vars); + } + + get _seedKey() { + return this.book?.key || ''; + } + + render() { + if (!this.book) return html``; + const title = this.book.title || ''; + const track = { + '--pane-count': String(PANES.length), + transform: `translateX(${(-100 / PANES.length) * this._paneIndex}%)`, + height: this._trackHeight ? `${this._trackHeight}px` : null, + }; + return html` + + +
+ ${this._warm ? html` +
+ ${PANES.map(name => html` +
${this._renderPane(name)}
+ `)} +
+ ` : nothing} +
+
+ `; + } + + _renderMain() { + const year = this.book.firstPublishYear; + return html` +
+ ${this.book.title}${year ? ` (${year})` : ''} +
+
+ ${SHELF_ROWS.map(row => html` + + `)} + ${this.shelf ? html` + + ` : nothing} +
+ ${this.hideRating ? nothing : html` +
+ ${this._renderStars()} +
+ `} +
+ +
+ `; + } + + /** + * The end of a shelf row. Already Read carries the date it holds and a + * chevron, because it leads to the date pane; the others only mark the + * shelf the book is on. + */ + _renderShelfTrail(row) { + if (row.id === SHELF.ALREADY_READ) { + return html` + ${this.readDate ? html`${formatReadDate(this.readDate)}` : nothing} + + `; + } + return this.shelf === row.id ? html`` : nothing; + } + + _renderStars() { + const shown = this._hoverRating || this.rating || 0; + // Once rated, the caption becomes an actionable "Clear rating" link. + const caption = this.rating + ? html`` + : html`${this.t('rateThisBook')}`; + return html` +
+ { this._hoverRating = 0; }}> + ${[1, 2, 3, 4, 5].map(n => html` + + `)} + + ${caption} +
+ `; + } + + /** + * Which row the recorded date is, so the pane shows the answer it already + * holds instead of reading as unanswered. Anything that is neither exactly + * today nor one of the offered years — a partial date included — belongs + * to "Other date". + */ + get _answeredBy() { + if (!this.readDate) return null; + const now = new Date(); + if (this.readDate === partialDate({ year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() })) return 'today'; + if (quickYears(now).some(y => this.readDate === String(y))) return this.readDate; + return 'other'; + } + + /** + * Asked straight after the reader marks a book read. Two one-tap answers + * cover most cases; "Other date" discloses the selects underneath itself + * rather than replacing the rows or taking a fourth pane, so the two quick + * answers stay one tap away and the row you pressed stays on screen as the + * anchor. The track measures the pane, so the growth animates for free. + * + * A year on its own is a valid check-in, which is what makes "In 2026" + * offerable at all. + */ + _renderCheckIn() { + const answered = this._answeredBy; + return html` +
+ +
+
${this.t('whenFinished')}
+
+ + ${quickYears().map(year => html` + + `)} + +
+ ${this._pickingDate ? this._renderDateFields() : nothing} + `; + } + + /** Year → month → day, each enabled by the one before it. */ + _renderDateFields() { + const { year, month, day } = this._date; + const thisYear = new Date().getFullYear(); + const years = Array.from({ length: 121 }, (_, i) => thisYear - i); + const days = month ? new Date(Number(year), Number(month), 0).getDate() : 31; + return html` +
{ if (e.key === 'Escape') { e.stopPropagation(); this._toggleDatePicker(); } }} + > + +
+ + + +
+
+ ${this.t('saveDate')} +
+
+ `; + } + + _renderLists() { + return html` +
+ + ${this._creating ? nothing : html` + + ${this.t('createList')} + + `} +
+ ${this._creating ? html` +
+ { if (e.key === 'Escape') { e.stopPropagation(); this._cancelCreate(); } }} + /> + ${this.t('create')} +
+ ` : html` +
+ { this._listFilter = e.target.value; }} + /> +
+ `} +
${this._renderListItems()}
+ `; + } + + _renderListItems() { + const lists = getLists(); + if (this._listsLoading || (lists === null && !this._listsFailed)) { + return html`
${this.t('loadingLists')}
`; + } + const entries = Object.entries(lists || {}); + if (!entries.length) return html`
${this.t('noLists')}
`; + const filter = this._listFilter.trim().toLowerCase(); + const shown = filter ? entries.filter(([, l]) => l.listName.toLowerCase().includes(filter)) : entries; + if (!shown.length) return html`
${this.t('noMatchingLists')}
`; + // Filtering shuffles which list sits at each index, so key the rows + // to keep Lit from rebuilding them; the other lists in this file are + // static and fine with index reconciliation. + return repeat(shown, ([key]) => key, ([key, list]) => { + const checked = list.members.includes(this._seedKey); + return html` + + `; + }); + } + + updated(changed) { + // Panes only exist once `book` is set, so observe them lazily. + if (!this._resizeObserver) { + const panes = this.shadowRoot.querySelectorAll('.pane'); + if (panes.length) { + this._resizeObserver = new ResizeObserver(() => this._syncTrackHeight()); + panes.forEach(pane => this._resizeObserver.observe(pane)); + } + } + if (changed.has('_pane')) this._syncTrackHeight(); + } + + connectedCallback() { + super.connectedCallback(); + // The store notifies on every lists change, wherever it was made, so + // a create or toggle in a sibling popover re-renders this one too. + this._unsubscribeLists = subscribeToLists(() => this.requestUpdate()); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._resizeObserver?.disconnect(); + this._unsubscribeLists?.(); + } + + /** Size the track to the active pane so the panel doesn't stretch to the taller one. */ + _syncTrackHeight() { + const pane = this.shadowRoot.querySelector(`.pane:nth-child(${this._paneIndex + 1})`); + // 0 means the popover is hidden; keep the last real height. + if (pane?.offsetHeight) this._trackHeight = pane.offsetHeight; + } + + // ── Popover lifecycle ──────────────────────────────────── + + _onOpen() { + this._warmUp(); // for opens that arrive without a click + this._pane = 'main'; + this._snap = false; + this._creating = false; + this._pickingDate = false; + this._listFilter = ''; + // Prefetch so the "in N lists" count is right on the first open, not + // only after a trip to the lists pane. One request per page — every + // popover reads the shared lists store. + if (this.userKey) this._loadLists({ quiet: true }); + } + + _onCloseRequest(e) { + // Escape from a sub-pane goes back a step instead of closing. + if (e.detail?.reason === 'escape' && this._pane !== 'main') { + e.preventDefault(); + this._backToMain(); + return; + } + // Reset to the main pane now, so the next open doesn't slide back from + // the lists pane. `snap` skips the slide while the popover fades out. + this._snap = true; + this._pane = 'main'; + this._creating = false; + this._pickingDate = false; + } + + _emitState() { + this.dispatchEvent(new CustomEvent('ol-book-state-change', { + bubbles: true, + composed: true, + detail: { key: this.book.key, shelf: this.shelf, rating: this.rating }, + })); + } + + _fail(error) { + if (error?.status === 401) return redirectToLogin(); + showToast(this.t('errorGeneric'), { type: 'error' }); + } + + /** + * Applies `optimistic` to our own state, runs `action`, and puts every + * property it touched back if that throws — rolling back from a snapshot is + * what keeps a handler from restoring one property and forgetting another. + * The busy flag both disables the rows and drops a click that beats the + * re-render. + */ + async _mutate(optimistic, action) { + if (this._busy) return; + const snapshot = Object.fromEntries(Object.keys(optimistic).map(key => [key, this[key]])); + Object.assign(this, optimistic); + this._busy = true; + try { + await action(); + } catch (error) { + Object.assign(this, snapshot); + this._fail(error); + } finally { + this._busy = false; + } + } + + // ── Shelves ────────────────────────────────────────────── + + async _onShelfClick(shelfId) { + const previous = this.shelf; + // Already Read leads to the date pane — that is what its chevron says, + // and it is the only way to change a date once given. Coming off the + // shelf is the "Remove from shelf" row's job. + if (shelfId === SHELF.ALREADY_READ && previous === SHELF.ALREADY_READ) { + return this._openCheckIn(); + } + return this._postShelf(shelfId); + } + + /** Takes the book off whichever shelf it is on. Also what the main button does. */ + _removeFromShelf() { + if (this.shelf) return this._postShelf(this.shelf); + } + + /** Posting the current shelf toggles it off server-side; any other shelf moves the book. */ + async _postShelf(shelfId) { + const previous = this.shelf; + const removing = previous === shelfId; + return this._mutate({ shelf: removing ? null : shelfId }, async() => { + await setShelf(this.book.key, shelfId, { editionKey: this.book.editionKey }); + trackEvent('ReadingLog', SHELF_EVENT[removing ? null : shelfId]); + this._emitState(); + // Only on the way in, and only when they chose the shelf themselves: + // rating moves a book to Already Read too, and interrupting that + // would turn one tap into two. + if (!removing && shelfId === SHELF.ALREADY_READ && previous !== SHELF.ALREADY_READ) { + this._openCheckIn(); + } + }); + } + + // ── Rating ─────────────────────────────────────────────── + + async _onRate(n) { + const next = this.rating === n ? null : n; + // The server moves a rated book to Already Read. + const optimistic = next ? { rating: next, shelf: SHELF.ALREADY_READ } : { rating: next }; + return this._mutate(optimistic, async() => { + await setRating(this.book.key, next, { editionKey: this.book.editionKey }); + trackEvent('StarRating', next ? 'BookRated' : 'RatingCleared'); + this._emitState(); + }); + } + + // ── Check-in ───────────────────────────────────────────── + + async _openCheckIn() { + this._pane = 'checkIn'; + // A date the shortcuts cannot express would otherwise sit unseen + // behind a collapsed row, so the pane opens on it. Focus still lands + // on the first row: the reader is being shown their answer, not asked + // to retype it. + this._pickingDate = this._answeredBy === 'other'; + // Seeded from the date already given, so "Other date" opens on it + // rather than making the reader re-enter what they are amending. + const [year = '', month = '', day = ''] = (this.readDate || '').split('-'); + this._date = { year, month: month.replace(/^0/, ''), day: day.replace(/^0/, '') }; + await this.updateComplete; + this.shadowRoot.querySelector(`.pane:nth-child(${PANES.indexOf('checkIn') + 1}) .row`)?.focus({ preventScroll: true }); + } + + /** Focus follows the disclosure: into the selects, and back to the row on collapse. */ + async _toggleDatePicker() { + this._pickingDate = !this._pickingDate; + await this.updateComplete; + const target = this._pickingDate ? '.select.year' : '.date-toggle'; + this.shadowRoot.querySelector(target)?.focus({ preventScroll: true }); + } + + /** Clearing a coarser part clears the finer ones, which the selects disable. */ + _setDatePart(part, value) { + const next = { ...this._date, [part]: value }; + if (part === 'year' && !value) next.month = next.day = ''; + if (part === 'month') next.day = value ? next.day : ''; + this._date = next; + } + + _onToday() { + const now = new Date(); + return this._saveCheckIn({ year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate() }); + } + + _onYear(year) { + return this._saveCheckIn({ year }); + } + + _onSaveDate(e) { + e.preventDefault(); + const { year, month, day } = this._date; + if (!year) return; + return this._saveCheckIn({ + year: Number(year), + month: month ? Number(month) : null, + day: day ? Number(day) : null, + }); + } + + async _saveCheckIn(date) { + if (this._dateBusy) return; + this._dateBusy = true; + try { + const saved = await setCheckIn(this.book.key, { ...date, editionKey: this.book.editionKey, eventId: this.eventId }); + // Keep our own copy so the main pane's Already Read row shows the + // date, and re-saving amends this event instead of adding one. + this.readDate = partialDate(date); + this.eventId = saved?.id ?? this.eventId ?? null; + trackEvent('CheckInPrompt', date.day ? 'SetDateDay' : date.month ? 'SetDateMonth' : 'SetDateYear'); + this.dispatchEvent(new CustomEvent('ol-book-check-in', { + bubbles: true, + composed: true, + detail: { key: this.book.key, date: this.readDate, eventId: this.eventId }, + })); + this._backToMain(); + } catch (error) { + this._fail(error); + } finally { + this._dateBusy = false; + } + } + + // ── Lists ──────────────────────────────────────────────── + + async _openLists() { + this._pane = 'lists'; + await this.updateComplete; + this.shadowRoot.querySelector(`.pane:nth-child(${PANES.indexOf('lists') + 1}) .input`)?.focus({ preventScroll: true }); + this._loadLists(); + } + + async _backToMain() { + this._pane = 'main'; + this._creating = false; + this._pickingDate = false; + await this.updateComplete; + this.shadowRoot.querySelector('.pane:nth-child(1) .group:last-child .row')?.focus({ preventScroll: true }); + } + + /** How many of the user's (loaded) lists contain this book. */ + get _listCount() { + const lists = getLists(); + if (!lists) return 0; + return Object.values(lists).filter(l => l.members.includes(this._seedKey)).length; + } + + /** `quiet` is for the open-time prefetch: no toast, no login bounce. */ + async _loadLists({ quiet = false } = {}) { + if (getLists()) return; + this._listsLoading = true; + try { + await loadLists(); + this._listsFailed = false; + } catch (error) { + // A quiet failure keeps the pane on its spinner, so opening it + // retries and reports. + if (quiet) return; + this._listsFailed = true; + this._fail(error); + } finally { + this._listsLoading = false; + } + } + + async _onListToggle(listKey, checked) { + try { + // The store applies the change optimistically and rolls it back + // for us on failure. + await toggleListSeed(listKey, this._seedKey, checked); + trackEvent('Lists', checked ? 'AddSeed' : 'RemoveSeed'); + } catch (error) { + this._fail(error); + } + } + + async _startCreate() { + this._creating = true; + await this.updateComplete; + this.shadowRoot.querySelector('form.field .input')?.focus({ preventScroll: true }); + } + + async _cancelCreate() { + this._creating = false; + await this.updateComplete; + this.shadowRoot.querySelector('.field .input')?.focus({ preventScroll: true }); + } + + async _onCreateSubmit(e) { + e.preventDefault(); + const name = e.target.querySelector('input').value.trim(); + if (!name || this._createBusy) return; + this._createBusy = true; + try { + // The store prepends the new list, so every popover shows it first. + const key = await createUserList(this.userKey, name, this._seedKey); + trackEvent('Lists', 'CreateList'); + this._creating = false; + this.dispatchEvent(new CustomEvent('ol-list-created', { + bubbles: true, + composed: true, + detail: { key, name, seedKey: this._seedKey }, + })); + } catch (error) { + this._fail(error); + } finally { + this._createBusy = false; + } + } +} + +customElements.define('ol-book-actions', OlBookActions); diff --git a/openlibrary/components/lit/OlBookCover.js b/openlibrary/components/lit/OlBookCover.js new file mode 100644 index 00000000000..a4706427f28 --- /dev/null +++ b/openlibrary/components/lit/OlBookCover.js @@ -0,0 +1,212 @@ +import { LitElement, css, html, nothing } from 'lit'; +import { translate } from './utils/labels.js'; +import './OlTooltip.js'; + +export const DEFAULT_LABELS = { + by: 'by %(name)s', +}; + +/** + * A book cover at a fixed 2:3 ratio: the artwork when there is one, a generated + * title/author panel when there isn't, and a corner for whatever the surface + * wants to float over it. + * + * Knows nothing about shelves, availability or search — give it a URL and a + * title. `overlay` is the slot the save button goes in; the component owns the + * corner position so a consumer never has to. + * + * A pointer gets a hover card carrying the title, year and author. + * `ol-tooltip` arms on the same media query a cover-card layout uses to + * hide that text below the cover, so exactly one of the two shows. + * + * @element ol-book-cover + * + * @prop {String} src - Cover image URL; empty draws the generated blank cover + * @prop {String} bookTitle - The book's title. Named `book-title` because a + * `title` attribute would draw a native browser tooltip over the whole host + * @prop {String} authors - Author names, already joined for display + * @prop {String} year - First publication year, shown in the hover card + * @prop {String} href - Link target; empty renders the cover unlinked + * @prop {String} size - "medium" (default) or "small"; small drops the author + * from the blank cover, which has no room for it + * @prop {Object} labels - Translated strings, merged over DEFAULT_LABELS + * + * @slot overlay - Pinned to the cover's top-right corner, over the artwork + * + * @fires ol-book-cover-click - The cover link was clicked. detail: { href } + */ +export class OlBookCover extends LitElement { + static properties = { + src: { type: String }, + bookTitle: { type: String, attribute: 'book-title' }, + authors: { type: String }, + year: { type: String }, + href: { type: String }, + size: { type: String, reflect: true }, + labels: { type: Object }, + }; + + static styles = css` + :host { + position: relative; + display: block; + aspect-ratio: 2 / 3; + border-radius: var(--border-radius-thumbnail); + overflow: hidden; + background: var(--color-surface-sunken); + font-family: var(--font-family-body); + } + + .link { + display: block; + height: 100%; + } + + /* Wraps the cover link only, keeping the overlay out of the trigger area. */ + ol-tooltip { + display: block; + height: 100%; + } + + .img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + .blank { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + box-sizing: border-box; + padding: var(--spacing-inset-sm); + background: linear-gradient(160deg, var(--neutral-600), var(--neutral-800)); + color: var(--color-text-inverse); + text-align: center; + } + + .blank__title { + font-family: var(--font-family-heading); + font-size: var(--font-size-title-medium); + font-weight: 500; + line-height: var(--line-height-tight); + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + } + + /* A 72px cover has room for neither the padding nor the type of a + full-size one. */ + :host([size="small"]) .blank { + padding: var(--spacing-inset-xs); + } + + :host([size="small"]) .blank__title { + font-size: var(--font-size-label-medium); + } + + .blank__author { + font-size: var(--font-size-label-small); + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.85; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* The corner is the cover's to own. Whatever is slotted in goes static + inside it — a wrapper like takes the corner and its + own trigger sits inside that. */ + slot[name="overlay"]::slotted(*) { + position: absolute; + top: 4px; + right: 4px; + } + + /* Panel content: styled here because it is a light child of this + component; only the panel chrome comes from ol-tooltip. */ + .tip { + font-size: var(--font-size-body-medium); + } + + .tip__title { + font-weight: 600; + } + + .tip__year, + .tip__byline { + color: var(--neutral-300); + } + + .tip__byline { + font-size: var(--font-size-label-medium); + } + `; + + constructor() { + super(); + this.src = ''; + this.bookTitle = ''; + this.authors = ''; + this.year = ''; + this.href = ''; + this.size = 'medium'; + this.labels = {}; + } + + t(key, vars) { + return translate(this.labels, DEFAULT_LABELS, key, vars); + } + + get _alt() { + return this.authors ? `${this.bookTitle} ${this.t('by', { name: this.authors })}` : this.bookTitle; + } + + render() { + const art = this.href + ? html`${this._renderArt()}` + : this._renderArt(); + return html` + ${art}${this._renderTip()} + + `; + } + + _renderArt() { + if (this.src) { + return html`${this._alt}`; + } + return html` + + ${this.bookTitle} + ${this.authors && this.size !== 'small' ? html`${this.authors}` : nothing} + + `; + } + + _renderTip() { + return html` +
+
+ ${this.bookTitle} + ${this.year ? html`(${this.year})` : nothing} +
+ ${this.authors ? html`` : nothing} +
+ `; + } + + _onClick() { + this.dispatchEvent(new CustomEvent('ol-book-cover-click', { + bubbles: true, + composed: true, + detail: { href: this.href }, + })); + } +} + +customElements.define('ol-book-cover', OlBookCover); diff --git a/openlibrary/components/lit/OlIcon.js b/openlibrary/components/lit/OlIcon.js index 5dd6b7d86ea..1a124284b99 100644 --- a/openlibrary/components/lit/OlIcon.js +++ b/openlibrary/components/lit/OlIcon.js @@ -22,18 +22,22 @@ import { glyphs } from './icons.generated.js'; * @prop {'sm' | 'md' | 'lg'} size - "sm" (16px) | "md" (20px, default) | "lg" (24px). * @prop {String} label - Accessible name; exposes the icon as role="img". Without * it the icon is aria-hidden. + * @prop {Boolean} filled - Paint the glyph's interior in currentColor, for + * on/off states like a saved bookmark or a rated star. * * @cssprop [--ol-icon-stroke-width] - Stroke weight, overriding the size default. * * @example * * + * */ export class OlIcon extends LitElement { static properties = { name: { type: String, reflect: true }, size: { type: String, reflect: true }, label: { type: String }, + filled: { type: Boolean, reflect: true }, }; // The host box is sized here and again in ol-icon.css. The duplication is @@ -78,6 +82,11 @@ export class OlIcon extends LitElement { :host([size='lg']) svg { stroke-width: var(--ol-icon-stroke-width, var(--icon-stroke-lg)); } + + /* The source's fill="none" is a presentation attribute, so CSS wins. */ + :host([filled]) svg { + fill: currentColor; + } `; constructor() { @@ -85,6 +94,7 @@ export class OlIcon extends LitElement { this.name = ''; this.size = 'md'; this.label = ''; + this.filled = false; } // Decorative by default; named only when the caller supplies a label. On the diff --git a/openlibrary/components/lit/OlMenuPopover.js b/openlibrary/components/lit/OlMenuPopover.js index 3db6540dfc3..e2a89447100 100644 --- a/openlibrary/components/lit/OlMenuPopover.js +++ b/openlibrary/components/lit/OlMenuPopover.js @@ -167,7 +167,6 @@ export class OlMenuPopover extends LitElement { render() { return html` diff --git a/openlibrary/components/lit/OlOptionsPopover.js b/openlibrary/components/lit/OlOptionsPopover.js index f8592b8c9ba..5f9dbb1e352 100644 --- a/openlibrary/components/lit/OlOptionsPopover.js +++ b/openlibrary/components/lit/OlOptionsPopover.js @@ -149,7 +149,7 @@ export class OlOptionsPopover extends FormAssociatedMixin(LitElement) { width: 16px; height: 16px; margin: 2px 0 0; - accent-color: var(--primary-blue); + accent-color: var(--color-primary); cursor: pointer; } @@ -260,7 +260,6 @@ export class OlOptionsPopover extends FormAssociatedMixin(LitElement) { render() { return html` ` popover; the split variant adds a main + * half that toggles between Want to Read and off without opening anything. + * Signed out, either shape sends the visitor to log in with the intent + * remembered. + * + * **Stateless by design.** It never writes to `shelf` or `rating` itself — it + * emits `ol-book-state-change` and the surface that owns the book applies it, + * which then flows back down. That keeps one book's state correct when the same + * book appears twice on a page, and it means an optimistic update and its + * rollback are the same code path in both directions. + * + * @element ol-shelf-button + * + * @prop {String} variant - "split" (default) or "icon" + * @prop {String} workKey - "/works/OL…W", the book this acts on + * @prop {String} editionKey - "OL…M", recorded with the shelf change when known + * @prop {String} bookTitle - Used in the accessible labels. Named `book-title` + * because a `title` attribute would draw a native browser tooltip + * @prop {Number} shelf - Current shelf id (1–4), or null when on none + * @prop {Number} rating - Current rating (1–5), or null. Passed through to the + * popover and echoed on every state change + * @prop {String} readDate - Check-in date, whole or partial, shown on the + * popover's Already Read row. Applied by the surface, like shelf and rating + * @prop {Number} eventId - Id of that check-in, so editing the date amends it + * @prop {String} userKey - "/people/" when signed in; empty sends the + * visitor to log in instead of opening the popover + * @prop {String} placement - ol-popover placement for the actions panel; + * unset uses its default + * @prop {Boolean} hideRating - Drop the popover's stars. For surfaces that + * already show a rating control for the same book + * @prop {Object} labels - Translated strings, merged over DEFAULT_LABELS + * + * @fires ol-book-state-change - The shelf or rating changed, optimistically or + * rolled back. detail: { key, shelf, rating } + * @fires ol-book-check-in - Re-fired from the popover when a finish date is + * saved. detail: { key, date, eventId } + */ +export class OlShelfButton extends LitElement { + /** A shelf change is in flight. Deliberately not reactive: it gates the + second click, it does not change how the button looks. */ + _pending = false; + + static properties = { + variant: { type: String, reflect: true }, + workKey: { type: String, attribute: 'work-key' }, + editionKey: { type: String, attribute: 'edition-key' }, + bookTitle: { type: String, attribute: 'book-title' }, + shelf: { type: Number }, + rating: { type: Number }, + readDate: { type: String, attribute: 'read-date' }, + eventId: { type: Number, attribute: 'event-id' }, + userKey: { type: String, attribute: 'user-key' }, + placement: { type: String }, + labels: { type: Object }, + hideRating: { type: Boolean, attribute: 'hide-rating' }, + }; + + static styles = css` + :host { + display: block; + font-family: var(--font-family-body); + } + + ol-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + } + + /* ── Split variant ────────────────────────────────────────── */ + + /* The two halves are one fused shape, so the container carries the + secondary ol-button treatment: raised shadow, inset specular edge, + and the press-scale (:active propagates up from either half). */ + .split { + display: flex; + border: 1px solid var(--color-border-subtle); + border-radius: var(--border-radius-button); + overflow: hidden; + background: var(--white); + --control-highlight-strength: 35%; + box-shadow: + var(--box-shadow-raised), + inset 0 1px 0 + color-mix( + in srgb, + var(--white) var(--control-highlight-strength), + var(--control-surface) + ); + transition: transform 0.08s; + } + + .split:active { + transform: scale(0.97); + } + + .split--on { + border-color: var(--color-control-selected-border); + background: var(--color-control-selected-bg); + /* Opaque twin of the tint, so the specular edge tones to it. */ + --control-surface: var(--color-control-selected-surface); + } + + .main, + .more { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: calc(var(--control-height-medium) - 2px); + border: 0; + background: none; + color: var(--color-text); + font-family: var(--font-family-button); + font-size: var(--font-size-body-medium); + cursor: pointer; + } + + .main { + flex: 1; + min-width: 0; + padding: 0 var(--spacing-sm); + white-space: nowrap; + overflow: hidden; + } + + .main span { + overflow: hidden; + text-overflow: ellipsis; + } + + .main--on { + color: var(--color-link); + } + + .split > ol-book-actions { + display: flex; + } + + .more { + width: 40px; + border-left: 1px solid var(--color-border-subtle); + } + + .split--on .more { + border-left-color: var(--color-control-selected-border); + color: var(--color-link); + } + + .main:hover, + .more:hover { + background: var(--color-hover-overlay); + } + + /* When on, hover deepens the blue tint instead of graying it. */ + .split--on .main:hover, + .split--on .more:hover { + background: var(--color-control-selected-bg-hover); + } + + .main:focus-visible, + .more:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: -2px; + } + + /* ── Icon variant ─────────────────────────────────────────── */ + + /* An outlined bookmark until the book is on a shelf, then filled. The + host is positioned by whatever it floats over (ol-book-cover's overlay + slot), so everything in here stays in flow. */ + .save { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + background: transparent; + color: var(--color-text); + cursor: pointer; + --control-highlight-strength: 35%; + transition: transform 0.08s; + } + + /* The visible circle is smaller than the 32px hit target. Same inset + specular edge as ol-button; the drop shadow is heavier since it floats + over cover art. */ + .save::before { + content: ""; + position: absolute; + inset: 4px; + border-radius: var(--border-radius-circle); + background: var(--white); + box-shadow: + 0 1px 4px var(--boxshadow-black), + inset 0 1px 0 + color-mix( + in srgb, + var(--white) var(--control-highlight-strength), + var(--control-surface) + ); + } + + .save ol-icon { + position: relative; + width: 14px; + height: 14px; + --ol-icon-stroke-width: 2.5; + } + + .save:hover { + transform: scale(1.08); + } + + .save:active { + transform: scale(0.95); + } + + .save:focus-visible { + outline: none; + } + + .save:focus-visible::before { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } + + /* Saved: the circle stays white; only the bookmark fills, in blue. */ + .save--on { + color: var(--primary-blue); + } + `; + + constructor() { + super(); + this.variant = 'split'; + this.workKey = ''; + this.editionKey = ''; + this.bookTitle = ''; + this.shelf = null; + this.rating = null; + this.userKey = ''; + this.labels = {}; + this.hideRating = false; + } + + t(key, vars) { + return translate(this.labels, DEFAULT_LABELS, key, vars); + } + + get _on() { + return this.shelf !== null && this.shelf !== undefined; + } + + render() { + return this.variant === 'icon' ? this._renderIcon() : this._renderSplit(); + } + + /** + * Wrap a trigger in the actions popover when there is a reader to act for. + * Signed out the trigger stands alone and its click goes to login. + */ + _withActions(trigger) { + if (!this.userKey) return trigger; + return html` + ${trigger} + `; + } + + _renderIcon() { + const on = this._on; + return this._withActions(html` + + `); + } + + _renderSplit() { + const on = this._on; + const label = this.t(SHELF_LABEL[this.shelf ?? SHELF.WANT_TO_READ]); + return html` +
+ + ${this._withActions(html` + + `)} +
+ `; + } + + _emitState(shelf, rating = this.rating) { + this.dispatchEvent(new CustomEvent('ol-book-state-change', { + bubbles: true, + composed: true, + detail: { key: this.workKey, shelf, rating }, + })); + } + + _onLoggedOut(e) { + e.preventDefault(); + // No resumeUrl: come back to the page they were on. On a book page that + // is the same thing, but from a list of results it is not — the legacy + // dropper returned them to their results too. + redirectToLogin({ action: this.t('wantToRead'), title: this.bookTitle }); + } + + async _onMainClick(e) { + if (!this.userKey) return this._onLoggedOut(e); + // The shelf we emit only comes back down as a property a tick later, so + // a second click before the request lands would toggle twice on the + // server while the button shows one change. + if (this._pending) return; + const previous = this.shelf ?? null; + // On a shelf → clicking removes; otherwise → Want to Read. + const target = previous ?? SHELF.WANT_TO_READ; + const next = previous === null ? SHELF.WANT_TO_READ : null; + this._emitState(next); + this._pending = true; + try { + await setShelf(this.workKey, target, { editionKey: this.editionKey }); + trackEvent('ReadingLog', SHELF_EVENT[next]); + } catch (error) { + this._emitState(previous); + if (error?.status === 401) return this._onLoggedOut(e); + showToast(this.t('errorGeneric'), { type: 'error' }); + } finally { + this._pending = false; + } + } +} + +customElements.define('ol-shelf-button', OlShelfButton); diff --git a/openlibrary/components/lit/index.js b/openlibrary/components/lit/index.js index 7a32640d19f..7774636a0af 100644 --- a/openlibrary/components/lit/index.js +++ b/openlibrary/components/lit/index.js @@ -27,3 +27,6 @@ export { OlToastRegion, showToast } from './OlToastRegion.js'; export { OpenLibraryOTP } from './OpenLibraryOTP.js'; export { OlCarousel } from './OlCarousel.js'; export { OlScorecard } from './OlScorecard.js'; +export { OlBookCover } from './OlBookCover.js'; +export { OlBookActions } from './OlBookActions.js'; +export { OlShelfButton } from './OlShelfButton.js'; diff --git a/openlibrary/components/lit/utils/books-api.js b/openlibrary/components/lit/utils/books-api.js new file mode 100644 index 00000000000..dde9c2227e7 --- /dev/null +++ b/openlibrary/components/lit/utils/books-api.js @@ -0,0 +1,143 @@ +/** + * Thin fetch wrappers for the book components. Every function resolves to + * parsed JSON and rejects with an Error carrying `.status`, so callers can + * branch on 401 (send to login) vs anything else (toast). + */ + +import { queueAction, buildPartialsUrl } from '../../../plugins/openlibrary/js/utils.js'; + +export const SHELF = Object.freeze({ + WANT_TO_READ: 1, + CURRENTLY_READING: 2, + ALREADY_READ: 3, + STOPPED_READING: 4, +}); + +/** Shelf id → key into the components' label objects. */ +export const SHELF_LABEL = Object.freeze({ + [SHELF.WANT_TO_READ]: 'wantToRead', + [SHELF.CURRENTLY_READING]: 'currentlyReading', + [SHELF.ALREADY_READ]: 'alreadyRead', + [SHELF.STOPPED_READING]: 'stoppedReading', +}); + +/** + * Matomo action names, kept identical to the legacy dropper's + * `data-ol-link-track`. Indexed by shelf id; `null` (no shelf) is the removal. + */ +export const SHELF_EVENT = Object.freeze({ + [SHELF.WANT_TO_READ]: 'WantToRead', + [SHELF.CURRENTLY_READING]: 'CurrentlyReading', + [SHELF.ALREADY_READ]: 'AlreadyRead', + [SHELF.STOPPED_READING]: 'StoppedReading', + null: 'RemoveFromShelf', +}); + +/** Work key "/works/OL1W" → "OL1W". */ +export function olid(key) { + return key.split('/').pop(); +} + +async function request(url, init) { + const response = await fetch(url, { credentials: 'same-origin', ...init }); + if (!response.ok) { + const error = new Error(`${init?.method || 'GET'} ${url} → ${response.status}`); + error.status = response.status; + throw error; + } + const data = await response.json(); + // `bookshelves.json` answers a rejected write with 200 and an `error` key, + // so checking the status alone would let a failed write look like a save. + if (data && data.error) { + const error = new Error(`${init?.method || 'GET'} ${url} → ${data.error}`); + error.status = response.status; + error.body = data; + throw error; + } + return data; +} + +function form(data) { + const body = new URLSearchParams(); + for (const [k, v] of Object.entries(data)) { + if (v !== undefined && v !== null) body.set(k, String(v)); + } + return { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }; +} + +function json(data) { + return { method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } }; +} + +/** + * POST /works/OL..W/bookshelves.json. Posting the shelf the work is already + * on removes it; `-1` removes unconditionally. + */ +export function setShelf(workKey, shelfId, { editionKey } = {}) { + return request(`/works/${olid(workKey)}/bookshelves.json`, form({ bookshelf_id: shelfId, edition_id: editionKey })); +} + +/** POST /works/OL..W/ratings.json. `null` clears the rating. */ +export function setRating(workKey, rating, { editionKey } = {}) { + return request(`/works/${olid(workKey)}/ratings.json`, form({ rating, edition_id: editionKey })); +} + +/** BookshelfEvent.FINISH — the only event type this client records. */ +const FINISH_EVENT = 3; + +/** + * POST /works/OL..W/check-ins — when the reader finished the book. + * `month` and `day` are optional: a year alone, or a year and month, are both + * valid check-ins, which is what lets the UI offer "in 2026". + * + * `eventId` edits that check-in in place. Without it the server records another + * one, which would count as a second book finished — so pass it whenever the + * reader is changing a date they already gave. + */ +export function setCheckIn(workKey, { year, month = null, day = null, editionKey, eventId = null } = {}) { + return request(`/works/${olid(workKey)}/check-ins`, json({ + event_type: FINISH_EVENT, + year, + month, + day, + edition_key: editionKey || null, + event_id: eventId || null, + })); +} + +/** + * The user's lists with membership: `{ [listKey]: { listName, members: [seedKey…] } }`. + * Reuses the dropper partial so the list-modelling stays in one place. + */ +export async function fetchUserLists() { + const data = await request(String(buildPartialsUrl('MyBooksDropperLists'))); + return data.listData || {}; +} + +export function addToList(listKey, seedKey) { + return request(`${listKey}/seeds.json`, json({ add: [{ key: seedKey }] })); +} + +export function removeFromList(listKey, seedKey) { + return request(`${listKey}/seeds.json`, json({ remove: [{ key: seedKey }] })); +} + +/** Resolves to `{ key, ... }` of the new list. */ +export function createList(userKey, name, seedKey) { + return request(`${userKey}/lists.json`, json({ name, description: '', seeds: seedKey ? [{ key: seedKey }] : [] })); +} + +/** Where a logged-out visitor lands back after signing in. */ +function resumeTarget(resumeUrl) { + return resumeUrl || window.location.pathname + window.location.search; +} + +/** + * Queue the action (js/utils.js `queueAction`, so the page the visitor lands + * on after signing in can pick it back up), then send them to log in. + */ +export function redirectToLogin({ action, title, type = 'book', resumeUrl } = {}) { + const target = resumeTarget(resumeUrl); + if (action && title) queueAction(action, title, target, type); + window.location.href = `/account/login?redirect=${encodeURIComponent(target)}`; +} diff --git a/openlibrary/components/lit/utils/dates.js b/openlibrary/components/lit/utils/dates.js new file mode 100644 index 00000000000..91e5b21b3b3 --- /dev/null +++ b/openlibrary/components/lit/utils/dates.js @@ -0,0 +1,51 @@ +/** + * Date helpers shared by the book components. The reading log stores partial + * dates — "2026", "2026-08" and "2026-08-22" are all valid — and these + * functions are the two directions of that schema plus the small pieces of + * calendar the UI needs. + */ + +let _months = null; +/** Month names in the page's language. Cached: the list never changes. */ +export function MONTHS() { + if (!_months) { + const lang = document.documentElement.lang || 'en'; + const format = new Intl.DateTimeFormat(lang, { month: 'long' }); + _months = Array.from({ length: 12 }, (_, i) => format.format(new Date(2000, i, 1))); + } + return _months; +} + +/** + * A stored (possibly partial) date for display, showing only what is known: + * "2026", "Aug 2026" or "Aug 22, 2026". + */ +export function formatReadDate(value) { + const [year, month, day] = String(value).split('-').map(Number); + if (!year) return ''; + const lang = document.documentElement.lang || 'en'; + const options = month + ? (day ? { year: 'numeric', month: 'short', day: 'numeric' } : { year: 'numeric', month: 'short' }) + : null; + if (!options) return String(year); + return new Intl.DateTimeFormat(lang, options).format(new Date(year, month - 1, day || 1)); +} + +/** + * The years the check-in prompt offers as one tap. For the first 30 days of a + * new year the year just gone stays on offer: that is when a reader is most + * likely logging something they finished before the turn, and "In 2025" on + * 25 January saves them the date picker. + */ +export function quickYears(now = new Date()) { + const year = now.getFullYear(); + const daysIn = Math.floor((now - new Date(year, 0, 1)) / 86400000); + return daysIn < 30 ? [year, year - 1] : [year]; +} + +/** The inverse of `formatReadDate`: `{year, month, day}` as the schema stores it. */ +export function partialDate({ year, month, day }) { + const pad = n => String(n).padStart(2, '0'); + if (!month) return String(year); + return day ? `${year}-${pad(month)}-${pad(day)}` : `${year}-${pad(month)}`; +} diff --git a/openlibrary/components/lit/utils/labels.js b/openlibrary/components/lit/utils/labels.js new file mode 100644 index 00000000000..6ff907d6c5f --- /dev/null +++ b/openlibrary/components/lit/utils/labels.js @@ -0,0 +1,23 @@ +/** + * Label resolution shared by the components that render translated strings. + * + * Each component owns a `DEFAULT_LABELS` map of English defaults and takes a + * `labels` object that overrides it. A composing parent passes its whole + * `labels` blob straight down — extra keys a child doesn't know are harmless, + * so one server-rendered blob can feed a whole subtree. + */ + +/** "%(name)s" style interpolation, matching the server-side i18n strings. */ +export function fmt(template, vars) { + return template.replace(/%\((\w+)\)s/g, (_, k) => (vars[k] ?? '')); +} + +/** + * Resolve one label: the instance override wins, then the component's own + * default, then the key itself — a missing string shows up as its key rather + * than as blank space. + */ +export function translate(labels, defaults, key, vars) { + const s = labels?.[key] ?? defaults?.[key] ?? key; + return vars ? fmt(s, vars) : s; +} diff --git a/openlibrary/components/lit/utils/lists-store.js b/openlibrary/components/lit/utils/lists-store.js new file mode 100644 index 00000000000..9b2a29bc04f --- /dev/null +++ b/openlibrary/components/lit/utils/lists-store.js @@ -0,0 +1,79 @@ +/** + * The signed-in reader's lists, shared by every book popover on the page. + * + * One canonical copy lives here. Components read `getLists()` and re-render + * on `subscribeToLists`; the mutating calls own both the optimistic update + * and its rollback. A change made through one popover is therefore correct + * in all of them, with no cross-component syncing. + */ + +import { fetchUserLists, addToList, removeFromList, createList } from './books-api.js'; + +/** `{ [listKey]: { listName, members: [seedKey…] } }`, or null before the first load. */ +let lists = null; +let inflight = null; +const subscribers = new Set(); + +export function getLists() { + return lists; +} + +/** Called after every store change. Returns the matching unsubscribe. */ +export function subscribeToLists(fn) { + subscribers.add(fn); + return () => subscribers.delete(fn); +} + +function notify() { + subscribers.forEach(fn => fn()); +} + +/** + * Fetch once per page; concurrent callers share the request. A failure clears + * the in-flight request so the next call retries. + */ +export async function loadLists() { + if (lists) return lists; + inflight ||= fetchUserLists(); + try { + lists = await inflight; + } catch (error) { + inflight = null; + throw error; + } + notify(); + return lists; +} + +/** Optimistic membership toggle; rolls back and rethrows on failure. */ +export async function toggleListSeed(listKey, seedKey, inList) { + const list = lists[listKey]; + const before = list.members; + list.members = inList ? [...before, seedKey] : before.filter(k => k !== seedKey); + notify(); + try { + await (inList ? addToList(listKey, seedKey) : removeFromList(listKey, seedKey)); + } catch (error) { + list.members = before; + notify(); + throw error; + } +} + +/** + * Create on the server, then prepend so the new list renders first + * everywhere. Resolves to the new list's key. + */ +export async function createUserList(userKey, name, seedKey) { + const created = await createList(userKey, name, seedKey); + lists = { [created.key]: { listName: name, members: seedKey ? [seedKey] : [] }, ...(lists || {}) }; + notify(); + return created.key; +} + +/** Forget everything (tests, or a mutation made outside the store). */ +export function resetListsStore() { + lists = null; + inflight = null; + notify(); +} diff --git a/openlibrary/core/bookshelves.py b/openlibrary/core/bookshelves.py index b13357f5599..2d67a808768 100644 --- a/openlibrary/core/bookshelves.py +++ b/openlibrary/core/bookshelves.py @@ -605,7 +605,7 @@ def user_has_read_work(cls, username: str, work_id: str) -> bool: return user_read_status == cls.PRESET_BOOKSHELVES["Already Read"] @classmethod - def get_users_read_status_of_works(cls, username: str, work_ids: list[str]) -> list: + def get_users_read_status_of_works(cls, username: str, work_ids: list[str] | list[int]) -> list: oldb = db.get_db() data = { "username": username, diff --git a/openlibrary/core/ratings.py b/openlibrary/core/ratings.py index e39e92ea535..c039a454078 100644 --- a/openlibrary/core/ratings.py +++ b/openlibrary/core/ratings.py @@ -150,6 +150,15 @@ def get_users_rating_for_work(cls, username: str, work_id: str | int) -> int | N rating: int | None = results[0].rating if results else None return rating + @classmethod + def get_users_ratings_of_works(cls, username: str, work_ids: list[int]) -> dict[int, int]: + """Map work_id -> rating for the subset of `work_ids` this user has rated.""" + if not work_ids: + return {} + oldb = db.get_db() + query = "SELECT work_id, rating FROM ratings WHERE username=$username AND work_id IN $work_ids" + return {row.work_id: row.rating for row in oldb.query(query, vars={"username": username, "work_ids": work_ids})} + @classmethod def remove(cls, username, work_id): oldb = db.get_db() diff --git a/openlibrary/plugins/openlibrary/design.py b/openlibrary/plugins/openlibrary/design.py index 96b25389507..e4b482f6571 100644 --- a/openlibrary/plugins/openlibrary/design.py +++ b/openlibrary/plugins/openlibrary/design.py @@ -22,7 +22,12 @@ from infogami.utils import delegate from infogami.utils.view import render_template +from openlibrary import accounts +from openlibrary.core.bookshelves import Bookshelves +from openlibrary.core.bookshelves_events import BookshelfEvent, BookshelvesEvents +from openlibrary.core.ratings import Ratings from openlibrary.plugins.openlibrary.design_tokens import load_token_categories +from openlibrary.utils import extract_numeric_id_from_olid logger = logging.getLogger("openlibrary.design") @@ -246,6 +251,33 @@ class Component: tag="ol-icon", api_table=False, ), + # --- Books ----------------------------------------------------------- + # The domain layer: everything here knows what a book is. Generic pieces + # they compose (Button, Popover) stay in their own sections. + Component( + "book-cover", + "Book Cover", + "A book's cover at a fixed 2:3 ratio, with a blank-cover fallback and a corner for one control.", + "design/components/book-cover.html.jinja", + group="Books", + tag="ol-book-cover", + ), + Component( + "shelf-button", + "Shelf Button", + "Putting a book on a reading-log shelf: a split button for a row, a bookmark for a cover.", + "design/components/shelf-button.html.jinja", + group="Books", + tag="ol-shelf-button", + ), + Component( + "book-actions", + "Book Actions", + "Per-book shelf, rating and add-to-list actions in a popover.", + "design/components/book-actions.html.jinja", + group="Books", + tag="ol-book-actions", + ), ) # Icon sources, one SVG per icon, grouped into folders by provenance. The file @@ -369,17 +401,48 @@ class DesignContext: groups: tuple[tuple[str, list[Component]], ...] = COMPONENT_GROUPS api: dict = field(default_factory=dict) token_categories: list = field(default_factory=list) + # The book demos write to the signed-in reader's real reading log and + # lists, so they need their key; empty sends the demo to log in instead. + user_key: str = "" + # Their shelf/rating/check-in for the demo works, keyed by OLID, so the + # demos open on real state without a client fetch: + # {"OL69612W": {"shelf": 3, "rating": 4, "read_date": "2026-08", "event_id": 7}}. + reading_state: dict = field(default_factory=dict) icons: list[str] = field(default_factory=list) +# The works the book demos read and write; must match the demo templates. +DEMO_WORK_OLIDS = ("OL69612W", "OL27448W") + + +def demo_reading_state(username: str) -> dict[str, dict]: + """The reader's shelf, rating, and last finish date for each demo work, keyed by OLID.""" + numeric_ids = [int(extract_numeric_id_from_olid(olid)) for olid in DEMO_WORK_OLIDS] + shelves = {row.work_id: row.bookshelf_id for row in Bookshelves.get_users_read_status_of_works(username, numeric_ids)} + ratings = Ratings.get_users_ratings_of_works(username, numeric_ids) + check_ins = {work_id: BookshelvesEvents.get_latest_event_date(username, work_id, BookshelfEvent.FINISH) for work_id in numeric_ids} + return { + olid: { + "shelf": shelves.get(work_id), + "rating": ratings.get(work_id), + "read_date": check_ins[work_id]["event_date"] if check_ins[work_id] else None, + "event_id": check_ins[work_id]["id"] if check_ins[work_id] else None, + } + for olid, work_id in zip(DEMO_WORK_OLIDS, numeric_ids) + } + + def build_context(section_id: str) -> DesignContext: section = next(candidate for candidate in SECTIONS if candidate.id == section_id) - context = DesignContext(section=section) + user = accounts.get_current_user() + context = DesignContext(section=section, user_key=user.key if user else "") if section_id == "foundations": context.token_categories = load_token_categories() elif section_id == "components": # Playground renders no API tables, so it pays for none. context.api = load_components() + if user: + context.reading_state = demo_reading_state(user.key.split("/")[-1]) elif section_id == "icons": context.icons = load_icons() # is one of three ways to draw a glyph, so the Icons section diff --git a/openlibrary/plugins/openlibrary/lists.py b/openlibrary/plugins/openlibrary/lists.py index 02decc2e71d..462a920a96e 100644 --- a/openlibrary/plugins/openlibrary/lists.py +++ b/openlibrary/plugins/openlibrary/lists.py @@ -44,6 +44,10 @@ from openlibrary.utils import olid_to_key from openlibrary.utils.request_context import site +# One pattern for every seed type: delegate keys its `pages` registry by the raw +# regex text, so both handlers below must register the identical string. +SEED_LISTS_PATH = r"(/(?:people|books|works|authors|subjects)/[^/]+)/lists" + def subject_key_to_seed(key: subjects.SubjectPseudoKey) -> SeedSubjectString: name_part = key.split("/")[-1].replace(",", "_").replace("__", "_") @@ -289,7 +293,7 @@ def convert_list(list): class lists(delegate.page): """Controller for displaying lists of a seed or lists of a person.""" - path = "(/(?:people|books|works|authors|subjects)/[^/]+)/lists" + path = SEED_LISTS_PATH def GET(self, path): # If logged in patron is viewing their lists page, use MyBooksTemplate @@ -596,7 +600,7 @@ def process_seeds( class lists_yaml(delegate.page): - path = "(/(?:people|books|works|authors|subjects)/[^/]+)/lists" + path = SEED_LISTS_PATH encoding = "yml" content_type = "text/yaml" diff --git a/openlibrary/templates/design.html b/openlibrary/templates/design.html index 17fab235727..30654323723 100644 --- a/openlibrary/templates/design.html +++ b/openlibrary/templates/design.html @@ -6,4 +6,4 @@ $# This page is developer-facing and intentionally English-only — no i18n. $ _x = ctx.setdefault('cssfile', 'design') -$:render_jinja_template("design/layout.html.jinja", section=data.section, sections=data.sections, groups=data.groups, api=data.api, token_categories=data.token_categories, icons=data.icons) +$:render_jinja_template("design/layout.html.jinja", section=data.section, sections=data.sections, groups=data.groups, api=data.api, token_categories=data.token_categories, user_key=data.user_key, reading_state=data.reading_state, icons=data.icons) diff --git a/openlibrary/templates/design/components/book-actions.html.jinja b/openlibrary/templates/design/components/book-actions.html.jinja new file mode 100644 index 00000000000..8d2306264a2 --- /dev/null +++ b/openlibrary/templates/design/components/book-actions.html.jinja @@ -0,0 +1,36 @@ +{% import "design/_example.html.jinja" as ex %} +{% macro demos() %} +

+ The per-book action popover: the four reading-log shelves, a star rating, an "Add to list" pane that slides in with a filter and inline list creation, and a check-in pane that asks when the reader finished the book. It composes Popover for the shell; you supply the trigger. State is optimistic — the UI updates first, an error toast rolls it back — and every accepted change fires ol-book-state-change so the surrounding card can follow. Only render it for signed-in readers; send everyone else to log in. +

+ {% call ex.example("Standalone", "Give it a book, the current shelf/rating, and the reader's key (needed to create lists). book takes JSON in the attribute, or the object itself if you set the .book property from JS; shelf and rating are plain numbers. The trigger is whatever you slot in. Signed in, this demo opens on your real state for The Lord of the Rings and saves every change to your account.", + code="\n \n
") %} + {# Server-rendered opening state — this demo writes to the reader's + actual reading log, so it opens on what's there. #} + {% set lotr = reading_state.get('OL27448W') or {} %} + + + Save + + +

Last change:

+ + {% endcall %} +
+

+ Shelf ids. 1 Want to Read, 2 Currently Reading, 3 Already Read, 4 Stopped Reading — the same ids as Bookshelves.PRESET_BOOKSHELVES and the bookshelf_id the endpoint takes. From JS, import SHELF from lit/utils/books-api.js instead of writing the number. +

+

+ Requests. Shelves post to /works/OL…W/bookshelves.json, ratings to /works/OL…W/ratings.json, check-ins to /works/OL…W/check-ins, lists through /partials/MyBooksDropperLists.json (read) and the lists/seeds endpoints (write). A 401 on any of them redirects to login. +

+
+{% endmacro %} diff --git a/openlibrary/templates/design/components/book-cover.html.jinja b/openlibrary/templates/design/components/book-cover.html.jinja new file mode 100644 index 00000000000..fe0ea7ea86a --- /dev/null +++ b/openlibrary/templates/design/components/book-cover.html.jinja @@ -0,0 +1,31 @@ +{% import "design/_example.html.jinja" as ex %} +{% macro demos() %} + {# Same server-rendered opening state as the shelf-button demos — it is the + same work, and the sync script only keeps them in step after a change. #} + {% set garden = reading_state.get('OL69612W') or {} %} +

+ A book cover at a fixed 2:3 ratio, with somewhere to float a control over it. It knows nothing about shelves, availability or search — give it an image URL and a title. When there is no artwork it draws a title/author panel instead, so a shelf of covers keeps its rhythm rather than opening a hole. The overlay slot is where the save button goes; the component owns the corner so a consumer never positions it. +

+ {% call ex.example("With artwork", "The common case. The image is lazy, and its alt text is the title and author — a cover is a picture of the book, not decoration.", output_style="display: flex; gap: 16px; width: 420px;") %} + + + {% endcall %} + {% call ex.example("No artwork", "The generated cover. The title clamps to four lines and the author sits under it in small caps. At size=\"small\" the author is dropped — a 72px row cover has no room for it.", output_style="display: flex; gap: 16px; align-items: flex-start;") %} + + + + + {% endcall %} + {% call ex.example("Hover card", "A pointer gets the title, year and author over the cover. ol-tooltip arms on the same media query cover-card layouts hide their caption with, so exactly one of the two ever shows.", output_style="display: flex; gap: 16px; width: 420px; padding-top: 48px;") %} + + + {% endcall %} + {% call ex.example("Overlay", "Anything slotted into overlay takes the top-right corner, over the artwork and outside the link — so hovering the control is not hovering the cover, and clicking it does not navigate.", + code="\n \n \n", + output_style="display: flex; gap: 16px; width: 420px;") %} + + + + + {% endcall %} +{% endmacro %} diff --git a/openlibrary/templates/design/components/shelf-button.html.jinja b/openlibrary/templates/design/components/shelf-button.html.jinja new file mode 100644 index 00000000000..ed4c48464df --- /dev/null +++ b/openlibrary/templates/design/components/shelf-button.html.jinja @@ -0,0 +1,43 @@ +{% import "design/_example.html.jinja" as ex %} +{% macro demos() %} + {# Server-rendered opening state, like every real surface: the demos write + to the reader's actual reading log, so they open on what's there. #} + {% set garden = reading_state.get('OL69612W') or {} %} +

+ The control that puts a book on a reading-log shelf, in the two shapes the site needs. Both open the same Book Actions popover; the split variant adds a main half that toggles Want to Read without opening anything. Signed out, either shape sends the visitor to log in with the intent remembered. +

+ {% call ex.example("Split", "For a row, where there is width for a label. The main half toggles the shown shelf; the chevron opens the popover. Signed in, this demo opens on your real state for The Secret Garden and saves every change to your account.", + code="\n", + output_style="display: flex; gap: 12px; width: 420px;") %} + + + {% endcall %} + {% call ex.example("Icon", "For a cover, where there is not. An outlined bookmark until the book is on a shelf, then filled — the circle stays white so it reads against any artwork. Same book as above, so the two stay in step.", + code="\n \n \n", + output_style="display: flex; gap: 16px; width: 300px;") %} + + + + + {% endcall %} + +
+

+ Signed in only for the popover. Without user-key no popover is built at all; the trigger stands alone and its click goes to login. The demos above pass your key when you are signed in, so they write to your real reading log. +

+
+{% endmacro %} diff --git a/openlibrary/utils/request_context.py b/openlibrary/utils/request_context.py index 35144a09a59..4dedec7cd27 100644 --- a/openlibrary/utils/request_context.py +++ b/openlibrary/utils/request_context.py @@ -147,7 +147,9 @@ def _parse_solr_editions_from_web() -> bool: """Parse solr_editions from web.py context.""" def read_query_string(): - return web.input(editions=None).get("editions") + # _method="GET" keeps this off the request body. Without it web.py parses + # multipart POSTs here, draining wsgi.input before the handler sees it. + return web.input(editions=None, _method="GET").get("editions") def read_cookie(): if "SOLR_EDITIONS" in web.ctx.env.get("HTTP_COOKIE", ""): diff --git a/static/icons/src/lucide/book-open.svg b/static/icons/src/lucide/book-open.svg new file mode 100644 index 00000000000..d56c97e380a --- /dev/null +++ b/static/icons/src/lucide/book-open.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/bookmark.svg b/static/icons/src/lucide/bookmark.svg new file mode 100644 index 00000000000..af6de82b1c4 --- /dev/null +++ b/static/icons/src/lucide/bookmark.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/calendar-check.svg b/static/icons/src/lucide/calendar-check.svg new file mode 100644 index 00000000000..8f85a80c809 --- /dev/null +++ b/static/icons/src/lucide/calendar-check.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/calendar-days.svg b/static/icons/src/lucide/calendar-days.svg new file mode 100644 index 00000000000..5384cda4931 --- /dev/null +++ b/static/icons/src/lucide/calendar-days.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/calendar.svg b/static/icons/src/lucide/calendar.svg new file mode 100644 index 00000000000..c8c65f359f7 --- /dev/null +++ b/static/icons/src/lucide/calendar.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/circle-pause.svg b/static/icons/src/lucide/circle-pause.svg new file mode 100644 index 00000000000..9c3a2db0a48 --- /dev/null +++ b/static/icons/src/lucide/circle-pause.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/list-plus.svg b/static/icons/src/lucide/list-plus.svg new file mode 100644 index 00000000000..5a52a88bb36 --- /dev/null +++ b/static/icons/src/lucide/list-plus.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/loader.svg b/static/icons/src/lucide/loader.svg new file mode 100644 index 00000000000..9e6ddba1ef4 --- /dev/null +++ b/static/icons/src/lucide/loader.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/plus.svg b/static/icons/src/lucide/plus.svg new file mode 100644 index 00000000000..537d4440a05 --- /dev/null +++ b/static/icons/src/lucide/plus.svg @@ -0,0 +1 @@ + diff --git a/static/icons/src/lucide/star.svg b/static/icons/src/lucide/star.svg new file mode 100644 index 00000000000..cad494c2e90 --- /dev/null +++ b/static/icons/src/lucide/star.svg @@ -0,0 +1 @@ + diff --git a/tests/unit/js/OlBookActions.test.js b/tests/unit/js/OlBookActions.test.js new file mode 100644 index 00000000000..d5a1bbff953 --- /dev/null +++ b/tests/unit/js/OlBookActions.test.js @@ -0,0 +1,653 @@ +/** + * Unit tests for : shelf/rating requests and their optimistic + * updates, the state-change event, and the add-to-list pane (load, filter, + * toggle, create). Network is stubbed at `fetch`. + */ +import { OlBookActions } from '../../../openlibrary/components/lit/OlBookActions.js'; +import { fmt } from '../../../openlibrary/components/lit/utils/labels.js'; +import { SHELF } from '../../../openlibrary/components/lit/utils/books-api.js'; +import { quickYears } from '../../../openlibrary/components/lit/utils/dates.js'; +import { getLists, resetListsStore } from '../../../openlibrary/components/lit/utils/lists-store.js'; + +const BOOK = { key: '/works/OL1W', title: 'Project Hail Mary', firstPublishYear: 2021, editionKey: 'OL9M' }; + +let calls; +let listData; + +function stubFetch({ failWith } = {}) { + calls = []; + listData = { + '/people/tester/lists/OL1L': { listName: 'Summer 2026', members: ['/works/OL7W'] }, + '/people/tester/lists/OL2L': { listName: 'Sci-fi to reread', members: ['/works/OL1W'] }, + }; + global.fetch = jest.fn(async(url, init) => { + calls.push({ url, init }); + if (failWith) return { ok: false, status: failWith, json: async() => ({}) }; + let body = {}; + if (url.endsWith('/partials/MyBooksDropperLists.json')) body = { dropper: '', listData }; + if (url.endsWith('/lists.json') && init?.method === 'POST') body = { key: '/people/tester/lists/OL3L', revision: 1 }; + if (url.includes('/check-ins')) body = { status: 'ok', id: 42 }; + return { ok: true, status: 200, json: async() => body }; + }); +} + +beforeAll(() => { + global.ResizeObserver = class { observe() {} disconnect() {} }; + window.matchMedia = query => ({ + matches: false, media: query, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, + }); +}); + +beforeEach(() => { + resetListsStore(); +}); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +async function tick(el) { + await new Promise(r => setTimeout(r, 0)); + await el.updateComplete; +} + +async function mount(props = {}) { + const el = new OlBookActions(); + el.book = BOOK; + el.userKey = '/people/tester'; + Object.assign(el, props); + const trigger = document.createElement('button'); + trigger.slot = 'trigger'; + el.appendChild(trigger); + document.body.appendChild(el); + await el.updateComplete; + // Open the popover so the panel exists. + el.shadowRoot.querySelector('ol-popover').open = true; + await tick(el); + return el; +} + +const q = (el, sel) => el.shadowRoot.querySelector(sel); +const qa = (el, sel) => [...el.shadowRoot.querySelectorAll(sel)]; + +describe('fmt', () => { + test('interpolates %(name)s placeholders', () => { + expect(fmt('by %(name)s', { name: 'Andy' })).toBe('by Andy'); + expect(fmt('%(count)s items', { count: 3 })).toBe('3 items'); + }); +}); + +describe('ol-book-actions shelves', () => { + test('renders header and four shelf rows with the current one checked', async() => { + stubFetch(); + const el = await mount({ shelf: SHELF.CURRENTLY_READING }); + expect(q(el, '.header').textContent.replace(/\s+/g, ' ').trim()).toBe('Project Hail Mary (2021)'); + const rows = qa(el, '.row[role="menuitemradio"]'); + expect(rows.map(r => r.getAttribute('aria-checked'))).toEqual(['false', 'true', 'false', 'false']); + }); + + test('clicking a shelf posts it, updates optimistically, and emits state', async() => { + stubFetch(); + const el = await mount(); + const events = []; + el.addEventListener('ol-book-state-change', e => events.push(e.detail)); + qa(el, '.row[role="menuitemradio"]')[0].click(); + expect(el.shelf).toBe(SHELF.WANT_TO_READ); + await tick(el); + const post = calls.find(c => c.url === '/works/OL1W/bookshelves.json'); + expect(post.init.method).toBe('POST'); + expect(post.init.body.get('bookshelf_id')).toBe('1'); + expect(post.init.body.get('edition_id')).toBe('OL9M'); + expect(events).toEqual([{ key: '/works/OL1W', shelf: SHELF.WANT_TO_READ, rating: null }]); + }); + + test('clicking the current shelf removes it', async() => { + stubFetch(); + const el = await mount({ shelf: SHELF.WANT_TO_READ }); + qa(el, '.row[role="menuitemradio"]')[0].click(); + expect(el.shelf).toBeNull(); + await tick(el); + // Server toggles off when it receives the current shelf id. + expect(calls.find(c => c.url === '/works/OL1W/bookshelves.json').init.body.get('bookshelf_id')).toBe('1'); + }); + + test('rolls back and toasts on failure', async() => { + stubFetch({ failWith: 500 }); + const el = await mount(); + qa(el, '.row[role="menuitemradio"]')[2].click(); + expect(el.shelf).toBe(SHELF.ALREADY_READ); + await tick(el); + expect(el.shelf).toBeNull(); + expect(document.querySelector('ol-toast')).not.toBeNull(); + }); +}); + +describe('ol-book-actions rating', () => { + test('rating posts and moves the book to Already Read', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.star')[3].click(); + expect(el.rating).toBe(4); + expect(el.shelf).toBe(SHELF.ALREADY_READ); + await tick(el); + const post = calls.find(c => c.url === '/works/OL1W/ratings.json'); + expect(post.init.body.get('rating')).toBe('4'); + expect(q(el, '.stars .caption').textContent).toBe('Clear rating'); + }); + + test('clicking the current star clears the rating', async() => { + stubFetch(); + const el = await mount({ rating: 2, shelf: SHELF.ALREADY_READ }); + qa(el, '.star')[1].click(); + expect(el.rating).toBeNull(); + await tick(el); + expect(calls.find(c => c.url === '/works/OL1W/ratings.json').init.body.has('rating')).toBe(false); + }); +}); + +describe('ol-book-actions lists pane', () => { + test('opening the popover prefetches lists so the count shows straight away', async() => { + stubFetch(); + const el = await mount(); + await tick(el); + expect(calls.some(c => c.url.endsWith('/partials/MyBooksDropperLists.json'))).toBe(true); + // The stub puts OL1W in one of the two lists. + expect(q(el, '.group:last-child .count').textContent).toBe('1'); + }); + + test('a failed prefetch stays silent and lets the pane retry', async() => { + stubFetch({ failWith: 500 }); + const el = await mount(); + await tick(el); + expect(getLists()).toBeNull(); + expect(q(el, '.group:last-child .count')).toBeNull(); + }); + + test('opens the pane, loads lists with membership and counts', async() => { + stubFetch(); + const el = await mount(); + q(el, '.group:last-child .row').click(); + await tick(el); + expect(el._pane).toBe('lists'); + // The track is translated by one panel width per pane index, so the + // slide is derived rather than a per-pane class. + expect(q(el, '.track').style.transform).toMatch(/^translateX\(-33\.3/); + expect(calls.some(c => c.url.endsWith('/partials/MyBooksDropperLists.json'))).toBe(true); + const rows = qa(el, '.list-row'); + expect(rows.map(r => r.querySelector('.name').textContent)).toEqual(['Summer 2026', 'Sci-fi to reread']); + expect(rows.map(r => r.querySelector('input').checked)).toEqual([false, true]); + expect(rows.map(r => r.querySelector('.count').textContent)).toEqual(['1', '1']); + }); + + test('filter narrows the rows', async() => { + stubFetch(); + const el = await mount(); + q(el, '.group:last-child .row').click(); + await tick(el); + const input = q(el, '.pane:nth-child(2) .input'); + input.value = 'sci'; + input.dispatchEvent(new Event('input')); + await el.updateComplete; + expect(qa(el, '.list-row')).toHaveLength(1); + input.value = 'zzz'; + input.dispatchEvent(new Event('input')); + await el.updateComplete; + expect(q(el, '.empty').textContent).toBe('No lists match.'); + }); + + test('toggling a checkbox adds/removes the seed', async() => { + stubFetch(); + const el = await mount(); + q(el, '.group:last-child .row').click(); + await tick(el); + const [first, second] = qa(el, '.list-row input'); + first.checked = true; + first.dispatchEvent(new Event('change')); + second.checked = false; + second.dispatchEvent(new Event('change')); + await tick(el); + const add = calls.find(c => c.url === '/people/tester/lists/OL1L/seeds.json'); + const remove = calls.find(c => c.url === '/people/tester/lists/OL2L/seeds.json'); + expect(JSON.parse(add.init.body)).toEqual({ add: [{ key: '/works/OL1W' }] }); + expect(JSON.parse(remove.init.body)).toEqual({ remove: [{ key: '/works/OL1W' }] }); + expect(qa(el, '.list-row .count').map(c => c.textContent)).toEqual(['2', '0']); + }); + + test('create list inlines an input, posts, and prepends the new list', async() => { + stubFetch(); + const el = await mount(); + q(el, '.group:last-child .row').click(); + await tick(el); + q(el, '.lists-header ol-button').click(); + await el.updateComplete; + const form = q(el, 'form.field'); + expect(form).not.toBeNull(); + form.querySelector('input').value = 'Gothic autumn'; + form.dispatchEvent(new Event('submit', { cancelable: true })); + await tick(el); + const post = calls.find(c => c.url === '/people/tester/lists.json'); + expect(JSON.parse(post.init.body)).toEqual({ name: 'Gothic autumn', description: '', seeds: [{ key: '/works/OL1W' }] }); + expect(el._creating).toBe(false); + const rows = qa(el, '.list-row'); + expect(rows[0].querySelector('.name').textContent).toBe('Gothic autumn'); + expect(rows[0].querySelector('input').checked).toBe(true); + }); + + test('Escape in the lists pane goes back instead of closing', async() => { + stubFetch(); + const el = await mount(); + q(el, '.group:last-child .row').click(); + await tick(el); + const popover = q(el, 'ol-popover'); + popover._requestClose('escape'); + await tick(el); + expect(popover.open).toBe(true); + expect(el._pane).toBe('main'); + popover._requestClose('escape'); + await tick(el); + expect(popover.open).toBe(false); + }); +}); + +describe('ol-book-actions shared lists', () => { + async function createList(el, name) { + q(el, '.group:last-child .row').click(); + await tick(el); + q(el, '.lists-header ol-button').click(); + await el.updateComplete; + const form = q(el, 'form.field'); + form.querySelector('input').value = name; + form.dispatchEvent(new Event('submit', { cancelable: true })); + await tick(el); + } + + test('creating a list announces it with ol-list-created', async() => { + stubFetch(); + const el = await mount(); + const seen = []; + document.addEventListener('ol-list-created', e => seen.push(e), { once: true }); + await createList(el, 'Gothic autumn'); + expect(seen).toHaveLength(1); + expect(seen[0].detail).toEqual({ key: '/people/tester/lists/OL3L', name: 'Gothic autumn', seedKey: '/works/OL1W' }); + }); + + test('a sibling popover picks up the new list without refetching', async() => { + stubFetch(); + const el = await mount(); + const sibling = await mount({ book: { ...BOOK, key: '/works/OL2W' } }); + await tick(sibling); + const fetches = () => calls.filter(c => c.url.endsWith('/partials/MyBooksDropperLists.json')).length; + const before = fetches(); + await createList(el, 'Gothic autumn'); + // The sibling reads the shared store, so the new list is already there. + sibling.shadowRoot.querySelector('.group:last-child .row').click(); + await tick(sibling); + const firstRow = qa(sibling, '.list-row')[0]; + expect(firstRow.querySelector('.name').textContent).toBe('Gothic autumn'); + // Unchecked for the sibling: only the creator's seed is on the list. + expect(firstRow.querySelector('input').checked).toBe(false); + expect(fetches()).toBe(before); + }); + +}); + +describe('ol-book-actions hide-rating', () => { + test('drops the stars but keeps shelves and lists', async() => { + stubFetch(); + const el = await mount({ hideRating: true }); + expect(q(el, '.group.rating')).toBeNull(); + expect(qa(el, '.group.shelves .row')).toHaveLength(4); + expect(q(el, '.group.lists-entry')).not.toBeNull(); + }); + + test('renders the stars by default', async() => { + stubFetch(); + const el = await mount(); + expect(q(el, '.group.rating')).not.toBeNull(); + }); +}); + +describe('ol-book-actions rejected writes', () => { + // bookshelves.json answers a rejected write with 200 and an `error` key, + // so a status-only check would let the optimistic update stand. + test('a 200 carrying `error` rolls the shelf back', async() => { + stubFetch(); + global.fetch = jest.fn(async(url, init) => { + calls.push({ url, init }); + return { ok: true, status: 200, json: async() => ({ error: 'Invalid bookshelf' }) }; + }); + const el = await mount(); + qa(el, '.group.shelves .row')[0].click(); + await tick(el); + expect(el.shelf).toBeNull(); + }); + + // A rating moves two properties, so rolling back the rating alone would + // leave the book on a shelf it was never put on. + test('a rejected rating rolls back the shelf it implied too', async() => { + stubFetch({ failWith: 500 }); + const el = await mount({ shelf: SHELF.CURRENTLY_READING }); + qa(el, '.star')[3].click(); + expect(el.shelf).toBe(SHELF.ALREADY_READ); + await tick(el); + expect(el.rating).toBeNull(); + expect(el.shelf).toBe(SHELF.CURRENTLY_READING); + }); + + test('a second write while one is in flight is dropped', async() => { + stubFetch(); + let land; + global.fetch = jest.fn((url, init) => { + calls.push({ url, init }); + return new Promise(resolve => { land = () => resolve({ ok: true, status: 200, json: async() => ({}) }); }); + }); + const el = await mount(); + + // Both land before the re-render that disables the stars, so the guard + // is what stops the second one. + qa(el, '.star')[2].click(); + qa(el, '.star')[4].click(); + await tick(el); + + expect(calls.filter(c => c.url === '/works/OL1W/ratings.json')).toHaveLength(1); + expect(el.rating).toBe(3); + land(); + }); +}); + +const checkInWrites = () => calls.filter(c => c.url.includes('/check-ins')); +const checkInPane = el => el.shadowRoot.querySelectorAll('.pane')[2]; +const paneRows = el => [...checkInPane(el).querySelectorAll('.row')]; +const yearRows = el => [...checkInPane(el).querySelectorAll('.row.year')]; +const otherDateRow = el => checkInPane(el).querySelector('.row.date-toggle'); + +describe('quickYears', () => { + test('one year once the new year has bedded in', () => { + expect(quickYears(new Date(2026, 7, 22))).toEqual([2026]); + }); + + test('the year just gone stays on offer for the first 30 days', () => { + expect(quickYears(new Date(2026, 0, 25))).toEqual([2026, 2025]); + expect(quickYears(new Date(2026, 0, 1))).toEqual([2026, 2025]); + }); + + test('and drops off after them', () => { + expect(quickYears(new Date(2026, 0, 31))).toEqual([2026]); + }); +}); + +describe('ol-book-actions check-in pane', () => { + test('marking a book read slides the date question in', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + expect(el._pane).toBe('checkIn'); + expect(paneRows(el).map(r => r.textContent.trim())).toEqual([ + 'Today', ...quickYears().map(y => `In ${y}`), 'Other date', + ]); + }); + + test('the other three shelves do not', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[1].click(); + await tick(el); + expect(el._pane).toBe('main'); + }); + + test('a book already on the shelf opens the pane to amend its date', async() => { + stubFetch(); + // What the row's chevron promises — and the only way to change a date + // once given. Coming off the shelf is the main button's job. + const el = await mount({ shelf: SHELF.ALREADY_READ }); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + expect(el._pane).toBe('checkIn'); + expect(calls.find(c => c.url === '/works/OL1W/bookshelves.json')).toBeUndefined(); + }); + + test('rating a book does not, even though the server moves it to Already Read', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.star')[3].click(); + await tick(el); + expect(el.shelf).toBe(SHELF.ALREADY_READ); + expect(el._pane).toBe('main'); + }); + + test('a failed shelf write asks nothing', async() => { + stubFetch({ failWith: 500 }); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + expect(el._pane).toBe('main'); + }); + + test('the date already given rides on the Already Read row', async() => { + stubFetch(); + const el = await mount({ shelf: SHELF.ALREADY_READ, readDate: '2026' }); + const row = qa(el, '.group.shelves .row')[2]; + expect(row.querySelector('.count').textContent).toBe('2026'); + // A chevron, not a check: the row leads to the date pane. + expect(row.querySelector('.trail').getAttribute('name')).toBe('chevron-right'); + }); + + test('a partial date shows only what is known', async() => { + stubFetch(); + const el = await mount({ shelf: SHELF.ALREADY_READ, readDate: '2026-08' }); + expect(qa(el, '.group.shelves .row')[2].querySelector('.count').textContent).toBe('Aug 2026'); + }); + + test('amending a date edits the same check-in rather than adding one', async() => { + stubFetch(); + const el = await mount({ shelf: SHELF.ALREADY_READ, readDate: '2025', eventId: 12 }); + const events = []; + el.addEventListener('ol-book-check-in', e => events.push(e.detail)); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + yearRows(el)[0].click(); + await tick(el); + const body = JSON.parse(checkInWrites()[0].init.body); + expect(body.event_id).toBe(12); + expect(events).toEqual([{ key: '/works/OL1W', date: String(new Date().getFullYear()), eventId: 42 }]); + }); + + test('Today posts a full date', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + paneRows(el)[0].click(); + await tick(el); + const now = new Date(); + expect(JSON.parse(checkInWrites()[0].init.body)).toEqual({ + event_type: 3, + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + edition_key: 'OL9M', + event_id: null, + }); + expect(el._pane).toBe('main'); + }); + + test('this year posts a year on its own', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + yearRows(el)[0].click(); + await tick(el); + const body = JSON.parse(checkInWrites()[0].init.body); + expect(body.year).toBe(new Date().getFullYear()); + expect(body.month).toBeNull(); + expect(body.day).toBeNull(); + }); + + test('other date reveals the selects, month and day gated in turn', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + otherDateRow(el).click(); + await tick(el); + + const selects = () => [...checkInPane(el).querySelectorAll('.select')]; + expect(selects()).toHaveLength(3); + // Disclosed under the row, not in place of it: the one-tap answers + // stay on screen. + expect(paneRows(el)).toHaveLength(2 + yearRows(el).length); + expect(selects()[1].disabled).toBe(true); + expect(selects()[2].disabled).toBe(true); + + el._setDatePart('year', '2024'); + await tick(el); + expect(selects()[1].disabled).toBe(false); + expect(selects()[2].disabled).toBe(true); + + el._setDatePart('month', '2'); + await tick(el); + expect(selects()[2].disabled).toBe(false); + // 2024 is a leap year, so February has to offer the 29th. + expect(selects()[2].querySelectorAll('option')).toHaveLength(30); + }); + + // The pane is as often amending a date as asking for one, so it has to show + // what it already holds — otherwise three unmarked rows read as unanswered. + describe('a date already recorded', () => { + const pad = n => String(n).padStart(2, '0'); + const now = new Date(); + const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + + const openPane = async readDate => { + stubFetch(); + const el = await mount({ shelf: SHELF.ALREADY_READ, readDate }); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + return el; + }; + const marked = el => paneRows(el) + .filter(r => r.getAttribute('aria-current') === 'true') + .map(r => r.querySelector('.label').textContent); + + test('today\'s date marks Today', async() => { + expect(marked(await openPane(today))).toEqual(['Today']); + }); + + test('a bare current year marks that year', async() => { + expect(marked(await openPane(String(now.getFullYear())))).toEqual([`In ${now.getFullYear()}`]); + }); + + test('anything else marks Other date and shows the date on the row', async() => { + const el = await openPane('1998-03-14'); + expect(marked(el)).toEqual(['Other date']); + expect(q(el, '.date-toggle .count').textContent).toBe('Mar 14, 1998'); + }); + + test('no date marks nothing', async() => { + expect(marked(await openPane(null))).toEqual([]); + }); + + // A date the shortcuts cannot express is invisible behind a collapsed + // row, so the pane opens on it. + test('a date no shortcut can express opens the selects, seeded', async() => { + const el = await openPane('1998-03-14'); + expect(el._pickingDate).toBe(true); + expect(qa(el, '.select').map(s => s.value)).toEqual(['1998', '3', '14']); + }); + + test('a partial date seeds only the parts it knows', async() => { + const el = await openPane('1998-03'); + expect(qa(el, '.select').map(s => s.value)).toEqual(['1998', '3', '']); + }); + + test('a date a shortcut covers leaves them closed', async() => { + expect((await openPane(today))._pickingDate).toBe(false); + expect((await openPane(String(now.getFullYear())))._pickingDate).toBe(false); + }); + + // Lit commits a select's own bindings before its children, so seeding + // through the select's .value silently dropped; the selection rides on + // each option instead. Clearing has to survive the same round trip. + test('clearing the year blanks the selects it gated', async() => { + const el = await openPane('1998-03-14'); + el._setDatePart('year', ''); + await tick(el); + expect(qa(el, '.select').map(s => s.value)).toEqual(['', '', '']); + }); + }); + + test('other date is a disclosure, so pressing it again closes the selects', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + + const toggle = () => checkInPane(el).querySelector('.date-toggle'); + // A down chevron, not a right one: nothing is being navigated to. + expect(toggle().querySelector('.trail').getAttribute('name')).toBe('chevron-down'); + expect(toggle().getAttribute('aria-expanded')).toBe('false'); + + toggle().click(); + await tick(el); + expect(toggle().getAttribute('aria-expanded')).toBe('true'); + + toggle().click(); + await tick(el); + expect(toggle().getAttribute('aria-expanded')).toBe('false'); + expect(checkInPane(el).querySelectorAll('.select')).toHaveLength(0); + // Closing the fields stays on the pane rather than backing out of it. + expect(el._pane).toBe('checkIn'); + }); + + test('Today still answers while the selects are open', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + otherDateRow(el).click(); + await tick(el); + paneRows(el)[0].click(); + await tick(el); + expect(JSON.parse(checkInWrites()[0].init.body).day).toBe(new Date().getDate()); + }); + + test('clearing the year clears what it gated', async() => { + stubFetch(); + const el = await mount(); + el._setDatePart('year', '2024'); + el._setDatePart('month', '6'); + el._setDatePart('day', '15'); + el._setDatePart('year', ''); + expect(el._date).toEqual({ year: '', month: '', day: '' }); + }); + + test('a partial date saves as a partial date', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + otherDateRow(el).click(); + await tick(el); + el._setDatePart('year', '2024'); + el._setDatePart('month', '6'); + await tick(el); + checkInPane(el).querySelector('form').dispatchEvent(new Event('submit', { cancelable: true })); + await tick(el); + const body = JSON.parse(checkInWrites()[0].init.body); + expect([body.year, body.month, body.day]).toEqual([2024, 6, null]); + }); + + test('Escape from the pane goes back rather than closing', async() => { + stubFetch(); + const el = await mount(); + qa(el, '.group.shelves .row')[2].click(); + await tick(el); + const event = new CustomEvent('ol-popover-close', { detail: { reason: 'escape' }, cancelable: true }); + q(el, 'ol-popover').dispatchEvent(event); + await tick(el); + expect(event.defaultPrevented).toBe(true); + expect(el._pane).toBe('main'); + }); +}); diff --git a/tests/unit/js/OlBookCover.test.js b/tests/unit/js/OlBookCover.test.js new file mode 100644 index 00000000000..a8dbd7cd5f8 --- /dev/null +++ b/tests/unit/js/OlBookCover.test.js @@ -0,0 +1,116 @@ +/** + * Unit tests for : the artwork/blank-cover branch, the + * accessible name, the optional hover card, and the overlay corner. + */ +import '../../../openlibrary/components/lit/OlBookCover.js'; + +beforeAll(() => { + // ol-tooltip reads matchMedia to detect a hover-capable pointer. + window.matchMedia = query => ({ + matches: false, + media: query, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + }); +}); + +async function mount(props = {}, inner = '') { + const el = document.createElement('ol-book-cover'); + Object.assign(el, { bookTitle: 'The Two Towers', authors: 'J.R.R. Tolkien', ...props }); + el.innerHTML = inner; + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +const q = (el, selector) => el.renderRoot.querySelector(selector); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('ol-book-cover artwork', () => { + test('a src renders the image, lazily, with title and author as its name', async() => { + const el = await mount({ src: '/covers/1-M.jpg' }); + const img = q(el, '.img'); + expect(img.getAttribute('src')).toBe('/covers/1-M.jpg'); + expect(img.getAttribute('loading')).toBe('lazy'); + expect(img.getAttribute('alt')).toBe('The Two Towers by J.R.R. Tolkien'); + expect(q(el, '.blank')).toBeNull(); + }); + + test('no src draws the blank cover, still named for a screen reader', async() => { + const el = await mount(); + const blank = q(el, '.blank'); + expect(blank.getAttribute('role')).toBe('img'); + expect(blank.getAttribute('aria-label')).toBe('The Two Towers by J.R.R. Tolkien'); + expect(q(el, '.blank__title').textContent).toBe('The Two Towers'); + expect(q(el, '.blank__author').textContent).toBe('J.R.R. Tolkien'); + }); + + test('a small blank cover drops the author, which has no room', async() => { + const el = await mount({ size: 'small' }); + expect(q(el, '.blank__title')).not.toBeNull(); + expect(q(el, '.blank__author')).toBeNull(); + }); + + test('with no author the name is the title alone', async() => { + const el = await mount({ src: '/c.jpg', authors: '' }); + expect(q(el, '.img').getAttribute('alt')).toBe('The Two Towers'); + }); + + test('labels override the byline joiner used in the accessible name', async() => { + const el = await mount({ src: '/c.jpg', labels: { by: 'par %(name)s' } }); + expect(q(el, '.img').getAttribute('alt')).toBe('The Two Towers par J.R.R. Tolkien'); + }); +}); + +describe('ol-book-cover link and hover card', () => { + test('an href wraps the artwork in a link and reports the click', async() => { + const el = await mount({ src: '/c.jpg', href: '/works/OL1W' }); + const link = q(el, '.link'); + expect(link.getAttribute('href')).toBe('/works/OL1W'); + + const seen = []; + el.addEventListener('ol-book-cover-click', e => seen.push(e.detail)); + link.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(seen).toEqual([{ href: '/works/OL1W' }]); + }); + + test('without an href the artwork is not a link', async() => { + const el = await mount({ src: '/c.jpg' }); + expect(q(el, '.link')).toBeNull(); + expect(q(el, '.img')).not.toBeNull(); + }); + + test('the hover card carries title, year and author, and wraps the link only', async() => { + const el = await mount({ src: '/c.jpg', href: '/works/OL1W', year: '1954' }); + const tip = q(el, 'ol-tooltip'); + expect(tip.querySelector('.link')).not.toBeNull(); + expect(tip.querySelector('[slot="content"]').textContent.replace(/\s+/g, ' ').trim()) + .toBe('The Two Towers (1954) J.R.R. Tolkien'); + }); + + test('a book with no year shows the title alone in the hover card', async() => { + const el = await mount({ src: '/c.jpg', href: '/w', authors: '' }); + expect(q(el, '.tip__year')).toBeNull(); + expect(q(el, '.tip__byline')).toBeNull(); + expect(q(el, '.tip__title').textContent).toBe('The Two Towers'); + }); +}); + +describe('ol-book-cover overlay', () => { + test('slotted content takes the corner and stays outside the link', async() => { + const el = await mount( + { src: '/c.jpg', href: '/w' }, + '', + ); + const slot = q(el, 'slot[name="overlay"]'); + expect(slot.assignedElements()[0].textContent).toBe('Save'); + // The tooltip wraps the link only, so hovering the save button is not + // hovering the cover. + expect(q(el, 'ol-tooltip').contains(slot)).toBe(false); + }); +}); diff --git a/tests/unit/js/OlShelfButton.test.js b/tests/unit/js/OlShelfButton.test.js new file mode 100644 index 00000000000..703d9002e0f --- /dev/null +++ b/tests/unit/js/OlShelfButton.test.js @@ -0,0 +1,246 @@ +/** + * Unit tests for : the two shapes, the signed-out branch, and + * the statelessness that matters most — it reports a shelf change and never + * writes its own `shelf`, so the surface that owns the book stays the one + * source of truth. Network is stubbed at `fetch`. + */ +import '../../../openlibrary/components/lit/OlShelfButton.js'; +import { SHELF } from '../../../openlibrary/components/lit/utils/books-api.js'; + +let fetchCalls; + +function stubFetch({ ok = true, status = 200 } = {}) { + fetchCalls = []; + global.fetch = jest.fn(async(url, init) => { + fetchCalls.push({ url, init }); + return { ok, status, json: async() => ({ bookshelves_affected: 1 }) }; + }); +} + +beforeAll(() => { + window.matchMedia = query => ({ + matches: false, media: query, + addEventListener() {}, removeEventListener() {}, + addListener() {}, removeListener() {}, + }); + global.ResizeObserver = class { observe() {} disconnect() {} }; +}); + +async function mount(props = {}) { + const el = document.createElement('ol-shelf-button'); + Object.assign(el, { + workKey: '/works/OL1W', + editionKey: 'OL1M', + bookTitle: 'The Two Towers', + shelf: null, + rating: null, + ...props, + }); + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +const q = (el, selector) => el.renderRoot.querySelector(selector); + +afterEach(() => { + document.body.innerHTML = ''; + document.cookie = 'pending_action=; path=/; max-age=0'; +}); + +/** The `pending_action` cookie a signed-out click leaves behind. */ +function pendingAction() { + const match = document.cookie.match(/(?:^|; )pending_action=([^;]*)/); + return match ? JSON.parse(decodeURIComponent(match[1])) : null; +} + +describe('ol-shelf-button shapes', () => { + test('split is the default: a main half and a menu half', async() => { + const el = await mount({ userKey: '/people/tester' }); + stubFetch(); + expect(q(el, '.split')).not.toBeNull(); + expect(q(el, '.main').textContent.trim()).toBe('Want to Read'); + expect(q(el, '.more').getAttribute('aria-label')).toBe('More options for The Two Towers'); + expect(q(el, '.save')).toBeNull(); + }); + + test('icon renders the bookmark and no main half', async() => { + const el = await mount({ variant: 'icon', userKey: '/people/tester' }); + expect(q(el, '.save').getAttribute('aria-label')).toBe('Save The Two Towers to your reading log'); + expect(q(el, '.main')).toBeNull(); + }); + + test('on a shelf, both shapes show it', async() => { + const split = await mount({ shelf: SHELF.ALREADY_READ, userKey: '/people/tester' }); + expect(q(split, '.main').textContent.trim()).toBe('Already Read'); + expect(q(split, '.split').classList.contains('split--on')).toBe(true); + + const icon = await mount({ variant: 'icon', shelf: SHELF.CURRENTLY_READING, userKey: '/people/tester' }); + expect(q(icon, '.save').classList.contains('save--on')).toBe(true); + expect(q(icon, '.save').getAttribute('aria-label')).toBe('The Two Towers is on your reading log'); + expect(q(icon, 'ol-icon').hasAttribute('filled')).toBe(true); + }); + + test('labels override the shelf names', async() => { + const el = await mount({ userKey: '/people/tester', labels: { wantToRead: 'À lire' } }); + expect(q(el, '.main').textContent.trim()).toBe('À lire'); + }); +}); + +describe('ol-shelf-button popover', () => { + test('signed in, the trigger is wrapped in the actions popover with the book\'s state', async() => { + const el = await mount({ shelf: SHELF.ALREADY_READ, rating: 4, userKey: '/people/tester' }); + const actions = q(el, 'ol-book-actions'); + expect(actions).not.toBeNull(); + expect(actions.shelf).toBe(SHELF.ALREADY_READ); + expect(actions.rating).toBe(4); + expect(actions.book).toEqual({ key: '/works/OL1W', title: 'The Two Towers', editionKey: 'OL1M' }); + expect(actions.querySelector('[slot="trigger"]')).not.toBeNull(); + }); + + test('signed out, no popover is built at all', async() => { + const el = await mount(); + expect(q(el, 'ol-book-actions')).toBeNull(); + expect(q(el, '.more')).not.toBeNull(); + }); +}); + +describe('ol-shelf-button state changes', () => { + test('clicking main adds to Want to Read and reports it before the request lands', async() => { + stubFetch(); + const el = await mount({ userKey: '/people/tester' }); + const seen = []; + el.addEventListener('ol-book-state-change', e => seen.push(e.detail)); + + q(el, '.main').click(); + // Reported optimistically, on the same tick as the click. + expect(seen).toEqual([{ key: '/works/OL1W', shelf: SHELF.WANT_TO_READ, rating: null }]); + + await new Promise(r => setTimeout(r, 0)); + const post = fetchCalls.find(c => c.url.endsWith('/works/OL1W/bookshelves.json')); + expect(post.init.method).toBe('POST'); + expect(post.init.body.get('bookshelf_id')).toBe(String(SHELF.WANT_TO_READ)); + expect(post.init.body.get('edition_id')).toBe('OL1M'); + }); + + test('clicking main while on a shelf removes it', async() => { + stubFetch(); + const el = await mount({ shelf: SHELF.ALREADY_READ, rating: 5, userKey: '/people/tester' }); + const seen = []; + el.addEventListener('ol-book-state-change', e => seen.push(e.detail)); + + q(el, '.main').click(); + expect(seen).toEqual([{ key: '/works/OL1W', shelf: null, rating: 5 }]); + + await new Promise(r => setTimeout(r, 0)); + // The removal is a POST against the shelf the book is already on. + const post = fetchCalls.find(c => c.url.endsWith('/works/OL1W/bookshelves.json')); + expect(post.init.body.get('bookshelf_id')).toBe(String(SHELF.ALREADY_READ)); + }); + + test('a second click before the request lands is dropped', async() => { + stubFetch(); + const el = await mount({ userKey: '/people/tester' }); + const seen = []; + el.addEventListener('ol-book-state-change', e => seen.push(e.detail)); + + q(el, '.main').click(); + q(el, '.main').click(); + await new Promise(r => setTimeout(r, 0)); + + // The surface has not applied the first change yet, so without the + // guard the second click would post the same toggle again. + expect(seen).toEqual([{ key: '/works/OL1W', shelf: SHELF.WANT_TO_READ, rating: null }]); + expect(fetchCalls.filter(c => c.url.endsWith('/works/OL1W/bookshelves.json'))).toHaveLength(1); + }); + + test('never writes its own shelf — the surface owns it', async() => { + stubFetch(); + const el = await mount({ userKey: '/people/tester' }); + q(el, '.main').click(); + await new Promise(r => setTimeout(r, 0)); + await el.updateComplete; + // No listener applied the change, so the button still shows the old state. + expect(el.shelf).toBeNull(); + expect(q(el, '.main').textContent.trim()).toBe('Want to Read'); + }); + + test('a failed write is reported back so the surface can roll its state back', async() => { + stubFetch({ ok: false, status: 500 }); + const el = await mount({ userKey: '/people/tester' }); + const seen = []; + el.addEventListener('ol-book-state-change', e => seen.push(e.detail)); + + q(el, '.main').click(); + await new Promise(r => setTimeout(r, 0)); + + expect(seen).toEqual([ + { key: '/works/OL1W', shelf: SHELF.WANT_TO_READ, rating: null }, + { key: '/works/OL1W', shelf: null, rating: null }, + ]); + }); + + test('the event crosses a shadow boundary so a composing parent hears it', async() => { + stubFetch(); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = host.attachShadow({ mode: 'open' }); + const el = document.createElement('ol-shelf-button'); + Object.assign(el, { workKey: '/works/OL2W', shelf: null, userKey: '/people/tester' }); + root.appendChild(el); + await el.updateComplete; + + const seen = []; + document.addEventListener('ol-book-state-change', e => seen.push(e.detail.key)); + q(el, '.main').click(); + expect(seen).toEqual(['/works/OL2W']); + }); +}); + +describe('ol-shelf-button signed out', () => { + // jsdom refuses the navigation redirectToLogin performs, so what is + // asserted here is the part that has to survive it: the click is cancelled + // and the intent is remembered. + test('clicking main cancels the click and remembers the book', async() => { + const el = await mount(); + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + q(el, '.main').dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(pendingAction()).toEqual({ + // The resume target is the page they were on — jsdom's '/' here. + // On a list of results the book's own page would strand them + // somewhere they never asked to go. + name: 'The Two Towers', url: '/', action: 'Want to Read', type: 'book', + }); + }); + + test('clicking the menu half remembers it too, rather than opening nothing', async() => { + const el = await mount(); + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + q(el, '.more').dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(pendingAction().name).toBe('The Two Towers'); + }); + + test('no write is attempted', async() => { + stubFetch(); + const el = await mount(); + q(el, '.main').click(); + await new Promise(r => setTimeout(r, 0)); + expect(fetchCalls).toHaveLength(0); + }); +}); + +describe('ol-shelf-button pass-through to the popover', () => { + test('hands hide-rating to ol-book-actions', async() => { + stubFetch(); + const el = await mount({ userKey: '/people/tester', hideRating: true }); + expect(q(el, 'ol-book-actions').hideRating).toBe(true); + }); + + test('it defaults to off', async() => { + stubFetch(); + const el = await mount({ userKey: '/people/tester' }); + expect(q(el, 'ol-book-actions').hideRating).toBe(false); + }); +});