diff --git a/.env.example b/.env.example index cf3705af0..2397a5b15 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,4 @@ EMAIL_PORT=587 EMAIL_HOST_USER=mail@your.domain.here EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true +EMAIL_USE_SSL=false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..b2cd33f89 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +**/vendor/** diff --git a/.eslintrc.js b/.eslintrc.js index d39859f19..b65fe9885 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,5 +6,85 @@ module.exports = { "es6": true }, - "extends": "eslint:recommended" + "extends": "eslint:recommended", + + "rules": { + // Possible Errors + "no-async-promise-executor": "error", + "no-await-in-loop": "error", + "no-class-assign": "error", + "no-confusing-arrow": "error", + "no-const-assign": "error", + "no-dupe-class-members": "error", + "no-duplicate-imports": "error", + "no-template-curly-in-string": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "require-atomic-updates": "error", + + // Best practices + "strict": "error", + "no-var": "error", + + // Stylistic Issues + "arrow-spacing": "error", + "capitalized-comments": [ + "warn", + "always", + { + "ignoreConsecutiveComments": true + }, + ], + "keyword-spacing": "error", + "lines-around-comment": [ + "error", + { + "beforeBlockComment": true, + "beforeLineComment": true, + "allowBlockStart": true, + "allowClassStart": true, + "allowObjectStart": true, + "allowArrayStart": true, + }, + ], + "no-multiple-empty-lines": [ + "error", + { + "max": 1, + }, + ], + "padded-blocks": [ + "error", + "never", + ], + "padding-line-between-statements": [ + "error", + { + // always before return + "blankLine": "always", + "prev": "*", + "next": "return", + }, + { + // always before block-like expressions + "blankLine": "always", + "prev": "*", + "next": "block-like", + }, + { + // always after variable declaration + "blankLine": "always", + "prev": [ "const", "let", "var" ], + "next": "*", + }, + { + // not necessary between variable declaration + "blankLine": "any", + "prev": [ "const", "let", "var" ], + "next": [ "const", "let", "var" ], + }, + ], + "space-before-blocks": "error", + } }; diff --git a/.github/workflows/lint-frontend.yaml b/.github/workflows/lint-frontend.yaml index 978bbbbe5..f370d83bc 100644 --- a/.github/workflows/lint-frontend.yaml +++ b/.github/workflows/lint-frontend.yaml @@ -3,12 +3,14 @@ name: Lint Frontend on: push: - branches: [ main, ci ] + branches: [ main, ci, frontend ] paths: - '.github/workflows/**' - 'static/**' + - '.eslintrc' + - '.stylelintrc' pull_request: - branches: [ main, ci ] + branches: [ main, ci, frontend ] jobs: lint: @@ -22,8 +24,10 @@ jobs: - name: Install modules run: yarn + # See .stylelintignore for files that are not linted. - name: Run stylelint - run: yarn stylelint **/static/**/*.css --report-needless-disables --report-invalid-scope-disables + run: yarn stylelint bookwyrm/static/**/*.css --report-needless-disables --report-invalid-scope-disables + # See .eslintignore for files that are not linted. - name: Run ESLint - run: yarn eslint . --ext .js,.jsx,.ts,.tsx + run: yarn eslint bookwyrm/static --ext .js,.jsx,.ts,.tsx diff --git a/.stylelintignore b/.stylelintignore index f456cb226..b2cd33f89 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,2 +1 @@ -bookwyrm/static/css/bulma.*.css* -bookwyrm/static/css/icons.css +**/vendor/** diff --git a/README.md b/README.md index e798fedf5..6dad4178a 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ If you edit the CSS or JavaScript, you will need to run Django's `collectstatic` ./bw-dev collectstatic ``` +If you have [installed yarn](https://yarnpkg.com/getting-started/install), you can run `yarn watch:static` to automatically run the previous script every time a change occurs in _bookwyrm/static_ directory. + ### Working with translations and locale files Text in the html files are wrapped in translation tags (`{% trans %}` and `{% blocktrans %}`), and Django generates locale files for all the strings in which you can add translations for the text. You can find existing translations in the `locale/` directory. @@ -156,24 +158,6 @@ The `production` branch of BookWyrm contains a number of tools not on the `main` Instructions for running BookWyrm in production: -- Get the application code: - `git clone git@github.com:mouse-reeve/bookwyrm.git` -- Switch to the `production` branch - `git checkout production` -- Create your environment variables file - `cp .env.example .env` - - Add your domain, email address, SMTP credentials - - Set a secure redis password and secret key - - Set a secure database password for postgres -- Update your nginx configuration in `nginx/default.conf` - - Replace `your-domain.com` with your domain name -- Run the application (this should also set up a Certbot ssl cert for your domain) with - `docker-compose up --build`, and make sure all the images build successfully -- When docker has built successfully, stop the process with `CTRL-C` -- Comment out the `command: certonly...` line in `docker-compose.yml` -- Run docker-compose in the background with: `docker-compose up -d` -- Initialize the database with: `./bw-dev initdb` -- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U ` and saves the backup to a safe location - Get the application code: `git clone git@github.com:mouse-reeve/bookwyrm.git` - Switch to the `production` branch diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index 35b786f71..c67c5dcad 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -10,6 +10,7 @@ from .note import Note, GeneratedNote, Article, Comment, Quotation from .note import Review, Rating from .note import Tombstone from .ordered_collection import OrderedCollection, OrderedCollectionPage +from .ordered_collection import CollectionItem, ListItem, ShelfItem from .ordered_collection import BookList, Shelf from .person import Person, PublicKey from .response import ActivitypubResponse diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 452f61e03..3d922b473 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -111,7 +111,7 @@ class ActivityObject: and hasattr(model, "ignore_activity") and model.ignore_activity(self) ): - raise ActivitySerializerError() + return None # check for an existing instance instance = instance or model.find_existing(self.serialize()) diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py index 6da608322..650f6a407 100644 --- a/bookwyrm/activitypub/ordered_collection.py +++ b/bookwyrm/activitypub/ordered_collection.py @@ -50,3 +50,30 @@ class OrderedCollectionPage(ActivityObject): next: str = None prev: str = None type: str = "OrderedCollectionPage" + + +@dataclass(init=False) +class CollectionItem(ActivityObject): + """ an item in a collection """ + + actor: str + type: str = "CollectionItem" + + +@dataclass(init=False) +class ListItem(CollectionItem): + """ a book on a list """ + + book: str + notes: str = None + approved: bool = True + order: int = None + type: str = "ListItem" + + +@dataclass(init=False) +class ShelfItem(CollectionItem): + """ a book on a list """ + + book: str + type: str = "ShelfItem" diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index 3686b3f3e..c3c84ee5b 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -4,7 +4,7 @@ from typing import List from django.apps import apps from .base_activity import ActivityObject, Signature, resolve_remote_id -from .book import Edition +from .ordered_collection import CollectionItem @dataclass(init=False) @@ -141,37 +141,27 @@ class Reject(Verb): class Add(Verb): """Add activity """ - target: str - object: Edition + target: ActivityObject + object: CollectionItem type: str = "Add" - notes: str = None - order: int = 0 - approved: bool = True def action(self): - """ add obj to collection """ - target = resolve_remote_id(self.target, refresh=False) - # we want to get the related field that isn't the book, this is janky af sorry - model = [t for t in type(target)._meta.related_objects if t.name != "edition"][ - 0 - ].related_model - self.to_model(model=model) + """ figure out the target to assign the item to a collection """ + target = resolve_remote_id(self.target) + item = self.object.to_model(save=False) + setattr(item, item.collection_field, target) + item.save() @dataclass(init=False) -class Remove(Verb): +class Remove(Add): """Remove activity """ - target: ActivityObject type: str = "Remove" def action(self): """ find and remove the activity object """ - target = resolve_remote_id(self.target, refresh=False) - model = [t for t in type(target)._meta.related_objects if t.name != "edition"][ - 0 - ].related_model - obj = self.to_model(model=model, save=False, allow_create=False) + obj = self.object.to_model(save=False, allow_create=False) obj.delete() diff --git a/bookwyrm/migrations/0064_auto_20210408_2208.py b/bookwyrm/migrations/0064_auto_20210408_2208.py new file mode 100644 index 000000000..84a1a1284 --- /dev/null +++ b/bookwyrm/migrations/0064_auto_20210408_2208.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.6 on 2021-04-08 22:08 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0063_auto_20210408_1556"), + ] + + operations = [ + migrations.AlterField( + model_name="listitem", + name="book_list", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="bookwyrm.list" + ), + ), + migrations.AlterField( + model_name="shelfbook", + name="shelf", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.shelf" + ), + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index 8dce42e4e..d0ab829d9 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -64,6 +64,7 @@ class ActivitypubMixin: ) if hasattr(self, "property_fields"): self.activity_fields += [ + # pylint: disable=cell-var-from-loop PropertyField(lambda a, o: set_activity_from_property_field(a, o, f)) for f in self.property_fields ] @@ -356,49 +357,59 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin): class CollectionItemMixin(ActivitypubMixin): """ for items that are part of an (Ordered)Collection """ - activity_serializer = activitypub.Add - object_field = collection_field = None + activity_serializer = activitypub.CollectionItem + + @property + def privacy(self): + """ inherit the privacy of the list, or direct if pending """ + collection_field = getattr(self, self.collection_field) + if self.approved: + return collection_field.privacy + return "direct" + + @property + def recipients(self): + """ the owner of the list is a direct recipient """ + collection_field = getattr(self, self.collection_field) + return [collection_field.user] def save(self, *args, broadcast=True, **kwargs): """ broadcast updated """ - created = not bool(self.id) # first off, we want to save normally no matter what super().save(*args, **kwargs) - # these shouldn't be edited, only created and deleted - if not broadcast or not created or not self.user.local: + # list items can be updateda, normally you would only broadcast on created + if not broadcast or not self.user.local: return # adding an obj to the collection - activity = self.to_add_activity() + activity = self.to_add_activity(self.user) self.broadcast(activity, self.user) - def delete(self, *args, **kwargs): + def delete(self, *args, broadcast=True, **kwargs): """ broadcast a remove activity """ - activity = self.to_remove_activity() + activity = self.to_remove_activity(self.user) super().delete(*args, **kwargs) - if self.user.local: + if self.user.local and broadcast: self.broadcast(activity, self.user) - def to_add_activity(self): + def to_add_activity(self, user): """ AP for shelving a book""" - object_field = getattr(self, self.object_field) collection_field = getattr(self, self.collection_field) return activitypub.Add( - id=self.get_remote_id(), - actor=self.user.remote_id, - object=object_field, + id="{:s}#add".format(collection_field.remote_id), + actor=user.remote_id, + object=self.to_activity_dataclass(), target=collection_field.remote_id, ).serialize() - def to_remove_activity(self): + def to_remove_activity(self, user): """ AP for un-shelving a book""" - object_field = getattr(self, self.object_field) collection_field = getattr(self, self.collection_field) return activitypub.Remove( - id=self.get_remote_id(), - actor=self.user.remote_id, - object=object_field, + id="{:s}#remove".format(collection_field.remote_id), + actor=user.remote_id, + object=self.to_activity_dataclass(), target=collection_field.remote_id, ).serialize() diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 880c41229..4d6b53cde 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -59,11 +59,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel): """ ok """ book = fields.ForeignKey( - "Edition", on_delete=models.PROTECT, activitypub_field="object" - ) - book_list = fields.ForeignKey( - "List", on_delete=models.CASCADE, activitypub_field="target" + "Edition", on_delete=models.PROTECT, activitypub_field="book" ) + book_list = models.ForeignKey("List", on_delete=models.CASCADE) user = fields.ForeignKey( "User", on_delete=models.PROTECT, activitypub_field="actor" ) @@ -72,8 +70,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel): order = fields.IntegerField(blank=True, null=True) endorsement = models.ManyToManyField("User", related_name="endorsers") - activity_serializer = activitypub.Add - object_field = "book" + activity_serializer = activitypub.ListItem collection_field = "book_list" def save(self, *args, **kwargs): diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 3209da5d9..5bbb84b9b 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -66,17 +66,14 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel): """ many to many join table for books and shelves """ book = fields.ForeignKey( - "Edition", on_delete=models.PROTECT, activitypub_field="object" - ) - shelf = fields.ForeignKey( - "Shelf", on_delete=models.PROTECT, activitypub_field="target" + "Edition", on_delete=models.PROTECT, activitypub_field="book" ) + shelf = models.ForeignKey("Shelf", on_delete=models.PROTECT) user = fields.ForeignKey( "User", on_delete=models.PROTECT, activitypub_field="actor" ) - activity_serializer = activitypub.Add - object_field = "book" + activity_serializer = activitypub.ShelfItem collection_field = "shelf" def save(self, *args, **kwargs): diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 52dbc2b20..146d4fff4 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -25,6 +25,7 @@ EMAIL_PORT = env("EMAIL_PORT", 587) EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True) +EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False) DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN")) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/bookwyrm.css similarity index 73% rename from bookwyrm/static/css/format.css rename to bookwyrm/static/css/bookwyrm.css index a01aff827..e4bca2032 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -3,7 +3,6 @@ html { scroll-padding-top: 20%; } -/* --- --- */ .image { overflow: hidden; } @@ -25,17 +24,8 @@ html { min-width: 75% !important; } -/* --- "disabled" for non-buttons --- */ -.is-disabled { - background-color: #dbdbdb; - border-color: #dbdbdb; - box-shadow: none; - color: #7a7a7a; - opacity: 0.5; - cursor: not-allowed; -} - -/* --- SHELVING --- */ +/** Shelving + ******************************************************************************/ /** @todo Replace icons with SVG symbols. @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ @@ -45,7 +35,9 @@ html { margin-left: 0.5em; } -/* --- TOGGLES --- */ +/** Toggles + ******************************************************************************/ + .toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true]:hover { background-color: hsl(171, 100%, 41%); @@ -57,12 +49,8 @@ html { display: none; } -.hidden { - display: none !important; -} - -.hidden.transition-y, -.hidden.transition-x { +.transition-x.is-hidden, +.transition-y.is-hidden { display: block !important; visibility: hidden !important; height: 0; @@ -71,16 +59,18 @@ html { padding: 0; } +.transition-x, .transition-y { - transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom; transition-duration: 0.5s; transition-timing-function: ease; } .transition-x { transition-property: width, margin-left, margin-right, padding-left, padding-right; - transition-duration: 0.5s; - transition-timing-function: ease; +} + +.transition-y { + transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom; } @media (prefers-reduced-motion: reduce) { @@ -121,7 +111,9 @@ html { content: '\e9d7'; } -/* --- BOOK COVERS --- */ +/** Book covers + ******************************************************************************/ + .cover-container { height: 250px; width: max-content; @@ -186,7 +178,9 @@ html { padding: 0.1em; } -/* --- AVATAR --- */ +/** Avatars + ******************************************************************************/ + .avatar { vertical-align: middle; display: inline; @@ -202,25 +196,57 @@ html { min-height: 96px; } -/* --- QUOTES --- */ -.quote blockquote { +/** Statuses: Quotes + * + * \e906: icon-quote-open + * \e905: icon-quote-close + * + * The `content` class on the blockquote allows to apply styles to markdown + * generated HTML in the quote: https://bulma.io/documentation/elements/content/ + * + * ```html + *
+ *
+ * User generated quote in markdown… + *
+ * + *

