Merge branch 'main' into suggestions-redis

This commit is contained in:
Mouse Reeve 2021-06-18 16:48:04 -07:00
commit 5b6048e4c6
222 changed files with 5284 additions and 3547 deletions

View file

@ -13,16 +13,10 @@ DEFAULT_LANGUAGE="English"
## 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_PORT=5432
POSTGRES_PASSWORD=fedireads
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
POSTGRES_HOST=db
@ -34,10 +28,8 @@ REDIS_ACTIVITY_PORT=6379
#REDIS_ACTIVITY_PASSWORD=redispassword345
# Redis as celery broker
#REDIS_BROKER_PORT=6379
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
@ -50,5 +42,14 @@ 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
# 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"

View file

@ -13,16 +13,10 @@ DEFAULT_LANGUAGE="English"
## 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_PORT=5432
POSTGRES_PASSWORD=securedbpassword123
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
POSTGRES_HOST=db
@ -36,8 +30,6 @@ REDIS_ACTIVITY_PASSWORD=redispassword345
# Redis as celery broker
REDIS_BROKER_PORT=6379
REDIS_BROKER_PASSWORD=redispassword123
CELERY_BROKER=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0
CELERY_RESULT_BACKEND=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0
FLOWER_PORT=8888
FLOWER_USER=mouse
@ -50,5 +42,14 @@ 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
# 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"

View file

@ -1,4 +1,4 @@
name: Lint Python
name: Python Formatting (run ./bw-dev black to fix)
on: [push, pull_request]

View file

@ -50,7 +50,6 @@ jobs:
SECRET_KEY: beepbeep
DEBUG: true
DOMAIN: your.domain.here
OL_URL: https://openlibrary.org
BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/
POSTGRES_PASSWORD: hunter2
@ -58,11 +57,13 @@ jobs:
POSTGRES_DB: github_actions
POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: ""
CELERY_RESULT_BACKEND: ""
REDIS_BROKER_PORT: 6379
FLOWER_PORT: 8888
EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
ENABLE_PREVIEW_IMAGES: true
run: |
python manage.py test

24
.github/workflows/pylint.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: Pylint
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pylint
- name: Analysing the code with pylint
run: |
pylint bookwyrm/ --ignore=migrations,tests --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801

3
.gitignore vendored
View file

@ -27,3 +27,6 @@
#nginx
nginx/default.conf
#macOS
**/.DS_Store

View file

@ -9,5 +9,3 @@ WORKDIR /app
COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir
RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean
COPY ./bookwyrm ./celerywyrm /app/

View file

@ -37,6 +37,7 @@ class Mention(Link):
@dataclass
# pylint: disable=invalid-name
class Signature:
"""public key block"""
@ -56,11 +57,11 @@ def naive_parse(activity_objects, activity_json, serializer=None):
activity_type = activity_json.get("type")
try:
serializer = activity_objects[activity_type]
except KeyError as e:
except KeyError as err:
# we know this exists and that we can't handle it
if activity_type in ["Question"]:
return None
raise ActivitySerializerError(e)
raise ActivitySerializerError(err)
return serializer(activity_objects=activity_objects, **activity_json)

View file

@ -6,6 +6,7 @@ from .base_activity import ActivityObject
from .image import Document
# pylint: disable=invalid-name
@dataclass(init=False)
class BookData(ActivityObject):
"""shared fields for all book data and authors"""
@ -18,6 +19,7 @@ class BookData(ActivityObject):
lastEditedBy: str = None
# pylint: disable=invalid-name
@dataclass(init=False)
class Book(BookData):
"""serializes an edition or work, abstract"""
@ -40,6 +42,7 @@ class Book(BookData):
type: str = "Book"
# pylint: disable=invalid-name
@dataclass(init=False)
class Edition(Book):
"""Edition instance of a book object"""
@ -57,6 +60,7 @@ class Edition(Book):
type: str = "Edition"
# pylint: disable=invalid-name
@dataclass(init=False)
class Work(Book):
"""work instance of a book object"""
@ -66,6 +70,7 @@ class Work(Book):
type: str = "Work"
# pylint: disable=invalid-name
@dataclass(init=False)
class Author(BookData):
"""author of a book"""

View file

@ -19,6 +19,7 @@ class Tombstone(ActivityObject):
return model.find_existing_by_remote_id(self.id)
# pylint: disable=invalid-name
@dataclass(init=False)
class Note(ActivityObject):
"""Note activity"""
@ -52,6 +53,7 @@ class GeneratedNote(Note):
type: str = "GeneratedNote"
# pylint: disable=invalid-name
@dataclass(init=False)
class Comment(Note):
"""like a note but with a book"""

View file

@ -5,6 +5,7 @@ from typing import List
from .base_activity import ActivityObject
# pylint: disable=invalid-name
@dataclass(init=False)
class OrderedCollection(ActivityObject):
"""structure of an ordered collection activity"""
@ -17,6 +18,7 @@ class OrderedCollection(ActivityObject):
type: str = "OrderedCollection"
# pylint: disable=invalid-name
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
"""an ordered collection with privacy settings"""
@ -41,6 +43,7 @@ class BookList(OrderedCollectionPrivate):
type: str = "BookList"
# pylint: disable=invalid-name
@dataclass(init=False)
class OrderedCollectionPage(ActivityObject):
"""structure of an ordered collection activity"""

View file

@ -6,6 +6,7 @@ from .base_activity import ActivityObject
from .image import Image
# pylint: disable=invalid-name
@dataclass(init=False)
class PublicKey(ActivityObject):
"""public key block"""
@ -15,6 +16,7 @@ class PublicKey(ActivityObject):
type: str = "PublicKey"
# pylint: disable=invalid-name
@dataclass(init=False)
class Person(ActivityObject):
"""actor activitypub json"""

View file

@ -1,3 +1,4 @@
""" ActivityPub-specific json response wrapper """
from django.http import JsonResponse
from .base_activity import ActivityEncoder

View file

@ -22,6 +22,7 @@ class Verb(ActivityObject):
self.object.to_model()
# pylint: disable=invalid-name
@dataclass(init=False)
class Create(Verb):
"""Create activity"""
@ -32,6 +33,7 @@ class Create(Verb):
type: str = "Create"
# pylint: disable=invalid-name
@dataclass(init=False)
class Delete(Verb):
"""Create activity"""
@ -57,6 +59,7 @@ class Delete(Verb):
# if we can't find it, we don't need to delete it because we don't have it
# pylint: disable=invalid-name
@dataclass(init=False)
class Update(Verb):
"""Update activity"""
@ -192,6 +195,7 @@ class Like(Verb):
self.to_model()
# pylint: disable=invalid-name
@dataclass(init=False)
class Announce(Verb):
"""boosting a status"""

View file

@ -55,6 +55,8 @@ class ActivityStream(RedisStore):
return (
models.Status.objects.select_subclasses()
.filter(id__in=statuses)
.select_related("user", "reply_parent")
.prefetch_related("mention_books", "mention_users")
.order_by("-published_date")
)
@ -199,6 +201,19 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
for stream in streams.values():
stream.add_status(instance)
if sender != models.Boost:
return
# remove the original post and other, earlier boosts
boosted = instance.boost.boosted_status
old_versions = models.Boost.objects.filter(
boosted_status__id=boosted.id,
created_date__lt=instance.created_date,
)
for stream in streams.values():
stream.remove_object_from_related_stores(boosted)
for status in old_versions:
stream.remove_object_from_related_stores(status)
@receiver(signals.post_delete, sender=models.Boost)
# pylint: disable=unused-argument
@ -206,7 +221,10 @@ def remove_boost_on_delete(sender, instance, *args, **kwargs):
"""boosts are deleted"""
# we're only interested in new statuses
for stream in streams.values():
# remove the boost
stream.remove_object_from_related_stores(instance)
# re-add the original status
stream.add_status(instance.boosted_status)
@receiver(signals.post_save, sender=models.UserFollows)

View file

@ -37,7 +37,7 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None):
def search(self, query, min_confidence=None, timeout=5):
"""free text search"""
params = {}
if min_confidence:
@ -46,6 +46,7 @@ class AbstractMinimalConnector(ABC):
data = self.get_search_data(
"%s%s" % (self.search_url, query),
params=params,
timeout=timeout,
)
results = []
@ -126,8 +127,8 @@ class AbstractConnector(AbstractMinimalConnector):
edition_data = data
try:
work_data = self.get_work_from_edition_data(data)
except (KeyError, ConnectorException) as e:
logger.exception(e)
except (KeyError, ConnectorException) as err:
logger.exception(err)
work_data = data
if not work_data or not edition_data:
@ -218,7 +219,7 @@ def dict_from_mappings(data, mappings):
return result
def get_data(url, params=None):
def get_data(url, params=None, timeout=10):
"""wrapper for request.get"""
# check if the url is blocked
if models.FederatedServer.is_blocked(url):
@ -234,23 +235,24 @@ def get_data(url, params=None):
"Accept": "application/json; charset=utf-8",
"User-Agent": settings.USER_AGENT,
},
timeout=timeout,
)
except (RequestError, SSLError, ConnectionError) as e:
logger.exception(e)
except (RequestError, SSLError, ConnectionError) as err:
logger.exception(err)
raise ConnectorException()
if not resp.ok:
raise ConnectorException()
try:
data = resp.json()
except ValueError as e:
logger.exception(e)
except ValueError as err:
logger.exception(err)
raise ConnectorException()
return data
def get_image(url):
def get_image(url, timeout=10):
"""wrapper for requesting an image"""
try:
resp = requests.get(
@ -258,9 +260,10 @@ def get_image(url):
headers={
"User-Agent": settings.USER_AGENT,
},
timeout=timeout,
)
except (RequestError, SSLError) as e:
logger.exception(e)
except (RequestError, SSLError) as err:
logger.exception(err)
return None
if not resp.ok:
return None

View file

@ -1,4 +1,5 @@
""" interface with whatever connectors the app has """
from datetime import datetime
import importlib
import logging
import re
@ -29,23 +30,25 @@ 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
if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url == "":
if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url != "":
# Search on ISBN
try:
result_set = connector.isbn_search(isbn)
except Exception as e: # pylint: disable=broad-except
logger.exception(e)
except Exception as err: # pylint: disable=broad-except
logger.exception(err)
# if this fails, we can still try regular search
# if no isbn search results, we fallback to generic search
if not result_set:
try:
result_set = connector.search(query, min_confidence=min_confidence)
except Exception as e: # pylint: disable=broad-except
except Exception as err: # pylint: disable=broad-except
# we don't want *any* error to crash the whole search page
logger.exception(e)
logger.exception(err)
continue
if return_first and result_set:
@ -59,6 +62,8 @@ def search(query, min_confidence=0.1, return_first=False):
"results": result_set,
}
)
if (datetime.now() - start_time).seconds >= timeout:
break
if return_first:
return None

View file

@ -74,7 +74,7 @@ class Connector(AbstractConnector):
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]},
}
def search(self, query, min_confidence=None):
def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
"""overrides default search function with confidence ranking"""
results = super().search(query)
if min_confidence:

View file

@ -3,7 +3,7 @@ from functools import reduce
import operator
from django.contrib.postgres.search import SearchRank, SearchVector
from django.db.models import Count, OuterRef, Subquery, F, Q
from django.db.models import OuterRef, Subquery, F, Q
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult
@ -114,6 +114,7 @@ class Connector(AbstractConnector):
def search_identifiers(query, *filters):
"""tries remote_id, isbn; defined as dedupe fields on the model"""
# pylint: disable=W0212
or_filters = [
{f.name: query}
for f in models.Edition._meta.get_fields()
@ -122,6 +123,8 @@ def search_identifiers(query, *filters):
results = models.Edition.objects.filter(
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
).distinct()
if results.count() <= 1:
return results
# when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen.
@ -146,19 +149,15 @@ def search_title_author(query, min_confidence, *filters):
)
results = (
models.Edition.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, query))
models.Edition.objects.annotate(rank=SearchRank(vector, query))
.filter(*filters, rank__gt=min_confidence)
.order_by("-rank")
)
# when there are multiple editions of the same work, pick the closest
editions_of_work = (
results.values("parent_work")
.annotate(Count("parent_work"))
.values_list("parent_work")
)
editions_of_work = results.values("parent_work__id").values_list("parent_work__id")
# filter out multiple editions of the same work
for work_id in set(editions_of_work):
editions = results.filter(parent_work=work_id)
default = editions.order_by("-edition_rank").first()

View file

