diff --git a/.env.dev.example b/.env.dev.example
index 9a4366e02..22e12de12 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -69,7 +69,7 @@ AWS_SECRET_ACCESS_KEY=
# or use_dominant_color_light / use_dominant_color_dark
PREVIEW_BG_COLOR=use_dominant_color_light
# Change to #FFF if you use use_dominant_color_dark
-PREVIEW_TEXT_COLOR="#363636"
+PREVIEW_TEXT_COLOR=#363636
PREVIEW_IMG_WIDTH=1200
PREVIEW_IMG_HEIGHT=630
-PREVIEW_DEFAULT_COVER_COLOR="#002549"
+PREVIEW_DEFAULT_COVER_COLOR=#002549
diff --git a/.env.prod.example b/.env.prod.example
index 56f52a286..2e0ced5e8 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -69,7 +69,7 @@ AWS_SECRET_ACCESS_KEY=
# or use_dominant_color_light / use_dominant_color_dark
PREVIEW_BG_COLOR=use_dominant_color_light
# Change to #FFF if you use use_dominant_color_dark
-PREVIEW_TEXT_COLOR="#363636"
+PREVIEW_TEXT_COLOR=#363636
PREVIEW_IMG_WIDTH=1200
PREVIEW_IMG_HEIGHT=630
-PREVIEW_DEFAULT_COVER_COLOR="#002549"
+PREVIEW_DEFAULT_COVER_COLOR=#002549
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index 1dfc406cb..20a175264 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -256,9 +256,7 @@ def get_data(url, params=None, timeout=10):
params=params,
headers={ # pylint: disable=line-too-long
"Accept": (
- "application/activity+json,"
- ' application/ld+json; profile="https://www.w3.org/ns/activitystreams",'
- " application/json; charset=utf-8"
+ 'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'
),
"User-Agent": settings.USER_AGENT,
},
@@ -266,7 +264,7 @@ def get_data(url, params=None, timeout=10):
)
except RequestException as err:
logger.exception(err)
- raise ConnectorException()
+ raise ConnectorException(err)
if not resp.ok:
raise ConnectorException()
@@ -274,7 +272,7 @@ def get_data(url, params=None, timeout=10):
data = resp.json()
except ValueError as err:
logger.exception(err)
- raise ConnectorException()
+ raise ConnectorException(err)
return data
diff --git a/bookwyrm/importers/__init__.py b/bookwyrm/importers/__init__.py
index d4890070b..dd3d62e8b 100644
--- a/bookwyrm/importers/__init__.py
+++ b/bookwyrm/importers/__init__.py
@@ -3,4 +3,5 @@
from .importer import Importer
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
+from .openlibrary_import import OpenLibraryImporter
from .storygraph_import import StorygraphImporter
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
index 94e6734e7..32800e772 100644
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -26,7 +26,7 @@ class Importer:
("authors", ["author", "authors", "primary author"]),
("isbn_10", ["isbn10", "isbn"]),
("isbn_13", ["isbn13", "isbn", "isbns"]),
- ("shelf", ["shelf", "exclusive shelf", "read status"]),
+ ("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]),
("review_name", ["review name"]),
("review_body", ["my review", "review"]),
("rating", ["my rating", "rating", "star rating"]),
@@ -36,9 +36,9 @@ class Importer:
]
date_fields = ["date_added", "date_started", "date_finished"]
shelf_mapping_guesses = {
- "to-read": ["to-read"],
- "read": ["read"],
- "reading": ["currently-reading", "reading"],
+ "to-read": ["to-read", "want to read"],
+ "read": ["read", "already read"],
+ "reading": ["currently-reading", "reading", "currently reading"],
}
def create_job(self, user, csv_file, include_reviews, privacy):
@@ -90,7 +90,10 @@ class Importer:
def get_shelf(self, normalized_row):
"""determine which shelf to use"""
- shelf_name = normalized_row["shelf"]
+ shelf_name = normalized_row.get("shelf")
+ if not shelf_name:
+ return None
+ shelf_name = shelf_name.lower()
shelf = [
s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs
]
@@ -106,6 +109,7 @@ class Importer:
user=user,
include_reviews=original_job.include_reviews,
privacy=original_job.privacy,
+ source=original_job.source,
# TODO: allow users to adjust mappings
mappings=original_job.mappings,
retry=True,
diff --git a/bookwyrm/importers/openlibrary_import.py b/bookwyrm/importers/openlibrary_import.py
new file mode 100644
index 000000000..ef1030609
--- /dev/null
+++ b/bookwyrm/importers/openlibrary_import.py
@@ -0,0 +1,13 @@
+""" handle reading a csv from openlibrary"""
+from . import Importer
+
+
+class OpenLibraryImporter(Importer):
+ """csv downloads from OpenLibrary"""
+
+ service = "OpenLibrary"
+
+ def __init__(self, *args, **kwargs):
+ self.row_mappings_guesses.append(("openlibrary_key", ["edition id"]))
+ self.row_mappings_guesses.append(("openlibrary_work_key", ["work id"]))
+ super().__init__(*args, **kwargs)
diff --git a/bookwyrm/importers/storygraph_import.py b/bookwyrm/importers/storygraph_import.py
index 9368115d4..67cbaa663 100644
--- a/bookwyrm/importers/storygraph_import.py
+++ b/bookwyrm/importers/storygraph_import.py
@@ -3,6 +3,6 @@ from . import Importer
class StorygraphImporter(Importer):
- """csv downloads from librarything"""
+ """csv downloads from Storygraph"""
service = "Storygraph"
diff --git a/bookwyrm/migrations/0121_user_summary_keys.py b/bookwyrm/migrations/0121_user_summary_keys.py
new file mode 100644
index 000000000..b14c92bea
--- /dev/null
+++ b/bookwyrm/migrations/0121_user_summary_keys.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.5 on 2021-12-22 11:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0120_list_embed_key"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="summary_keys",
+ field=models.JSONField(null=True),
+ ),
+ ]
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index c46795856..919bbf0db 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -25,7 +25,7 @@ def construct_search_term(title, author):
# Strip brackets (usually series title from search term)
title = re.sub(r"\s*\([^)]*\)\s*", "", title)
# Open library doesn't like including author initials in search term.
- author = re.sub(r"(\w\.)+\s*", "", author)
+ author = re.sub(r"(\w\.)+\s*", "", author) if author else ""
return " ".join([title, author])
@@ -88,7 +88,9 @@ class ImportItem(models.Model):
return
if self.isbn:
- self.book = self.get_book_from_isbn()
+ self.book = self.get_book_from_identifier()
+ elif self.openlibrary_key:
+ self.book = self.get_book_from_identifier(field="openlibrary_key")
else:
# don't fall back on title/author search if isbn is present.
# you're too likely to mismatch
@@ -98,10 +100,10 @@ class ImportItem(models.Model):
else:
self.book_guess = book
- def get_book_from_isbn(self):
- """search by isbn"""
+ def get_book_from_identifier(self, field="isbn"):
+ """search by isbn or other unique identifier"""
search_result = connector_manager.first_search_result(
- self.isbn, min_confidence=0.999
+ getattr(self, field), min_confidence=0.999
)
if search_result:
# it's already in the right format
@@ -114,6 +116,8 @@ class ImportItem(models.Model):
def get_book_from_title_author(self):
"""search by title and author"""
+ if not self.title:
+ return None, 0
search_term = construct_search_term(self.title, self.author)
search_result = connector_manager.first_search_result(
search_term, min_confidence=0.1
@@ -145,6 +149,13 @@ class ImportItem(models.Model):
self.normalized_data.get("isbn_10")
)
+ @property
+ def openlibrary_key(self):
+ """the edition identifier is preferable to the work key"""
+ return self.normalized_data.get("openlibrary_key") or self.normalized_data.get(
+ "openlibrary_work_key"
+ )
+
@property
def shelf(self):
"""the goodreads shelf field"""
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 4d98f5c57..deec2a441 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -148,6 +148,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
size=8,
default=get_feed_filter_choices,
)
+ # annual summary keys
+ summary_keys = models.JSONField(null=True)
preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css
index f385e6295..4d9aabb4a 100644
--- a/bookwyrm/static/css/bookwyrm.css
+++ b/bookwyrm/static/css/bookwyrm.css
@@ -93,6 +93,9 @@ body {
display: inline !important;
}
+/** File input styles
+ ******************************************************************************/
+
input[type=file]::file-selector-button {
-moz-appearance: none;
-webkit-appearance: none;
@@ -119,17 +122,15 @@ input[type=file]::file-selector-button:hover {
color: #363636;
}
-details .dropdown-menu {
- display: block !important;
+/** General `details` element styles
+ ******************************************************************************/
+
+summary {
+ cursor: pointer;
}
-details.dropdown[open] summary.dropdown-trigger::before {
- content: "";
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
+summary::-webkit-details-marker {
+ display: none;
}
summary::marker {
@@ -147,6 +148,57 @@ summary::marker {
margin-top: 1em;
}
+/** Details dropdown
+ ******************************************************************************/
+
+details.dropdown[open] summary.dropdown-trigger::before {
+ content: "";
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+}
+
+details .dropdown-menu {
+ display: block !important;
+}
+
+details .dropdown-menu button {
+ /* Fix weird Safari defaults */
+ box-sizing: border-box;
+}
+
+details.dropdown .dropdown-menu button:focus-visible,
+details.dropdown .dropdown-menu a:focus-visible {
+ outline-style: auto;
+ outline-offset: -2px;
+}
+
+@media only screen and (max-width: 768px) {
+ details.dropdown[open] summary.dropdown-trigger::before {
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 30;
+ }
+
+ details .dropdown-menu {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ z-index: 100;
+ }
+
+ details .dropdown-menu > * {
+ pointer-events: all;
+ }
+}
+
/** Shelving
******************************************************************************/
@@ -555,6 +607,68 @@ ol.ordered-list li::before {
padding: 0 0.75em;
}
+/* Breadcrumbs
+ ******************************************************************************/
+
+.books-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(10em, 1fr));
+ gap: 1.5rem;
+ align-items: end;
+ justify-items: center;
+}
+
+.books-grid > .is-big {
+ grid-column: span 2;
+ grid-row: span 2;
+ justify-self: stretch;
+ padding: 1.5rem 1.5rem 0;
+}
+
+.books-grid .book-cover {
+ width: 100%;
+}
+
+.books-grid .book-title {
+ --height-basis: 1.35rem;
+
+ display: block;
+ margin-top: 0.5rem;
+ line-height: var(--height-basis);
+ min-height: calc(2 * var(--height-basis));
+}
+
+/* Copy
+ ******************************************************************************/
+
+.horizontal-copy {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.horizontal-copy textarea {
+ min-width: initial;
+ white-space: nowrap;
+}
+
+.horizontal-copy button {
+ align-self: stretch;
+ height: unset;
+}
+
+.vertical-copy {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.75rem;
+}
+
+.vertical-copy button {
+ width: 100%;
+}
+
/* Dimensions
* @todo These could be in rem.
******************************************************************************/
diff --git a/bookwyrm/static/css/fonts/dm_serif_display/OFL.txt b/bookwyrm/static/css/fonts/dm_serif_display/OFL.txt
new file mode 100644
index 000000000..929fd66e7
--- /dev/null
+++ b/bookwyrm/static/css/fonts/dm_serif_display/OFL.txt
@@ -0,0 +1,95 @@
+Copyright 2014-2018 Adobe (http://www.adobe.com/), with Reserved Font Name
+'Source'. All Rights Reserved. Source is a trademark of Adobe in the United
+States and/or other countries. Copyright 2019 Google LLC.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff b/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff
new file mode 100644
index 000000000..a51242aeb
Binary files /dev/null and b/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff differ
diff --git a/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff2 b/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff2
new file mode 100644
index 000000000..a2058d588
Binary files /dev/null and b/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff2 differ
diff --git a/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff b/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff
new file mode 100644
index 000000000..db28fb1de
Binary files /dev/null and b/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff differ
diff --git a/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff2 b/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff2
new file mode 100644
index 000000000..ac4cd6b4f
Binary files /dev/null and b/bookwyrm/static/css/fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff2 differ
diff --git a/bookwyrm/static/css/fonts/dm_serif_display/generator_config.txt b/bookwyrm/static/css/fonts/dm_serif_display/generator_config.txt
new file mode 100644
index 000000000..dd0e038cf
--- /dev/null
+++ b/bookwyrm/static/css/fonts/dm_serif_display/generator_config.txt
@@ -0,0 +1,30 @@
+# Font Squirrel Font-face Generator Configuration File
+# Upload this file to the generator to recreate the settings
+# you used to create these fonts.
+
+{
+ "mode": "optimal",
+ "formats":
+ [
+ "woff",
+ "woff2"
+ ],
+ "tt_instructor": "default",
+ "fix_gasp": "xy",
+ "fix_vertical_metrics": "Y",
+ "metrics_ascent": "",
+ "metrics_descent": "",
+ "metrics_linegap": "",
+ "add_spaces": "Y",
+ "add_hyphens": "Y",
+ "fallback": "none",
+ "fallback_custom": "100",
+ "options_subset": "basic",
+ "subset_custom": "",
+ "subset_custom_range": "",
+ "subset_ot_features_list": "",
+ "css_stylesheet": "stylesheet.css",
+ "filename_suffix": "-webfont",
+ "emsquare": "2048",
+ "spacing_adjustment": "0"
+}
diff --git a/bookwyrm/static/css/vendor/dm_serif_display.css b/bookwyrm/static/css/vendor/dm_serif_display.css
new file mode 100644
index 000000000..53a014b0e
--- /dev/null
+++ b/bookwyrm/static/css/vendor/dm_serif_display.css
@@ -0,0 +1,19 @@
+@font-face {
+ font-family: 'dm_serif_display';
+ src: url('../fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff2') format('woff2'),
+ url('../fonts/dm_serif_display/dmserifdisplay-italic-webfont.woff') format('woff');
+ font-weight: normal;
+ font-style: italic;
+}
+
+@font-face {
+ font-family: 'dm_serif_display';
+ src: url('../fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff2') format('woff2'),
+ url('../fonts/dm_serif_display/dmserifdisplay-regular-webfont.woff') format('woff');
+ font-weight: normal;
+ font-style: normal;
+}
+
+.is-serif {
+ font-family: 'dm_serif_display', Georgia, serif;
+}
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index 2b78bf518..c79471fe7 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -51,6 +51,12 @@ let BookWyrm = new class {
'click',
this.duplicateInput.bind(this)
+ ))
+ document.querySelectorAll('details.dropdown')
+ .forEach(node => node.addEventListener(
+ 'toggle',
+ this.handleDetailsDropdown.bind(this)
+
))
}
@@ -465,10 +471,8 @@ let BookWyrm = new class {
copyButtonEl.textContent = textareaEl.dataset.copytextLabel;
copyButtonEl.classList.add(
- "mt-2",
"button",
"is-small",
- "is-fullwidth",
"is-primary",
"is-light"
);
@@ -482,4 +486,101 @@ let BookWyrm = new class {
textareaEl.parentNode.appendChild(copyButtonEl)
}
+
+ /**
+ * Handle the details dropdown component.
+ *
+ * @param {Event} event - Event fired by a `details` element
+ * with the `dropdown` class name, on toggle.
+ * @return {undefined}
+ */
+ handleDetailsDropdown(event) {
+ const detailsElement = event.target;
+ const summaryElement = detailsElement.querySelector('summary');
+ const menuElement = detailsElement.querySelector('.dropdown-menu');
+ const htmlElement = document.querySelector('html');
+
+ if (detailsElement.open) {
+ // Focus first menu element
+ menuElement.querySelectorAll(
+ 'a[href]:not([disabled]), button:not([disabled])'
+ )[0].focus();
+
+ // Enable focus trap
+ menuElement.addEventListener('keydown', this.handleFocusTrap);
+
+ // Close on Esc
+ detailsElement.addEventListener('keydown', handleEscKey);
+
+ // Clip page if Mobile
+ if (this.isMobile()) {
+ htmlElement.classList.add('is-clipped');
+ }
+ } else {
+ summaryElement.focus();
+
+ // Disable focus trap
+ menuElement.removeEventListener('keydown', this.handleFocusTrap);
+
+ // Unclip page
+ if (this.isMobile()) {
+ htmlElement.classList.remove('is-clipped');
+ }
+ }
+
+ function handleEscKey(event) {
+ if (event.key !== 'Escape') {
+ return;
+ }
+
+ summaryElement.click();
+ }
+ }
+
+ /**
+ * Check if windows matches mobile media query.
+ *
+ * @return {Boolean}
+ */
+ isMobile() {
+ return window.matchMedia("(max-width: 768px)").matches;
+ }
+
+ /**
+ * Focus trap handler
+ *
+ * @param {Event} event - Keydown event.
+ * @return {undefined}
+ */
+ handleFocusTrap(event) {
+ if (event.key !== 'Tab') {
+ return;
+ }
+
+ const focusableEls = event.currentTarget.querySelectorAll(
+ [
+ 'a[href]:not([disabled])',
+ 'button:not([disabled])',
+ 'textarea:not([disabled])',
+ 'input:not([type="hidden"]):not([disabled])',
+ 'select:not([disabled])',
+ 'details:not([disabled])',
+ '[tabindex]:not([tabindex="-1"]):not([disabled])'
+ ].join(',')
+ );
+ const firstFocusableEl = focusableEls[0];
+ const lastFocusableEl = focusableEls[focusableEls.length - 1];
+
+ if (event.shiftKey ) /* Shift + tab */ {
+ if (document.activeElement === firstFocusableEl) {
+ lastFocusableEl.focus();
+ event.preventDefault();
+ }
+ } else /* Tab */ {
+ if (document.activeElement === lastFocusableEl) {
+ firstFocusableEl.focus();
+ event.preventDefault();
+ }
+ }
+ }
}();
diff --git a/bookwyrm/static/js/check_all.js b/bookwyrm/static/js/check_all.js
deleted file mode 100644
index fd29f2cd6..000000000
--- a/bookwyrm/static/js/check_all.js
+++ /dev/null
@@ -1,34 +0,0 @@
-
-(function() {
- 'use strict';
-
- /**
- * Toggle all descendant checkboxes of a target.
- *
- * Use `data-target="ID_OF_TARGET"` on the node on which the event is listened
- * to (checkbox, button, link…), where_ID_OF_TARGET_ should be the ID of an
- * ancestor for the checkboxes.
- *
- * @example
- *
- * @param {Event} event
- * @return {undefined}
- */
- function toggleAllCheckboxes(event) {
- const mainCheckbox = event.target;
-
- document
- .querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
- .forEach(checkbox => checkbox.checked = mainCheckbox.checked);
- }
-
- document
- .querySelectorAll('[data-action="toggle-all"]')
- .forEach(input => {
- input.addEventListener('change', toggleAllCheckboxes);
- });
-})();
diff --git a/bookwyrm/templates/annual_summary/layout.html b/bookwyrm/templates/annual_summary/layout.html
new file mode 100644
index 000000000..ac418d70e
--- /dev/null
+++ b/bookwyrm/templates/annual_summary/layout.html
@@ -0,0 +1,256 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load static %}
+
+
+{% block title %}{% blocktrans %}{{ year }} in the books{% endblocktrans %}{% endblock %}
+
+{% block head_links %}
+
+{% endblock %}
+
+{% block content %}
+{% with display_name=summary_user.display_name %}
+{% if user == summary_user %}
+
+ {% with year=paginated_years|first %}
+ {% if year %}
+
+ 📚✨
+ {% blocktrans %}{{ year }} in the books{% endblocktrans %}
+ ✨📚
+
+
+ {% blocktrans %}{{ display_name }}’s year of reading{% endblocktrans %}
+
+
+
+
+
+ {% trans "Share this page" %}
+
+
+
+
+
+ {% if year_key %}
+
+
+
+ {% endif %}
+
+ {% if user == summary_user %}
+ {% if year_key %}
+
+
+
{% trans "Sharing status: public with key" %}
+
{% trans "The page can be seen by anyone with the complete address." %}
+
+
+
+ {% else %}
+
+
+
{% trans "Sharing status: private" %}
+
{% trans "The page is private, only you can see it." %}
+
+
+
+ {% endif %}
+
{% trans "When you make your page private, the old key won’t give access to the page anymore. A new key will be created if the page is once again made public." %}
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+{% if not books %}
+
{% blocktrans %}Sadly {{ display_name }} didn’t finish any book in {{ year }}{% endblocktrans %}
+{% else %}
+
+
+
+
+ {% blocktrans %}In {{ year }}, {{ display_name }} read {{ books_total }} books for a total of {{ pages_total }} pages!{% endblocktrans %}
+
+
{% trans "That’s great!" %}
+
+
+ {% blocktrans %}That makes an average of {{ pages_average }} pages per book.{% endblocktrans %}
+
+{% endif %}
+{% endwith %}
+{% endblock %}
diff --git a/bookwyrm/templates/components/dropdown.html b/bookwyrm/templates/components/dropdown.html
index b3710271a..afa6d050b 100644
--- a/bookwyrm/templates/components/dropdown.html
+++ b/bookwyrm/templates/components/dropdown.html
@@ -7,6 +7,7 @@
class="
dropdown control
{% if right %}is-right{% endif %}
+ has-text-left
"
>
-
+
-{% if request.user.show_goal and not goal and tab.key == 'home' %}
-{% now 'Y' as year %}
-
- {% include 'feed/goal_card.html' with year=year %}
-
-
-{% endif %}
+ {% if request.user.show_goal and not goal and tab.key == 'home' %}
+ {% now 'Y' as year %}
+
+ {% include 'feed/goal_card.html' with year=year %}
+
+
+ {% endif %}
+ {% if annual_summary_year and tab.key == 'home' %}
+
+ {% include 'feed/summary_card.html' with year=annual_summary_year %}
+
+
+ {% endif %}
{% endif %}
{# activity feed #}
diff --git a/bookwyrm/templates/feed/summary_card.html b/bookwyrm/templates/feed/summary_card.html
new file mode 100644
index 000000000..a5bc4643a
--- /dev/null
+++ b/bookwyrm/templates/feed/summary_card.html
@@ -0,0 +1,22 @@
+{% extends 'components/card.html' %}
+{% load i18n %}
+
+{% block card-header %}
+
+ 📚
+ ✨
+ {% blocktrans %}{{ year }} in the books{% endblocktrans %}
+
+{% endblock %}
+
+{% block card-content %}
+
+{% blocktrans %}The end of the year is the best moment to take stock of all the books read during the last 12 months. How many pages have you read? Which book is your best-rated of the year? We compiled these stats, and more!{% endblocktrans %}
+