diff --git a/.env.dev.example b/.env.dev.example deleted file mode 100644 index 22e12de1..00000000 --- a/.env.dev.example +++ /dev/null @@ -1,75 +0,0 @@ -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr" - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG=true -USE_HTTPS=false - -DOMAIN=your.domain.here -#EMAIL=your@email.here - -# Used for deciding which editions to prefer -DEFAULT_LANGUAGE="English" - -## Leave unset to allow all hosts -# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" - -MEDIA_ROOT=images/ - -PGPORT=5432 -POSTGRES_PASSWORD=securedbypassword123 -POSTGRES_USER=fedireads -POSTGRES_DB=fedireads -POSTGRES_HOST=db - -# Redis activity stream manager -MAX_STREAM_LENGTH=200 -REDIS_ACTIVITY_HOST=redis_activity -REDIS_ACTIVITY_PORT=6379 -#REDIS_ACTIVITY_PASSWORD=redispassword345 - -# Redis as celery broker -REDIS_BROKER_PORT=6379 -#REDIS_BROKER_PASSWORD=redispassword123 - -FLOWER_PORT=8888 -#FLOWER_USER=mouse -#FLOWER_PASSWORD=changeme - -EMAIL_HOST=smtp.mailgun.org -EMAIL_PORT=587 -EMAIL_HOST_USER=mail@your.domain.here -EMAIL_HOST_PASSWORD=emailpassword123 -EMAIL_USE_TLS=true -EMAIL_USE_SSL=false - -# Thumbnails Generation -ENABLE_THUMBNAIL_GENERATION=false - -# S3 configuration -USE_S3=false -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= - -# Commented are example values if you use a non-AWS, S3-compatible service -# AWS S3 should work with only AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME -# non-AWS S3-compatible services will need AWS_STORAGE_BUCKET_NAME, -# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL - -# AWS_STORAGE_BUCKET_NAME= # "example-bucket-name" -# AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud" -# AWS_S3_REGION_NAME=None # "fr-par" -# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud" - - -# Preview image generation can be computing and storage intensive -# ENABLE_PREVIEW_IMAGES=True - -# Specify RGB tuple or RGB hex strings, -# 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_IMG_WIDTH=1200 -PREVIEW_IMG_HEIGHT=630 -PREVIEW_DEFAULT_COVER_COLOR=#002549 diff --git a/.env.prod.example b/.env.example similarity index 93% rename from .env.prod.example rename to .env.example index e85e2e8a..9f315a80 100644 --- a/.env.prod.example +++ b/.env.example @@ -16,6 +16,7 @@ DEFAULT_LANGUAGE="English" MEDIA_ROOT=images/ +# Database configuration PGPORT=5432 POSTGRES_PASSWORD=securedbypassword123 POSTGRES_USER=fedireads @@ -32,16 +33,25 @@ REDIS_ACTIVITY_PASSWORD=redispassword345 REDIS_BROKER_PORT=6379 REDIS_BROKER_PASSWORD=redispassword123 +# Monitoring for celery FLOWER_PORT=8888 FLOWER_USER=mouse FLOWER_PASSWORD=changeme +# Email config EMAIL_HOST=smtp.mailgun.org EMAIL_PORT=587 EMAIL_HOST_USER=mail@your.domain.here EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true EMAIL_USE_SSL=false +EMAIL_SENDER_NAME=admin +# defaults to DOMAIN +EMAIL_SENDER_DOMAIN= + +# Query timeouts +SEARCH_TIMEOUT=15 +QUERY_TIMEOUT=5 # Thumbnails Generation ENABLE_THUMBNAIL_GENERATION=false diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index 03875193..00e08dad 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -46,6 +46,8 @@ jobs: POSTGRES_HOST: 127.0.0.1 CELERY_BROKER: "" REDIS_BROKER_PORT: 6379 + REDIS_BROKER_PASSWORD: beep + USE_DUMMY_CACHE: true FLOWER_PORT: 8888 EMAIL_HOST: "smtp.mailgun.org" EMAIL_PORT: 587 diff --git a/.github/workflows/prettier.yaml b/.github/workflows/prettier.yaml new file mode 100644 index 00000000..80696060 --- /dev/null +++ b/.github/workflows/prettier.yaml @@ -0,0 +1,24 @@ +# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions +name: JavaScript Prettier (run ./bw-dev prettier to fix) + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + name: Lint with Prettier + runs-on: ubuntu-20.04 + + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it. + - uses: actions/checkout@v2 + + - name: Install modules + run: npm install . + + # See .stylelintignore for files that are not linted. + - name: Run Prettier + run: npx prettier --check bookwyrm/static/js/*.js diff --git a/.stylelintrc.js b/.stylelintrc.js index eadc4a89..68c68f89 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -12,6 +12,9 @@ module.exports = { "custom-properties", "declarations" ], - "indentation": 4 + "indentation": 4, + "property-no-vendor-prefix": null, + "color-function-notation": null, + "declaration-block-no-redundant-longhand-properties": null, } }; diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index 4cba9939..f2dd43fb 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -397,9 +397,15 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg """build a user's feeds when they join""" if not created or not instance.local: return + transaction.on_commit( + lambda: populate_streams_on_account_create_command(instance.id) + ) + +def populate_streams_on_account_create_command(instance_id): + """wait for the transaction to complete""" for stream in streams: - populate_stream_task.delay(stream, instance.id) + populate_stream_task.delay(stream, instance_id) @receiver(signals.pre_save, sender=models.ShelfBook) diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 20a17526..5ed57df1 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -35,7 +35,7 @@ class AbstractMinimalConnector(ABC): for field in self_fields: setattr(self, field, getattr(info, field)) - def search(self, query, min_confidence=None, timeout=5): + def search(self, query, min_confidence=None, timeout=settings.QUERY_TIMEOUT): """free text search""" params = {} if min_confidence: @@ -52,12 +52,13 @@ class AbstractMinimalConnector(ABC): results.append(self.format_search_result(doc)) return results - def isbn_search(self, query): + def isbn_search(self, query, timeout=settings.QUERY_TIMEOUT): """isbn search""" params = {} data = self.get_search_data( f"{self.isbn_search_url}{query}", params=params, + timeout=timeout, ) results = [] diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 45530cd6..3bdd5cb4 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -11,6 +11,7 @@ from django.db.models import signals from requests import HTTPError from bookwyrm import book_search, models +from bookwyrm.settings import SEARCH_TIMEOUT from bookwyrm.tasks import app logger = logging.getLogger(__name__) @@ -30,7 +31,6 @@ def search(query, min_confidence=0.1, return_first=False): isbn = re.sub(r"[\W_]", "", query) maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13 - timeout = 15 start_time = datetime.now() for connector in get_connectors(): result_set = None @@ -62,7 +62,7 @@ def search(query, min_confidence=0.1, return_first=False): "results": result_set, } ) - if (datetime.now() - start_time).seconds >= timeout: + if (datetime.now() - start_time).seconds >= SEARCH_TIMEOUT: break if return_first: diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 08fd9ef8..efef1263 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -69,7 +69,7 @@ def format_email(email_name, data): def send_email(recipient, subject, html_content, text_content): """use a task to send the email""" email = EmailMultiAlternatives( - subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient] + subject, text_content, settings.EMAIL_SENDER, [recipient] ) email.attach_alternative(html_content, "text/html") email.send() diff --git a/bookwyrm/importers/librarything_import.py b/bookwyrm/importers/librarything_import.py index 1b61a6f1..37730dee 100644 --- a/bookwyrm/importers/librarything_import.py +++ b/bookwyrm/importers/librarything_import.py @@ -14,7 +14,8 @@ class LibrarythingImporter(Importer): """use the dataclass to create the formatted row of data""" remove_brackets = lambda v: re.sub(r"\[|\]", "", v) if v else None normalized = {k: remove_brackets(entry.get(v)) for k, v in mappings.items()} - isbn_13 = normalized["isbn_13"].split(", ") + isbn_13 = normalized.get("isbn_13") + isbn_13 = isbn_13.split(", ") if isbn_13 else [] normalized["isbn_13"] = isbn_13[1] if len(isbn_13) > 0 else None return normalized diff --git a/bookwyrm/lists_stream.py b/bookwyrm/lists_stream.py new file mode 100644 index 00000000..f6a35cc2 --- /dev/null +++ b/bookwyrm/lists_stream.py @@ -0,0 +1,251 @@ +""" access the list streams stored in redis """ +from django.dispatch import receiver +from django.db import transaction +from django.db.models import signals, Count, Q + +from bookwyrm import models +from bookwyrm.redis_store import RedisStore +from bookwyrm.tasks import app, MEDIUM, HIGH + + +class ListsStream(RedisStore): + """all the lists you can see""" + + def stream_id(self, user): # pylint: disable=no-self-use + """the redis key for this user's instance of this stream""" + if isinstance(user, int): + # allows the function to take an int or an obj + return f"{user}-lists" + return f"{user.id}-lists" + + def get_rank(self, obj): # pylint: disable=no-self-use + """lists are sorted by updated date""" + return obj.updated_date.timestamp() + + def add_list(self, book_list): + """add a list to users' feeds""" + # the pipeline contains all the add-to-stream activities + self.add_object_to_related_stores(book_list) + + def add_user_lists(self, viewer, user): + """add a user's lists to another user's feed""" + # only add the lists that the viewer should be able to see + lists = models.List.privacy_filter(viewer).filter(user=user) + self.bulk_add_objects_to_store(lists, self.stream_id(viewer)) + + def remove_user_lists(self, viewer, user, exclude_privacy=None): + """remove a user's list from another user's feed""" + # remove all so that followers only lists are removed + lists = user.list_set + if exclude_privacy: + lists = lists.exclude(privacy=exclude_privacy) + self.bulk_remove_objects_from_store(lists.all(), self.stream_id(viewer)) + + def get_list_stream(self, user): + """load the lists to be displayed""" + lists = self.get_store(self.stream_id(user)) + return ( + models.List.objects.filter(id__in=lists) + .annotate(item_count=Count("listitem", filter=Q(listitem__approved=True))) + # hide lists with no approved books + .filter(item_count__gt=0) + .select_related("user") + .prefetch_related("listitem_set") + .order_by("-updated_date") + .distinct() + ) + + def populate_lists(self, user): + """go from zero to a timeline""" + self.populate_store(self.stream_id(user)) + + def get_audience(self, book_list): # pylint: disable=no-self-use + """given a list, what users should see it""" + # everybody who could plausibly see this list + audience = models.User.objects.filter( + is_active=True, + local=True, # we only create feeds for users of this instance + ).exclude( # not blocked + Q(id__in=book_list.user.blocks.all()) | Q(blocks=book_list.user) + ) + + group = book_list.group + # only visible to the poster and mentioned users + if book_list.privacy == "direct": + if group: + audience = audience.filter( + Q(id=book_list.user.id) # if the user is the post's author + | ~Q(groups=group.memberships) # if the user is in the group + ) + else: + audience = audience.filter( + Q(id=book_list.user.id) # if the user is the post's author + ) + # only visible to the poster's followers and tagged users + elif book_list.privacy == "followers": + if group: + audience = audience.filter( + Q(id=book_list.user.id) # if the user is the list's owner + | Q(following=book_list.user) # if the user is following the pwmer + # if a user is in the group + | Q(memberships__group__id=book_list.group.id) + ) + else: + audience = audience.filter( + Q(id=book_list.user.id) # if the user is the list's owner + | Q(following=book_list.user) # if the user is following the pwmer + ) + return audience.distinct() + + def get_stores_for_object(self, obj): + return [self.stream_id(u) for u in self.get_audience(obj)] + + def get_lists_for_user(self, user): # pylint: disable=no-self-use + """given a user, what lists should they see on this stream""" + return models.List.privacy_filter( + user, + privacy_levels=["public", "followers"], + ) + + def get_objects_for_store(self, store): + user = models.User.objects.get(id=store.split("-")[0]) + return self.get_lists_for_user(user) + + +@receiver(signals.post_save, sender=models.List) +# pylint: disable=unused-argument +def add_list_on_create(sender, instance, created, *args, **kwargs): + """add newly created lists streamsstreams""" + if not created: + return + # when creating new things, gotta wait on the transaction + transaction.on_commit(lambda: add_list_on_create_command(instance.id)) + + +@receiver(signals.post_delete, sender=models.List) +# pylint: disable=unused-argument +def remove_list_on_delete(sender, instance, *args, **kwargs): + """remove deleted lists to streams""" + remove_list_task.delay(instance.id) + + +def add_list_on_create_command(instance_id): + """runs this code only after the database commit completes""" + add_list_task.delay(instance_id) + + +@receiver(signals.post_save, sender=models.UserFollows) +# pylint: disable=unused-argument +def add_lists_on_follow(sender, instance, created, *args, **kwargs): + """add a newly followed user's lists to feeds""" + if not created or not instance.user_subject.local: + return + add_user_lists_task.delay(instance.user_subject.id, instance.user_object.id) + + +@receiver(signals.post_delete, sender=models.UserFollows) +# pylint: disable=unused-argument +def remove_lists_on_unfollow(sender, instance, *args, **kwargs): + """remove lists from a feed on unfollow""" + if not instance.user_subject.local: + return + # remove all but public lists + remove_user_lists_task.delay( + instance.user_subject.id, instance.user_object.id, exclude_privacy="public" + ) + + +@receiver(signals.post_save, sender=models.UserBlocks) +# pylint: disable=unused-argument +def remove_lists_on_block(sender, instance, *args, **kwargs): + """remove lists from all feeds on block""" + # blocks apply ot all feeds + if instance.user_subject.local: + remove_user_lists_task.delay(instance.user_subject.id, instance.user_object.id) + + # and in both directions + if instance.user_object.local: + remove_user_lists_task.delay(instance.user_object.id, instance.user_subject.id) + + +@receiver(signals.post_delete, sender=models.UserBlocks) +# pylint: disable=unused-argument +def add_lists_on_unblock(sender, instance, *args, **kwargs): + """add lists back to all feeds on unblock""" + # make sure there isn't a block in the other direction + if models.UserBlocks.objects.filter( + user_subject=instance.user_object, + user_object=instance.user_subject, + ).exists(): + return + + # add lists back to streams with lists from anyone + if instance.user_subject.local: + add_user_lists_task.delay( + instance.user_subject.id, + instance.user_object.id, + ) + + # add lists back to streams with lists from anyone + if instance.user_object.local: + add_user_lists_task.delay( + instance.user_object.id, + instance.user_subject.id, + ) + + +@receiver(signals.post_save, sender=models.User) +# pylint: disable=unused-argument +def populate_lists_on_account_create(sender, instance, created, *args, **kwargs): + """build a user's feeds when they join""" + if not created or not instance.local: + return + transaction.on_commit(lambda: add_list_on_account_create_command(instance.id)) + + +def add_list_on_account_create_command(user_id): + """wait for the transaction to complete""" + populate_lists_task.delay(user_id) + + +# ---- TASKS +@app.task(queue=MEDIUM) +def populate_lists_task(user_id): + """background task for populating an empty list stream""" + user = models.User.objects.get(id=user_id) + ListsStream().populate_lists(user) + + +@app.task(queue=MEDIUM) +def remove_list_task(list_id): + """remove a list from any stream it might be in""" + stores = models.User.objects.filter(local=True, is_active=True).values_list( + "id", flat=True + ) + + # delete for every store + stores = [ListsStream().stream_id(idx) for idx in stores] + ListsStream().remove_object_from_related_stores(list_id, stores=stores) + + +@app.task(queue=HIGH) +def add_list_task(list_id): + """add a list to any stream it should be in""" + book_list = models.List.objects.get(id=list_id) + ListsStream().add_list(book_list) + + +@app.task(queue=MEDIUM) +def remove_user_lists_task(viewer_id, user_id, exclude_privacy=None): + """remove all lists by a user from a viewer's stream""" + viewer = models.User.objects.get(id=viewer_id) + user = models.User.objects.get(id=user_id) + ListsStream().remove_user_lists(viewer, user, exclude_privacy=exclude_privacy) + + +@app.task(queue=MEDIUM) +def add_user_lists_task(viewer_id, user_id): + """add all lists by a user to a viewer's stream""" + viewer = models.User.objects.get(id=viewer_id) + user = models.User.objects.get(id=user_id) + ListsStream().add_user_lists(viewer, user) diff --git a/bookwyrm/management/commands/populate_lists_streams.py b/bookwyrm/management/commands/populate_lists_streams.py new file mode 100644 index 00000000..e3c30bab --- /dev/null +++ b/bookwyrm/management/commands/populate_lists_streams.py @@ -0,0 +1,35 @@ +""" Re-create list streams """ +from django.core.management.base import BaseCommand +from bookwyrm import lists_stream, models + + +def populate_lists_streams(): + """build all the lists streams for all the users""" + print("Populating lists streams") + users = models.User.objects.filter( + local=True, + is_active=True, + ).order_by("-last_active_date") + print("This may take a long time! Please be patient.") + for user in users: + print(".", end="") + lists_stream.populate_lists_task.delay(user.id) + print("\nAll done, thank you for your patience!") + + +class Command(BaseCommand): + """start all over with lists streams""" + + help = "Populate list streams for all users" + + def add_arguments(self, parser): + parser.add_argument( + "--stream", + default=None, + help="Specifies which time of stream to populate", + ) + + # pylint: disable=no-self-use,unused-argument + def handle(self, *args, **options): + """run feed builder""" + populate_lists_streams() diff --git a/bookwyrm/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py index a04d7f5a..5f83670c 100644 --- a/bookwyrm/management/commands/populate_streams.py +++ b/bookwyrm/management/commands/populate_streams.py @@ -1,18 +1,20 @@ """ Re-create user streams """ from django.core.management.base import BaseCommand -from bookwyrm import activitystreams, models +from bookwyrm import activitystreams, lists_stream, models def populate_streams(stream=None): """build all the streams for all the users""" streams = [stream] if stream else activitystreams.streams.keys() - print("Populations streams", streams) + print("Populating streams", streams) users = models.User.objects.filter( local=True, is_active=True, ).order_by("-last_active_date") print("This may take a long time! Please be patient.") for user in users: + print(".", end="") + lists_stream.populate_lists_task.delay(user.id) for stream_key in streams: print(".", end="") activitystreams.populate_stream_task.delay(stream_key, user.id) diff --git a/bookwyrm/migrations/0122_alter_annualgoal_year.py b/bookwyrm/migrations/0122_alter_annualgoal_year.py new file mode 100644 index 00000000..90af5fcc --- /dev/null +++ b/bookwyrm/migrations/0122_alter_annualgoal_year.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.5 on 2022-01-04 18:59 + +import bookwyrm.models.user +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0121_user_summary_keys"), + ] + + operations = [ + migrations.AlterField( + model_name="annualgoal", + name="year", + field=models.IntegerField(default=bookwyrm.models.user.get_current_year), + ), + ] diff --git a/bookwyrm/migrations/0123_alter_user_preferred_language.py b/bookwyrm/migrations/0123_alter_user_preferred_language.py new file mode 100644 index 00000000..db4c50c6 --- /dev/null +++ b/bookwyrm/migrations/0123_alter_user_preferred_language.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.5 on 2022-01-04 22:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0122_alter_annualgoal_year"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("de-de", "Deutsch (German)"), + ("es-es", "Español (Spanish)"), + ("gl-es", "Galego (Galician)"), + ("fr-fr", "Français (French)"), + ("lt-lt", "Lietuvių (Lithuanian)"), + ("pt-br", "Português do Brasil (Brazilian Portuguese)"), + ("pt-pt", "Português Europeu (European Portuguese)"), + ("zh-hans", "简体中文 (Simplified Chinese)"), + ("zh-hant", "繁體中文 (Traditional Chinese)"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0124_auto_20220106_1759.py b/bookwyrm/migrations/0124_auto_20220106_1759.py new file mode 100644 index 00000000..068bedbb --- /dev/null +++ b/bookwyrm/migrations/0124_auto_20220106_1759.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.10 on 2022-01-06 17:59 + +from django.contrib.auth.models import AbstractUser +from django.db import migrations + + +def get_admins(apps, schema_editor): + """add any superusers to the "admin" group""" + + db_alias = schema_editor.connection.alias + groups = apps.get_model("auth", "Group") + try: + group = groups.objects.using(db_alias).get(name="admin") + except groups.DoesNotExist: + # for tests + return + + users = apps.get_model("bookwyrm", "User") + admins = users.objects.using(db_alias).filter(is_superuser=True) + + for admin in admins: + admin.groups.add(group) + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0123_alter_user_preferred_language"), + ] + + operations = [ + migrations.RunPython(get_admins, reverse_code=migrations.RunPython.noop), + ] diff --git a/bookwyrm/migrations/0125_alter_user_preferred_language.py b/bookwyrm/migrations/0125_alter_user_preferred_language.py new file mode 100644 index 00000000..fe407a75 --- /dev/null +++ b/bookwyrm/migrations/0125_alter_user_preferred_language.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.10 on 2022-01-09 01:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0124_auto_20220106_1759"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("de-de", "Deutsch (German)"), + ("es-es", "Español (Spanish)"), + ("gl-es", "Galego (Galician)"), + ("it-it", "Italiano (Italian)"), + ("fr-fr", "Français (French)"), + ("lt-lt", "Lietuvių (Lithuanian)"), + ("no-no", "Norsk (Norwegian)"), + ("pt-br", "Português do Brasil (Brazilian Portuguese)"), + ("pt-pt", "Português Europeu (European Portuguese)"), + ("zh-hans", "简体中文 (Simplified Chinese)"), + ("zh-hant", "繁體中文 (Traditional Chinese)"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py index 5cc11afd..5edac57d 100644 --- a/bookwyrm/models/author.py +++ b/bookwyrm/models/author.py @@ -1,6 +1,8 @@ """ database schema for info about authors """ import re from django.contrib.postgres.indexes import GinIndex +from django.core.cache import cache +from django.core.cache.utils import make_template_fragment_key from django.db import models from bookwyrm import activitypub @@ -34,6 +36,17 @@ class Author(BookDataModel): ) bio = fields.HtmlField(null=True, blank=True) + def save(self, *args, **kwargs): + """clear related template caches""" + # clear template caches + if self.id: + cache_keys = [ + make_template_fragment_key("titleby", [book]) + for book in self.book_set.values_list("id", flat=True) + ] + cache.delete_many(cache_keys) + return super().save(*args, **kwargs) + @property def isni_link(self): """generate the url from the isni id""" diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index f62678f7..f8d3b781 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -84,6 +84,7 @@ class BookWyrmModel(models.Model): # you can see groups of which you are a member if ( hasattr(self, "memberships") + and viewer.is_authenticated and self.memberships.filter(user=viewer).exists() ): return diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 0a551bf2..a9dd9508 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -3,6 +3,8 @@ import re from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex +from django.core.cache import cache +from django.core.cache.utils import make_template_fragment_key from django.db import models, transaction from django.db.models import Prefetch from django.dispatch import receiver @@ -185,6 +187,11 @@ class Book(BookDataModel): """can't be abstract for query reasons, but you shouldn't USE it""" if not isinstance(self, Edition) and not isinstance(self, Work): raise ValueError("Books should be added as Editions or Works") + + # clear template caches + cache_key = make_template_fragment_key("titleby", [self.id]) + cache.delete(cache_key) + return super().save(*args, **kwargs) def get_remote_id(self): diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index fc7a9df8..03417454 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -1,5 +1,7 @@ """ defines relationships between users """ from django.apps import apps +from django.core.cache import cache +from django.core.cache.utils import make_template_fragment_key from django.db import models, transaction, IntegrityError from django.db.models import Q @@ -36,6 +38,20 @@ class UserRelationship(BookWyrmModel): """the remote user needs to recieve direct broadcasts""" return [u for u in [self.user_subject, self.user_object] if not u.local] + def save(self, *args, **kwargs): + """clear the template cache""" + # invalidate the template cache + cache_keys = [ + make_template_fragment_key( + "follow_button", [self.user_subject.id, self.user_object.id] + ), + make_template_fragment_key( + "follow_button", [self.user_object.id, self.user_subject.id] + ), + ] + cache.delete_many(cache_keys) + super().save(*args, **kwargs) + class Meta: """relationships should be unique""" diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index c7c0a425..ee138d97 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -82,6 +82,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): if not self.reply_parent: self.thread_id = self.id + super().save(broadcast=False, update_fields=["thread_id"]) def delete(self, *args, **kwargs): # pylint: disable=unused-argument diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index deec2a44..bd340b01 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -415,12 +415,17 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): return activity_object +def get_current_year(): + """sets default year for annual goal to this year""" + return timezone.now().year + + class AnnualGoal(BookWyrmModel): """set a goal for how many books you read in a year""" user = models.ForeignKey("User", on_delete=models.PROTECT) goal = models.IntegerField(validators=[MinValueValidator(1)]) - year = models.IntegerField(default=timezone.now().year) + year = models.IntegerField(default=get_current_year) privacy = models.CharField( max_length=255, default="public", choices=fields.PrivacyLevels.choices ) diff --git a/bookwyrm/redis_store.py b/bookwyrm/redis_store.py index 595868ff..964409e8 100644 --- a/bookwyrm/redis_store.py +++ b/bookwyrm/redis_store.py @@ -30,7 +30,8 @@ class RedisStore(ABC): # add the status to the feed pipeline.zadd(store, value) # trim the store - pipeline.zremrangebyrank(store, 0, -1 * self.max_length) + if self.max_length: + pipeline.zremrangebyrank(store, 0, -1 * self.max_length) if not execute: return pipeline # and go! @@ -38,10 +39,15 @@ class RedisStore(ABC): def remove_object_from_related_stores(self, obj, stores=None): """remove an object from all stores""" + # if the stoers are provided, the object can just be an id + if stores and isinstance(obj, int): + obj_id = obj + else: + obj_id = obj.id stores = self.get_stores_for_object(obj) if stores is None else stores pipeline = r.pipeline() for store in stores: - pipeline.zrem(store, -1, obj.id) + pipeline.zrem(store, -1, obj_id) pipeline.execute() def bulk_add_objects_to_store(self, objs, store): @@ -49,7 +55,7 @@ class RedisStore(ABC): pipeline = r.pipeline() for obj in objs[: self.max_length]: pipeline.zadd(store, self.get_value(obj)) - if objs: + if objs and self.max_length: pipeline.zremrangebyrank(store, 0, -1 * self.max_length) pipeline.execute() @@ -73,7 +79,7 @@ class RedisStore(ABC): pipeline.zadd(store, self.get_value(obj)) # only trim the store if objects were added - if queryset.exists(): + if queryset.exists() and self.max_length: pipeline.zremrangebyrank(store, 0, -1 * self.max_length) pipeline.execute() diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 540e7339..d9c5ac22 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _ env = Env() env.read_env() DOMAIN = env("DOMAIN") -VERSION = "0.1.0" +VERSION = "0.1.1" PAGE_LENGTH = env("PAGE_LENGTH", 15) DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") @@ -24,7 +24,9 @@ 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 = f"admin@{DOMAIN}" +EMAIL_SENDER_NAME = env("EMAIL_SENDER_NAME", "admin") +EMAIL_SENDER_DOMAIN = env("EMAIL_SENDER_NAME", DOMAIN) +EMAIL_SENDER = f"{EMAIL_SENDER_NAME}@{EMAIL_SENDER_DOMAIN}" # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -119,15 +121,43 @@ STREAMS = [ {"key": "books", "name": _("Books Timeline"), "shortname": _("Books")}, ] +# Search configuration +# total time in seconds that the instance will spend searching connectors +SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 15)) +# timeout for a query to an individual connector +QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5)) + +# Redis cache backend +if env("USE_DUMMY_CACHE", False): + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + } + } +else: + # pylint: disable=line-too-long + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://:{REDIS_ACTIVITY_PASSWORD}@{REDIS_ACTIVITY_HOST}:{REDIS_ACTIVITY_PORT}/0", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } + } + + SESSION_ENGINE = "django.contrib.sessions.backends.cache" + SESSION_CACHE_ALIAS = "default" + # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", - "NAME": env("POSTGRES_DB", "fedireads"), - "USER": env("POSTGRES_USER", "fedireads"), - "PASSWORD": env("POSTGRES_PASSWORD", "fedireads"), + "NAME": env("POSTGRES_DB", "bookwyrm"), + "USER": env("POSTGRES_USER", "bookwyrm"), + "PASSWORD": env("POSTGRES_PASSWORD", "bookwyrm"), "HOST": env("POSTGRES_HOST", ""), "PORT": env("PGPORT", 5432), }, @@ -166,9 +196,12 @@ LANGUAGES = [ ("de-de", _("Deutsch (German)")), ("es-es", _("Español (Spanish)")), ("gl-es", _("Galego (Galician)")), + ("it-it", _("Italiano (Italian)")), ("fr-fr", _("Français (French)")), ("lt-lt", _("Lietuvių (Lithuanian)")), - ("pt-br", _("Português - Brasil (Brazilian Portuguese)")), + ("no-no", _("Norsk (Norwegian)")), + ("pt-br", _("Português do Brasil (Brazilian Portuguese)")), + ("pt-pt", _("Português Europeu (European Portuguese)")), ("zh-hans", _("简体中文 (Simplified Chinese)")), ("zh-hant", _("繁體中文 (Traditional Chinese)")), ] @@ -189,6 +222,7 @@ USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)" # Imagekit generated thumbnails ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False) IMAGEKIT_CACHEFILE_DIR = "thumbnails" +IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = "bookwyrm.thumbnail_generation.Strategy" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css index 629815b8..4d960734 100644 --- a/bookwyrm/static/css/bookwyrm.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -8,6 +8,44 @@ body { flex-direction: column; } +button { + border: none; + margin: 0; + padding: 0; + width: auto; + overflow: visible; + background: transparent; + + /* inherit font, color & alignment from ancestor */ + color: inherit; + font: inherit; + text-align: inherit; + + /* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */ + line-height: normal; + + /* Corrects font smoothing for webkit */ + -webkit-font-smoothing: inherit; + -moz-osx-font-smoothing: inherit; + + /* Corrects inability to style clickable `input` types in iOS */ + -webkit-appearance: none; + + /* Generalizes pointer cursor */ + cursor: pointer; +} + +button::-moz-focus-inner { + /* Remove excess padding and border in Firefox 4+ */ + border: 0; + padding: 0; +} + +/* Better accessibility for keyboard users */ +*:focus-visible { + outline-style: auto !important; +} + .image { overflow: hidden; } @@ -29,10 +67,38 @@ body { overflow-x: auto; } +.modal-card { + pointer-events: none; +} + +.modal-card > * { + pointer-events: all; +} + +/* stylelint-disable no-descending-specificity */ +.modal-card:focus { + outline-style: auto; +} + +.modal-card:focus:not(:focus-visible) { + outline-style: initial; +} + +.modal-card:focus-visible { + outline-style: auto; +} +/* stylelint-enable no-descending-specificity */ + .modal-card.is-fullwidth { min-width: 75% !important; } +@media only screen and (min-width: 769px) { + .modal-card.is-thin { + width: 350px !important; + } +} + .modal-card-body { max-height: 70vh; } @@ -69,6 +135,18 @@ body { border-bottom: 1px solid #ededed; border-right: 0; } + + .is-flex-direction-row-mobile { + flex-direction: row !important; + } + + .is-flex-direction-column-mobile { + flex-direction: column !important; + } +} + +.tag.is-small { + height: auto; } .button.is-transparent { @@ -93,10 +171,34 @@ body { display: inline !important; } +button .button-invisible-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 1rem; + box-sizing: border-box; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + background: rgba(0, 0, 0, 66%); + color: white; + opacity: 0; + transition: opacity 0.2s ease; +} + +button:hover .button-invisible-overlay, +button:active .button-invisible-overlay, +button:focus-visible .button-invisible-overlay { + opacity: 1; +} + /** File input styles ******************************************************************************/ -input[type=file]::file-selector-button { +input[type="file"]::file-selector-button { -moz-appearance: none; -webkit-appearance: none; background-color: #fff; @@ -117,7 +219,7 @@ input[type=file]::file-selector-button { white-space: nowrap; } -input[type=file]::file-selector-button:hover { +input[type="file"]::file-selector-button:hover { border-color: #b5b5b5; color: #363636; } @@ -125,7 +227,7 @@ input[type=file]::file-selector-button:hover { /** General `details` element styles ******************************************************************************/ -summary { +details summary { cursor: pointer; } @@ -133,22 +235,22 @@ summary::-webkit-details-marker { display: none; } -summary::marker { +details summary::marker { content: none; } -.detail-pinned-button summary { +details.detail-pinned-button summary { position: absolute; right: 0; } -.detail-pinned-button form { +details.detail-pinned-button form { float: left; - width: -webkit-fill-available; + width: 100%; margin-top: 1em; } -/** Details dropdown +/** Dropdown w/ Details element ******************************************************************************/ details.dropdown[open] summary.dropdown-trigger::before { @@ -160,11 +262,11 @@ details.dropdown[open] summary.dropdown-trigger::before { right: 0; } -details .dropdown-menu { +details.dropdown .dropdown-menu { display: block !important; } -details .dropdown-menu button { +details.dropdown .dropdown-menu button { /* Fix weird Safari defaults */ box-sizing: border-box; } @@ -177,7 +279,7 @@ details.dropdown .dropdown-menu a:focus-visible { @media only screen and (max-width: 768px) { details.dropdown[open] summary.dropdown-trigger::before { - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(0, 0, 0, 50%); z-index: 30; } @@ -199,13 +301,53 @@ details.dropdown .dropdown-menu a:focus-visible { } } +/** Details panel + ******************************************************************************/ + +details.details-panel { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 10%); + transition: box-shadow 0.2s ease; + padding: 0.75rem; +} + +details[open].details-panel, +details.details-panel:hover { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 20%); +} + +details.details-panel summary { + position: relative; +} + +details.details-panel summary .details-close { + position: absolute; + right: 0; + top: 0; + transform: rotate(45deg); + transition: transform 0.2s ease; +} + +details[open].details-panel summary .details-close { + transform: rotate(0deg); +} + +@media only screen and (min-width: 769px) { + .details-panel .filters-field:not(:last-child) { + border-right: 1px solid rgba(0, 0, 0, 10%); + margin-top: 0.75rem; + margin-bottom: 0.75rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + } +} + /** Shelving ******************************************************************************/ /** @todo Replace icons with SVG symbols. @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ .shelf-option:disabled > *::after { - font-family: "icomoon"; /* stylelint-disable font-family-no-missing-generic-family-keyword */ + font-family: icomoon; /* stylelint-disable font-family-no-missing-generic-family-keyword */ content: "\e919"; /* icon-check */ margin-left: 0.5em; } @@ -213,14 +355,14 @@ details.dropdown .dropdown-menu a:focus-visible { /** Toggles ******************************************************************************/ -.toggle-button[aria-pressed=true], -.toggle-button[aria-pressed=true]:hover { - background-color: hsl(171, 100%, 41%); +.toggle-button[aria-pressed="true"], +.toggle-button[aria-pressed="true"]:hover { + background-color: hsl(171deg, 100%, 41%); color: white; } -.hide-active[aria-pressed=true], -.hide-inactive[aria-pressed=false] { +.hide-active[aria-pressed="true"], +.hide-inactive[aria-pressed="false"] { display: none; } @@ -277,36 +419,36 @@ details.dropdown .dropdown-menu a:focus-visible { /* All stars are visually filled by default. */ .form-rate-stars .icon::before { - content: '\e9d9'; /* icon-star-full */ + content: "\e9d9"; /* icon-star-full */ } /* Icons directly following half star inputs are marked as half */ .form-rate-stars input.half:checked ~ .icon::before { - content: '\e9d8'; /* icon-star-half */ + content: "\e9d8"; /* icon-star-half */ } /* stylelint-disable no-descending-specificity */ .form-rate-stars input.half:checked + input + .icon:hover::before { - content: '\e9d8' !important; /* icon-star-half */ + content: "\e9d8" !important; /* icon-star-half */ } /* Icons directly following half check inputs that follow the checked input are emptied. */ .form-rate-stars input.half:checked + input + .icon ~ .icon::before { - content: '\e9d7'; /* icon-star-empty */ + content: "\e9d7"; /* icon-star-empty */ } /* Icons directly following inputs that follow the checked input are emptied. */ .form-rate-stars input:checked ~ input + .icon::before { - content: '\e9d7'; /* icon-star-empty */ + content: "\e9d7"; /* icon-star-empty */ } /* When a label is hovered, repeat the fill-all-then-empty-following pattern. */ .form-rate-stars:hover .icon.icon::before { - content: '\e9d9' !important; /* icon-star-full */ + content: "\e9d9" !important; /* icon-star-full */ } .form-rate-stars .icon:hover ~ .icon::before { - content: '\e9d7' !important; /* icon-star-empty */ + content: "\e9d7" !important; /* icon-star-empty */ } /** Book covers @@ -374,6 +516,8 @@ details.dropdown .dropdown-menu a:focus-visible { display: flex; align-items: center; justify-content: center; + flex-direction: column; + gap: 1em; white-space: initial; text-align: center; } @@ -412,7 +556,7 @@ details.dropdown .dropdown-menu a:focus-visible { .quote > blockquote::before, .quote > blockquote::after { - font-family: 'icomoon'; + font-family: icomoon; position: absolute; } @@ -612,8 +756,8 @@ ol.ordered-list li::before { .books-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(10em, 1fr)); - gap: 1.5rem; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; align-items: end; justify-items: stretch; } @@ -622,7 +766,6 @@ ol.ordered-list li::before { grid-column: span 2; grid-row: span 2; justify-self: stretch; - padding: 1.5rem 1.5rem 0; } .books-grid .book-cover { @@ -638,6 +781,13 @@ ol.ordered-list li::before { min-height: calc(2 * var(--height-basis)); } +@media only screen and (min-width: 769px) { + .books-grid { + gap: 1.5rem; + grid-template-columns: repeat(auto-fill, minmax(8em, 1fr)); + } +} + /* Copy ******************************************************************************/ @@ -1051,3 +1201,93 @@ ol.ordered-list li::before { margin-bottom: 0.75rem !important; } } + +/* Gaps (for Flexbox and Grid) + * + * Those are supplementary rules to Bulma’s. They follow the same conventions. + * Add those you’ll need. + ******************************************************************************/ + +.is-gap-0 { + gap: 0; +} + +.is-gap-1 { + gap: 0.25rem; +} + +.is-gap-2 { + gap: 0.5rem; +} + +.is-gap-3 { + gap: 0.75rem; +} + +.is-gap-4 { + gap: 1rem; +} + +.is-gap-5 { + gap: 1.5rem; +} + +.is-gap-6 { + gap: 3rem; +} + +.is-row-gap-0 { + row-gap: 0; +} + +.is-row-gap-1 { + row-gap: 0.25rem; +} + +.is-row-gap-2 { + row-gap: 0.5rem; +} + +.is-row-gap-3 { + row-gap: 0.75rem; +} + +.is-row-gap-4 { + row-gap: 1rem; +} + +.is-row-gap-5 { + row-gap: 1.5rem; +} + +.is-row-gap-6 { + row-gap: 3rem; +} + +.is-column-gap-0 { + column-gap: 0; +} + +.is-column-gap-1 { + column-gap: 0.25rem; +} + +.is-column-gap-2 { + column-gap: 0.5rem; +} + +.is-column-gap-3 { + column-gap: 0.75rem; +} + +.is-column-gap-4 { + column-gap: 1rem; +} + +.is-column-gap-5 { + column-gap: 1.5rem; +} + +.is-column-gap-6 { + column-gap: 3rem; +} diff --git a/bookwyrm/static/css/vendor/icons.css b/bookwyrm/static/css/vendor/icons.css index 9c35b1be..4bc7cca5 100644 --- a/bookwyrm/static/css/vendor/icons.css +++ b/bookwyrm/static/css/vendor/icons.css @@ -25,6 +25,10 @@ -moz-osx-font-smoothing: grayscale; } +.icon.is-small { + font-size: small; +} + .icon-book:before { content: "\e901"; } diff --git a/bookwyrm/static/js/block_href.js b/bookwyrm/static/js/block_href.js deleted file mode 100644 index fc20a6ab..00000000 --- a/bookwyrm/static/js/block_href.js +++ /dev/null @@ -1,21 +0,0 @@ -/* exported BlockHref */ - -let BlockHref = new class { - constructor() { - document.querySelectorAll('[data-href]') - .forEach(t => t.addEventListener('click', this.followLink.bind(this))); - } - - /** - * Follow a fake link - * - * @param {Event} event - * @return {undefined} - */ - followLink(event) { - const url = event.currentTarget.dataset.href; - - window.location.href = url; - } -}(); - diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index c79471fe..cf3944b3 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -1,7 +1,7 @@ /* exported BookWyrm */ /* globals TabGroup */ -let BookWyrm = new class { +let BookWyrm = new (class { constructor() { this.MAX_FILE_SIZE_BYTES = 10 * 1000000; this.initOnDOMLoaded(); @@ -10,54 +10,43 @@ let BookWyrm = new class { } initEventListeners() { - document.querySelectorAll('[data-controls]') - .forEach(button => button.addEventListener( - 'click', - this.toggleAction.bind(this)) - ); + 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(".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(".hidden-form input") + .forEach((button) => button.addEventListener("change", this.revealForm.bind(this))); - document.querySelectorAll('[data-hides]') - .forEach(button => button.addEventListener( - 'change', - this.hideForm.bind(this)) - ); + document + .querySelectorAll("[data-hides]") + .forEach((button) => button.addEventListener("change", this.hideForm.bind(this))); - document.querySelectorAll('[data-back]') - .forEach(button => button.addEventListener( - 'click', - this.back) - ); + document + .querySelectorAll("[data-back]") + .forEach((button) => button.addEventListener("click", this.back)); - document.querySelectorAll('input[type="file"]') - .forEach(node => node.addEventListener( - 'change', - this.disableIfTooLarge.bind(this) - )); - - document.querySelectorAll('[data-duplicate]') - .forEach(node => node.addEventListener( - 'click', - this.duplicateInput.bind(this) - - )) - document.querySelectorAll('details.dropdown') - .forEach(node => node.addEventListener( - 'toggle', - this.handleDetailsDropdown.bind(this) - - )) + document + .querySelectorAll('input[type="file"]') + .forEach((node) => node.addEventListener("change", this.disableIfTooLarge.bind(this))); + + document + .querySelectorAll("button[data-modal-open]") + .forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this))); + + document + .querySelectorAll("[data-duplicate]") + .forEach((node) => node.addEventListener("click", this.duplicateInput.bind(this))); + + document + .querySelectorAll("details.dropdown") + .forEach((node) => + node.addEventListener("toggle", this.handleDetailsDropdown.bind(this)) + ); } /** @@ -66,15 +55,15 @@ let BookWyrm = new class { initOnDOMLoaded() { const bookwyrm = this; - window.addEventListener('DOMContentLoaded', function() { - document.querySelectorAll('.tab-group') - .forEach(tabs => new TabGroup(tabs)); - document.querySelectorAll('input[type="file"]').forEach( - bookwyrm.disableIfTooLarge.bind(bookwyrm) - ); - document.querySelectorAll('[data-copytext]').forEach( - bookwyrm.copyText.bind(bookwyrm) - ); + window.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll(".tab-group").forEach((tabs) => new TabGroup(tabs)); + document + .querySelectorAll('input[type="file"]') + .forEach(bookwyrm.disableIfTooLarge.bind(bookwyrm)); + document.querySelectorAll("[data-copytext]").forEach(bookwyrm.copyText.bind(bookwyrm)); + document + .querySelectorAll(".modal.is-active") + .forEach(bookwyrm.handleActiveModal.bind(bookwyrm)); }); } @@ -83,8 +72,7 @@ let BookWyrm = new class { */ initReccuringTasks() { // Polling - document.querySelectorAll('[data-poll]') - .forEach(liveArea => this.polling(liveArea)); + document.querySelectorAll("[data-poll]").forEach((liveArea) => this.polling(liveArea)); } /** @@ -110,15 +98,19 @@ let BookWyrm = new class { const bookwyrm = this; delay = delay || 10000; - delay += (Math.random() * 1000); + delay += Math.random() * 1000; - setTimeout(function() { - fetch('/api/updates/' + counter.dataset.poll) - .then(response => response.json()) - .then(data => bookwyrm.updateCountElement(counter, data)); + 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); + bookwyrm.polling(counter, delay * 1.25); + }, + delay, + counter + ); } /** @@ -133,60 +125,56 @@ let BookWyrm = new class { const count_by_type = data.count_by_type; const currentCount = counter.innerText; const hasMentions = data.has_mentions; - const allowedStatusTypesEl = document.getElementById('unread-notifications-wrapper'); + const allowedStatusTypesEl = document.getElementById("unread-notifications-wrapper"); // If we're on the right counter element - if (counter.closest('[data-poll-wrapper]').contains(allowedStatusTypesEl)) { + if (counter.closest("[data-poll-wrapper]").contains(allowedStatusTypesEl)) { const allowedStatusTypes = JSON.parse(allowedStatusTypesEl.textContent); // For keys in common between allowedStatusTypes and count_by_type // This concerns 'review', 'quotation', 'comment' - count = allowedStatusTypes.reduce(function(prev, currentKey) { + count = allowedStatusTypes.reduce(function (prev, currentKey) { const currentValue = count_by_type[currentKey] | 0; return prev + currentValue; }, 0); // Add all the "other" in count_by_type if 'everything' is allowed - if (allowedStatusTypes.includes('everything')) { + if (allowedStatusTypes.includes("everything")) { // Clone count_by_type with 0 for reviews/quotations/comments - const count_by_everything_else = Object.assign( - {}, - count_by_type, - {review: 0, quotation: 0, comment: 0} - ); + const count_by_everything_else = Object.assign({}, count_by_type, { + review: 0, + quotation: 0, + comment: 0, + }); - count = Object.keys(count_by_everything_else).reduce( - function(prev, currentKey) { - const currentValue = - count_by_everything_else[currentKey] | 0 + count = Object.keys(count_by_everything_else).reduce(function (prev, currentKey) { + const currentValue = count_by_everything_else[currentKey] | 0; - return prev + currentValue; - }, - count - ); + return prev + currentValue; + }, count); } } if (count != currentCount) { - this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1); + this.addRemoveClass(counter.closest("[data-poll-wrapper]"), "is-hidden", count < 1); counter.innerText = count; - this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-danger', hasMentions); + this.addRemoveClass(counter.closest("[data-poll-wrapper]"), "is-danger", hasMentions); } } /** * Show form. - * + * * @param {Event} event * @return {undefined} */ revealForm(event) { let trigger = event.currentTarget; - let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0]; + let hidden = trigger.closest(".hidden-form").querySelectorAll(".is-hidden")[0]; if (hidden) { - this.addRemoveClass(hidden, 'is-hidden', !hidden); + this.addRemoveClass(hidden, "is-hidden", !hidden); } } @@ -198,10 +186,10 @@ let BookWyrm = new class { */ hideForm(event) { let trigger = event.currentTarget; - let targetId = trigger.dataset.hides - let visible = document.getElementById(targetId) + let targetId = trigger.dataset.hides; + let visible = document.getElementById(targetId); - this.addRemoveClass(visible, 'is-hidden', true); + this.addRemoveClass(visible, "is-hidden", true); } /** @@ -216,31 +204,34 @@ let BookWyrm = new class { if (!trigger.dataset.allowDefault || event.currentTarget == event.target) { event.preventDefault(); } - let pressed = trigger.getAttribute('aria-pressed') === 'false'; + 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' - )); + 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')) { + if (targetId && !trigger.classList.contains("pulldown-menu")) { let target = document.getElementById(targetId); - this.addRemoveClass(target, 'is-hidden', !pressed); - this.addRemoveClass(target, 'is-active', pressed); + this.addRemoveClass(target, "is-hidden", !pressed); + this.addRemoveClass(target, "is-active", pressed); } // Show/hide pulldown-menus. - if (trigger.classList.contains('pulldown-menu')) { + if (trigger.classList.contains("pulldown-menu")) { this.toggleMenu(trigger, targetId); } // Show/hide container. - let container = document.getElementById('hide_' + targetId); + let container = document.getElementById("hide_" + targetId); if (container) { this.toggleContainer(container, pressed); @@ -277,14 +268,14 @@ let BookWyrm = new class { * @return {undefined} */ toggleMenu(trigger, targetId) { - let expanded = trigger.getAttribute('aria-expanded') == 'false'; + let expanded = trigger.getAttribute("aria-expanded") == "false"; - trigger.setAttribute('aria-expanded', expanded); + trigger.setAttribute("aria-expanded", expanded); if (targetId) { let target = document.getElementById(targetId); - this.addRemoveClass(target, 'is-active', expanded); + this.addRemoveClass(target, "is-active", expanded); } } @@ -296,7 +287,7 @@ let BookWyrm = new class { * @return {undefined} */ toggleContainer(container, pressed) { - this.addRemoveClass(container, 'is-hidden', pressed); + this.addRemoveClass(container, "is-hidden", pressed); } /** @@ -333,7 +324,7 @@ let BookWyrm = new class { node.focus(); - setTimeout(function() { + setTimeout(function () { node.selectionStart = node.selectionEnd = 10000; }, 0); } @@ -353,15 +344,17 @@ let BookWyrm = new class { 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 - )); + relatedforms.forEach((relatedForm) => + bookwyrm.addRemoveClass( + relatedForm, + "is-hidden", + relatedForm.className.indexOf("is-hidden") == -1 + ) + ); - this.ajaxPost(form).catch(error => { + this.ajaxPost(form).catch((error) => { // @todo Display a notification in the UI instead. - console.warn('Request failed:', error); + console.warn("Request failed:", error); }); } @@ -373,11 +366,11 @@ let BookWyrm = new class { */ ajaxPost(form) { return fetch(form.action, { - method : "POST", + method: "POST", body: new FormData(form), headers: { - 'Accept': 'application/json', - } + Accept: "application/json", + }, }); } @@ -402,21 +395,112 @@ let BookWyrm = new class { const element = eventOrElement.currentTarget || eventOrElement; const submits = element.form.querySelectorAll('[type="submit"]'); - const warns = element.parentElement.querySelectorAll('.file-too-big'); - const isTooBig = element.files && - element.files[0] && - element.files[0].size > MAX_FILE_SIZE_BYTES; + const warns = element.parentElement.querySelectorAll(".file-too-big"); + const isTooBig = + element.files && element.files[0] && element.files[0].size > MAX_FILE_SIZE_BYTES; if (isTooBig) { - submits.forEach(submitter => submitter.disabled = true); - warns.forEach( - sib => addRemoveClass(sib, 'is-hidden', false) - ); + submits.forEach((submitter) => (submitter.disabled = true)); + warns.forEach((sib) => addRemoveClass(sib, "is-hidden", false)); } else { - submits.forEach(submitter => submitter.disabled = false); - warns.forEach( - sib => addRemoveClass(sib, 'is-hidden', true) - ); + submits.forEach((submitter) => (submitter.disabled = false)); + warns.forEach((sib) => addRemoveClass(sib, "is-hidden", true)); + } + } + + /** + * Handle the modal component with a button trigger. + * + * @param {Event} event - Event fired by an element + * with the `data-modal-open` attribute + * pointing to a modal by its id. + * @return {undefined} + * + * See https://github.com/bookwyrm-social/bookwyrm/pull/1633 + * for information about using the modal. + */ + handleModalButton(event) { + const { handleFocusTrap } = this; + const modalButton = event.currentTarget; + const targetModalId = modalButton.dataset.modalOpen; + const htmlElement = document.querySelector("html"); + const modal = document.getElementById(targetModalId); + + if (!modal) { + return; + } + + // Helper functions + function handleModalOpen(modalElement) { + event.preventDefault(); + + htmlElement.classList.add("is-clipped"); + modalElement.classList.add("is-active"); + modalElement.getElementsByClassName("modal-card")[0].focus(); + + const closeButtons = modalElement.querySelectorAll("[data-modal-close]"); + + closeButtons.forEach((button) => { + button.addEventListener("click", function () { + handleModalClose(modalElement); + }); + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + handleModalClose(modalElement); + } + }); + + modalElement.addEventListener("keydown", handleFocusTrap); + } + + function handleModalClose(modalElement) { + modalElement.removeEventListener("keydown", handleFocusTrap); + htmlElement.classList.remove("is-clipped"); + modalElement.classList.remove("is-active"); + modalButton.focus(); + } + + // Open modal + handleModalOpen(modal); + } + + /** + * Handle the modal component when opened at page load. + * + * @param {Element} modalElement - Active modal element + * @return {undefined} + * + */ + handleActiveModal(modalElement) { + if (!modalElement) { + return; + } + + const { handleFocusTrap } = this; + + modalElement.getElementsByClassName("modal-card")[0].focus(); + + const closeButtons = modalElement.querySelectorAll("[data-modal-close]"); + + closeButtons.forEach((button) => { + button.addEventListener("click", function () { + handleModalClose(modalElement); + }); + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + handleModalClose(modalElement); + } + }); + + modalElement.addEventListener("keydown", handleFocusTrap); + + function handleModalClose(modalElement) { + modalElement.removeEventListener("keydown", handleFocusTrap); + history.back(); } } @@ -428,31 +512,27 @@ let BookWyrm = new class { * @return {undefined} */ displayPopUp(url, windowName) { - window.open( - url, - windowName, - "left=100,top=100,width=430,height=600" - ); + window.open(url, windowName, "left=100,top=100,width=430,height=600"); } - duplicateInput (event ) { + duplicateInput(event) { const trigger = event.currentTarget; - const input_id = trigger.dataset['duplicate'] + const input_id = trigger.dataset["duplicate"]; const orig = document.getElementById(input_id); const parent = orig.parentNode; - const new_count = parent.querySelectorAll("input").length + 1 + const new_count = parent.querySelectorAll("input").length + 1; let input = orig.cloneNode(); - input.id += ("-" + (new_count)) - input.value = "" + input.id += "-" + new_count; + input.value = ""; let label = parent.querySelector("label").cloneNode(); - label.setAttribute("for", input.id) + label.setAttribute("for", input.id); - parent.appendChild(label) - parent.appendChild(input) + parent.appendChild(label); + parent.appendChild(input); } /** @@ -467,24 +547,19 @@ let BookWyrm = new class { copyText(textareaEl) { const text = textareaEl.textContent; - const copyButtonEl = document.createElement('button'); + const copyButtonEl = document.createElement("button"); copyButtonEl.textContent = textareaEl.dataset.copytextLabel; - copyButtonEl.classList.add( - "button", - "is-small", - "is-primary", - "is-light" - ); - copyButtonEl.addEventListener('click', () => { - navigator.clipboard.writeText(text).then(function() { - textareaEl.classList.add('is-success'); - copyButtonEl.classList.replace('is-primary', 'is-success'); + copyButtonEl.classList.add("button", "is-small", "is-primary", "is-light"); + copyButtonEl.addEventListener("click", () => { + navigator.clipboard.writeText(text).then(function () { + textareaEl.classList.add("is-success"); + copyButtonEl.classList.replace("is-primary", "is-success"); copyButtonEl.textContent = textareaEl.dataset.copytextSuccess; }); }); - textareaEl.parentNode.appendChild(copyButtonEl) + textareaEl.parentNode.appendChild(copyButtonEl); } /** @@ -496,41 +571,41 @@ let BookWyrm = new class { */ handleDetailsDropdown(event) { const detailsElement = event.target; - const summaryElement = detailsElement.querySelector('summary'); - const menuElement = detailsElement.querySelector('.dropdown-menu'); - const htmlElement = document.querySelector('html'); + 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(); - + menuElement + .querySelectorAll("a[href]:not([disabled]), button:not([disabled])")[0] + .focus(); + // Enable focus trap - menuElement.addEventListener('keydown', this.handleFocusTrap); - + menuElement.addEventListener("keydown", this.handleFocusTrap); + // Close on Esc - detailsElement.addEventListener('keydown', handleEscKey); + detailsElement.addEventListener("keydown", handleEscKey); // Clip page if Mobile if (this.isMobile()) { - htmlElement.classList.add('is-clipped'); + htmlElement.classList.add("is-clipped"); } } else { summaryElement.focus(); - + // Disable focus trap - menuElement.removeEventListener('keydown', this.handleFocusTrap); + menuElement.removeEventListener("keydown", this.handleFocusTrap); // Unclip page if (this.isMobile()) { - htmlElement.classList.remove('is-clipped'); + htmlElement.classList.remove("is-clipped"); } } function handleEscKey(event) { - if (event.key !== 'Escape') { - return; + if (event.key !== "Escape") { + return; } summaryElement.click(); @@ -553,34 +628,34 @@ let BookWyrm = new class { * @return {undefined} */ handleFocusTrap(event) { - if (event.key !== 'Tab') { - return; + if (event.key !== "Tab") { + return; } const focusableEls = event.currentTarget.querySelectorAll( [ - 'a[href]:not([disabled])', - 'button:not([disabled])', - 'textarea:not([disabled])', + "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(',') + "select:not([disabled])", + "details:not([disabled])", + '[tabindex]:not([tabindex="-1"]):not([disabled])', + ].join(",") ); - const firstFocusableEl = focusableEls[0]; + const firstFocusableEl = focusableEls[0]; const lastFocusableEl = focusableEls[focusableEls.length - 1]; - if (event.shiftKey ) /* Shift + tab */ { - if (document.activeElement === firstFocusableEl) { + if (event.shiftKey) { + /* Shift + tab */ if (document.activeElement === firstFocusableEl) { lastFocusableEl.focus(); event.preventDefault(); } - } else /* Tab */ { + } /* Tab */ else { if (document.activeElement === lastFocusableEl) { firstFocusableEl.focus(); event.preventDefault(); } } } -}(); +})(); diff --git a/bookwyrm/static/js/localstorage.js b/bookwyrm/static/js/localstorage.js index b485ed7e..7d0dc9d8 100644 --- a/bookwyrm/static/js/localstorage.js +++ b/bookwyrm/static/js/localstorage.js @@ -1,13 +1,13 @@ /* exported LocalStorageTools */ /* globals BookWyrm */ -let LocalStorageTools = new class { +let LocalStorageTools = new (class { constructor() { - document.querySelectorAll('[data-hide]') - .forEach(t => this.setDisplay(t)); + document.querySelectorAll("[data-hide]").forEach((t) => this.setDisplay(t)); - document.querySelectorAll('.set-display') - .forEach(t => t.addEventListener('click', this.updateDisplay.bind(this))); + document + .querySelectorAll(".set-display") + .forEach((t) => t.addEventListener("click", this.updateDisplay.bind(this))); } /** @@ -23,8 +23,9 @@ let LocalStorageTools = new class { window.localStorage.setItem(key, value); - document.querySelectorAll('[data-hide="' + key + '"]') - .forEach(node => this.setDisplay(node)); + document + .querySelectorAll('[data-hide="' + key + '"]') + .forEach((node) => this.setDisplay(node)); } /** @@ -38,6 +39,6 @@ let LocalStorageTools = new class { let key = node.dataset.hide; let value = window.localStorage.getItem(key); - BookWyrm.addRemoveClass(node, 'is-hidden', value); + BookWyrm.addRemoveClass(node, "is-hidden", value); } -}(); +})(); diff --git a/bookwyrm/static/js/status_cache.js b/bookwyrm/static/js/status_cache.js index dbc238c4..b19489c1 100644 --- a/bookwyrm/static/js/status_cache.js +++ b/bookwyrm/static/js/status_cache.js @@ -1,22 +1,21 @@ /* exported StatusCache */ /* globals BookWyrm */ -let StatusCache = new class { +let StatusCache = new (class { constructor() { - document.querySelectorAll('[data-cache-draft]') - .forEach(t => t.addEventListener('change', this.updateDraft.bind(this))); + document + .querySelectorAll("[data-cache-draft]") + .forEach((t) => t.addEventListener("change", this.updateDraft.bind(this))); - document.querySelectorAll('[data-cache-draft]') - .forEach(t => this.populateDraft(t)); + document.querySelectorAll("[data-cache-draft]").forEach((t) => this.populateDraft(t)); - document.querySelectorAll('.submit-status') - .forEach(button => button.addEventListener( - 'submit', - this.submitStatus.bind(this)) - ); + document + .querySelectorAll(".submit-status") + .forEach((button) => button.addEventListener("submit", this.submitStatus.bind(this))); - document.querySelectorAll('.form-rate-stars label.icon') - .forEach(button => button.addEventListener('click', this.toggleStar.bind(this))); + document + .querySelectorAll(".form-rate-stars label.icon") + .forEach((button) => button.addEventListener("click", this.toggleStar.bind(this))); } /** @@ -80,25 +79,26 @@ let StatusCache = new class { event.preventDefault(); - BookWyrm.addRemoveClass(form, 'is-processing', true); - trigger.setAttribute('disabled', null); + BookWyrm.addRemoveClass(form, "is-processing", true); + trigger.setAttribute("disabled", null); - BookWyrm.ajaxPost(form).finally(() => { - // Change icon to remove ongoing activity on the current UI. - // Enable back the element used to submit the form. - BookWyrm.addRemoveClass(form, 'is-processing', false); - trigger.removeAttribute('disabled'); - }) - .then(response => { - if (!response.ok) { - throw new Error(); - } - this.submitStatusSuccess(form); - }) - .catch(error => { - console.warn(error); - this.announceMessage('status-error-message'); - }); + BookWyrm.ajaxPost(form) + .finally(() => { + // Change icon to remove ongoing activity on the current UI. + // Enable back the element used to submit the form. + BookWyrm.addRemoveClass(form, "is-processing", false); + trigger.removeAttribute("disabled"); + }) + .then((response) => { + if (!response.ok) { + throw new Error(); + } + this.submitStatusSuccess(form); + }) + .catch((error) => { + console.warn(error); + this.announceMessage("status-error-message"); + }); } /** @@ -112,12 +112,16 @@ let StatusCache = new class { let copy = element.cloneNode(true); copy.id = null; - element.insertAdjacentElement('beforebegin', copy); + element.insertAdjacentElement("beforebegin", copy); - BookWyrm.addRemoveClass(copy, 'is-hidden', false); - setTimeout(function() { - copy.remove(); - }, 10000, copy); + BookWyrm.addRemoveClass(copy, "is-hidden", false); + setTimeout( + function () { + copy.remove(); + }, + 10000, + copy + ); } /** @@ -131,8 +135,9 @@ let StatusCache = new class { form.reset(); // Clear localstorage - form.querySelectorAll('[data-cache-draft]') - .forEach(node => window.localStorage.removeItem(node.dataset.cacheDraft)); + form.querySelectorAll("[data-cache-draft]").forEach((node) => + window.localStorage.removeItem(node.dataset.cacheDraft) + ); // Close modals let modal = form.closest(".modal.is-active"); @@ -142,8 +147,11 @@ let StatusCache = new class { // Update shelve buttons if (form.reading_status) { - document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']") - .forEach(button => this.cycleShelveButtons(button, form.reading_status.value)); + document + .querySelectorAll("[data-shelve-button-book='" + form.book.value + "']") + .forEach((button) => + this.cycleShelveButtons(button, form.reading_status.value) + ); } return; @@ -156,7 +164,7 @@ let StatusCache = new class { document.querySelector("[data-controls=" + reply.id + "]").click(); } - this.announceMessage('status-success-message'); + this.announceMessage("status-success-message"); } /** @@ -172,8 +180,9 @@ let StatusCache = new class { let next_identifier = shelf.dataset.shelfNext; // Set all buttons to hidden - button.querySelectorAll("[data-shelf-identifier]") - .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true)); + button + .querySelectorAll("[data-shelf-identifier]") + .forEach((item) => BookWyrm.addRemoveClass(item, "is-hidden", true)); // Button that should be visible now let next = button.querySelector("[data-shelf-identifier=" + next_identifier + "]"); @@ -183,15 +192,17 @@ let StatusCache = new class { // ------ update the dropdown buttons // Remove existing hidden class - button.querySelectorAll("[data-shelf-dropdown-identifier]") - .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false)); + button + .querySelectorAll("[data-shelf-dropdown-identifier]") + .forEach((item) => BookWyrm.addRemoveClass(item, "is-hidden", false)); // Remove existing disabled states - button.querySelectorAll("[data-shelf-dropdown-identifier] button") - .forEach(item => item.disabled = false); + button + .querySelectorAll("[data-shelf-dropdown-identifier] button") + .forEach((item) => (item.disabled = false)); - next_identifier = next_identifier == 'complete' ? 'read' : next_identifier; + next_identifier = next_identifier == "complete" ? "read" : next_identifier; // Disable the current state button.querySelector( @@ -206,8 +217,9 @@ let StatusCache = new class { BookWyrm.addRemoveClass(main_button, "is-hidden", true); // Just hide the other two menu options, idk what to do with them - button.querySelectorAll("[data-extra-options]") - .forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true)); + button + .querySelectorAll("[data-extra-options]") + .forEach((item) => BookWyrm.addRemoveClass(item, "is-hidden", true)); // Close menu let menu = button.querySelector("details[open]"); @@ -235,5 +247,4 @@ let StatusCache = new class { halfStar.checked = "checked"; } } -}(); - +})(); diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index 86c181a2..ea6bc886 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -2,7 +2,8 @@ import math import logging from django.dispatch import receiver -from django.db.models import signals, Count, Q +from django.db import transaction +from django.db.models import signals, Count, Q, Case, When, IntegerField from bookwyrm import models from bookwyrm.redis_store import RedisStore, r @@ -29,6 +30,7 @@ class SuggestedUsers(RedisStore): def get_counts_from_rank(self, rank): # pylint: disable=no-self-use """calculate mutuals count and shared books count from rank""" + # pylint: disable=c-extension-no-member return { "mutuals": math.floor(rank), # "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1, @@ -84,24 +86,17 @@ class SuggestedUsers(RedisStore): def get_suggestions(self, user, local=False): """get suggestions""" values = self.get_store(self.store_id(user), withscores=True) - results = [] + annotations = [ + When(pk=int(pk), then=self.get_counts_from_rank(score)["mutuals"]) + for (pk, score) in values + ] # annotate users with mutuals and shared book counts - for user_id, rank in values: - counts = self.get_counts_from_rank(rank) - try: - user = models.User.objects.get( - id=user_id, is_active=True, bookwyrm_user=True - ) - except models.User.DoesNotExist as err: - # if this happens, the suggestions are janked way up - logger.exception(err) - continue - user.mutuals = counts["mutuals"] - if (local and user.local) or not local: - results.append(user) - if len(results) >= 5: - break - return results + users = models.User.objects.filter( + is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values] + ).annotate(mutuals=Case(*annotations, output_field=IntegerField(), default=0)) + if local: + users = users.filter(local=True) + return users.order_by("-mutuals")[:5] def get_annotated_users(viewer, *args, **kwargs): @@ -119,16 +114,17 @@ def get_annotated_users(viewer, *args, **kwargs): ), distinct=True, ), - # shared_books=Count( - # "shelfbook", - # filter=Q( - # ~Q(id=viewer.id), - # shelfbook__book__parent_work__in=[ - # s.book.parent_work for s in viewer.shelfbook_set.all() - # ], - # ), - # distinct=True, - # ), + # pylint: disable=line-too-long + # shared_books=Count( + # "shelfbook", + # filter=Q( + # ~Q(id=viewer.id), + # shelfbook__book__parent_work__in=[ + # s.book.parent_work for s in viewer.shelfbook_set.all() + # ], + # ), + # distinct=True, + # ), ) ) @@ -197,7 +193,7 @@ def update_user(sender, instance, created, update_fields=None, **kwargs): """an updated user, neat""" # a new user is found, create suggestions for them if created and instance.local: - rerank_suggestions_task.delay(instance.id) + transaction.on_commit(lambda: update_new_user_command(instance.id)) # we know what fields were updated and discoverability didn't change if not instance.bookwyrm_user or ( @@ -217,6 +213,11 @@ def update_user(sender, instance, created, update_fields=None, **kwargs): remove_user_task.delay(instance.id) +def update_new_user_command(instance_id): + """wait for transaction to complete""" + rerank_suggestions_task.delay(instance_id) + + @receiver(signals.post_save, sender=models.FederatedServer) def domain_level_update(sender, instance, created, update_fields=None, **kwargs): """remove users on a domain block""" diff --git a/bookwyrm/templates/about/about.html b/bookwyrm/templates/about/about.html new file mode 100644 index 00000000..d39d7048 --- /dev/null +++ b/bookwyrm/templates/about/about.html @@ -0,0 +1,141 @@ +{% extends 'about/layout.html' %} +{% load humanize %} +{% load i18n %} +{% load utilities %} +{% load bookwyrm_tags %} +{% load cache %} + +{% block title %} +{% trans "About" %} +{% endblock %} + +{% block about_content %} +{# seven day cache #} +{% cache 604800 about_page %} +{% get_book_superlatives as superlatives %} +
+