@ -1,10 +1,20 @@
""" customize the info available in context for rendering templates """
from bookwyrm import models
from bookwyrm import models, settings
def site_settings(request): # pylint: disable=unused-argument
"""include the custom info about the site"""
request_protocol = "https://"
if not request.is_secure():
request_protocol = "http://"
return {
"site": models.SiteSettings.objects.get(),
"active_announcements": models.Announcement.active_announcements(),
"static_url": settings.STATIC_URL,
"media_url": settings.MEDIA_URL,
"static_path": settings.STATIC_PATH,
"media_path": settings.MEDIA_PATH,
"preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES,
"request_protocol": request_protocol,
}

View file

@ -22,6 +22,7 @@ class CustomForm(ModelForm):
css_classes["number"] = "input"
css_classes["checkbox"] = "checkbox"
css_classes["textarea"] = "textarea"
# pylint: disable=super-with-arguments
super(CustomForm, self).__init__(*args, **kwargs)
for visible in self.visible_fields():
if hasattr(visible.field.widget, "input_type"):
@ -150,6 +151,12 @@ class LimitedEditUserForm(CustomForm):
help_texts = {f: None for f in fields}
class DeleteUserForm(CustomForm):
class Meta:
model = models.User
fields = ["password"]
class UserGroupForm(CustomForm):
class Meta:
model = models.User
@ -175,8 +182,6 @@ class EditionForm(CustomForm):
"authors",
"parent_work",
"shelves",
"subjects", # TODO
"subject_places", # TODO
"connector",
]

View file

@ -67,8 +67,8 @@ def import_data(source, job_id):
for item in job.items.all():
try:
item.resolve()
except Exception as e: # pylint: disable=broad-except
logger.exception(e)
except Exception as err: # pylint: disable=broad-except
logger.exception(err)
item.fail_reason = "Error loading book"
item.save()
continue

View file

@ -0,0 +1,65 @@
""" Generate preview images """
from django.core.management.base import BaseCommand
from bookwyrm import models, preview_images
# pylint: disable=line-too-long
class Command(BaseCommand):
"""Creates previews for existing objects"""
help = "Generate preview images"
def add_arguments(self, parser):
parser.add_argument(
"--all",
"-a",
action="store_true",
help="Generates images for ALL types: site, users and books. Can use a lot of computing power.",
)
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""generate preview images"""
self.stdout.write(
" | Hello! I will be generating preview images for your instance."
)
if options["all"]:
self.stdout.write(
"🧑‍🎨 ⎨ This might take quite long if your instance has a lot of books and users."
)
self.stdout.write(" | ✧ Thank you for your patience ✧")
else:
self.stdout.write("🧑‍🎨 ⎨ I will only generate the instance preview image.")
self.stdout.write(" | ✧ Be right back! ✧")
# Site
self.stdout.write(" → Site preview image: ", ending="")
preview_images.generate_site_preview_image_task.delay()
self.stdout.write(" OK 🖼")
if options["all"]:
# Users
users = models.User.objects.filter(
local=True,
is_active=True,
)
self.stdout.write(
" → User preview images ({}): ".format(len(users)), ending=""
)
for user in users:
preview_images.generate_user_preview_image_task.delay(user.id)
self.stdout.write(".", ending="")
self.stdout.write(" OK 🖼")
# Books
books = models.Book.objects.select_subclasses().filter()
self.stdout.write(
" → Book preview images ({}): ".format(len(books)), ending=""
)
for book in books:
preview_images.generate_edition_preview_image_task.delay(book.id)
self.stdout.write(".", ending="")
self.stdout.write(" OK 🖼")
self.stdout.write("🧑‍🎨 ⎨ Im all done! ✧ Enjoy ✧")

View file

@ -0,0 +1,32 @@
# Generated by Django 3.2 on 2021-05-26 12:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0075_announcement"),
]
operations = [
migrations.AddField(
model_name="book",
name="preview_image",
field=models.ImageField(
blank=True, null=True, upload_to="previews/covers/"
),
),
migrations.AddField(
model_name="sitesettings",
name="preview_image",
field=models.ImageField(blank=True, null=True, upload_to="previews/logos/"),
),
migrations.AddField(
model_name="user",
name="preview_image",
field=models.ImageField(
blank=True, null=True, upload_to="previews/avatars/"
),
),
]

View file

@ -2,10 +2,13 @@
import re
from django.db import models
from django.dispatch import receiver
from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE
from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_PREVIEW_IMAGES
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
@ -82,10 +85,14 @@ class Book(BookDataModel):
cover = fields.ImageField(
upload_to="covers/", blank=True, null=True, alt_field="alt_text"
)
preview_image = models.ImageField(
upload_to="previews/covers/", blank=True, null=True
)
first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
objects = InheritanceManager()
field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
@property
def author_text(self):
@ -293,3 +300,17 @@ def isbn_13_to_10(isbn_13):
if checkdigit == 10:
checkdigit = "X"
return converted + str(checkdigit)
# pylint: disable=unused-argument
@receiver(models.signals.post_save, sender=Edition)
def preview_image(instance, *args, **kwargs):
"""create preview image on book create"""
if not ENABLE_PREVIEW_IMAGES:
return
changed_fields = {}
if instance.field_tracker:
changed_fields = instance.field_tracker.changed()
if len(changed_fields) > 0:
generate_edition_preview_image_task.delay(instance.id)

View file

@ -1,5 +1,6 @@
""" activitypub-aware django model fields """
from dataclasses import MISSING
import imghdr
import re
from uuid import uuid4
@ -9,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.forms import ClearableFileInput, ImageField as DjangoImageField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
@ -200,6 +202,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
*args, max_length=255, choices=PrivacyLevels.choices, default="public"
)
# pylint: disable=invalid-name
def set_field_from_activity(self, instance, data):
to = data.to
cc = data.cc
@ -218,6 +221,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
if hasattr(instance, "mention_users"):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
# pylint: disable=protected-access
followers = instance.user.__class__._meta.get_field(
"followers"
).field_to_activity(instance.user.followers)
@ -332,6 +336,18 @@ class TagField(ManyToManyField):
return items
class ClearableFileInputWithWarning(ClearableFileInput):
"""max file size warning"""
template_name = "widgets/clearable_file_input_with_warning.html"
class CustomImageField(DjangoImageField):
"""overwrites image field for form"""
widget = ClearableFileInputWithWarning
def image_serializer(value, alt):
"""helper for serializing images"""
if value and hasattr(value, "url"):
@ -391,10 +407,19 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if not response:
return None
image_name = str(uuid4()) + "." + url.split(".")[-1]
image_content = ContentFile(response.content)
image_name = str(uuid4()) + "." + imghdr.what(None, image_content.read())
return [image_name, image_content]
def formfield(self, **kwargs):
"""special case for forms"""
return super().formfield(
**{
"form_class": CustomImageField,
**kwargs,
}
)
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
"""activitypub-aware datetime field"""

View file

@ -75,7 +75,12 @@ class ImportItem(models.Model):
def resolve(self):
"""try various ways to lookup a book"""
self.book = self.get_book_from_isbn() or self.get_book_from_title_author()
if self.isbn:
self.book = self.get_book_from_isbn()
else:
# don't fall back on title/author search is isbn is present.
# you're too likely to mismatch
self.get_book_from_title_author()
def get_book_from_isbn(self):
"""search by isbn"""

View file

@ -93,7 +93,8 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
)
class Meta:
# A book may only be placed into a list once, and each order in the list may be used only
# once
"""A book may only be placed into a list once,
and each order in the list may be used only once"""
unique_together = (("book", "book_list"), ("order", "book_list"))
ordering = ("-created_date",)

View file

@ -99,7 +99,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
status = "follow_request"
activity_serializer = activitypub.Follow
def save(self, *args, broadcast=True, **kwargs):
def save(self, *args, broadcast=True, **kwargs): # pylint: disable=arguments-differ
"""make sure the follow or block relationship doesn't already exist"""
# if there's a request for a follow that already exists, accept it
# without changing the local database state

View file

@ -4,9 +4,12 @@ import datetime
from Crypto import Random
from django.db import models, IntegrityError
from django.dispatch import receiver
from django.utils import timezone
from model_utils import FieldTracker
from bookwyrm.settings import DOMAIN
from bookwyrm.preview_images import generate_site_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
from .base_model import BookWyrmModel
from .user import User
@ -35,6 +38,9 @@ class SiteSettings(models.Model):
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
favicon = models.ImageField(upload_to="logos/", null=True, blank=True)
preview_image = models.ImageField(
upload_to="previews/logos/", null=True, blank=True
)
# footer
support_link = models.CharField(max_length=255, null=True, blank=True)
@ -42,6 +48,8 @@ class SiteSettings(models.Model):
admin_email = models.EmailField(max_length=255, null=True, blank=True)
footer_item = models.TextField(null=True, blank=True)
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
@classmethod
def get(cls):
"""gets the site settings db entry or defaults"""
@ -119,3 +127,15 @@ class PasswordReset(models.Model):
def link(self):
"""formats the invite link"""
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
# pylint: disable=unused-argument
@receiver(models.signals.post_save, sender=SiteSettings)
def preview_image(instance, *args, **kwargs):
"""Update image preview for the default site image"""
if not ENABLE_PREVIEW_IMAGES:
return
changed_fields = instance.field_tracker.changed()
if len(changed_fields) > 0:
generate_site_preview_image_task.delay()

View file

@ -5,11 +5,15 @@ import re
from django.apps import apps
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils import timezone
from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import ENABLE_PREVIEW_IMAGES
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
@ -304,6 +308,8 @@ class Review(Status):
max_digits=3,
)
field_tracker = FieldTracker(fields=["rating"])
@property
def pure_name(self):
"""clarify review names for mastodon serialization"""
@ -398,3 +404,17 @@ class Boost(ActivityMixin, Status):
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
# pylint: disable=unused-argument
@receiver(models.signals.post_save)
def preview_image(instance, sender, *args, **kwargs):
"""Updates book previews if the rating has changed"""
if not ENABLE_PREVIEW_IMAGES or sender not in (Review, ReviewRating):
return
changed_fields = instance.field_tracker.changed()
if len(changed_fields) > 0:
edition = instance.book
generate_edition_preview_image_task.delay(edition.id)

View file