Book Title by Author

+ *
+ * ``` + ******************************************************************************/ + +.quote > blockquote { position: relative; padding-left: 2em; } -.quote blockquote::before, -.quote blockquote::after { +.quote > blockquote::before, +.quote > blockquote::after { font-family: 'icomoon'; position: absolute; } -.quote blockquote::before { +.quote > blockquote::before { content: "\e906"; top: 0; left: 0; } -.quote blockquote::after { +.quote > blockquote::after { content: "\e905"; right: 0; } + +/* States + ******************************************************************************/ + +/* "disabled" for non-buttons */ + +.is-disabled { + background-color: #dbdbdb; + border-color: #dbdbdb; + box-shadow: none; + color: #7a7a7a; + opacity: 0.5; + cursor: not-allowed; +} diff --git a/bookwyrm/static/css/bulma.css.map b/bookwyrm/static/css/vendor/bulma.css.map similarity index 100% rename from bookwyrm/static/css/bulma.css.map rename to bookwyrm/static/css/vendor/bulma.css.map diff --git a/bookwyrm/static/css/bulma.min.css b/bookwyrm/static/css/vendor/bulma.min.css similarity index 100% rename from bookwyrm/static/css/bulma.min.css rename to bookwyrm/static/css/vendor/bulma.min.css diff --git a/bookwyrm/static/css/icons.css b/bookwyrm/static/css/vendor/icons.css similarity index 86% rename from bookwyrm/static/css/icons.css rename to bookwyrm/static/css/vendor/icons.css index 9915ecd18..c78af145d 100644 --- a/bookwyrm/static/css/icons.css +++ b/bookwyrm/static/css/vendor/icons.css @@ -1,10 +1,13 @@ + +/** @todo Replace icons with SVG symbols. + @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ @font-face { font-family: 'icomoon'; - src: url('fonts/icomoon.eot?n5x55'); - src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'), - url('fonts/icomoon.ttf?n5x55') format('truetype'), - url('fonts/icomoon.woff?n5x55') format('woff'), - url('fonts/icomoon.svg?n5x55#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?n5x55'); + src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?n5x55') format('truetype'), + url('../fonts/icomoon.woff?n5x55') format('woff'), + url('../fonts/icomoon.svg?n5x55#icomoon') format('svg'); font-weight: normal; font-style: normal; font-display: block; diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js new file mode 100644 index 000000000..485daf15b --- /dev/null +++ b/bookwyrm/static/js/bookwyrm.js @@ -0,0 +1,285 @@ +/* exported BookWyrm */ +/* globals TabGroup */ + +let BookWyrm = new class { + constructor() { + this.initOnDOMLoaded(); + this.initReccuringTasks(); + this.initEventListeners(); + } + + initEventListeners() { + document.querySelectorAll('[data-controls]') + .forEach(button => button.addEventListener( + 'click', + this.toggleAction.bind(this)) + ); + + document.querySelectorAll('.interaction') + .forEach(button => button.addEventListener( + 'submit', + this.interact.bind(this)) + ); + + document.querySelectorAll('.hidden-form input') + .forEach(button => button.addEventListener( + 'change', + this.revealForm.bind(this)) + ); + + document.querySelectorAll('[data-back]') + .forEach(button => button.addEventListener( + 'click', + this.back) + ); + } + + /** + * Execute code once the DOM is loaded. + */ + initOnDOMLoaded() { + window.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('.tab-group') + .forEach(tabs => new TabGroup(tabs)); + }); + } + + /** + * Execute recurring tasks. + */ + initReccuringTasks() { + // Polling + document.querySelectorAll('[data-poll]') + .forEach(liveArea => this.polling(liveArea)); + } + + /** + * Go back in browser history. + * + * @param {Event} event + * @return {undefined} + */ + back(event) { + event.preventDefault(); + history.back(); + } + + /** + * Update a counter with recurring requests to the API + * The delay is slightly randomized and increased on each cycle. + * + * @param {Object} counter - DOM node + * @param {int} delay - frequency for polling in ms + * @return {undefined} + */ + polling(counter, delay) { + const bookwyrm = this; + + delay = delay || 10000; + delay += (Math.random() * 1000); + + setTimeout(function() { + fetch('/api/updates/' + counter.dataset.poll) + .then(response => response.json()) + .then(data => bookwyrm.updateCountElement(counter, data)); + + bookwyrm.polling(counter, delay * 1.25); + }, delay, counter); + } + + /** + * Update a counter. + * + * @param {object} counter - DOM node + * @param {object} data - json formatted response from a fetch + * @return {undefined} + */ + updateCountElement(counter, data) { + const currentCount = counter.innerText; + const count = data.count; + + if (count != currentCount) { + this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1); + counter.innerText = count; + } + } + + /** + * Toggle form. + * + * @param {Event} event + * @return {undefined} + */ + revealForm(event) { + let trigger = event.currentTarget; + let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0]; + + this.addRemoveClass(hidden, 'is-hidden', !hidden); + } + + /** + * Execute actions on targets based on triggers. + * + * @param {Event} event + * @return {undefined} + */ + toggleAction(event) { + let trigger = event.currentTarget; + let pressed = trigger.getAttribute('aria-pressed') === 'false'; + let targetId = trigger.dataset.controls; + + // Toggle pressed status on all triggers controlling the same target. + document.querySelectorAll('[data-controls="' + targetId + '"]') + .forEach(otherTrigger => otherTrigger.setAttribute( + 'aria-pressed', + otherTrigger.getAttribute('aria-pressed') === 'false' + )); + + // @todo Find a better way to handle the exception. + if (targetId && ! trigger.classList.contains('pulldown-menu')) { + let target = document.getElementById(targetId); + + this.addRemoveClass(target, 'is-hidden', !pressed); + this.addRemoveClass(target, 'is-active', pressed); + } + + // Show/hide pulldown-menus. + if (trigger.classList.contains('pulldown-menu')) { + this.toggleMenu(trigger, targetId); + } + + // Show/hide container. + let container = document.getElementById('hide-' + targetId); + + if (container) { + this.toggleContainer(container, pressed); + } + + // Check checkbox, if appropriate. + let checkbox = trigger.dataset.controlsCheckbox; + + if (checkbox) { + this.toggleCheckbox(checkbox, pressed); + } + + // Set focus, if appropriate. + let focus = trigger.dataset.focusTarget; + + if (focus) { + this.toggleFocus(focus); + } + } + + /** + * Show or hide menus. + * + * @param {Event} event + * @return {undefined} + */ + toggleMenu(trigger, targetId) { + let expanded = trigger.getAttribute('aria-expanded') == 'false'; + + trigger.setAttribute('aria-expanded', expanded); + + if (targetId) { + let target = document.getElementById(targetId); + + this.addRemoveClass(target, 'is-active', expanded); + } + } + + /** + * Show or hide generic containers. + * + * @param {object} container - DOM node + * @param {boolean} pressed - Is the trigger pressed? + * @return {undefined} + */ + toggleContainer(container, pressed) { + this.addRemoveClass(container, 'is-hidden', pressed); + } + + /** + * Check or uncheck a checbox. + * + * @param {object} checkbox - DOM node + * @param {boolean} pressed - Is the trigger pressed? + * @return {undefined} + */ + toggleCheckbox(checkbox, pressed) { + document.getElementById(checkbox).checked = !!pressed; + } + + /** + * Give the focus to an element. + * Only move the focus based on user interactions. + * + * @param {string} nodeId - ID of the DOM node to focus (button, link…) + * @return {undefined} + */ + toggleFocus(nodeId) { + let node = document.getElementById(nodeId); + + node.focus(); + + setTimeout(function() { + node.selectionStart = node.selectionEnd = 10000; + }, 0); + } + + /** + * Make a request and update the UI accordingly. + * This function is used for boosts, favourites, follows and unfollows. + * + * @param {Event} event + * @return {undefined} + */ + interact(event) { + event.preventDefault(); + + const bookwyrm = this; + const form = event.currentTarget; + const relatedforms = document.querySelectorAll(`.${form.dataset.id}`); + + // Toggle class on all related forms. + relatedforms.forEach(relatedForm => bookwyrm.addRemoveClass( + relatedForm, + 'is-hidden', + relatedForm.className.indexOf('is-hidden') == -1 + )); + + this.ajaxPost(form).catch(error => { + // @todo Display a notification in the UI instead. + console.warn('Request failed:', error); + }); + } + + /** + * Submit a form using POST. + * + * @param {object} form - Form to be submitted + * @return {Promise} + */ + ajaxPost(form) { + return fetch(form.action, { + method : "POST", + body: new FormData(form) + }); + } + + /** + * Add or remove a class based on a boolean condition. + * + * @param {object} node - DOM node to change class on + * @param {string} classname - Name of the class + * @param {boolean} add - Add? + * @return {undefined} + */ + addRemoveClass(node, classname, add) { + if (add) { + node.classList.add(classname); + } else { + node.classList.remove(classname); + } + } +} diff --git a/bookwyrm/static/js/check_all.js b/bookwyrm/static/js/check_all.js index 07d30a686..fd29f2cd6 100644 --- a/bookwyrm/static/js/check_all.js +++ b/bookwyrm/static/js/check_all.js @@ -1,17 +1,34 @@ -/* exported toggleAllCheckboxes */ -/** - * Toggle all descendant checkboxes of a target. - * - * Use `data-target="ID_OF_TARGET"` on the node being listened to. - * - * @param {Event} event - change Event - * @return {undefined} - */ -function toggleAllCheckboxes(event) { - const mainCheckbox = event.target; +(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(`#${mainCheckbox.dataset.target} [type="checkbox"]`) - .forEach(checkbox => {checkbox.checked = mainCheckbox.checked;}); -} + .querySelectorAll('[data-action="toggle-all"]') + .forEach(input => { + input.addEventListener('change', toggleAllCheckboxes); + }); +})(); diff --git a/bookwyrm/static/js/localstorage.js b/bookwyrm/static/js/localstorage.js index aa79ee303..059557799 100644 --- a/bookwyrm/static/js/localstorage.js +++ b/bookwyrm/static/js/localstorage.js @@ -1,20 +1,43 @@ -/* exported updateDisplay */ -/* globals addRemoveClass */ +/* exported LocalStorageTools */ +/* globals BookWyrm */ -// set javascript listeners -function updateDisplay(e) { - // used in set reading goal - var key = e.target.getAttribute('data-id'); - var value = e.target.getAttribute('data-value'); - window.localStorage.setItem(key, value); +let LocalStorageTools = new class { + constructor() { + document.querySelectorAll('[data-hide]') + .forEach(t => this.setDisplay(t)); - document.querySelectorAll('[data-hide="' + key + '"]') - .forEach(t => setDisplay(t)); -} - -function setDisplay(el) { - // used in set reading goal - var key = el.getAttribute('data-hide'); - var value = window.localStorage.getItem(key); - addRemoveClass(el, 'hidden', value); + document.querySelectorAll('.set-display') + .forEach(t => t.addEventListener('click', this.updateDisplay.bind(this))); + } + + /** + * Update localStorage, then display content based on keys in localStorage. + * + * @param {Event} event + * @return {undefined} + */ + updateDisplay(event) { + // used in set reading goal + let key = event.target.dataset.id; + let value = event.target.dataset.value; + + window.localStorage.setItem(key, value); + + document.querySelectorAll('[data-hide="' + key + '"]') + .forEach(node => this.setDisplay(node)); + } + + /** + * Toggle display of a DOM node based on its value in the localStorage. + * + * @param {object} node - DOM node to toggle. + * @return {undefined} + */ + setDisplay(node) { + // used in set reading goal + let key = node.dataset.hide; + let value = window.localStorage.getItem(key); + + BookWyrm.addRemoveClass(node, 'is-hidden', value); + } } diff --git a/bookwyrm/static/js/shared.js b/bookwyrm/static/js/shared.js deleted file mode 100644 index 7a198619c..000000000 --- a/bookwyrm/static/js/shared.js +++ /dev/null @@ -1,169 +0,0 @@ -/* globals setDisplay TabGroup toggleAllCheckboxes updateDisplay */ - -// set up javascript listeners -window.onload = function() { - // buttons that display or hide content - document.querySelectorAll('[data-controls]') - .forEach(t => t.onclick = toggleAction); - - // javascript interactions (boost/fav) - Array.from(document.getElementsByClassName('interaction')) - .forEach(t => t.onsubmit = interact); - - // handle aria settings on menus - Array.from(document.getElementsByClassName('pulldown-menu')) - .forEach(t => t.onclick = toggleMenu); - - // hidden submit button in a form - document.querySelectorAll('.hidden-form input') - .forEach(t => t.onchange = revealForm); - - // polling - document.querySelectorAll('[data-poll]') - .forEach(el => polling(el)); - - // browser back behavior - document.querySelectorAll('[data-back]') - .forEach(t => t.onclick = back); - - Array.from(document.getElementsByClassName('tab-group')) - .forEach(t => new TabGroup(t)); - - // display based on localstorage vars - document.querySelectorAll('[data-hide]') - .forEach(t => setDisplay(t)); - - // update localstorage - Array.from(document.getElementsByClassName('set-display')) - .forEach(t => t.onclick = updateDisplay); - - // Toggle all checkboxes. - document - .querySelectorAll('[data-action="toggle-all"]') - .forEach(input => { - input.addEventListener('change', toggleAllCheckboxes); - }); -}; - -function back(e) { - e.preventDefault(); - history.back(); -} - -function polling(el, delay) { - delay = delay || 10000; - delay += (Math.random() * 1000); - setTimeout(function() { - fetch('/api/updates/' + el.getAttribute('data-poll')) - .then(response => response.json()) - .then(data => updateCountElement(el, data)); - polling(el, delay * 1.25); - }, delay, el); -} - -function updateCountElement(el, data) { - const currentCount = el.innerText; - const count = data.count; - if (count != currentCount) { - addRemoveClass(el.closest('[data-poll-wrapper]'), 'hidden', count < 1); - el.innerText = count; - } -} - - -function revealForm(e) { - var hidden = e.currentTarget.closest('.hidden-form').getElementsByClassName('hidden')[0]; - if (hidden) { - removeClass(hidden, 'hidden'); - } -} - - -function toggleAction(e) { - var el = e.currentTarget; - var pressed = el.getAttribute('aria-pressed') == 'false'; - - var targetId = el.getAttribute('data-controls'); - document.querySelectorAll('[data-controls="' + targetId + '"]') - .forEach(t => t.setAttribute('aria-pressed', (t.getAttribute('aria-pressed') == 'false'))); - - if (targetId) { - var target = document.getElementById(targetId); - addRemoveClass(target, 'hidden', !pressed); - addRemoveClass(target, 'is-active', pressed); - } - - // show/hide container - var container = document.getElementById('hide-' + targetId); - if (container) { - addRemoveClass(container, 'hidden', pressed); - } - - // set checkbox, if appropriate - var checkbox = el.getAttribute('data-controls-checkbox'); - if (checkbox) { - document.getElementById(checkbox).checked = !!pressed; - } - - // set focus, if appropriate - var focus = el.getAttribute('data-focus-target'); - if (focus) { - var focusEl = document.getElementById(focus); - focusEl.focus(); - setTimeout(function(){ focusEl.selectionStart = focusEl.selectionEnd = 10000; }, 0); - } -} - -function interact(e) { - e.preventDefault(); - ajaxPost(e.target); - var identifier = e.target.getAttribute('data-id'); - Array.from(document.getElementsByClassName(identifier)) - .forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1)); -} - -function toggleMenu(e) { - var el = e.currentTarget; - var expanded = el.getAttribute('aria-expanded') == 'false'; - el.setAttribute('aria-expanded', expanded); - var targetId = el.getAttribute('data-controls'); - if (targetId) { - var target = document.getElementById(targetId); - addRemoveClass(target, 'is-active', expanded); - } -} - -function ajaxPost(form) { - fetch(form.action, { - method : "POST", - body: new FormData(form) - }); -} - -function addRemoveClass(el, classname, bool) { - if (bool) { - addClass(el, classname); - } else { - removeClass(el, classname); - } -} - -function addClass(el, classname) { - var classes = el.className.split(' '); - if (classes.indexOf(classname) > -1) { - return; - } - el.className = classes.concat(classname).join(' '); -} - -function removeClass(el, className) { - var classes = []; - if (el.className) { - classes = el.className.split(' '); - } - const idx = classes.indexOf(className); - if (idx > -1) { - classes.splice(idx, 1); - } - el.className = classes.join(' '); -} diff --git a/bookwyrm/static/js/tabs.js b/bookwyrm/static/js/vendor/tabs.js similarity index 100% rename from bookwyrm/static/js/tabs.js rename to bookwyrm/static/js/vendor/tabs.js diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index b91cebbac..d263e0e07 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -6,24 +6,36 @@ {% block title %}{{ book.title }}{% endblock %} {% block content %} -
+{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %} +