+ {% blocktrans with site_name=site.name %}Welcome to {{ site_name }}!{% endblocktrans %} +

+ +

+ {% blocktrans trimmed with site_name=site.name %} + {{ site_name }} is part of BookWyrm, a network of independent, self-directed communities for readers. + While you can interact seamlessly with users anywhere in the BookWyrm network, this community is unique. + {% endblocktrans %} +

+ +
+ {% if top_rated %} + {% with book=superlatives.top_rated.default_edition rating=top_rated.rating %} +
+
+ +
+ {% blocktrans trimmed with title=book|book_title book_path=book.local_path site_name=site.name rating=rating|floatformat:1 %} + {{ title }} is {{ site_name }}'s most beloved book, with an average rating of {{ rating }} out of 5. + {% endblocktrans %} +
+
+
+ {% endwith %} + {% endif %} + + {% if wanted %} + {% with book=superlatives.wanted.default_edition %} +
+
+ +
+ {% blocktrans trimmed with title=book|book_title book_path=book.local_path site_name=site.name %} + More {{ site_name }} users want to read {{ title }} than any other book. + {% endblocktrans %} +
+
+
+ {% endwith %} + {% endif %} + + {% if controversial %} + {% with book=superlatives.controversial.default_edition %} +
+
+ +
+ {% blocktrans trimmed with title=book|book_title book_path=book.local_path site_name=site.name %} + {{ title }} has the most divisive ratings of any book on {{ site_name }}. + {% endblocktrans %} +
+
+
+ {% endwith %} + {% endif %} +
+ +

