diff --git a/.env.dev.example b/.env.dev.example new file mode 100644 index 000000000..6b7e37a9d --- /dev/null +++ b/.env.dev.example @@ -0,0 +1,50 @@ +# 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=false + +DOMAIN=your.domain.here +#EMAIL=your@email.here + +## Leave unset to allow all hosts +# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" + +OL_URL=https://openlibrary.org + +## Database backend to use. +## Default is postgres, sqlite is for dev quickstart only (NOT production!!!) +BOOKWYRM_DATABASE_BACKEND=postgres + +MEDIA_ROOT=images/ + +POSTGRES_PASSWORD=securedbpassword123 +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 +CELERY_BROKER=redis://redis_broker:6379/0 +CELERY_RESULT_BACKEND=redis://redis_broker:6379/0 + +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 + +# Set this to true when initializing certbot for domain, false when not +CERTBOT_INIT=false diff --git a/.env.example b/.env.prod.example similarity index 91% rename from .env.example rename to .env.prod.example index e8302af42..0013bf9d2 100644 --- a/.env.example +++ b/.env.prod.example @@ -44,3 +44,7 @@ EMAIL_PORT=587 EMAIL_HOST_USER=mail@your.domain.here EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true +EMAIL_USE_SSL=false + +# Set this to true when initializing certbot for domain, false when not +CERTBOT_INIT=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/.gitignore b/.gitignore index 71fa61bfa..cf88e9878 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ #Node tools /node_modules/ + +#nginx +nginx/default.conf 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..90d3ad46b 100644 --- a/README.md +++ b/README.md @@ -9,29 +9,17 @@ Social reading and reviewing, decentralized with ActivityPub - [What it is and isn't](#what-it-is-and-isnt) - [The role of federation](#the-role-of-federation) - [Features](#features) -- [Setting up the developer environment](#setting-up-the-developer-environment) -- [Installing in Production](#installing-in-production) - [Book data](#book-data) +- [Set up Bookwyrm](#set-up-bookwyrm) ## Joining BookWyrm -BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list. +BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://docs.joinbookwyrm.com/instances.html) list. + +You can request an invite by entering your email address at https://bookwyrm.social. + ## Contributing -There are many ways you can contribute to this project, regardless of your level of technical expertise. - -### Feedback and feature requests -Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues). - -### Code contributions -Code contributions are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out. - -If you have questions about the project or contributing, you can set up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min). - -### Translation -Do you speak a language besides English? BookWyrm needs localization! If you're comfortable using git and want to get into the code, there are [instructions](#working-with-translations-and-locale-files) on how to create and edit localization files. If you feel more comfortable working in a regular text editor and would prefer not to run the application, get in touch directly and we can figure out a system, like emailing a text file, that works best. - -### Financial Support -BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo). +See [contributing](https://docs.joinbookwyrm.com/contributing.html) for code, translation or monetary contributions. ## About BookWyrm ### What it is and isn't @@ -43,7 +31,7 @@ BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks. ### Features -Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going! +Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/bookwyrm-social/bookwyrm/issues) to get the conversation going! - Posting about books - Compose reviews, with or without ratings, which are aggregated in the book page - Compose other kinds of statuses about books, such as: @@ -73,8 +61,8 @@ Since the project is still in its early stages, the features are growing every d Web backend - [Django](https://www.djangoproject.com/) web server - [PostgreSQL](https://www.postgresql.org/) database -- [ActivityPub](http://activitypub.rocks/) federation -- [Celery](http://celeryproject.org/) task queuing +- [ActivityPub](https://activitypub.rocks/) federation +- [Celery](https://docs.celeryproject.org/) task queuing - [Redis](https://redis.io/) task backend - [Redis (again)](https://redis.io/) activity stream manager @@ -89,246 +77,9 @@ Deployment - [Flower](https://github.com/mher/flower) celery monitoring - [Nginx](https://nginx.org/en/) HTTP server -## Setting up the developer environment - -Set up the environment file: - -``` bash -cp .env.example .env -``` - -For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain. - -You'll have to install the Docker and docker-compose. When you're ready, run: - -```bash -docker-compose build -docker-compose run --rm web python manage.py migrate -docker-compose run --rm web python manage.py initdb -docker-compose up -``` - -Once the build is complete, you can access the instance at `localhost:1333` - -### Editing static files -If you edit the CSS or JavaScript, you will need to run Django's `collectstatic` command in order for your changes to have effect. You can do this by running: -``` bash -./bw-dev collectstatic -``` - -### 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. - -The application's language is set by a request header sent by your browser to the application, so to change the language of the application, you can change the default language requested by your browser. - -#### Adding a locale -To start translation into a language which is currently supported, run the django-admin `makemessages` command with the language code for the language you want to add (like `de` for German, or `en-gb` for British English): -``` bash -./bw-dev makemessages -l -``` - -#### Editing a locale -When you have a locale file, open the `django.po` in the directory for the language (for example, if you were adding German, `locale/de/LC_MESSAGES/django.po`. All the the text in the application will be shown in paired strings, with `msgid` as the original text, and `msgstr` as the translation (by default, this is set to an empty string, and will display the original text). - -Add your translations to the `msgstr` strings. As the messages in the application are updated, `gettext` will sometimes add best-guess fuzzy matched options for those translations. When a message is marked as fuzzy, it will not be used in the application, so be sure to remove it when you translate that line. - -When you're done, compile the locale by running: - -``` bash -./bw-dev compilemessages -``` - -You can add the `-l ` to only compile one language. When you refresh the application, you should see your translations at work. - -## Installing in Production - -This project is still young and isn't, at the moment, very stable, so please proceed with caution when running in production. - -### Server setup -- Get a domain name and set up DNS for your server -- Set your server up with appropriate firewalls for running a web application (this instruction set is tested against Ubuntu 20.04) -- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings -- Install Docker and docker-compose - -### Install and configure BookWyrm - -The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and individual changes to container config to enable things like SSL or regular backups. - -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 - `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 - - If you aren't using the `www` subdomain, remove the www.your-domain.com version of the domain from the `server_name` in the first server block in `nginx/default.conf` and remove the `-d www.${DOMAIN}` flag at the end of the `certbot` command in `docker-compose.yml`. - - If you are running another web-server on your host machine, you will need to follow the [reverse-proxy instructions](#running-bookwyrm-behind-a-reverse-proxy) -- 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 - - If you are running other services on your host machine, you may run into errors where services fail when attempting to bind to a port. - See the [troubleshooting guide](#port-conflicts) for advice on resolving this. -- When docker has built successfully, stop the process with `CTRL-C` -- Comment out the `command: certonly...` line in `docker-compose.yml`, and uncomment the following line (`command: renew ...`) so that the certificate will be automatically renewed. -- Uncomment the https redirect and `server` block in `nginx/default.conf` (lines 17-48). -- Run docker-compose in the background with: `docker-compose up -d` -- Initialize the database with: `./bw-dev initdb` - -Congrats! You did it, go to your domain and enjoy the fruits of your labors. - -### Configure your instance -- Register a user account in the application UI -- Make your account a superuser (warning: do *not* use django's `createsuperuser` command) - - On your server, open the django shell - `./bw-dev shell` - - Load your user and make it a superuser - ```python - from bookwyrm import models - user = models.User.objects.get(id=1) - user.is_staff = True - user.is_superuser = True - user.save() - ``` - - Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance - -### Backups - -BookWyrm's db service dumps a backup copy of its database to its `/backups` directory daily at midnight UTC. -Backups are named `backup__%Y-%m-%d.sql`. - -The db service has an optional script for periodically pruning the backups directory so that all recent daily backups are kept, but for older backups, only weekly or monthly backups are kept. -To enable this script: -- Uncomment the final line in `postgres-docker/cronfile` -- rebuild your instance `docker-compose up --build` - -You can copy backups from the backups volume to your host machine with `docker cp`: -- Run `docker-compose ps` to confirm the db service's full name (it's probably `bookwyrm_db_1`. -- Run `docker cp :/backups ` - -### Updating your instance - -When there are changes available in the production branch, you can install and get them running on your instance using the command `./bw-dev update`. This does a number of things: -- `git pull` gets the updated code from the git repository. If there are conflicts, you may need to run `git pull` separately and resolve the conflicts before trying the `./bw-dev update` script again. -- `docker-compose build` rebuilds the images, which ensures that the correct packages are installed. This step takes a long time and is only needed when the dependencies (including pip `requirements.txt` packages) have changed, so you can comment it out if you want a quicker update path and don't mind un-commenting it as needed. -- `docker-compose exec web python manage.py migrate` runs the database migrations in Django -- `docker-compose exec web python manage.py collectstatic --no-input` loads any updated static files (such as the JavaScript and CSS) -- `docker-compose restart` reloads the docker containers - -### Re-building activity streams - -If something goes awry with user timelines, and you want to re-create them en mass, there's a management command for that: -`docker-compose run --rm web python manage.py rebuild_feeds` - -### Port Conflicts - -BookWyrm has multiple services that run on their default ports. -This means that, depending on what else you are running on your host machine, you may run into errors when building or running BookWyrm when attempts to bind to those ports fail. - -If this occurs, you will need to change your configuration to run services on different ports. -This may require one or more changes the following files: -- `docker-compose.yml` -- `nginx/default.conf` -- `.env` (You create this file yourself during setup) - -E.g., If you need Redis to run on a different port: -- In `docker-compose.yml`: - - In `services` -> `redis` -> `command`, add `--port YOUR_PORT` to the command - - In `services` -> `redis` -> `ports`, change `6379:6379` to your port -- In `.env`, update `REDIS_PORT` - -If you are already running a web-server on your machine, you will need to set up a reverse-proxy. - -#### Running BookWyrm Behind a Reverse-Proxy - -If you are running another web-server on your machine, you should have it handle proxying web requests to BookWyrm. - -The default BookWyrm configuration already has an nginx server that proxies requests to the django app that handles SSL and directly serves static files. -The static files are stored in a Docker volume that several BookWyrm services access, so it is not recommended to remove this server completely. - -To run BookWyrm behind a reverse-proxy, make the following changes: -- In `nginx/default.conf`: - - Comment out the two default servers - - Uncomment the server labeled Reverse-Proxy server - - Replace `your-domain.com` with your domain name -- In `docker-compose.yml`: - - In `services` -> `nginx` -> `ports`, comment out the default ports and add `- 8001:8001` - - In `services` -> `nginx` -> `volumes`, comment out the two volumes that begin `./certbot/` - - In `services`, comment out the `certbot` service - -At this point, you can follow, the [setup](#server-setup) instructions as listed. -Once docker is running, you can access your BookWyrm instance at `http://localhost:8001` (**NOTE:** your server is not accessible over `https`). - -Steps for setting up a reverse-proxy are server dependent. - -##### Nginx - -Before you can set up nginx, you will need to locate your nginx configuration directory, which is dependent on your platform and how you installed nginx. -See [nginx's guide](http://nginx.org/en/docs/beginners_guide.html) for details. - -To set up your server: -- In you `nginx.conf` file, ensure that `include servers/*;` isn't commented out. -- In your nginx `servers` directory, create a new file named after your domain containing the following information: - ```nginx - server { - server_name your-domain.com www.your-domain.com; - - location / { - proxy_pass http://localhost:8000; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - - location /images/ { - proxy_pass http://localhost:8001; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - - location /static/ { - proxy_pass http://localhost:8001; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - - listen [::]:80 ssl; - listen 80 ssl; - } - ``` -- run `sudo certbot run --nginx --email YOUR_EMAIL -d your-domain.com -d www.your-domain.com` -- restart nginx - -If everything worked correctly, your BookWyrm instance should now be externally accessible. - ## Book data The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written. -There are three concepts in the book data model: -- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition` -- `Work`, the theoretical umbrella concept of a book that encompasses every edition of the book, and -- `Edition`, a concrete, actually published version of a book - -Whenever a user interacts with a book, they are interacting with a specific edition. Every work has a default edition, but the user can select other editions. Reviews aggregated for all editions of a work when you view an edition's page. +## Set up Bookwyrm +See the [installation instructions](https://docs.joinbookwyrm.com/developer-environment.html) on how to set up Bookwyrm in developer environment or production. 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/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 2483cc62b..2fe5d825c 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -219,6 +219,12 @@ def dict_from_mappings(data, mappings): def get_data(url, params=None): """ wrapper for request.get """ + # check if the url is blocked + if models.FederatedServer.is_blocked(url): + raise ConnectorException( + "Attempting to load data from blocked url: {:s}".format(url) + ) + try: resp = requests.get( url, diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index b159a89ef..7c41323c0 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -281,3 +281,9 @@ class ReportForm(CustomForm): class Meta: model = models.Report fields = ["user", "reporter", "statuses", "note"] + + +class ServerForm(CustomForm): + class Meta: + model = models.FederatedServer + exclude = ["remote_id"] diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index d6101c877..a86a1652e 100644 --- a/bookwyrm/management/commands/initdb.py +++ b/bookwyrm/management/commands/initdb.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType -from bookwyrm.models import Connector, SiteSettings, User +from bookwyrm.models import Connector, FederatedServer, SiteSettings, User from bookwyrm.settings import DOMAIN @@ -107,6 +107,16 @@ def init_connectors(): ) +def init_federated_servers(): + """ big no to nazis """ + built_in_blocks = ["gab.ai", "gab.com"] + for server in built_in_blocks: + FederatedServer.objects.create( + server_name=server, + status="blocked", + ) + + def init_settings(): SiteSettings.objects.create() @@ -118,4 +128,5 @@ class Command(BaseCommand): init_groups() init_permissions() init_connectors() + init_federated_servers() init_settings() diff --git a/bookwyrm/migrations/0063_auto_20210407_1827.py b/bookwyrm/migrations/0063_auto_20210407_1827.py new file mode 100644 index 000000000..0bd0f2ae4 --- /dev/null +++ b/bookwyrm/migrations/0063_auto_20210407_1827.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.6 on 2021-04-07 18:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0062_auto_20210407_1545"), + ] + + operations = [ + migrations.AddField( + model_name="federatedserver", + name="notes", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="federatedserver", + name="application_type", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="federatedserver", + name="application_version", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="federatedserver", + name="status", + field=models.CharField( + choices=[("federated", "Federated"), ("blocked", "Blocked")], + default="federated", + max_length=255, + ), + ), + ] 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/migrations/0064_merge_20210410_1633.py b/bookwyrm/migrations/0064_merge_20210410_1633.py new file mode 100644 index 000000000..77ad541e9 --- /dev/null +++ b/bookwyrm/migrations/0064_merge_20210410_1633.py @@ -0,0 +1,13 @@ +# Generated by Django 3.1.8 on 2021-04-10 16:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0063_auto_20210408_1556"), + ("bookwyrm", "0063_auto_20210407_1827"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0065_merge_20210411_1702.py b/bookwyrm/migrations/0065_merge_20210411_1702.py new file mode 100644 index 000000000..2bdc425dc --- /dev/null +++ b/bookwyrm/migrations/0065_merge_20210411_1702.py @@ -0,0 +1,13 @@ +# Generated by Django 3.1.8 on 2021-04-11 17:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0064_auto_20210408_2208"), + ("bookwyrm", "0064_merge_20210410_1633"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0066_user_deactivation_reason.py b/bookwyrm/migrations/0066_user_deactivation_reason.py new file mode 100644 index 000000000..bb3173a7c --- /dev/null +++ b/bookwyrm/migrations/0066_user_deactivation_reason.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.8 on 2021-04-12 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0065_merge_20210411_1702"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("self_deletion", "Self Deletion"), + ("moderator_deletion", "Moderator Deletion"), + ("domain_block", "Domain Block"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index 8dce42e4e..ce16460e6 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 ] @@ -152,7 +153,7 @@ class ActivitypubMixin: # unless it's a dm, all the followers should receive the activity if privacy != "direct": # we will send this out to a subset of all remote users - queryset = user_model.objects.filter( + queryset = user_model.viewer_aware_objects(user).filter( local=False, ) # filter users first by whether they're using the desired software @@ -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/base_model.py b/bookwyrm/models/base_model.py index cb2fc851e..261c96868 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -31,6 +31,36 @@ class BookWyrmModel(models.Model): """ how to link to this object in the local app """ return self.get_remote_id().replace("https://%s" % DOMAIN, "") + def visible_to_user(self, viewer): + """ is a user authorized to view an object? """ + # make sure this is an object with privacy owned by a user + if not hasattr(self, "user") or not hasattr(self, "privacy"): + return None + + # viewer can't see it if the object's owner blocked them + if viewer in self.user.blocks.all(): + return False + + # you can see your own posts and any public or unlisted posts + if viewer == self.user or self.privacy in ["public", "unlisted"]: + return True + + # you can see the followers only posts of people you follow + if ( + self.privacy == "followers" + and self.user.followers.filter(id=viewer.id).first() + ): + return True + + # you can see dms you are tagged in + if hasattr(self, "mention_users"): + if ( + self.privacy == "direct" + and self.mention_users.filter(id=viewer.id).first() + ): + return True + return False + @receiver(models.signals.post_save) # pylint: disable=unused-argument diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py index 8f7d903e4..aa2b2f6af 100644 --- a/bookwyrm/models/federated_server.py +++ b/bookwyrm/models/federated_server.py @@ -1,17 +1,51 @@ """ connections to external ActivityPub servers """ +from urllib.parse import urlparse from django.db import models from .base_model import BookWyrmModel +FederationStatus = models.TextChoices( + "Status", + [ + "federated", + "blocked", + ], +) + class FederatedServer(BookWyrmModel): """ store which servers we federate with """ server_name = models.CharField(max_length=255, unique=True) - # federated, blocked, whatever else - status = models.CharField(max_length=255, default="federated") + status = models.CharField( + max_length=255, default="federated", choices=FederationStatus.choices + ) # is it mastodon, bookwyrm, etc - application_type = models.CharField(max_length=255, null=True) - application_version = models.CharField(max_length=255, null=True) + application_type = models.CharField(max_length=255, null=True, blank=True) + application_version = models.CharField(max_length=255, null=True, blank=True) + notes = models.TextField(null=True, blank=True) + def block(self): + """ block a server """ + self.status = "blocked" + self.save() -# TODO: blocked servers + # deactivate all associated users + self.user_set.filter(is_active=True).update( + is_active=False, deactivation_reason="domain_block" + ) + + def unblock(self): + """ unblock a server """ + self.status = "federated" + self.save() + + self.user_set.filter(deactivation_reason="domain_block").update( + is_active=True, deactivation_reason=None + ) + + @classmethod + def is_blocked(cls, url): + """ look up if a domain is blocked """ + url = urlparse(url) + domain = url.netloc + return cls.objects.filter(server_name=domain, status="blocked").exists() 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/models/user.py b/bookwyrm/models/user.py index c519f76c9..15ceb19bd 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -24,6 +24,16 @@ from .federated_server import FederatedServer from . import fields, Review +DeactivationReason = models.TextChoices( + "DeactivationReason", + [ + "self_deletion", + "moderator_deletion", + "domain_block", + ], +) + + class User(OrderedCollectionPageMixin, AbstractUser): """ a user who wants to read books """ @@ -111,6 +121,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): default=str(pytz.utc), max_length=255, ) + deactivation_reason = models.CharField( + max_length=255, choices=DeactivationReason.choices, null=True, blank=True + ) name_field = "username" property_fields = [("following_link", "following")] @@ -138,7 +151,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): def viewer_aware_objects(cls, viewer): """ the user queryset filtered for the context of the logged in user """ queryset = cls.objects.filter(is_active=True) - if viewer.is_authenticated: + if viewer and viewer.is_authenticated: queryset = queryset.exclude(blocks=viewer) return queryset diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 6329fbc9f..7ea8c5950 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -24,7 +24,8 @@ EMAIL_HOST = env("EMAIL_HOST") EMAIL_PORT = env("EMAIL_PORT", 587) EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = env("EMAIL_USE_TLS", True) +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, ...) @@ -97,7 +98,7 @@ WSGI_APPLICATION = "bookwyrm.wsgi.application" # redis/activity streams settings REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost") REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379) -REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD") +REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None) MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200)) STREAMS = ["home", "local", "federated"] @@ -166,7 +167,7 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.0/howto/static-files/ +# https://docs.djangoproject.com/en/3.1/howto/static-files/ PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_URL = "/static/" 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 %} -
{% endif %} -
+
{% for result_set in book_results|slice:"1:" %} {% if result_set.results %}
diff --git a/bookwyrm/templates/settings/admin_layout.html b/bookwyrm/templates/settings/admin_layout.html index 9340da9e1..4f71a2284 100644 --- a/bookwyrm/templates/settings/admin_layout.html +++ b/bookwyrm/templates/settings/admin_layout.html @@ -6,7 +6,14 @@ {% block content %}
-