- {{ book.title }}{% if book.subtitle %}: - {{ book.subtitle }}{% endif %} + + {{ book.title }}{% if book.subtitle %}: + {{ book.subtitle }} + {% endif %} + + {% if book.series %} - ({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})
+ + + + + ({{ book.series }} + {% if book.series_number %} #{{ book.series_number }}{% endif %}) + +
{% endif %}

{% if book.authors %}

- {% trans "by" %} {% include 'snippets/authors.html' with book=book %} + {% trans "by" %} {% include 'snippets/authors.html' with book=book %}

{% endif %}
- {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} + {% if user_authenticated and can_edit_book %} - {% if request.user.is_authenticated and not book.cover %} + {% if user_authenticated and not book.cover %}
{% trans "Add cover" as button_text %} {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %} @@ -60,7 +72,7 @@ {% if book.isbn_13 %}
{% trans "ISBN:" %}
-
{{ book.isbn_13 }}
+
{{ book.isbn_13 }}
{% endif %} @@ -89,18 +101,35 @@
-

+

+ + {# @todo Is it possible to not hard-code the value? #} + + + {% include 'snippets/stars.html' with rating=rating %} - {% blocktrans count counter=review_count %}({{ review_count }} review){% plural %}({{ review_count }} reviews){% endblocktrans %} + + {% blocktrans count counter=review_count trimmed %} + ({{ review_count }} review) + {% plural %} + ({{ review_count }} reviews) + {% endblocktrans %}

- {% include 'snippets/trimmed_text.html' with full=book|book_description %} + {% with full=book|book_description itemprop='abstract' %} + {% include 'snippets/trimmed_text.html' %} + {% endwith %} - {% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %} + {% if user_authenticated and can_edit_book and not book|book_description %} {% trans 'Add Description' as button_text %} {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %} - -
-
- {% for review in reviews %} -
- {% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %} -
- {% endfor %} - -
- {% for rating in ratings %} -
- -
- {% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %} + +
+ {% for rating in ratings %} + {% with user=rating.user %} +
+
+
+ {% include 'snippets/avatar.html' %} +
+ +
+ +
+

{% trans "rated it" %}

+ + {% include 'snippets/stars.html' with rating=rating.rating %} +
+ +
+
+
+ {% endwith %} + {% endfor %} +
+
+ {% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %} +
- +{% endwith %} {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html index 0ab354012..a16332c5d 100644 --- a/bookwyrm/templates/book/publisher_info.html +++ b/bookwyrm/templates/book/publisher_info.html @@ -1,24 +1,69 @@ +{% spaceless %} + {% load i18n %} +

- {% if book.physical_format and not book.pages %} - {{ book.physical_format | title }} - {% elif book.physical_format and book.pages %} - {% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %} - {% elif book.pages %} - {% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %} - {% endif %} + {% with format=book.physical_format pages=book.pages %} + {% if format %} + {% comment %} + @todo The bookFormat property is limited to a list of values whereas the book edition is free text. + @see https://schema.org/bookFormat + {% endcomment %} + + {% endif %} + + {% if pages %} + + {% endif %} + + {% if format and not pages %} + {% blocktrans %}{{ format }}{% endblocktrans %} + {% elif format and pages %} + {% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %} + {% elif pages %} + {% blocktrans %}{{ pages }} pages{% endblocktrans %} + {% endif %} + {% endwith %}

+ {% if book.languages %} -

- {% blocktrans with languages=book.languages|join:", " %}{{ languages }} language{% endblocktrans %} -

+ {% for language in book.languages %} + + {% endfor %} + +

+ {% with languages=book.languages|join:", " %} + {% blocktrans %}{{ languages }} language{% endblocktrans %} + {% endwith %} +

{% endif %} +

- {% if book.published_date and book.publishers %} - {% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} - {% elif book.published_date %} - {% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %} - {% elif book.publishers %} - {% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %} - {% endif %} + {% with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %} + {% if date or book.first_published_date %} + + {% endif %} + + {% comment %} + @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor. + @see https://schema.org/Publisher + {% endcomment %} + {% if book.publishers %} + {% for publisher in book.publishers %} + + {% endfor %} + {% endif %} + + {% if date and publisher %} + {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} + {% elif date %} + {% blocktrans %}Published {{ date }}{% endblocktrans %} + {% elif publisher %} + {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %} + {% endif %} + {% endwith %}

+{% endspaceless %} diff --git a/bookwyrm/templates/components/dropdown.html b/bookwyrm/templates/components/dropdown.html index 72582ddc3..734d62764 100644 --- a/bookwyrm/templates/components/dropdown.html +++ b/bookwyrm/templates/components/dropdown.html @@ -1,13 +1,34 @@ +{% spaceless %} {% load bookwyrm_tags %} + {% with 0|uuid as uuid %} - {% endwith %} +{% endspaceless %} diff --git a/bookwyrm/templates/components/inline_form.html b/bookwyrm/templates/components/inline_form.html index 40915a928..0b2c1300a 100644 --- a/bookwyrm/templates/components/inline_form.html +++ b/bookwyrm/templates/components/inline_form.html @@ -1,5 +1,5 @@ {% load i18n %} -