+ {% trans "Track your reading, talk about books, write reviews, and discover what to read next. Always ad-free, anti-corporate, and community-oriented, BookWyrm is human-scale software, designed to stay small and personal. If you have feature requests, bug reports, or grand dreams, reach out and make yourself heard." %} +

+ +
+ +
+
+

{% trans "Meet your admins" %}

+

+ {% url "conduct" as coc_path %} + {% blocktrans with site_name=site.name %} + {{ site_name }}'s moderators and administrators keep the site up and running, enforce the code of conduct, and respond when users report spam and bad behavior. + {% endblocktrans %} +

+
+ +
+ {% for user in admins %} +
+
+ {% with role=user.groups.first.name %} +
+ + {% if role == "moderator" %} + {% trans "Moderator" %} + {% else %} + {% trans "Admin" %} + {% endif %} + +
+ {% endwith %} + +
+ {% include 'user/user_preview.html' with user=user %} +
+ + {% if request.user.is_authenticated and user.id != request.user.id %} + + {% endif %} +
+
+ {% endfor %} +
+
+ +{% endcache %} +{% endblock %} diff --git a/bookwyrm/templates/about/conduct.html b/bookwyrm/templates/about/conduct.html new file mode 100644 index 00000000..a38a0168 --- /dev/null +++ b/bookwyrm/templates/about/conduct.html @@ -0,0 +1,15 @@ +{% extends 'about/layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Code of Conduct" %}{% endblock %} + + +{% block about_content %} +
+

{% trans "Code of Conduct" %}

+
+ {{ site.code_of_conduct | safe }} +
+
+ +{% endblock %} diff --git a/bookwyrm/templates/about/layout.html b/bookwyrm/templates/about/layout.html new file mode 100644 index 00000000..458e4b1d --- /dev/null +++ b/bookwyrm/templates/about/layout.html @@ -0,0 +1,57 @@ +{% extends 'landing/layout.html' %} +{% load humanize %} +{% load i18n %} + +{% block about_panel %} +
+ {% include "snippets/about.html" with size="m" %} + {% if active_users %} + + {% endif %} +
+{% endblock %} + +{% block panel %} +
+ + +
+ {% block about_content %}{% endblock %} +
+
+{% endblock %} diff --git a/bookwyrm/templates/about/privacy.html b/bookwyrm/templates/about/privacy.html new file mode 100644 index 00000000..c43b9da1 --- /dev/null +++ b/bookwyrm/templates/about/privacy.html @@ -0,0 +1,15 @@ +{% extends 'about/layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Privacy Policy" %}{% endblock %} + +{% block about_content %} + +
+

{% trans "Privacy Policy" %}

+
+ {{ site.privacy_policy | safe }} +
+
+ +{% endblock %} diff --git a/bookwyrm/templates/annual_summary/layout.html b/bookwyrm/templates/annual_summary/layout.html index cc800db5..3d179625 100644 --- a/bookwyrm/templates/annual_summary/layout.html +++ b/bookwyrm/templates/annual_summary/layout.html @@ -58,7 +58,15 @@ {% if year_key %}
- +
{% endif %} @@ -101,13 +109,17 @@ {% if not books %} -

{% blocktrans %}Sadly {{ display_name }} didn’t finish any book in {{ year }}{% endblocktrans %}

+

{% blocktrans %}Sadly {{ display_name }} didn’t finish any books in {{ year }}{% endblocktrans %}

{% else %}

- {% blocktrans with pages_total=pages_total|intcomma %}In {{ year }}, {{ display_name }} read {{ books_total }} books
for a total of {{ pages_total }} pages!{% endblocktrans %} + {% blocktrans trimmed count counter=books_total with pages_total=pages_total|intcomma %} + In {{ year }}, {{ display_name }} read {{ books_total }} book
for a total of {{ pages_total }} pages! + {% plural %} + In {{ year }}, {{ display_name }} read {{ books_total }} books
for a total of {{ pages_total }} pages! + {% endblocktrans %}

{% trans "That’s great!" %}

@@ -130,7 +142,7 @@ {% if book_pages_lowest and book_pages_highest %} + {% if goal_status and goal_status.percent >= 100 %} +
+
+

+ {% with goal=goal_status.goal goal_percent=goal_status.percent %} + {% blocktrans trimmed count counter=goal %} + {{ display_name }} set a goal of reading {{ goal }} book in {{ year }},
+ and achieved {{ goal_percent }}% of that goal + {% plural %} + {{ display_name }} set a goal of reading {{ goal }} books in {{ year }},
+ and achieved {{ goal_percent }}% of that goal + {% endblocktrans %} + {% endwith %} +

+

{% trans "Way to go!" %}

+
+
+ +
+
+
+
+
+ {% endif %} + {% if ratings_total > 0 %}

- {% blocktrans %}{{ display_name }} left {{ ratings_total }} ratings,
their average rating is {{ rating_average }}{% endblocktrans %} + {% blocktrans trimmed count counter=ratings_total %} + {{ display_name }} left {{ ratings_total }} rating,
their average rating is {{ rating_average }} + {% plural %} + {{ display_name }} left {{ ratings_total }} ratings,
their average rating is {{ rating_average }} + {% endblocktrans %}

-
- {% include 'snippets/book_cover.html' with book=book_rating_highest.book cover_class='is-w-auto-tablet is-h-l-mobile' %} + {% if book_rating_highest %}
@@ -224,7 +265,7 @@

- {% blocktrans %}All the books {{ display_name }} read in 2021{% endblocktrans %} + {% blocktrans %}All the books {{ display_name }} read in {{ year }}{% endblocktrans %}

@@ -233,7 +274,7 @@
{% for book in books %} - {% if book.id in best_ratings_books_ids %} + {% if books_total > 12 and book.id in best_ratings_books_ids %} {% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto' size='xxlarge' %} @@ -242,7 +283,7 @@ {% else %} - {% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto' size='large' %} + {% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto' size='xlarge' %} {{ book.title }} diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html index 66ecb062..2f3ed6f0 100644 --- a/bookwyrm/templates/author/author.html +++ b/bookwyrm/templates/author/author.html @@ -92,10 +92,11 @@ {% trans "View on OpenLibrary" %} {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} - {% with controls_text="ol_sync" controls_uid=author.id %} - {% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %} - {% include "author/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %} - {% endwith %} + + {% include "author/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" id="openlibrary_sync" %} {% endif %}
{% endif %} @@ -107,10 +108,11 @@ {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} - {% with controls_text="iv_sync" controls_uid=author.id %} - {% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %} - {% include "author/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %} - {% endwith %} + + {% include "author/sync_modal.html" with source="inventaire.io" source_name="Inventaire" id="inventaire_sync" %} {% endif %}
{% endif %} diff --git a/bookwyrm/templates/author/sync_modal.html b/bookwyrm/templates/author/sync_modal.html index a061ada8..a6e032cb 100644 --- a/bookwyrm/templates/author/sync_modal.html +++ b/bookwyrm/templates/author/sync_modal.html @@ -19,12 +19,8 @@ {% endblock %} {% block modal-footer %} - - -{% trans "Cancel" as button_text %} -{% include 'snippets/toggle/toggle_button.html' with text=button_text %} + + {% endblock %} {% block modal-form-close %}{% endblock %} diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 27d061a2..19dbccbd 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -61,24 +61,53 @@
- {% include 'snippets/book_cover.html' with size='xxlarge' size_mobile='medium' book=book cover_class='is-h-m-mobile' %} + {% if not book.cover %} + {% if user_authenticated %} + + {% join "add_cover" book.id as modal_id %} + {% include 'book/cover_add_modal.html' with id=modal_id %} + {% if request.GET.cover_error %} +

{% trans "Failed to load cover" %}

+ {% endif %} + {% else %} + {% include 'snippets/book_cover.html' with book=book cover_class='is-h-m-mobile' %} + {% endif %} + {% endif %} + + {% if book.cover %} + + {% include 'book/cover_show_modal.html' with book=book id="cover_show_modal" %} + {% endif %} + {% include 'snippets/rate_action.html' with user=request.user book=book %}
{% include 'snippets/shelve_button/shelve_button.html' %}
- {% 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" %} - {% include 'book/cover_modal.html' with book=book controls_text="add_cover" controls_uid=book.id %} - {% if request.GET.cover_error %} -

{% trans "Failed to load cover" %}

- {% endif %} -
- {% endif %} -
{% with book=book %}
@@ -93,23 +122,30 @@ {% trans "Load data" as button_text %} {% if book.openlibrary_key %}

- {% trans "View on OpenLibrary" %} + + {% trans "View on OpenLibrary" %} + {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} - {% with controls_text="ol_sync" controls_uid=book.id %} - {% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %} - {% include "book/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %} - {% endwith %} + + {% include "book/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" id="openlibrary_sync" %} {% endif %}

{% endif %} {% if book.inventaire_id %}

- {% trans "View on Inventaire" %} + + {% trans "View on Inventaire" %} + + {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} - {% with controls_text="iv_sync" controls_uid=book.id %} - {% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %} - {% include "book/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %} - {% endwith %} + + {% include "book/sync_modal.html" with source="inventaire.io" source_name="Inventaire" id="inventaire_sync" %} {% endif %}

{% endif %} diff --git a/bookwyrm/templates/book/cover_modal.html b/bookwyrm/templates/book/cover_add_modal.html similarity index 90% rename from bookwyrm/templates/book/cover_modal.html rename to bookwyrm/templates/book/cover_add_modal.html index f09b4495..e8207ff4 100644 --- a/bookwyrm/templates/book/cover_modal.html +++ b/bookwyrm/templates/book/cover_add_modal.html @@ -29,8 +29,7 @@ {% block modal-footer %} -{% trans "Cancel" as button_text %} -{% include 'snippets/toggle/toggle_button.html' with text=button_text %} + {% endblock %} -{% block modal-form-close %}{% endblock %} +{% block modal-form-close %}{% endblock %} diff --git a/bookwyrm/templates/book/cover_show_modal.html b/bookwyrm/templates/book/cover_show_modal.html new file mode 100644 index 00000000..f244aa53 --- /dev/null +++ b/bookwyrm/templates/book/cover_show_modal.html @@ -0,0 +1,12 @@ +{% load i18n %} +{% load static %} + + diff --git a/bookwyrm/templates/snippets/delete_readthrough_modal.html b/bookwyrm/templates/book/delete_readthrough_modal.html similarity index 61% rename from bookwyrm/templates/snippets/delete_readthrough_modal.html rename to bookwyrm/templates/book/delete_readthrough_modal.html index 24d0ff98..5b3a74cc 100644 --- a/bookwyrm/templates/snippets/delete_readthrough_modal.html +++ b/bookwyrm/templates/book/delete_readthrough_modal.html @@ -2,11 +2,17 @@ {% load i18n %} {% block modal-title %}{% trans "Delete these read dates?" %}{% endblock %} + {% block modal-body %} {% if readthrough.progress_updates|length > 0 %} -{% blocktrans with count=readthrough.progress_updates|length %}You are deleting this readthrough and its {{ count }} associated progress updates.{% endblocktrans %} + {% blocktrans trimmed with count=readthrough.progress_updates|length %} + You are deleting this readthrough and its {{ count }} associated progress updates. + {% endblocktrans %} +{% else %} + {% trans "This action cannot be un-done" %} {% endif %} {% endblock %} + {% block modal-footer %}
{% csrf_token %} @@ -14,7 +20,6 @@ - {% trans "Cancel" as button_text %} - {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="delete_readthrough" controls_uid=readthrough.id %} +
{% endblock %} diff --git a/bookwyrm/templates/book/readthrough.html b/bookwyrm/templates/book/readthrough.html index 12430f75..e711ecd0 100644 --- a/bookwyrm/templates/book/readthrough.html +++ b/bookwyrm/templates/book/readthrough.html @@ -1,6 +1,7 @@ {% load i18n %} {% load humanize %} {% load tz %} +{% load utilities %}
@@ -10,14 +11,14 @@ {% if readthrough.finish_date or readthrough.progress %}
  • {% if readthrough.finish_date %} - {{ readthrough.finish_date | localtime | naturalday }}: {% trans "finished" %} + {{ readthrough.finish_date | localtime | naturalday }}: {% trans "finished" %} {% else %} - {% if readthrough.progress_mode == 'PG' %} - {% include 'snippets/page_text.html' with page=readthrough.progress total_pages=book.pages %} - {% else %} - {{ readthrough.progress }}% - {% endif %} + {% if readthrough.progress_mode == 'PG' %} + {% include 'snippets/page_text.html' with page=readthrough.progress total_pages=book.pages %} + {% else %} + {{ readthrough.progress }}% + {% endif %} {% endif %} {% if readthrough.progress %} @@ -47,6 +48,7 @@ {% endif %}
  • {% endif %} + {% if readthrough.start_date %}
  • {{ readthrough.start_date | localtime | naturalday }}: {% trans "started" %}
  • {% endif %} @@ -60,7 +62,11 @@
    {% trans "Delete these read dates" as button_text %} - {% include 'snippets/toggle/toggle_button.html' with class="is-small" text=button_text icon="x" controls_text="delete_readthrough" controls_uid=readthrough.id focus="modal_title_delete_readthrough" %} +
    @@ -79,4 +85,5 @@
    -{% include 'snippets/delete_readthrough_modal.html' with controls_text="delete_readthrough" controls_uid=readthrough.id no_body=True %} +{% join "delete_readthrough" readthrough.id as modal_id %} +{% include 'book/delete_readthrough_modal.html' with id=modal_id %} diff --git a/bookwyrm/templates/book/sync_modal.html b/bookwyrm/templates/book/sync_modal.html index d80bf25f..6e5df0c0 100644 --- a/bookwyrm/templates/book/sync_modal.html +++ b/bookwyrm/templates/book/sync_modal.html @@ -19,12 +19,8 @@ {% endblock %} {% block modal-footer %} - - -{% trans "Cancel" as button_text %} -{% include 'snippets/toggle/toggle_button.html' with text=button_text %} + + {% endblock %} {% block modal-form-close %}{% endblock %} diff --git a/bookwyrm/templates/components/modal.html b/bookwyrm/templates/components/modal.html index 2eabd2e2..3e1cc759 100644 --- a/bookwyrm/templates/components/modal.html +++ b/bookwyrm/templates/components/modal.html @@ -1,40 +1,33 @@ {% load i18n %} -