{% block header %}{% endblock %}

+
+
+

{% block header %}{% endblock %}

+
+
+ {% block edit-button %}{% endblock %} +
+
diff --git a/bookwyrm/templates/settings/edit_server.html b/bookwyrm/templates/settings/edit_server.html new file mode 100644 index 000000000..c5702c848 --- /dev/null +++ b/bookwyrm/templates/settings/edit_server.html @@ -0,0 +1,71 @@ +{% extends 'settings/admin_layout.html' %} +{% load i18n %} +{% block title %}{% trans "Add server" %}{% endblock %} + +{% block header %} +{% trans "Add server" %} +{% trans "Back to server list" %} +{% endblock %} + +{% block panel %} + +
+ +
+ + + {% csrf_token %} +
+
+
+ + + {% for error in form.server_name.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+ +
+ +
+
+
+
+
+ + + {% for error in form.application_type.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+ + + {% for error in form.application_version.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+
+

+ + +

+ + + + +{% endblock %} diff --git a/bookwyrm/templates/settings/federated_server.html b/bookwyrm/templates/settings/federated_server.html index 13715bfb2..14b78af0c 100644 --- a/bookwyrm/templates/settings/federated_server.html +++ b/bookwyrm/templates/settings/federated_server.html @@ -1,67 +1,116 @@ {% extends 'settings/admin_layout.html' %} {% block title %}{{ server.server_name }}{% endblock %} {% load i18n %} +{% load bookwyrm_tags %} {% block header %} {{ server.server_name }} + +{% if server.status == "blocked" %}{% trans "Blocked" %} +{% endif %} + {% trans "Back to server list" %} {% endblock %} {% block panel %} +
+
+

{% trans "Details" %}

+
+
+
{% trans "Software:" %}
+
{{ server.application_type }}
+
+
+
{% trans "Version:" %}
+
{{ server.application_version }}
+
+
+
{% trans "Status:" %}
+
{{ server.status }}
+
+
+
+ +
+

{% trans "Activity" %}

+
+
+
{% trans "Users:" %}
+
+ {{ users.count }} + {% if server.user_set.count %}({% trans "View all" %}){% endif %} +
+
+
+
{% trans "Reports:" %}
+
+ {{ reports.count }} + {% if reports.count %}({% trans "View all" %}){% endif %} +
+
+
+
{% trans "Followed by us:" %}
+
+ {{ followed_by_us.count }} +
+
+
+
{% trans "Followed by them:" %}
+
+ {{ followed_by_them.count }} +
+
+
+
{% trans "Blocked by us:" %}
+
+ {{ blocked_by_us.count }} +
+
+
+
+
+
-

{% trans "Details" %}

-
-
-
{% trans "Software:" %}
-
{{ server.application_type }}
+
+
+

{% trans "Notes" %}

-
-
{% trans "Version:" %}
-
{{ server.application_version }}
+
+ {% trans "Edit" as button_text %} + {% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-notes" %}
-
-
{% trans "Status:" %}
-
Federated
-
-
+ + {% if server.notes %} +

{{ server.notes|to_markdown|safe }}

+ {% endif %} +
-

{% trans "Activity" %}

-
-
-
{% trans "Users:" %}
-
- {{ users.count }} - {% if server.user_set.count %}({% trans "View all" %}){% endif %} -
-
-
-
{% trans "Reports:" %}
-
- {{ reports.count }} - {% if reports.count %}({% trans "View all" %}){% endif %} -
-
-
-
{% trans "Followed by us:" %}
-
- {{ followed_by_us.count }} -
-
-
-
{% trans "Followed by them:" %}
-
- {{ followed_by_them.count }} -
-
-
-
{% trans "Blocked by us:" %}
-
- {{ blocked_by_us.count }} -
-
-
+

{% trans "Actions" %}

+ {% if server.status != 'blocked' %} +
+ {% csrf_token %} + +

{% trans "All users from this instance will be deactivated." %}

+
+ {% else %} +
+ {% csrf_token %} + +

{% trans "All users from this instance will be re-activated." %}

+
+ {% endif %}
{% endblock %} diff --git a/bookwyrm/templates/settings/federation.html b/bookwyrm/templates/settings/federation.html index 696d7a205..9eb0f8b12 100644 --- a/bookwyrm/templates/settings/federation.html +++ b/bookwyrm/templates/settings/federation.html @@ -4,8 +4,15 @@ {% block header %}{% trans "Federated Servers" %}{% endblock %} -{% block panel %} +{% block edit-button %} + + + {% trans "Add server" %} + + +{% endblock %} +{% block panel %} {% url 'settings-federation' as url %} diff --git a/bookwyrm/templates/settings/server_blocklist.html b/bookwyrm/templates/settings/server_blocklist.html new file mode 100644 index 000000000..0de49acd7 --- /dev/null +++ b/bookwyrm/templates/settings/server_blocklist.html @@ -0,0 +1,67 @@ +{% extends 'settings/admin_layout.html' %} +{% load i18n %} +{% block title %}{% trans "Add server" %}{% endblock %} + +{% block header %} +{% trans "Import Blocklist" %} +{% trans "Back to server list" %} +{% endblock %} + +{% block panel %} + +
+ +
+ +{% if succeeded and not failed %} +

{% trans "Success!" %}

+{% elif succeeded or failed %} +
+ {% if succeeded %} +

{% trans "Successfully blocked:" %} {{ succeeded }}

+ {% endif %} +

{% trans "Failed:" %}

+
    + {% for item in failed %} +
  • +
    +{{ item }}
    +
    +
  • + {% endfor %} +
+
+{% endif %} + + + {% csrf_token %} +
+ + + +
+ + + + +{% endblock %} diff --git a/bookwyrm/templates/snippets/authors.html b/bookwyrm/templates/snippets/authors.html index dd94b4714..9459b0fe4 100644 --- a/bookwyrm/templates/snippets/authors.html +++ b/bookwyrm/templates/snippets/authors.html @@ -1 +1,17 @@ -{% for author in book.authors.all %}{{ author.name }}{% if not forloop.last %}, {% endif %}{% endfor %} +{% spaceless %} +{% comment %} + @todo The author property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor. + @see https://schema.org/Author +{% endcomment %} +{% for author in book.authors.all %} + {% if not forloop.last %}, {% endif %} +{% endfor %} +{% endspaceless %} diff --git a/bookwyrm/templates/snippets/book_cover.html b/bookwyrm/templates/snippets/book_cover.html index 0dbc3672d..ce47819e8 100644 --- a/bookwyrm/templates/snippets/book_cover.html +++ b/bookwyrm/templates/snippets/book_cover.html @@ -1,13 +1,29 @@ +{% spaceless %} + {% load bookwyrm_tags %} +{% load i18n %} +
-{% if book.cover %} -{{ book.alt_text }} -{% else %} -
- No cover -
-

{{ book.alt_text }}

+ {% if book.cover %} + {{ book.alt_text }} + {% else %} +
+ {% trans + +
+

{{ book.alt_text }}

+
-
-{% endif %} + {% endif %}
+{% endspaceless %} diff --git a/bookwyrm/templates/snippets/boost_button.html b/bookwyrm/templates/snippets/boost_button.html index 3bc1b6012..e590c58d8 100644 --- a/bookwyrm/templates/snippets/boost_button.html +++ b/bookwyrm/templates/snippets/boost_button.html @@ -2,7 +2,7 @@ {% load i18n %} {% with status.id|uuid as uuid %} -
+ {% csrf_token %} -
+ {% csrf_token %} -
+ {% csrf_token %} - + {% csrf_token %} {% if user.manually_approves_followers and request.user not in user.followers.all %} diff --git a/bookwyrm/templates/snippets/rate_action.html b/bookwyrm/templates/snippets/rate_action.html index 9fee692da..711c3b3e1 100644 --- a/bookwyrm/templates/snippets/rate_action.html +++ b/bookwyrm/templates/snippets/rate_action.html @@ -11,7 +11,7 @@ {% include 'snippets/form_rate_stars.html' with book=book classes='mb-1 has-text-warning-dark' default_rating=book|user_rating:request.user %} - -