@ -6,15 +6,18 @@ from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.postgres.fields import CICharField
from django.core.validators import MinValueValidator
from django.dispatch import receiver
from django.db import models
from django.utils import timezone
from model_utils import FieldTracker
import pytz
from bookwyrm import activitypub
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN
from bookwyrm.preview_images import generate_user_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
from bookwyrm.utils import regex
@ -70,6 +73,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activitypub_field="icon",
alt_field="alt_text",
)
preview_image = models.ImageField(
upload_to="previews/avatars/", blank=True, null=True
)
followers = fields.ManyToManyField(
"self",
link_only=True,
@ -117,6 +123,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
name_field = "username"
property_fields = [("following_link", "following")]
field_tracker = FieldTracker(fields=["name", "avatar"])
@property
def following_link(self):
@ -232,7 +239,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def save(self, *args, **kwargs):
"""populate fields for new local users"""
created = not bool(self.id)
if not self.local and not re.match(regex.full_username, self.username):
if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = "%s@%s" % (self.username, actor_parts.netloc)
@ -356,7 +363,7 @@ class AnnualGoal(BookWyrmModel):
def get_remote_id(self):
"""put the year in the path"""
return "%s/goal/%d" % (self.user.remote_id, self.year)
return "{:s}/goal/{:d}".format(self.user.remote_id, self.year)
@property
def books(self):
@ -381,17 +388,16 @@ class AnnualGoal(BookWyrmModel):
return {r.book.id: r.rating for r in reviews}
@property
def progress_percent(self):
"""how close to your goal, in percent form"""
return int(float(self.book_count / self.goal) * 100)
@property
def book_count(self):
def progress(self):
"""how many books you've read this year"""
return self.user.readthrough_set.filter(
count = self.user.readthrough_set.filter(
finish_date__year__gte=self.year,
finish_date__year__lt=self.year + 1,
).count()
return {
"count": count,
"percent": int(float(count / self.goal) * 100),
}
@app.task
@ -444,3 +450,15 @@ def get_remote_reviews(outbox):
if not activity["type"] == "Review":
continue
activitypub.Review(**activity).to_model()
# pylint: disable=unused-argument
@receiver(models.signals.post_save, sender=User)
def preview_image(instance, *args, **kwargs):
"""create preview images when user is updated"""
if not ENABLE_PREVIEW_IMAGES:
return
changed_fields = instance.field_tracker.changed()
if len(changed_fields) > 0:
generate_user_preview_image_task.delay(instance.id)

424
bookwyrm/preview_images.py Normal file
View file

@ -0,0 +1,424 @@
""" Generate social media preview images for twitter/mastodon/etc """
import math
import os
import textwrap
from io import BytesIO
from uuid import uuid4
import colorsys
from colorthief import ColorThief
from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageColor
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db.models import Avg
from bookwyrm import models, settings
from bookwyrm.tasks import app
IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT
BG_COLOR = settings.PREVIEW_BG_COLOR
TEXT_COLOR = settings.PREVIEW_TEXT_COLOR
DEFAULT_COVER_COLOR = settings.PREVIEW_DEFAULT_COVER_COLOR
TRANSPARENT_COLOR = (0, 0, 0, 0)
margin = math.floor(IMG_HEIGHT / 10)
gutter = math.floor(margin / 2)
inner_img_height = math.floor(IMG_HEIGHT * 0.8)
inner_img_width = math.floor(inner_img_height * 0.7)
font_dir = os.path.join(settings.STATIC_ROOT, "fonts/public_sans")
def get_font(font_name, size=28):
"""Loads custom font"""
if font_name == "light":
font_path = os.path.join(font_dir, "PublicSans-Light.ttf")
if font_name == "regular":
font_path = os.path.join(font_dir, "PublicSans-Regular.ttf")
elif font_name == "bold":
font_path = os.path.join(font_dir, "PublicSans-Bold.ttf")
try:
font = ImageFont.truetype(font_path, size)
except OSError:
font = ImageFont.load_default()
return font
def generate_texts_layer(texts, content_width):
"""Adds text for images"""
font_text_zero = get_font("bold", size=20)
font_text_one = get_font("bold", size=48)
font_text_two = get_font("bold", size=40)
font_text_three = get_font("regular", size=40)
text_layer = Image.new("RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR)
text_layer_draw = ImageDraw.Draw(text_layer)
text_y = 0
if "text_zero" in texts and texts["text_zero"]:
# Text one (Book title)
text_zero = textwrap.fill(texts["text_zero"], width=72)
text_layer_draw.multiline_text(
(0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR
)
try:
text_y = text_y + font_text_zero.getsize_multiline(text_zero)[1] + 16
except (AttributeError, IndexError):
text_y = text_y + 26
if "text_one" in texts and texts["text_one"]:
# Text one (Book title)
text_one = textwrap.fill(texts["text_one"], width=28)
text_layer_draw.multiline_text(
(0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR
)
try:
text_y = text_y + font_text_one.getsize_multiline(text_one)[1] + 16
except (AttributeError, IndexError):
text_y = text_y + 26
if "text_two" in texts and texts["text_two"]:
# Text one (Book subtitle)
text_two = textwrap.fill(texts["text_two"], width=36)
text_layer_draw.multiline_text(
(0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR
)
try:
text_y = text_y + font_text_one.getsize_multiline(text_two)[1] + 16
except (AttributeError, IndexError):
text_y = text_y + 26
if "text_three" in texts and texts["text_three"]:
# Text three (Book authors)
text_three = textwrap.fill(texts["text_three"], width=36)
text_layer_draw.multiline_text(
(0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR
)
text_layer_box = text_layer.getbbox()
return text_layer.crop(text_layer_box)
def generate_instance_layer(content_width):
"""Places components for instance preview"""
font_instance = get_font("light", size=28)
site = models.SiteSettings.objects.get()
if site.logo_small:
logo_img = Image.open(site.logo_small)
else:
try:
static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png")
logo_img = Image.open(static_path)
except FileNotFoundError:
logo_img = None
instance_layer = Image.new("RGBA", (content_width, 62), color=TRANSPARENT_COLOR)
instance_text_x = 0
if logo_img:
logo_img.thumbnail((50, 50), Image.ANTIALIAS)
instance_layer.paste(logo_img, (0, 0))
instance_text_x = instance_text_x + 60
instance_layer_draw = ImageDraw.Draw(instance_layer)
instance_layer_draw.text(
(instance_text_x, 10), site.name, font=font_instance, fill=TEXT_COLOR
)
line_width = 50 + 10 + font_instance.getsize(site.name)[0]
line_layer = Image.new(
"RGBA", (line_width, 2), color=(*(ImageColor.getrgb(TEXT_COLOR)), 50)
)
instance_layer.alpha_composite(line_layer, (0, 60))
return instance_layer
def generate_rating_layer(rating, content_width):
"""Places components for rating preview"""
try:
icon_star_full = Image.open(
os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
)
icon_star_empty = Image.open(
os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
)
icon_star_half = Image.open(
os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
)
except FileNotFoundError:
return None
icon_size = 64
icon_margin = 10
rating_layer_base = Image.new(
"RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR
)
rating_layer_color = Image.new("RGBA", (content_width, icon_size), color=TEXT_COLOR)
rating_layer_mask = Image.new(
"RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR
)
position_x = 0
for _ in range(math.floor(rating)):
rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
position_x = position_x + icon_size + icon_margin
if math.floor(rating) != math.ceil(rating):
rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
position_x = position_x + icon_size + icon_margin
for _ in range(5 - math.ceil(rating)):
rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
position_x = position_x + icon_size + icon_margin
rating_layer_mask = rating_layer_mask.getchannel("A")
rating_layer_mask = ImageOps.invert(rating_layer_mask)
rating_layer_composite = Image.composite(
rating_layer_base, rating_layer_color, rating_layer_mask
)
return rating_layer_composite
def generate_default_inner_img():
"""Adds cover image"""
font_cover = get_font("light", size=28)
default_cover = Image.new(
"RGB", (inner_img_width, inner_img_height), color=DEFAULT_COVER_COLOR
)
default_cover_draw = ImageDraw.Draw(default_cover)
text = "no image :("
text_dimensions = font_cover.getsize(text)
text_coords = (
math.floor((inner_img_width - text_dimensions[0]) / 2),
math.floor((inner_img_height - text_dimensions[1]) / 2),
)
default_cover_draw.text(text_coords, text, font=font_cover, fill="white")
return default_cover
# pylint: disable=too-many-locals
def generate_preview_image(
texts=None, picture=None, rating=None, show_instance_layer=True
):
"""Puts everything together"""
texts = texts or {}
# Cover
try:
inner_img_layer = Image.open(picture)
inner_img_layer.thumbnail((inner_img_width, inner_img_height), Image.ANTIALIAS)
color_thief = ColorThief(picture)
dominant_color = color_thief.get_color(quality=1)
except: # pylint: disable=bare-except
inner_img_layer = generate_default_inner_img()
dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR)
# Color
if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]:
image_bg_color = "rgb(%s, %s, %s)" % dominant_color
# Adjust color
image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)]
image_bg_color_hls = colorsys.rgb_to_hls(*image_bg_color_rgb)
if BG_COLOR == "use_dominant_color_light":
lightness = max(0.9, image_bg_color_hls[1])
else:
lightness = min(0.15, image_bg_color_hls[1])
image_bg_color_hls = (
image_bg_color_hls[0],
lightness,
image_bg_color_hls[2],
)
image_bg_color = tuple(
math.ceil(x * 255) for x in colorsys.hls_to_rgb(*image_bg_color_hls)
)
else:
image_bg_color = BG_COLOR
# Background (using the color)
img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color)
# Contents
inner_img_x = margin + inner_img_width - inner_img_layer.width
inner_img_y = math.floor((IMG_HEIGHT - inner_img_layer.height) / 2)
content_x = margin + inner_img_width + gutter
content_width = IMG_WIDTH - content_x - margin
contents_layer = Image.new(
"RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR
)
contents_composite_y = 0
if show_instance_layer:
instance_layer = generate_instance_layer(content_width)
contents_layer.alpha_composite(instance_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + instance_layer.height + gutter
texts_layer = generate_texts_layer(texts, content_width)
contents_layer.alpha_composite(texts_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + texts_layer.height + gutter
if rating:
# Add some more margin
contents_composite_y = contents_composite_y + gutter
rating_layer = generate_rating_layer(rating, content_width)
if rating_layer:
contents_layer.alpha_composite(rating_layer, (0, contents_composite_y))
contents_composite_y = contents_composite_y + rating_layer.height + gutter
contents_layer_box = contents_layer.getbbox()
contents_layer_height = contents_layer_box[3] - contents_layer_box[1]
contents_y = math.floor((IMG_HEIGHT - contents_layer_height) / 2)
if show_instance_layer:
# Remove Instance Layer from centering calculations
contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2)
contents_y = max(contents_y, margin)
# Composite layers
img.paste(
inner_img_layer, (inner_img_x, inner_img_y), inner_img_layer.convert("RGBA")
)
img.alpha_composite(contents_layer, (content_x, contents_y))
return img.convert("RGB")
def save_and_cleanup(image, instance=None):
"""Save and close the file"""
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
return False
file_name = "%s-%s.jpg" % (str(instance.id), str(uuid4()))
image_buffer = BytesIO()
try:
try:
old_path = instance.preview_image.path
except ValueError:
old_path = ""
# Save
image.save(image_buffer, format="jpeg", quality=75)
instance.preview_image = InMemoryUploadedFile(
ContentFile(image_buffer.getvalue()),
"preview_image",
file_name,
"image/jpg",
image_buffer.tell(),
None,
)
save_without_broadcast = isinstance(instance, (models.Book, models.User))
if save_without_broadcast:
instance.save(broadcast=False)
else:
instance.save()
# Clean up old file after saving
if os.path.exists(old_path):
os.remove(old_path)
finally:
image_buffer.close()
return True
# pylint: disable=invalid-name
@app.task
def generate_site_preview_image_task():
"""generate preview_image for the website"""
if not settings.ENABLE_PREVIEW_IMAGES:
return
site = models.SiteSettings.objects.get()
if site.logo:
logo = site.logo
else:
logo = os.path.join(settings.STATIC_ROOT, "images/logo.png")
texts = {
"text_zero": settings.DOMAIN,
"text_one": site.name,
"text_three": site.instance_tagline,
}
image = generate_preview_image(texts=texts, picture=logo, show_instance_layer=False)
save_and_cleanup(image, instance=site)
# pylint: disable=invalid-name
@app.task
def generate_edition_preview_image_task(book_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
return
book = models.Book.objects.select_subclasses().get(id=book_id)
rating = models.Review.objects.filter(
privacy="public",
deleted=False,
book__in=[book_id],
).aggregate(Avg("rating"))["rating__avg"]
texts = {
"text_one": book.title,
"text_two": book.subtitle,
"text_three": book.author_text,
}
image = generate_preview_image(texts=texts, picture=book.cover, rating=rating)
save_and_cleanup(image, instance=book)
@app.task
def generate_user_preview_image_task(user_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
return
user = models.User.objects.get(id=user_id)
texts = {
"text_one": user.display_name,
"text_three": "@{}@{}".format(user.localname, settings.DOMAIN),
}
if user.avatar:
avatar = user.avatar
else:
avatar = os.path.join(settings.STATIC_ROOT, "images/default_avi.jpg")
image = generate_preview_image(texts=texts, picture=avatar)
save_and_cleanup(image, instance=user)

View file

@ -14,13 +14,18 @@ PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
# celery
CELERY_BROKER = env("CELERY_BROKER")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND")
CELERY_BROKER = "redis://:{}@redis_broker:{}/0".format(
requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
)
CELERY_RESULT_BACKEND = "redis://:{}@redis_broker:{}/0".format(
requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
)
CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = env("EMAIL_PORT", 587)
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
@ -37,6 +42,14 @@ LOCALE_PATHS = [
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Preview image
ENABLE_PREVIEW_IMAGES = env.bool("ENABLE_PREVIEW_IMAGES", False)
PREVIEW_BG_COLOR = env.str("PREVIEW_BG_COLOR", "use_dominant_color_light")
PREVIEW_TEXT_COLOR = env.str("PREVIEW_TEXT_COLOR", "#363636")
PREVIEW_IMG_WIDTH = env.int("PREVIEW_IMG_WIDTH", 1200)
PREVIEW_IMG_HEIGHT = env.int("PREVIEW_IMG_HEIGHT", 630)
PREVIEW_DEFAULT_COVER_COLOR = env.str("PREVIEW_DEFAULT_COVER_COLOR", "#002549")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
@ -47,7 +60,6 @@ SECRET_KEY = env("SECRET_KEY")
DEBUG = env.bool("DEBUG", True)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"])
OL_URL = env("OL_URL")
# Application definition
@ -109,10 +121,8 @@ STREAMS = ["home", "local", "federated"]
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
BOOKWYRM_DATABASE_BACKEND = env("BOOKWYRM_DATABASE_BACKEND", "postgres")
BOOKWYRM_DBS = {
"postgres": {
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": env("POSTGRES_DB", "fedireads"),
"USER": env("POSTGRES_USER", "fedireads"),
@ -122,8 +132,6 @@ BOOKWYRM_DBS = {
},
}
DATABASES = {"default": BOOKWYRM_DBS[BOOKWYRM_DATABASE_BACKEND]}
LOGIN_URL = "/login/"
AUTH_USER_MODEL = "bookwyrm.User"
@ -131,6 +139,7 @@ AUTH_USER_MODEL = "bookwyrm.User"
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
# pylint: disable=line-too-long
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
@ -174,8 +183,10 @@ USE_TZ = True
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_URL = "/static/"
STATIC_PATH = "%s/%s" % (DOMAIN, env("STATIC_ROOT", "static"))
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
MEDIA_URL = "/images/"
MEDIA_PATH = "%s/%s" % (DOMAIN, env("MEDIA_ROOT", "images"))
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (

View file

@ -73,6 +73,7 @@ class Signature:
self.headers = headers
self.signature = signature
# pylint: disable=invalid-name
@classmethod
def parse(cls, request):
"""extract and parse a signature from an http request"""

View file

@ -43,6 +43,19 @@ body {
white-space: nowrap !important;
width: 0.01em !important;
}
.m-0-mobile {
margin: 0 !important;
}
.card-footer.is-stacked-mobile {
flex-direction: column;
}
.card-footer.is-stacked-mobile .card-footer-item:not(:last-child) {
border-bottom: 1px solid #ededed;
border-right: 0;
}
}
.button.is-transparent {
@ -331,6 +344,49 @@ body {
}
}
/* Book list
******************************************************************************/
ol.ordered-list {
list-style: none;
counter-reset: list-counter;
}
ol.ordered-list li {
counter-increment: list-counter;
}
ol.ordered-list li::before {
content: counter(list-counter);
position: absolute;
left: -20px;
width: 20px;
height: 24px;
background-color: #fff;
border: 1px solid #dbdbdb;
border-right: 0;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
display: flex;
justify-content: center;
align-items: center;
color: #888;
font-size: 0.8em;
font-weight: bold;
}
@media only screen and (max-width: 768px) {
ol.ordered-list li::before {
left: 0;
z-index: 1;
border: 0;
border-right: 1px solid #dbdbdb;
border-bottom: 1px solid #dbdbdb;
border-radius: 0;
border-bottom-right-radius: 2px;
}
}
/* Dimensions
* @todo These could be in rem.
******************************************************************************/

View file

@ -0,0 +1,94 @@
Copyright (c) 2015, Pablo Impallari, Rodrigo Fuenzalida
(Modified by Dan O. Williams and USWDS) (https://github.com/uswds/public-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -3,6 +3,7 @@
let BookWyrm = new class {
constructor() {
this.MAX_FILE_SIZE_BYTES = 10 * 1000000;
this.initOnDOMLoaded();
this.initReccuringTasks();
this.initEventListeners();
@ -32,15 +33,26 @@ let BookWyrm = new class {
'click',
this.back)
);
document.querySelectorAll('input[type="file"]')
.forEach(node => node.addEventListener(
'change',
this.disableIfTooLarge.bind(this)
));
}
/**
* Execute code once the DOM is loaded.
*/
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)
);
});
}
@ -126,6 +138,7 @@ let BookWyrm = new class {
* @return {undefined}
*/
toggleAction(event) {
event.preventDefault();
let trigger = event.currentTarget;
let pressed = trigger.getAttribute('aria-pressed') === 'false';
let targetId = trigger.dataset.controls;
@ -170,6 +183,8 @@ let BookWyrm = new class {
if (focus) {
this.toggleFocus(focus);
}
return false;
}
/**
@ -284,4 +299,27 @@ let BookWyrm = new class {
node.classList.remove(classname);
}
}
}
disableIfTooLarge(eventOrElement) {
const { addRemoveClass, MAX_FILE_SIZE_BYTES } = this;
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;
if (isTooBig) {
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)
);
}
}
}();

View file

@ -17,7 +17,7 @@ let LocalStorageTools = new class {
* @return {undefined}
*/
updateDisplay(event) {
// used in set reading goal
// Used in set reading goal
let key = event.target.dataset.id;
let value = event.target.dataset.value;
@ -34,10 +34,10 @@ let LocalStorageTools = new class {
* @return {undefined}
*/
setDisplay(node) {
// used in set reading goal
// Used in set reading goal
let key = node.dataset.hide;
let value = window.localStorage.getItem(key);
BookWyrm.addRemoveClass(node, 'is-hidden', value);
}
}
}();

View file

@ -15,49 +15,83 @@
<div class="column is-narrow">
<a href="{{ author.local_path }}/edit">
<span class="icon icon-pencil" title="{% trans 'Edit Author' %}" aria-hidden="True"></span>
<span>{% trans "Edit Author" %}</span>
<span class="is-hidden-mobile">{% trans "Edit Author" %}</span>
</a>
</div>
{% endif %}
</div>
</div>
<div class="block content columns">
{% if author.aliases or author.born or author.died or author.wikipedia_link %}
<div class="column is-narrow">
<div class="box">
<div class="block columns" itemscope itemtype="https://schema.org/Person">
<meta itemprop="name" content="{{ author.name }}">
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %}
<div class="column is-two-fifths">
<div class="box py-2">
<dl>
{% if author.aliases %}
<div class="is-flex">
<dt class="mr-1">{% trans "Aliases:" %}</dt>
<dd itemprop="aliases">{{ author.aliases|join:', ' }}</dd>
<div class="is-flex is-flex-wrap-wrap my-1">
<dt class="has-text-weight-bold mr-1">{% trans "Aliases:" %}</dt>
{% for alias in author.aliases %}
<dd itemprop="alternateName" content="{{alias}}">
{{alias}}{% if not forloop.last %},&nbsp;{% endif %}
</dd>
{% endfor %}
</div>
{% endif %}
{% if author.born %}
<div class="is-flex">
<dt class="mr-1">{% trans "Born:" %}</dt>
<dd itemprop="aliases">{{ author.born|naturalday }}</dd>
<div class="is-flex my-1">
<dt class="has-text-weight-bold mr-1">{% trans "Born:" %}</dt>
<dd itemprop="birthDate">{{ author.born|naturalday }}</dd>
</div>
{% endif %}
{% if author.aliases %}
<div class="is-flex">
<dt class="mr-1">{% trans "Died:" %}</dt>
<dd itemprop="aliases">{{ author.died|naturalday }}</dd>
{% if author.died %}
<div class="is-flex my-1">
<dt class="has-text-weight-bold mr-1">{% trans "Died:" %}</dt>
<dd itemprop="deathDate">{{ author.died|naturalday }}</dd>
</div>
{% endif %}
</dl>
{% if author.wikipedia_link %}
<p><a href="{{ author.wikipedia_link }}" rel=”noopener” target="_blank">{% trans "Wikipedia" %}</a></p>
{% endif %}
{% if author.openlibrary_key %}
<p class="mb-0">
<a href="https://openlibrary.org/authors/{{ author.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a>
<p class="my-1">
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel=”noopener” target="_blank">
{% trans "Wikipedia" %}
</a>
</p>
{% endif %}
{% if author.openlibrary_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://openlibrary.org/authors/{{ author.openlibrary_key }}" target="_blank" rel="noopener">
{% trans "View on OpenLibrary" %}
</a>
</p>
{% endif %}
{% if author.inventaire_id %}
<p class="mb-0">
<a href="https://inventaire.io/entity/{{ author.inventaire_id }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a>
<p class="my-1">
<a itemprop="sameAs" href="https://inventaire.io/entity/{{ author.inventaire_id }}" target="_blank" rel="noopener">
{% trans "View on Inventaire" %}
</a>
</p>
{% endif %}
{% if author.librarything_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener">
{% trans "View on LibraryThing" %}
</a>
</p>
{% endif %}
{% if author.goodreads_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener">
{% trans "View on Goodreads" %}
</a>
</p>
{% endif %}
</div>

View file

@ -29,67 +29,85 @@
<div class="columns">
<div class="column">
<h2 class="title is-4">{% trans "Metadata" %}</h2>
<p class="mb-2"><label class="label" for="id_name">{% trans "Name:" %}</label> {{ form.name }}</p>
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_name">{% trans "Name:" %}</label>
{{ form.name }}
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_aliases">{% trans "Aliases:" %}</label>
{{ form.aliases }}
<span class="help">{% trans "Separate multiple values with commas." %}</span>
</p>
{% for error in form.aliases.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.aliases.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_bio">{% trans "Bio:" %}</label> {{ form.bio }}</p>
{% for error in form.bio.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_bio">{% trans "Bio:" %}</label>
{{ form.bio }}
{% for error in form.bio.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_wikipedia_link">{% trans "Wikipedia link:" %}</label> {{ form.wikipedia_link }}</p>
<p class="field"><label class="label" for="id_wikipedia_link">{% trans "Wikipedia link:" %}</label> {{ form.wikipedia_link }}</p>
{% for error in form.wikipedia_link.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="mb-2">
<div class="field">
<label class="label" for="id_born">{% trans "Birth date:" %}</label>
<input type="date" name="born" value="{{ form.born.value|date:'Y-m-d' }}" class="input" id="id_born">
</p>
{% for error in form.born.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.born.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_died">{% trans "Death date:" %}</label>
<input type="date" name="died" value="{{ form.died.value|date:'Y-m-d' }}" class="input" id="id_died">
</p>
{% for error in form.died.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.died.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</div>
<div class="column">
<h2 class="title is-4">{% trans "Author Identifiers" %}</h2>
<p class="mb-2"><label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label> {{ form.openlibrary_key }}</p>
{% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label>
{{ form.openlibrary_key }}
{% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label> {{ form.inventaire_id }}</p>
{% for error in form.inventaire_id.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label>
{{ form.inventaire_id }}
{% for error in form.inventaire_id.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_librarything_key">{% trans "Librarything key:" %}</label> {{ form.librarything_key }}</p>
{% for error in form.librarything_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_librarything_key">{% trans "Librarything key:" %}</label>
{{ form.librarything_key }}
{% for error in form.librarything_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_goodreads_key">{% trans "Goodreads key:" %}</label> {{ form.goodreads_key }}</p>
{% for error in form.goodreads_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_goodreads_key">{% trans "Goodreads key:" %}</label>
{{ form.goodreads_key }}
{% for error in form.goodreads_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</div>
</div>

View file

@ -1,35 +1,47 @@
{% extends 'layout.html' %}
{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %}
{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %}{% load layout %}
{% block title %}{{ book.title }}{% endblock %}
{% block title %}{{ book|book_title }}{% endblock %}
{% block opengraph_images %}
{% include 'snippets/opengraph_images.html' with image=book.preview_image %}
{% endblock %}
{% block content %}
{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
<div class="block" itemscope itemtype="https://schema.org/Book">
<div class="columns is-mobile">
<div class="column">
<h1 class="title">
<span itemprop="name">
{{ book.title }}{% if book.subtitle %}:
<small>{{ book.subtitle }}</small>
{% endif %}
</span>
{% if book.series %}
<meta itemprop="isPartOf" content="{{ book.series }}">
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
<small class="has-text-grey-dark">
({{ book.series }}
{% if book.series_number %} #{{ book.series_number }}{% endif %})
</small>
<br>
{% endif %}
<h1 class="title" itemprop="name">
{{ book.title }}
</h1>
{% if book.subtitle or book.series %}
<p class="subtitle title is-5">
{% if book.subtitle %}
<meta
itemprop="alternativeHeadline"
content="{{ book.subtitle | escape }}"
>
<span class="has-text-weight-bold">
{{ book.subtitle }}
</span>
{% endif %}
{% if book.series %}
<meta itemprop="isPartOf" content="{{ book.series | escape }}">
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})
{% endif %}
</p>
{% endif %}
{% if book.authors %}
<h2 class="subtitle">
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
</h2>
<div class="subtitle">
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
</div>
{% endif %}
</div>
@ -37,7 +49,7 @@
<div class="column is-narrow">
<a href="{{ book.id }}/edit">
<span class="icon icon-pencil" title="{% trans "Edit Book" %}" aria-hidden=True></span>
<span>{% trans "Edit Book" %}</span>
<span class="is-hidden-mobile">{% trans "Edit Book" %}</span>
</a>
</div>
{% endif %}
@ -85,7 +97,7 @@
<div class="column is-three-fifths">
<div class="block">
<h3
<div
class="field is-grouped"
itemprop="aggregateRating"
itemscope
@ -103,7 +115,7 @@
{% plural %}
({{ review_count }} reviews)
{% endblocktrans %}
</h3>
</div>
{% with full=book|book_description itemprop='abstract' %}
{% include 'snippets/trimmed_text.html' %}
@ -137,7 +149,7 @@
{# user's relationship to the book #}
<div class="block">
{% for shelf in user_shelves %}
{% for shelf in user_shelfbooks %}
<p>
{% blocktrans with path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}This edition is on your <a href="{{ path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
@ -181,7 +193,7 @@
<p>{% trans "You don't have any reading activity for this book." %}</p>
{% endif %}
{% for readthrough in readthroughs %}
{% include 'snippets/readthrough.html' with readthrough=readthrough %}
{% include 'book/readthrough.html' with readthrough=readthrough %}
{% endfor %}
</section>
<hr aria-hidden="true">

View file

@ -14,11 +14,25 @@
{% endif %}
</h1>
{% if book %}
<div>
<p>{% trans "Added:" %} {{ book.created_date | naturaltime }}</p>
<p>{% trans "Updated:" %} {{ book.updated_date | naturaltime }}</p>
<p>{% trans "Last edited by:" %} <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p>
</div>
<dl>
<div class="is-flex">
<dt class="has-text-weight-semibold">{% trans "Added:" %}</dt>
<dd class="ml-2">{{ book.created_date | naturaltime }}</dd>
</div>
<div class="is-flex">
<dt class="has-text-weight-semibold">{% trans "Updated:" %}</dt>
<dd class="ml-2">{{ book.updated_date | naturaltime }}</dd>
</div>
{% if book.last_edited_by %}
<div class="is-flex">
<dt class="has-text-weight-semibold">{% trans "Last edited by:" %}</dt>
<dd class="ml-2"><a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></dd>
</div>
{% endif %}
</dl>
{% endif %}
</header>
@ -38,21 +52,28 @@
{% if confirm_mode %}
<div class="box">
<h2 class="title is-4">{% trans "Confirm Book Info" %}</h2>
<div class="columns">
<div class="columns mb-4">
{% if author_matches %}
<input type="hidden" name="author-match-count" value="{{ author_matches|length }}">
<div class="column is-half">
{% for author in author_matches %}
<fieldset class="mb-4">
<legend class="title is-5 mb-1">{% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %}</legend>
<fieldset>
<legend class="title is-5 mb-1">
{% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %}
</legend>
{% with forloop.counter0 as counter %}
{% for match in author.matches %}
<label><input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required> {{ match.name }}</label>
<label class="label mb-2">
<input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required>
{{ match.name }}
</label>
<p class="help">
<a href="{{ match.local_path }}" target="_blank">{% blocktrans with book_title=match.book_set.first.title %}Author of <em>{{ book_title }}</em>{% endblocktrans %}</a>
</p>
{% endfor %}
<label><input type="radio" name="author_match-{{ counter }}" value="{{ author.name }}" required> {% trans "This is a new author" %}</label>
<label class="label">
<input type="radio" name="author_match-{{ counter }}" value="{{ author.name }}" required> {% trans "This is a new author" %}
</label>
{% endwith %}
</fieldset>
{% endfor %}
@ -64,11 +85,17 @@
{% if not book %}
<div class="column is-half">
<fieldset>
<legend class="title is-5 mb-1">{% trans "Is this an edition of an existing work?" %}</legend>
<legend class="title is-5 mb-1">
{% trans "Is this an edition of an existing work?" %}
</legend>
{% for match in book_matches %}
<label class="label"><input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}</label>
<label class="label">
<input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}
</label>
{% endfor %}
<label><input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}</label>
<label>
<input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}
</label>
</fieldset>
</div>
{% endif %}
@ -89,76 +116,79 @@
<section class="block">
<h2 class="title is-4">{% trans "Metadata" %}</h2>
<p class="mb-2">
<div class="field">
<label class="label" for="id_title">{% trans "Title:" %}</label>
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title">
</p>
{% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
</p>
{% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_description">{% trans "Description:" %}</label> {{ form.description }} </p>
{% for error in form.description.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_description">{% trans "Description:" %}</label>
{{ form.description }}
{% for error in form.description.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_series">{% trans "Series:" %}</label>
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
</p>
{% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_series_number">{% trans "Series number:" %}</label>
{{ form.series_number }}
</p>
{% for error in form.series_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.series_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_languages">{% trans "Languages:" %}</label>
{{ form.languages }}
<span class="help">{% trans "Separate multiple values with commas." %}</span>
</p>
{% for error in form.languages.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.languages.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_publishers">{% trans "Publisher:" %}</label>
{{ form.publishers }}
<span class="help">{% trans "Separate multiple values with commas." %}</span>
</p>
{% for error in form.publishers.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.publishers.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_first_published_date">{% trans "First published date:" %}</label>
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %}>
</p>
{% for error in form.first_published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.first_published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2">
<div class="field">
<label class="label" for="id_published_date">{% trans "Published date:" %}</label>
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %}>
</p>
{% for error in form.published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</section>
<section class="block">
@ -166,16 +196,23 @@
{% if book.authors.exists %}
<fieldset>
{% for author in book.authors.all %}
<label class="label mb-2">
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %}>
{% blocktrans with name=author.name path=author.local_path %}Remove <a href="{{ path }}">{{ name }}</a>{% endblocktrans %}
</label>
<div class="is-flex is-justify-content-space-between">
<label class="label mb-2">
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %}>
{% blocktrans with name=author.name %}Remove {{ name }}{% endblocktrans %}
</label>
<p class="help">
<a href="{{ author.local_path }}">{% blocktrans with name=author.name %}Author page for {{ name }}{% endblocktrans %}</a>
</p>
</div>
{% endfor %}
</fieldset>
{% endif %}
<label class="label" for="id_add_author">{% trans "Add Authors:" %}</label>
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'John Doe, Jane Smith' %}" value="{{ add_author }}" {% if confirm_mode %}readonly{% endif %}>
<span class="help">{% trans "Separate multiple values with commas." %}</span>
<div class="field">
<label class="label" for="id_add_author">{% trans "Add Authors:" %}</label>
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'John Doe, Jane Smith' %}" value="{{ add_author }}" {% if confirm_mode %}readonly{% endif %}>
<span class="help">{% trans "Separate multiple values with commas." %}</span>
</div>
</section>
</div>
@ -188,17 +225,17 @@
<div class="column">
<div class="block">
<p>
<div class="field">
<label class="label" for="id_cover">{% trans "Upload cover:" %}</label>
{{ form.cover }}
</p>
</div>
{% if book %}
<p>
<div class="field">
<label class="label" for="id_cover_url">
{% trans "Load cover from url:" %}
</label>
<input class="input" name="cover-url" id="id_cover_url">
</p>
</div>
{% endif %}
{% for error in form.cover.errors %}
<p class="help is-danger">{{ error | escape }}</p>
@ -209,51 +246,72 @@
<div class="block">
<h2 class="title is-4">{% trans "Physical Properties" %}</h2>
<p class="mb-2"><label class="label" for="id_physical_format">{% trans "Format:" %}</label> {{ form.physical_format }} </p>
{% for error in form.physical_format.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% for error in form.physical_format.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_physical_format">{% trans "Format:" %}</label>
{{ form.physical_format }}
{% for error in form.physical_format.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_pages">{% trans "Pages:" %}</label> {{ form.pages }} </p>
{% for error in form.pages.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_pages">{% trans "Pages:" %}</label>
{{ form.pages }}
{% for error in form.pages.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</div>
<div class="block">
<h2 class="title is-4">{% trans "Book Identifiers" %}</h2>
<p class="mb-2"><label class="label" for="id_isbn_13">{% trans "ISBN 13:" %}</label> {{ form.isbn_13 }} </p>
{% for error in form.isbn_13.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_isbn_13">{% trans "ISBN 13:" %}</label>
{{ form.isbn_13 }}
{% for error in form.isbn_13.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_isbn_10">{% trans "ISBN 10:" %}</label> {{ form.isbn_10 }} </p>
{% for error in form.isbn_10.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_isbn_10">{% trans "ISBN 10:" %}</label>
{{ form.isbn_10 }}
{% for error in form.isbn_10.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_openlibrary_key">{% trans "Openlibrary ID:" %}</label> {{ form.openlibrary_key }} </p>
{% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary ID:" %}</label>
{{ form.openlibrary_key }}
{% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label> {{ form.inventaire_id }} </p>
{% for error in form.inventaire_id.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label>
{{ form.inventaire_id }}
{% for error in form.inventaire_id.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_oclc_number">{% trans "OCLC Number:" %}</label> {{ form.oclc_number }} </p>
{% for error in form.oclc_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_oclc_number">{% trans "OCLC Number:" %}</label>
{{ form.oclc_number }}
{% for error in form.oclc_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<p class="mb-2"><label class="label" for="id_asin">{% trans "ASIN:" %}</label> {{ form.asin }} </p>
{% for error in form.ASIN.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<div class="field">
<label class="label" for="id_asin">{% trans "ASIN:" %}</label>
{{ form.asin }}
{% for error in form.ASIN.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
</div>
</div>
</div>
@ -261,7 +319,7 @@
{% if not confirm_mode %}
<div class="block">
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
<a class="button" href="{{ book.local_path}}">{% trans "Cancel" %}</a>
<a class="button" href="{{ book.local_path }}">{% trans "Cancel" %}</a>
</div>
{% endif %}
</form>

View file

@ -40,7 +40,7 @@
</div>
<div class="column is-narrow">
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True right=True %}
</div>
</div>
{% endfor %}

View file

@ -1,8 +1,8 @@
{% load i18n %}
{% load humanize %}
{% load tz %}
<div class="content box is-shadowless has-background-white-bis">
<div id="hide-edit-readthrough-{{ readthrough.id }}">
<div class="content">
<div id="hide-edit-readthrough-{{ readthrough.id }}" class="box is-shadowless has-background-white-bis">
<div class="columns">
<div class="column">
{% trans "Progress Updates:" %}

View file

@ -1,7 +1,7 @@
{% load i18n %}
<div
role="dialog"
class="modal is-hidden"
class="modal {% if active %}is-active{% else %}is-hidden{% endif %}"
id="{{ controls_text }}-{{ controls_uid }}"
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
aria-modal="true"
@ -11,7 +11,7 @@
{% trans "Close" as label %}
<div class="modal-card">
<header class="modal-card-head" tabindex="0" id="modal-title-{{ controls_text }}-{{ controls_uid }}">
<h2 class="modal-card-title" id="modal-card-title-{{ controls_text }}-{{ controls_uid }}">
<h2 class="modal-card-title is-flex-shrink-1" id="modal-card-title-{{ controls_text }}-{{ controls_uid }}">
{% block modal-title %}{% endblock %}
</h2>
{% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %}

View file

@ -20,8 +20,8 @@
{% csrf_token %}
<button class="button is-primary" type="submit">Join Directory</button>
<p class="help">
{% url 'settings-profile' as path %}
{% blocktrans %}You can opt-out at any time in your <a href="{{ path }}">profile settings.</a>{% endblocktrans %}
{% url 'prefs-profile' as path %}
{% blocktrans with path=path %}You can opt-out at any time in your <a href="{{ path }}">profile settings.</a>{% endblocktrans %}
</p>
</form>
</div>

View file

@ -5,7 +5,7 @@
<div class="select is-small mt-1 mb-3">
<select name="{{ book.id }}" aria-label="{% blocktrans with book_title=book.title %}Have you read {{ book_title }}?{% endblocktrans %}">
<option disabled selected value>Add to your books</option>
{% for shelf in request.user.shelf_set.all %}
{% for shelf in user_shelves %}
<option value="{{ shelf.id }}">{{ shelf.name }}</option>
{% endfor %}
</select>

View file

@ -41,8 +41,8 @@
</label>
</div>
<div class="field">
<label class="label">
<p>{% trans "Privacy setting for imported reviews:" %}</p>
<label>
<span class="label">{% trans "Privacy setting for imported reviews:" %}</span>
{% include 'snippets/privacy_select.html' with no_label=True %}
</label>
</div>

View file

@ -7,14 +7,19 @@
{% block content %}{% spaceless %}
<div class="block">
<h1 class="title">{% trans "Import Status" %}</h1>
<a href="{% url 'import' %}" class="has-text-weight-normal help subtitle is-link">{% trans "Back to imports" %}</a>
<p>
{% trans "Import started:" %} {{ job.created_date | naturaltime }}
</p>
{% if job.complete %}
<p>
{% trans "Import completed:" %} {{ task.date_done | naturaltime }}
</p>
<dl>
<div class="is-flex">
<dt class="has-text-weight-medium">{% trans "Import started:" %}</dt>
<dd class="ml-2">{{ job.created_date | naturaltime }}</dd>
</div>
{% if job.complete %}
<div class="is-flex">
<dt class="has-text-weight-medium">{% trans "Import completed:" %}</dt>
<dd class="ml-2">{{ task.date_done | naturaltime }}</dd>
</div>
</dl>
{% elif task.failed %}
<div class="notification is-danger">{% trans "TASK FAILED" %}</div>
{% endif %}
@ -22,8 +27,9 @@
<div class="block">
{% if not job.complete %}
{% trans "Import still in progress." %}
<p>
{% trans "Import still in progress." %}
<br/>
{% trans "(Hit reload to update!)" %}
</p>
{% endif %}
@ -49,16 +55,13 @@
<fieldset id="failed-imports">
<ul>
{% for item in failed_items %}
<li class="pb-1">
<input class="checkbox" type="checkbox" name="import_item" value="{{ item.id }}" id="import-item-{{ item.id }}">
<label for="import-item-{{ item.id }}">
Line {{ item.index }}:
<strong>{{ item.data.Title }}</strong> by
{{ item.data.Author }}
</label>
<p>
<li class="mb-2 is-flex is-align-items-start">
<input class="checkbox mt-1" type="checkbox" name="import_item" value="{{ item.id }}" id="import-item-{{ item.id }}">
<label class="ml-1" for="import-item-{{ item.id }}">
{% blocktrans with index=item.index title=item.data.Title author=item.data.Author %}Line {{ index }}: <strong>{{ title }}</strong> by {{ author }}{% endblocktrans %}
<br/>
{{ item.fail_reason }}.
</p>
</label>
</li>
{% endfor %}
</ul>
@ -104,7 +107,11 @@
{% endif %}
<div class="block">
{% if job.complete %}
<h2 class="title is-4">{% trans "Successfully imported" %}</h2>
{% else %}
<h2 class="title is-4">{% trans "Import Progress" %}</h2>
{% endif %}
<table class="table">
<tr>
<th>

View file

@ -8,16 +8,21 @@
<link rel="stylesheet" href="/static/css/vendor/icons.css">
<link rel="stylesheet" href="/static/css/bookwyrm.css">
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}/images/{{ site.favicon }}{% else %}/static/images/favicon.ico{% endif %}">
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{{ media_url }}{{ site.favicon }}{% else %}/static/images/favicon.ico{% endif %}">
{% if preview_images_enabled is True %}
<meta name="twitter:card" content="summary_large_image">
{% else %}
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:title" content="{% if title %}{{ title }} | {% endif %}{{ site.name }}">
<meta name="og:title" content="{% if title %}{{ title }} | {% endif %}{{ site.name }}">
<meta name="twitter:description" content="{{ site.instance_tagline }}">
<meta name="og:description" content="{{ site.instance_tagline }}">
<meta name="twitter:image" content="{% if site.logo %}/images/{{ site.logo }}{% else %}/static/images/logo.png{% endif %}">
<meta name="og:image" content="{% if site.logo %}/images/{{ site.logo }}{% else %}/static/images/logo.png{% endif %}">
{% block opengraph_images %}
{% include 'snippets/opengraph_images.html' %}
{% endblock %}
<meta name="twitter:image:alt" content="BookWyrm Logo">
</head>
<body>
@ -25,7 +30,7 @@
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
<img class="image logo" src="{% if site.logo_small %}{{ media_url }}{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
</a>
<form class="navbar-item column" action="/search/">
<div class="field has-addons">
@ -54,15 +59,15 @@
<div class="navbar-menu" id="main-nav">
<div class="navbar-start">
{% if request.user.is_authenticated %}
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
{% trans "Your books" %}
</a>
<a href="/#feed" class="navbar-item">
{% trans "Feed" %}
</a>
<a href="{% url 'lists' %}" class="navbar-item">
{% trans "Lists" %}
</a>
<a href="{% url 'directory' %}" class="navbar-item">
{% trans "Directory" %}
</a>
{% endif %}
</div>
@ -82,21 +87,16 @@
<span class="ml-2">{{ request.user.display_name }}</span>
</a>
<ul class="navbar-dropdown" id="navbar-dropdown">
<li>
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
{% trans 'Your Books' %}
</a>
</li>
<li>
<a href="{% url 'direct-messages' %}" class="navbar-item">
{% trans "Direct Messages" %}
</a>
</li>
<li>
<a href="{% url 'directory' %}" class="navbar-item">
{% trans 'Directory' %}
</a>
</li>
<li>
<a href="{% url 'import' %}" class="navbar-item">
{% trans 'Import Books' %}
</a>
</li>
<li>
<a href="{% url 'prefs-profile' %}" class="navbar-item">
{% trans 'Settings' %}
@ -134,12 +134,14 @@
<span class="is-sr-only">{% trans "Notifications" %}</span>
</span>
</span>
{% with request.user.unread_notification_count as notification_count %}
<span
class="{% if not request.user.unread_notification_count %}is-hidden {% elif request.user.has_unread_mentions %}is-danger {% endif %}tag is-medium transition-x"
class="{% if not notification_count %}is-hidden {% elif request.user.has_unread_mentions %}is-danger {% endif %}tag is-medium transition-x"
data-poll-wrapper
>
<span data-poll="notifications">{{ request.user.unread_notification_count }}</span>
<span data-poll="notifications">{{ notification_count }}</span>
</span>
{% endwith %}
</a>
</div>
{% else %}
@ -193,8 +195,11 @@
<div class="section is-flex-grow-1">
<div class="container">
{# almost every view needs to know the user shelves #}
{% with request.user.shelf_set.all as user_shelves %}
{% block content %}
{% endblock %}
{% endwith %}
</div>
</div>
@ -203,7 +208,7 @@
<div class="columns">
<div class="column is-one-fifth">
<p>
<a href="{% url 'about' %}">{% trans "About this server" %}</a>
<a href="{% url 'about' %}">{% trans "About this instance" %}</a>
</p>
{% if site.admin_email %}
<p>

View file

@ -27,7 +27,7 @@
{% if not items.object_list.exists %}
<p>{% trans "This list is currently empty" %}</p>
{% else %}
<ol start="{{ items.start_index }}">
<ol start="{{ items.start_index }}" class="ordered-list">
{% for item in items %}
<li class="block mb-5">
<div class="card">
@ -35,16 +35,17 @@
<div
class="
card-content p-0 mb-0
columns is-mobile is-gapless
columns is-gapless
is-mobile
"
>
<div class="column is-2-mobile is-cover align to-t">
<div class="column is-3-mobile is-2-tablet is-cover align to-t">
<a href="{{ item.book.local_path }}" aria-hidden="true">
{% include 'snippets/book_cover.html' with cover_class='is-w-auto is-h-m-tablet' %}
{% include 'snippets/book_cover.html' with cover_class='is-w-auto is-h-m-tablet is-align-items-flex-start' %}
</a>
</div>
<div class="column ml-3">
<div class="column mx-3 my-2">
<p>
{% include 'snippets/book_titleby.html' %}
</p>
@ -59,16 +60,19 @@
</div>
{% endwith %}
<div class="card-footer has-background-white-bis is-align-items-baseline">
<div class="card-footer is-stacked-mobile has-background-white-bis is-align-items-stretch">
<div class="card-footer-item">
<div>
<p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
<p>{% blocktrans with username=item.user.display_name user_path=item.user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
</div>
</div>
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
<div class="card-footer-item">
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
<div class="field has-addons mb-0">
<div class="control">
<label for="input-list-position" class="button is-transparent is-small">{% trans "List position" %}</label>
</div>
{% csrf_token %}
<div class="control">
<input id="input-list-position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
@ -77,7 +81,6 @@
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
</div>
</div>
<label for="input-list-position" class="help">{% trans "List position" %}</label>
</form>
</div>
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
@ -96,24 +99,36 @@
</section>
<section class="column is-one-quarter">
<h2>{% trans "Sort List" %}</h2>
<h2 class="title is-5">
{% trans "Sort List" %}
</h2>
<form name="sort" action="{% url 'list' list.id %}" method="GET" class="block">
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
<div class="select is-fullwidth">
{{ sort_form.sort_by }}
<div class="field">
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
<div class="select is-fullwidth">
{{ sort_form.sort_by }}
</div>
</div>
<label class="label" for="id_direction">{% trans "Direction" %}</label>
<div class="select is-fullwidth">
{{ sort_form.direction }}
<div class="field">
<label class="label" for="id_direction">{% trans "Direction" %}</label>
<div class="select is-fullwidth">
{{ sort_form.direction }}
</div>
</div>
<div>
<div class="field">
<button class="button is-primary is-fullwidth" type="submit">
{% trans "Sort List" %}
</button>
</div>
</form>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2>
<h2 class="title is-5 mt-6">
{% if list.curation == 'open' or request.user == list.user %}
{% trans "Add Books" %}
{% else %}
{% trans "Suggest Books" %}
{% endif %}
</h2>
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
<div class="field has-addons">
<div class="control">

View file

@ -2,7 +2,7 @@
<div class="columns is-multiline">
{% for list in lists %}
<div class="column is-one-quarter">
<div class="card">
<div class="card is-stretchable">
<header class="card-header">
<h4 class="card-header-title">
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>

View file

@ -10,7 +10,6 @@
<p class="subtitle help">
{% include 'lists/created_text.html' with list=list %}
</p>
{% include 'snippets/trimmed_text.html' with full=list.description %}
</div>
{% if request.user == list.user %}
<div class="column is-narrow">
@ -20,6 +19,10 @@
{% endif %}
</header>
<div class="block content">
{% include 'snippets/trimmed_text.html' with full=list.description %}
</div>
<div class="block">
{% include 'lists/edit_form.html' with controls_text="edit-list" %}
</div>

View file

@ -3,7 +3,7 @@
{% block title %}
{% if server %}
{% blocktrans with server_name=server.server_name %}Reports: {{ server_name }}{% endblocktrans %}
{% blocktrans with instance_name=server.server_name %}Reports: {{ instance_name }}{% endblocktrans %}
{% else %}
{% trans "Reports" %}
{% endif %}
@ -11,7 +11,7 @@
{% block header %}
{% if server %}
{% blocktrans with server_name=server.server_name %}Reports: <small>{{ server_name }}</small>{% endblocktrans %}
{% blocktrans with instance_name=server.server_name %}Reports: <small>{{ instance_name }}</small>{% endblocktrans %}
<a href="{% url 'settings-reports' %}" class="help has-text-weight-normal">Clear filters</a>
{% else %}
{% trans "Reports" %}

View file

@ -56,7 +56,7 @@
<span class="icon icon-warning"></span>
{% endif %}
</div>
<div class="column">
<div class="column is-clipped">
<div class="block">
<p>
{# DESCRIPTION #}
@ -137,7 +137,7 @@
{# PREVIEW #}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
<div class="columns">
<div class="column">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">

View file

@ -1,4 +1,4 @@
{% extends 'preferences/preferences_layout.html' %}
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Blocked Users" %}{{ author.name }}{% endblock %}
@ -18,7 +18,7 @@
<a href="{{ user.local_path }}">{% include 'snippets/avatar.html' with user=user %} {{ user.display_name }}</a>
</p>
<p class="mr-2">
{% include 'snippets/block_button.html' with user=user %}
{% include 'snippets/block_button.html' with user=user blocks=True %}
</p>
</li>
{% endfor %}

View file

@ -1,4 +1,4 @@
{% extends 'preferences/preferences_layout.html' %}
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Change Password" %}{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Delete Account" %}{% endblock %}
{% block header %}
{% trans "Delete Account" %}
{% endblock %}
{% block panel %}
<div class="block">
<h2 class="title is-4">{% trans "Permanently delete account" %}</h2>
<p class="notification is-danger is-light">
{% trans "Deleting your account cannot be undone. The username will not be available to register in the future." %}
</p>
<form name="delete-user" action="{% url 'prefs-delete' %}" method="post">
{% csrf_token %}
<div class="field">
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required>
{% for error in form.password.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
</form>
</div>
{% endblock %}

View file

@ -1,4 +1,4 @@
{% extends 'preferences/preferences_layout.html' %}
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Edit Profile" %}{% endblock %}

View file

@ -18,6 +18,10 @@
{% url 'prefs-password' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Change Password" %}</a>
</li>
<li>
{% url 'prefs-delete' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
</li>
</ul>
<h2 class="menu-label">{% trans "Relationships" %}</h2>
<ul class="menu-list">

View file

@ -0,0 +1,14 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}
{% blocktrans trimmed with book_title=book.title %}
Finish "{{ book_title }}"
{% endblocktrans %}
{% endblock %}
{% block content %}
{% include "snippets/shelve_button/finish_reading_modal.html" with book=book active=True %}
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}
{% blocktrans trimmed with book_title=book.title %}
Start "{{ book_title }}"
{% endblocktrans %}
{% endblock %}
{% block content %}
{% include "snippets/shelve_button/start_reading_modal.html" with book=book active=True %}
{% endblock %}

View file

@ -0,0 +1,14 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}
{% blocktrans trimmed with book_title=book.title %}
Want to Read "{{ book_title }}"
{% endblocktrans %}
{% endblock %}
{% block content %}
{% include "snippets/shelve_button/want_to_read_modal.html" with book=book active=True no_body=True %}
{% endblock %}

View file

@ -36,7 +36,7 @@
</li>
<li>
{% url 'settings-federation' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Federated Servers" %}</a>
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Federated Instances" %}</a>
</li>
</ul>
{% endif %}

View file

@ -16,8 +16,8 @@
<form class="control" action="{% url 'settings-announcements-delete' announcement.id %}" method="post">
{% csrf_token %}
<button type="submit" class="button is-danger">
<span class="icon icon-x" aria-hidden="true"></span>
<span>{% trans "Delete" %}</span>
<span class="icon icon-x m-0-mobile" aria-hidden="true"></span>
<span class="is-sr-only-mobile">{% trans "Delete" %}</span>
</button>
</form>
</div>

View file

@ -2,7 +2,11 @@
{% load i18n %}
{% block header %}
{% if announcement %}
{% trans "Edit Announcement" %}
{% else %}
{% trans "Create Announcement" %}
{% endif %}
{% endblock %}
{% block form %}
@ -24,7 +28,7 @@
</p>
<p>
<label class="label" for="id_event_date">Event date:</label>
<input type="date" name="event_date" value="{{ form.event_date.value }}" class="input" id="id_event_date">
<input type="date" name="event_date" value="{{ form.event_date.value|date:'Y-m-d' }}" class="input" id="id_event_date">
{% for error in form.event_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
@ -34,7 +38,7 @@
<div class="column">
<p>
<label class="label" for="id_start_date">Start date:</label>
<input type="date" name="start_date" class="input" id="id_start_date">
<input type="date" name="start_date" class="input" value="{{ form.start_date.value|date:'Y-m-d' }}" id="id_start_date">
{% for error in form.start_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
@ -43,7 +47,7 @@
<div class="column">
<p>
<label class="label" for="id_end_date">End date:</label>
<input type="date" name="end_date" class="input" id="id_end_date">
<input type="date" name="end_date" class="input" id="id_end_date" value="{{ form.end_date.value|date:'Y-m-d' }}">
{% for error in form.end_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}

View file

@ -1,10 +1,10 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% block title %}{% trans "Add server" %}{% endblock %}
{% block title %}{% trans "Add instance" %}{% endblock %}
{% block header %}
{% trans "Add server" %}
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
{% trans "Add instance" %}
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to instance list" %}</a>
{% endblock %}
{% block panel %}
@ -17,7 +17,7 @@
</li>
{% url 'settings-add-federated-server' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Add server" %}</a>
<a href="{{ url }}">{% trans "Add instance" %}</a>
</li>
</ul>
</div>
@ -26,14 +26,14 @@
{% csrf_token %}
<div class="columns">
<div class="column is-half">
<div>
<div class="field">
<label class="label" for="id_server_name">{% trans "Instance:" %}</label>
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com">
{% for error in form.server_name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div>
<div class="field">
<label class="label" for="id_status">{% trans "Status:" %}</label>
<div class="select">
<select name="status" class="" id="id_status">
@ -44,14 +44,14 @@
</div>
</div>
<div class="column is-half">
<div>
<div class="field">
<label class="label" for="id_application_type">{% trans "Software:" %}</label>
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}">
{% for error in form.application_type.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div>
<div class="field">
<label class="label" for="id_application_version">{% trans "Version:" %}</label>
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}">
{% for error in form.application_version.errors %}
@ -60,10 +60,10 @@
</div>
</div>
</div>
<p>
<div class="field">
<label class="label" for="id_notes">{% trans "Notes:" %}</label>
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea>
</p>
</div>
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
</form>

View file

@ -1,13 +1,13 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% block title %}{% trans "Federated Servers" %}{% endblock %}
{% block title %}{% trans "Federated Instances" %}{% endblock %}
{% block header %}{% trans "Federated Servers" %}{% endblock %}
{% block header %}{% trans "Federated Instances" %}{% endblock %}
{% block edit-button %}
<a href="{% url 'settings-import-blocklist' %}">
<span class="icon icon-plus" title="{% trans 'Add server' %}" aria-hidden="True"></span>
<span>{% trans "Add server" %}</span>
<span class="icon icon-plus" title="{% trans 'Add instance' %}" aria-hidden="True"></span>
<span class="is-hidden-mobile">{% trans "Add instance" %}</span>
</a>
{% endblock %}
@ -16,7 +16,7 @@
<tr>
{% url 'settings-federation' as url %}
<th>
{% trans "Server name" as text %}
{% trans "Instance name" as text %}
{% include 'snippets/table-sort-header.html' with field="server_name" sort=sort text=text %}
</th>
<th>

View file

@ -1,10 +1,10 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% block title %}{% trans "Add server" %}{% endblock %}
{% block title %}{% trans "Add instance" %}{% endblock %}
{% block header %}
{% trans "Import Blocklist" %}
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to instance list" %}</a>
{% endblock %}
{% block panel %}
@ -17,7 +17,7 @@
</li>
{% url 'settings-add-federated-server' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Add server" %}</a>
<a href="{{ url }}">{% trans "Add instance" %}</a>
</li>
</ul>
</div>
@ -51,7 +51,7 @@
<pre>
[
{
"instance": "example.server.com",
"instance": "example.instance.com",
"url": "https://link.to.more/info"
},
...

View file

@ -11,23 +11,23 @@
{% csrf_token %}
<section class="block" id="instance-info">
<h2 class="title is-4">{% trans "Instance Info" %}</h2>
<div class="control">
<div class="field">
<label class="label" for="id_name">{% trans "Instance Name:" %}</label>
{{ site_form.name }}
</div>
<div class="control">
<div class="field">
<label class="label" for="id_instance_tagline">{% trans "Tagline:" %}</label>
{{ site_form.instance_tagline }}
</div>
<div class="control">
<div class="field">
<label class="label" for="id_instance_description">{% trans "Instance description:" %}</label>
{{ site_form.instance_description }}
</div>
<div class="control">
<div class="field">
<label class="label" for="id_code_of_conduct">{% trans "Code of conduct:" %}</label>
{{ site_form.code_of_conduct }}
</div>
<div class="control">
<div class="field">
<label class="label" for="id_privacy_policy">{% trans "Privacy Policy:" %}</label>
{{ site_form.privacy_policy }}
</div>
@ -57,19 +57,19 @@
<section class="block" id="footer">
<h2 class="title is-4">{% trans "Footer Content" %}</h2>
<div class="control">
<div class="field">
<label class="label" for="id_support_link">{% trans "Support link:" %}</label>
<input type="text" name="support_link" maxlength="255" class="input" id="id_support_link" placeholder="https://www.patreon.com/bookwyrm"{% if site.support_link %} value="{{ site.support_link }}"{% endif %}>
</div>
<div class="control">
<div class="field">
<label class="label" for="id_support_title">{% trans "Support title:" %}</label>
<input type="text" name="support_title" maxlength="100" class="input" id="id_support_title" placeholder="Patreon"{% if site.support_title %} value="{{ site.support_title }}"{% endif %}>
</div>
<div class="control">
<div class="field">
<label class="label" for="id_admin_email">{% trans "Admin email:" %}</label>
{{ site_form.admin_email }}
</div>
<div class="control">
<div class="field">
<label class="label" for="id_footer_item">{% trans "Additional info:" %}</label>
{{ site_form.footer_item }}
</div>
@ -79,15 +79,19 @@
<section class="block" id="registration">
<h2 class="title is-4">{% trans "Registration" %}</h2>
<div class="control">
<label class="label" for="id_allow_registration">{% trans "Allow registration:" %}
{{ site_form.allow_registration }}
<div class="field">
<label class="label" for="id_allow_registration">
{{ site_form.allow_registration }}
{% trans "Allow registration" %}
</label>
</div>
<div class="control">
<label class="label" for="id_allow_invite_requests">{% trans "Allow invite requests:" %}
{{ site_form.allow_invite_requests }}
<div class="field">
<label class="label" for="id_allow_invite_requests">
{{ site_form.allow_invite_requests }}
{% trans "Allow invite requests" %}
</label>
</div>
<div class="control">
<div class="field">
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
{{ site_form.registration_closed_text }}
</div>

View file

@ -4,7 +4,7 @@
class="notification mb-1 p-3{% if not admin_mode %} is-hidden{% endif %} transition-y"
{% if not admin_mode %}data-hide="hide-announcement-{{ announcement.id }}"{% endif %}
>
<div class="columns mb-0">
<div class="columns mb-0 is-mobile">
<div class="column pb-0">
{% if announcement.event_date %}
<strong>{{ announcement.event_date|naturalday|title }}:</strong>

View file

@ -1,5 +1,5 @@
{% load i18n %}
{% if not user in request.user.blocks.all %}
{% if not blocks %}
<form name="blocks" method="post" action="/block/{{ user.id }}">
{% csrf_token %}
<button class="button is-danger is-light is-small {{ class }}" type="submit">{% trans "Block" %}</button>

View file

@ -3,14 +3,31 @@
{% load i18n %}
{% with status.id|uuid as uuid %}
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% with request.user|boosted:status as boosted %}
<form
name="boost"
action="/boost/{{ status.id }}"
method="post"
class="interaction boost-{{ status.id }}-{{ uuid }} {% if boosted %}is-hidden{% endif %}"
data-id="boost-{{ status.id }}-{{ uuid }}"
>
{% csrf_token %}
<button class="button is-small is-light is-transparent" type="submit" {% if not status.boostable %}disabled{% endif %}>
<button
class="button is-small is-light is-transparent"
type="submit"
{% if not status.boostable %}disabled{% endif %}
>
<span class="icon icon-boost m-0-mobile" title="{% trans 'Boost' %}"></span>
<span class="is-sr-only-mobile">{% trans "Boost" %}</span>
</button>
</form>
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
<form
name="unboost"
action="/unboost/{{ status.id }}"
method="post"
class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not boosted %}is-hidden{% endif %}"
data-id="boost-{{ status.id }}-{{ uuid }}"
>
{% csrf_token %}
<button class="button is-small is-light is-transparent" type="submit">
<span class="icon icon-boost has-text-primary m-0-mobile" title="{% trans 'Un-boost' %}"></span>
@ -18,3 +35,4 @@
</button>
</form>
{% endwith %}
{% endwith %}

View file

@ -3,7 +3,8 @@
{% load i18n %}
{% with status.id|uuid as uuid %}
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% with request.user|liked:status as liked %}
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if liked %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small is-light is-transparent" type="submit">
<span class="icon icon-heart m-0-mobile" title="{% trans 'Like' %}">
@ -11,7 +12,7 @@
<span class="is-sr-only-mobile">{% trans "Like" %}</span>
</button>
</form>
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not liked %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-light is-transparent is-small" type="submit">
<span class="icon icon-heart has-text-primary m-0-mobile" title="{% trans 'Un-like' %}"></span>
@ -19,3 +20,4 @@
</button>
</form>
{% endwith %}
{% endwith %}

View file

@ -1,7 +1,7 @@
{% load i18n %}
{% if request.user == user or not request.user.is_authenticated %}
{% elif user in request.user.blocks.all %}
{% include 'snippets/block_button.html' %}
{% include 'snippets/block_button.html' with blocks=True %}
{% else %}
<div class="field{% if not minimal %} has-addons{% else %} mb-0{% endif %}">

View file

@ -11,7 +11,7 @@
class="is-sr-only"
type="radio"
name="rating"
value="0"
value=""
{% if default_rating == 0 or not default_rating %}checked{% endif %}
>

View file

@ -1,16 +1,19 @@
{% load i18n %}
{% load humanize %}
{% with goal.progress as progress %}
<p>
{% if goal.progress_percent >= 100 %}
{% if progress.percent >= 100 %}
{% trans "Success!" %}
{% elif goal.progress_percent %}
{% blocktrans with percent=goal.progress_percent %}{{ percent }}% complete!{% endblocktrans %}
{% elif progress.percent %}
{% blocktrans with percent=progress.percent %}{{ percent }}% complete!{% endblocktrans %}
{% endif %}
{% if goal.user == request.user %}
{% blocktrans with read_count=goal.book_count|intcomma goal_count=goal.goal|intcomma path=goal.local_path %}You've read <a href="{{ path }}">{{ read_count }} of {{ goal_count}} books</a>.{% endblocktrans %}
{% blocktrans with read_count=progress.count|intcomma goal_count=goal.goal|intcomma path=goal.local_path %}You've read <a href="{{ path }}">{{ read_count }} of {{ goal_count}} books</a>.{% endblocktrans %}
{% else %}
{% blocktrans with username=goal.user.display_name read_count=goal.book_count|intcomma goal_count=goal.goal|intcomma path=goal.local_path %}{{ username }} has read <a href="{{ path }}">{{ read_count }} of {{ goal_count}} books</a>.{% endblocktrans %}
{% blocktrans with username=goal.user.display_name read_count=progress.count|intcomma goal_count=goal.goal|intcomma path=goal.local_path %}{{ username }} has read <a href="{{ path }}">{{ read_count }} of {{ goal_count}} books</a>.{% endblocktrans %}
{% endif %}
</p>
<progress class="progress is-large" value="{{ goal.book_count }}" max="{{ goal.goal }}" aria-hidden="true">{{ goal.progress_percent }}%</progress>
<progress class="progress is-large" value="{{ progress.count }}" max="{{ goal.goal }}" aria-hidden="true">{{ progress.percent }}%</progress>
{% endwith %}

View file

@ -0,0 +1,12 @@
{% if preview_images_enabled is True %}
{% if image %}
<meta name="twitter:image" content="{{ request.scheme }}://{{ media_path }}{{ image }}">
<meta name="og:image" content="{{ request.scheme }}://{{ media_path }}{{ image }}">
{% else %}
<meta name="twitter:image" content="{{ request.scheme }}://{{ media_path }}{{ site.preview_image }}">
<meta name="og:image" content="{{ request.scheme }}://{{ media_path }}{{ site.preview_image }}">
{% endif %}
{% else %}
<meta name="twitter:image" content="{{ request.scheme }}://{% if site.logo %}{{ media_path }}{{ site.logo }}{% else %}{{ static_path }}/images/logo.png{% endif %}">
<meta name="og:image" content="{{ request.scheme }}://{% if site.logo %}{{ media_path }}{{ site.logo }}{% else %}{{ static_path }}/images/logo.png{% endif %}">
{% endif %}

View file

@ -3,10 +3,10 @@
<input type="hidden" name="id" value="{{ readthrough.id }}">
<input type="hidden" name="book" value="{{ book.id }}">
<div class="field">
<label class="label" tabindex="0" id="add-readthrough-focus">
<label class="label" tabindex="0" id="add-readthrough-focus" for="id_start_date-{{ readthrough.id }}">
{% trans "Started reading" %}
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</label>
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</div>
{# Only show progress for editing existing readthroughs #}
{% if readthrough.id and not readthrough.finish_date %}
@ -26,8 +26,8 @@
</div>
{% endif %}
<div class="field">
<label class="label">
<label class="label" for="id_finish_date-{{ readthrough.id }}">
{% trans "Finished reading" %}
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ readthrough.id }}" value="{{ readthrough.finish_date | date:"Y-m-d" }}">
</label>
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ readthrough.id }}" value="{{ readthrough.finish_date | date:"Y-m-d" }}">
</div>

View file

@ -6,7 +6,7 @@
{% endblock %}
{% block dropdown-list %}
{% for shelf in request.user.shelf_set.all %}
{% for shelf in user_shelves %}
<li role="menuitem" class="dropdown-item p-0">
<form name="shelve" action="/shelve/" method="post">
{% csrf_token %}

View file

@ -7,7 +7,7 @@
{% block modal-form-open %}
<form name="finish-reading" action="/finish-reading/{{ book.id }}" method="post">
<form name="finish-reading" action="{% url 'reading-status' 'finish' book.id %}" method="post">
{% endblock %}
{% block modal-body %}
@ -15,16 +15,16 @@
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<div class="field">
<label class="label">
<label class="label" for="finish_id_start_date-{{ uuid }}">
{% trans "Started reading" %}
<input type="date" name="start_date" class="input" id="finish_id_start_date-{{ uuid }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</label>
<input type="date" name="start_date" class="input" id="finish_id_start_date-{{ uuid }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</div>
<div class="field">
<label class="label">
<label class="label" for="id_finish_date-{{ uuid }}">
{% trans "Finished reading" %}
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ uuid }}" value="{% now "Y-m-d" %}">
</label>
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ uuid }}" value="{% now "Y-m-d" %}">
</div>
</section>
{% endblock %}
@ -38,7 +38,7 @@
</label>
{% include 'snippets/privacy_select.html' %}
</div>
<div class="column">
<div class="column has-text-right">
<button type="submit" class="button is-success">{% trans "Save" %}</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="finish-reading" controls_uid=uuid %}

View file

@ -13,7 +13,7 @@
</div>
{% else %}
<div class="control">
{% include 'snippets/shelve_button/shelve_button_options.html' with class="shelf-option is-small" shelves=request.user.shelf_set.all active_shelf=active_shelf button_uuid=uuid %}
{% include 'snippets/shelve_button/shelve_button_options.html' with class="shelf-option is-small" shelves=user_shelves active_shelf=active_shelf button_uuid=uuid %}
</div>
{% include 'snippets/shelve_button/shelve_button_dropdown.html' with class="is-small" button_uuid=uuid%}
{% endif %}
@ -25,7 +25,7 @@
{% include 'snippets/shelve_button/finish_reading_modal.html' with book=active_shelf.book controls_text="finish-reading" controls_uid=uuid readthrough=readthrough %}
{% include 'snippets/shelve_button/progress_update_modal.html' with book=shelf_book.book controls_text="progress-update" controls_uid=uuid readthrough=readthrough %}
{% include 'snippets/shelve_button/progress_update_modal.html' with book=active_shelf_book.book controls_text="progress-update" controls_uid=uuid readthrough=readthrough %}
{% endwith %}
{% endif %}

View file

@ -7,5 +7,5 @@
{% endblock %}
{% block dropdown-list %}
{% include 'snippets/shelve_button/shelve_button_options.html' with active_shelf=active_shelf shelves=request.user.shelf_set.all dropdown=True class="shelf-option is-fullwidth is-small is-radiusless is-white" %}
{% include 'snippets/shelve_button/shelve_button_options.html' with active_shelf=active_shelf shelves=user_shelves dropdown=True class="shelf-option is-fullwidth is-small is-radiusless is-white" %}
{% endblock %}

View file

@ -7,16 +7,25 @@
{% if dropdown %}<li role="menuitem" class="dropdown-item p-0">{% endif %}
<div class="{% if not dropdown and active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}">
{% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% trans "Start reading" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %}
{% url 'reading-status' 'start' book.id as fallback_url %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current fallback_url=fallback_url %}
{% endif %}{% elif shelf.identifier == 'read' and active_shelf.shelf.identifier == 'read' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
<button type="button" class="button {{ class }}" disabled><span>{% trans "Read" %}</span>
{% endif %}{% elif shelf.identifier == 'read' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% trans "Finish reading" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="finish-reading" controls_uid=button_uuid focus="modal-title-finish-reading" disabled=is_current %}
{% url 'reading-status' 'finish' book.id as fallback_url %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="finish-reading" controls_uid=button_uuid focus="modal-title-finish-reading" disabled=is_current fallback_url=fallback_url %}
{% endif %}{% elif shelf.identifier == 'to-read' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% trans "Want to read" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="want-to-read" controls_uid=button_uuid focus="modal-title-want-to-read" disabled=is_current %}
{% url 'reading-status' 'want' book.id as fallback_url %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="want-to-read" controls_uid=button_uuid focus="modal-title-want-to-read" disabled=is_current fallback_url=fallback_url %}
{% endif %}{% elif shelf.editable %}
<form name="shelve" action="/shelve/" method="post">
{% csrf_token %}
@ -44,7 +53,9 @@
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}">
<button class="button is-fullwidth is-small{% if dropdown %} is-radiusless{% endif %} is-danger is-light" type="submit">{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %}</button>
<button class="button is-fullwidth is-small{% if dropdown %} is-radiusless{% endif %} is-danger is-light" type="submit">
{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %}
</button>
</form>
</li>
{% endif %}

View file

@ -2,21 +2,23 @@
{% load i18n %}
{% block modal-title %}
{% blocktrans with book_title=book.title %}Start "<em>{{ book_title }}</em>"{% endblocktrans %}
{% blocktrans trimmed with book_title=book.title %}
Start "<em>{{ book_title }}</em>"
{% endblocktrans %}
{% endblock %}
{% block modal-form-open %}
<form name="start-reading" action="/start-reading/{{ book.id }}" method="post">
<form name="start-reading" action="{% url 'reading-status' 'start' book.id %}" method="post">
{% endblock %}
{% block modal-body %}
<section class="modal-card-body">
{% csrf_token %}
<div class="field">
<label class="label">
<label class="label" for="start_id_start_date-{{ uuid }}">
{% trans "Started reading" %}
<input type="date" name="start_date" class="input" id="start_id_start_date-{{ uuid }}" value="{% now "Y-m-d" %}">
</label>
<input type="date" name="start_date" class="input" id="start_id_start_date-{{ uuid }}" value="{% now "Y-m-d" %}">
</div>
</section>
{% endblock %}
@ -30,7 +32,7 @@
</label>
{% include 'snippets/privacy_select.html' %}
</div>
<div class="column">
<div class="column has-text-right">
<button class="button is-success" type="submit">{% trans "Save" %}</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="start-reading" controls_uid=uuid %}

View file

@ -6,7 +6,7 @@
{% endblock %}
{% block modal-form-open %}
<form name="shelve" action="/shelve/" method="post">
<form name="shelve" action="{% url 'reading-status' 'want' book.id %}" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="to-read">

View file

@ -15,7 +15,7 @@
<div class="card-footer-item">
{# moderation options #}
<form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
<form name="delete-{{ status.id }}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}

View file

@ -11,7 +11,7 @@
{% if status.user == request.user %}
{# things you can do to your own statuses #}
<li role="menuitem" class="dropdown-item p-0">
<form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
<form name="delete-{{ status.id }}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete status" %}
@ -20,7 +20,7 @@
</li>
{% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %}
<li role="menuitem" class="dropdown-item p-0">
<form class="" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post">
<form class="" name="delete-{{ status.id }}" action="{% url 'redraft' status.id %}" method="post">
{% csrf_token %}
<button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete & re-draft" %}
@ -39,7 +39,7 @@
{% include 'snippets/report_button.html' with user=status.user status=status %}
</li>
<li role="menuitem" class="dropdown-item p-0">
{% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %}
{% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" blocks=False %}
</li>
{% endif %}
{% endblock %}

View file

@ -1,5 +1,12 @@
{% if fallback_url %}
<form name="fallback-form-{{ controls_uuid}}" method="GET" action="{{ fallback_url }}">
{% endif %}
<button
type="button"
{% if not fallback_url %}
type="button"
{% else %}
type="submit"
{% endif %}
class="{% if not nonbutton %}button {% endif %}{{ class }}{% if button_type %} {{ button_type }}{% endif %}"
data-controls="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}"
{% if focus %}data-focus-target="{{ focus }}{% if controls_uid %}-{{ controls_uid }}{% endif %}"{% endif %}
@ -20,3 +27,6 @@
<span>{{ text }}</span>
{% endif %}
</button>
{% if fallback_url %}
</form>
{% endif %}

View file

@ -16,6 +16,6 @@
{% include 'snippets/report_button.html' with user=user class="is-fullwidth" %}
</li>
<li role="menuitem">
{% include 'snippets/block_button.html' with user=user class="is-fullwidth" %}
{% include 'snippets/block_button.html' with user=user class="is-fullwidth" blocks=False %}
</li>
{% endblock %}

View file

@ -3,9 +3,14 @@
{% load humanize %}
{% load utilities %}
{% load markdown %}
{% load layout %}
{% block title %}{{ user.display_name }}{% endblock %}
{% block opengraph_images %}
{% include 'snippets/opengraph_images.html' with image=user.preview_image %}
{% endblock %}
{% block content %}
<header class="block">
{% block header %}

View file

@ -37,6 +37,7 @@
<div class="column is-narrow">
{% trans "Create shelf" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" controls_text="create-shelf-form" focus="create-shelf-form-header" %}
<a class="button" href="{% url 'import' %}">{% trans "Import Books" %}</a>
</div>
{% endif %}
</div>

View file

@ -13,7 +13,7 @@
<div class="column is-narrow">
<a href="{% url 'prefs-profile' %}">
<span class="icon icon-pencil" title="Edit profile" aria-hidden="true"></span>
<span>{% trans "Edit profile" %}</span>
<span class="is-hidden-mobile">{% trans "Edit profile" %}</span>
</a>
</div>
{% endif %}
@ -26,7 +26,7 @@
<h2 class="title">
{% include 'user/shelf/books_header.html' %}
</h2>
<div class="columns">
<div class="columns is-mobile scroll-x">
{% for shelf in shelves %}
<div class="column is-narrow">
<h3>{{ shelf.name }}
@ -60,7 +60,7 @@
<div class="column is-narrow">
<a target="_blank" href="{{ user.local_path }}/rss">
<span class="icon icon-rss" aria-hidden="true"></span>
<span>{% trans "RSS feed" %}</span>
<span class="is-hidden-mobile">{% trans "RSS feed" %}</span>
</a>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show more