2
0
Fork 0

Merge branch 'production' into nix

This commit is contained in:
D Anzorge 2022-01-18 02:26:25 +01:00
commit 56da9dcdb3
339 changed files with 27764 additions and 8889 deletions
.env.dev.example.env.example
.github/workflows
.stylelintrc.js
bookwyrm
activitypub
activitystreams.pyapps.py
connectors
emailing.pyforms.py
importers
lists_stream.py
management/commands
migrations
models
redis_store.pysettings.py
static
suggested_users.py
telemetry
templates

View file

@ -1,75 +0,0 @@
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG=true
USE_HTTPS=false
DOMAIN=your.domain.here
#EMAIL=your@email.here
# Used for deciding which editions to prefer
DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
MEDIA_ROOT=images/
PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
POSTGRES_HOST=db
# Redis activity stream manager
MAX_STREAM_LENGTH=200
REDIS_ACTIVITY_HOST=redis_activity
REDIS_ACTIVITY_PORT=6379
#REDIS_ACTIVITY_PASSWORD=redispassword345
# Redis as celery broker
REDIS_BROKER_PORT=6379
#REDIS_BROKER_PASSWORD=redispassword123
FLOWER_PORT=8888
#FLOWER_USER=mouse
#FLOWER_PASSWORD=changeme
EMAIL_HOST=smtp.mailgun.org
EMAIL_PORT=587
EMAIL_HOST_USER=mail@your.domain.here
EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false
# Thumbnails Generation
ENABLE_THUMBNAIL_GENERATION=false
# S3 configuration
USE_S3=false
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# Commented are example values if you use a non-AWS, S3-compatible service
# AWS S3 should work with only AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME
# non-AWS S3-compatible services will need AWS_STORAGE_BUCKET_NAME,
# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL
# AWS_STORAGE_BUCKET_NAME= # "example-bucket-name"
# AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud"
# AWS_S3_REGION_NAME=None # "fr-par"
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
# Preview image generation can be computing and storage intensive
# ENABLE_PREVIEW_IMAGES=True
# Specify RGB tuple or RGB hex strings,
# or use_dominant_color_light / use_dominant_color_dark
PREVIEW_BG_COLOR=use_dominant_color_light
# Change to #FFF if you use use_dominant_color_dark
PREVIEW_TEXT_COLOR=#363636
PREVIEW_IMG_WIDTH=1200
PREVIEW_IMG_HEIGHT=630
PREVIEW_DEFAULT_COVER_COLOR=#002549

View file

@ -16,6 +16,7 @@ DEFAULT_LANGUAGE="English"
MEDIA_ROOT=images/
# Database configuration
PGPORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
@ -27,21 +28,34 @@ MAX_STREAM_LENGTH=200
REDIS_ACTIVITY_HOST=redis_activity
REDIS_ACTIVITY_PORT=6379
REDIS_ACTIVITY_PASSWORD=redispassword345
# Optional, use a different redis database (defaults to 0)
# REDIS_ACTIVITY_DB_INDEX=0
# Redis as celery broker
REDIS_BROKER_PORT=6379
REDIS_BROKER_PASSWORD=redispassword123
# Optional, use a different redis database (defaults to 0)
# REDIS_BROKER_DB_INDEX=0
# Monitoring for celery
FLOWER_PORT=8888
FLOWER_USER=mouse
FLOWER_PASSWORD=changeme
# Email config
EMAIL_HOST=smtp.mailgun.org
EMAIL_PORT=587
EMAIL_HOST_USER=mail@your.domain.here
EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false
EMAIL_SENDER_NAME=admin
# defaults to DOMAIN
EMAIL_SENDER_DOMAIN=
# Query timeouts
SEARCH_TIMEOUT=15
QUERY_TIMEOUT=5
# Thumbnails Generation
ENABLE_THUMBNAIL_GENERATION=false
@ -73,3 +87,19 @@ PREVIEW_TEXT_COLOR=#363636
PREVIEW_IMG_WIDTH=1200
PREVIEW_IMG_HEIGHT=630
PREVIEW_DEFAULT_COVER_COLOR=#002549
# Below are example keys if you want to enable automatically
# sending telemetry to an OTLP-compatible service. Many of
# the main monitoring apps have OLTP collectors, including
# NewRelic, DataDog, and Honeycomb.io - consult their
# documentation for setup instructions, and what exactly to
# put below!
#
# Service name is an arbitrary tag that is attached to any
# data sent, used to distinguish different sources. Useful
# for sending prod and dev metrics to the same place and
# keeping them separate, for instance!
OTEL_EXPORTER_OTLP_ENDPOINT= # API endpoint for your provider
OTEL_EXPORTER_OTLP_HEADERS= # Any headers required, usually authentication info
OTEL_SERVICE_NAME= # Service name to identify your app

View file

@ -46,6 +46,8 @@ jobs:
POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: ""
REDIS_BROKER_PORT: 6379
REDIS_BROKER_PASSWORD: beep
USE_DUMMY_CACHE: true
FLOWER_PORT: 8888
EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587

24
.github/workflows/prettier.yaml vendored Normal file
View file

@ -0,0 +1,24 @@
# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
name: JavaScript Prettier (run ./bw-dev prettier to fix)
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
lint:
name: Lint with Prettier
runs-on: ubuntu-20.04
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
- uses: actions/checkout@v2
- name: Install modules
run: npm install .
# See .stylelintignore for files that are not linted.
- name: Run Prettier
run: npx prettier --check bookwyrm/static/js/*.js

View file

@ -12,6 +12,9 @@ module.exports = {
"custom-properties",
"declarations"
],
"indentation": 4
"indentation": 4,
"property-no-vendor-prefix": null,
"color-function-notation": null,
"declaration-block-no-redundant-longhand-properties": null,
}
};

View file

@ -20,22 +20,6 @@ class ActivityEncoder(JSONEncoder):
return o.__dict__
@dataclass
class Link:
"""for tagging a book in a status"""
href: str
name: str
type: str = "Link"
@dataclass
class Mention(Link):
"""a subtype of Link for mentioning an actor"""
type: str = "Mention"
@dataclass
# pylint: disable=invalid-name
class Signature:
@ -198,8 +182,9 @@ class ActivityObject:
)
return instance
def serialize(self):
def serialize(self, **kwargs):
"""convert to dictionary with context attr"""
omit = kwargs.get("omit", ())
data = self.__dict__.copy()
# recursively serialize
for (k, v) in data.items():
@ -208,8 +193,9 @@ class ActivityObject:
data[k] = v.serialize()
except TypeError:
pass
data = {k: v for (k, v) in data.items() if v is not None}
data["@context"] = "https://www.w3.org/ns/activitystreams"
data = {k: v for (k, v) in data.items() if v is not None and k not in omit}
if "@context" not in omit:
data["@context"] = "https://www.w3.org/ns/activitystreams"
return data
@ -222,35 +208,32 @@ def set_related_field(
model = apps.get_model(f"bookwyrm.{model_name}", require_ready=True)
origin_model = apps.get_model(f"bookwyrm.{origin_model_name}", require_ready=True)
with transaction.atomic():
if isinstance(data, str):
existing = model.find_existing_by_remote_id(data)
if existing:
data = existing.to_activity()
else:
data = get_data(data)
activity = model.activity_serializer(**data)
if isinstance(data, str):
existing = model.find_existing_by_remote_id(data)
if existing:
data = existing.to_activity()
else:
data = get_data(data)
activity = model.activity_serializer(**data)
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
raise ValueError(f"Invalid related remote id: {related_remote_id}")
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
raise ValueError(f"Invalid related remote id: {related_remote_id}")
# set the origin's remote id on the activity so it will be there when
# the model instance is created
# edition.parentWork = instance, for example
model_field = getattr(model, related_field_name)
if hasattr(model_field, "activitypub_field"):
setattr(
activity, getattr(model_field, "activitypub_field"), instance.remote_id
)
item = activity.to_model()
# set the origin's remote id on the activity so it will be there when
# the model instance is created
# edition.parentWork = instance, for example
model_field = getattr(model, related_field_name)
if hasattr(model_field, "activitypub_field"):
setattr(activity, getattr(model_field, "activitypub_field"), instance.remote_id)
item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
if not hasattr(model_field, "activitypub_field"):
setattr(item, related_field_name, instance)
item.save()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
if not hasattr(model_field, "activitypub_field"):
setattr(item, related_field_name, instance)
item.save()
def get_model_from_type(activity_type):
@ -304,3 +287,27 @@ def resolve_remote_id(
# if we're refreshing, "result" will be set and we'll update it
return item.to_model(model=model, instance=result, save=save)
@dataclass(init=False)
class Link(ActivityObject):
"""for tagging a book in a status"""
href: str
name: str = None
mediaType: str = None
id: str = None
attributedTo: str = None
type: str = "Link"
def serialize(self, **kwargs):
"""remove fields"""
omit = ("id", "type", "@context")
return super().serialize(omit=omit)
@dataclass(init=False)
class Mention(Link):
"""a subtype of Link for mentioning an actor"""
type: str = "Mention"

View file

@ -17,6 +17,8 @@ class BookData(ActivityObject):
goodreadsKey: str = None
bnfId: str = None
lastEditedBy: str = None
links: List[str] = field(default_factory=lambda: [])
fileLinks: List[str] = field(default_factory=lambda: [])
# pylint: disable=invalid-name

View file

@ -15,6 +15,11 @@ class PublicKey(ActivityObject):
publicKeyPem: str
type: str = "PublicKey"
def serialize(self, **kwargs):
"""remove fields"""
omit = ("type", "@context")
return super().serialize(omit=omit)
# pylint: disable=invalid-name
@dataclass(init=False)

View file

@ -397,9 +397,15 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
"""build a user's feeds when they join"""
if not created or not instance.local:
return
transaction.on_commit(
lambda: populate_streams_on_account_create_command(instance.id)
)
def populate_streams_on_account_create_command(instance_id):
"""wait for the transaction to complete"""
for stream in streams:
populate_stream_task.delay(stream, instance.id)
populate_stream_task.delay(stream, instance_id)
@receiver(signals.pre_save, sender=models.ShelfBook)

13
bookwyrm/apps.py Normal file
View file

@ -0,0 +1,13 @@
from django.apps import AppConfig
from bookwyrm import settings
class BookwyrmConfig(AppConfig):
name = "bookwyrm"
verbose_name = "BookWyrm"
def ready(self):
if settings.OTEL_EXPORTER_OTLP_ENDPOINT:
from bookwyrm.telemetry import open_telemetry
open_telemetry.instrumentDjango()

View file

@ -35,7 +35,7 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None, timeout=5):
def search(self, query, min_confidence=None, timeout=settings.QUERY_TIMEOUT):
"""free text search"""
params = {}
if min_confidence:
@ -52,12 +52,13 @@ class AbstractMinimalConnector(ABC):
results.append(self.format_search_result(doc))
return results
def isbn_search(self, query):
def isbn_search(self, query, timeout=settings.QUERY_TIMEOUT):
"""isbn search"""
params = {}
data = self.get_search_data(
f"{self.isbn_search_url}{query}",
params=params,
timeout=timeout,
)
results = []

View file

@ -11,6 +11,7 @@ from django.db.models import signals
from requests import HTTPError
from bookwyrm import book_search, models
from bookwyrm.settings import SEARCH_TIMEOUT
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@ -30,7 +31,6 @@ def search(query, min_confidence=0.1, return_first=False):
isbn = re.sub(r"[\W_]", "", query)
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
timeout = 15
start_time = datetime.now()
for connector in get_connectors():
result_set = None
@ -62,7 +62,7 @@ def search(query, min_confidence=0.1, return_first=False):
"results": result_set,
}
)
if (datetime.now() - start_time).seconds >= timeout:
if (datetime.now() - start_time).seconds >= SEARCH_TIMEOUT:
break
if return_first:

View file

@ -69,7 +69,7 @@ def format_email(email_name, data):
def send_email(recipient, subject, html_content, text_content):
"""use a task to send the email"""
email = EmailMultiAlternatives(
subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient]
subject, text_content, settings.EMAIL_SENDER, [recipient]
)
email.attach_alternative(html_content, "text/html")
email.send()

View file

@ -216,6 +216,18 @@ class CoverForm(CustomForm):
help_texts = {f: None for f in fields}
class LinkDomainForm(CustomForm):
class Meta:
model = models.LinkDomain
fields = ["name"]
class FileLinkForm(CustomForm):
class Meta:
model = models.FileLink
fields = ["url", "filetype", "availability", "book", "added_by"]
class EditionForm(CustomForm):
class Meta:
model = models.Edition
@ -230,6 +242,8 @@ class EditionForm(CustomForm):
"shelves",
"connector",
"search_vector",
"links",
"file_links",
]
widgets = {
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
@ -439,7 +453,7 @@ class GroupForm(CustomForm):
class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "statuses", "note"]
fields = ["user", "reporter", "statuses", "links", "note"]
class EmailBlocklistForm(CustomForm):
@ -478,3 +492,19 @@ class SortListForm(forms.Form):
("descending", _("Descending")),
),
)
class ReadThroughForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
cleaned_data = super().clean()
start_date = cleaned_data.get("start_date")
finish_date = cleaned_data.get("finish_date")
if start_date > finish_date:
self.add_error(
"finish_date", _("Reading finish date cannot be before start date.")
)
class Meta:
model = models.ReadThrough
fields = ["user", "book", "start_date", "finish_date"]

View file

@ -14,7 +14,8 @@ class LibrarythingImporter(Importer):
"""use the dataclass to create the formatted row of data"""
remove_brackets = lambda v: re.sub(r"\[|\]", "", v) if v else None
normalized = {k: remove_brackets(entry.get(v)) for k, v in mappings.items()}
isbn_13 = normalized["isbn_13"].split(", ")
isbn_13 = normalized.get("isbn_13")
isbn_13 = isbn_13.split(", ") if isbn_13 else []
normalized["isbn_13"] = isbn_13[1] if len(isbn_13) > 0 else None
return normalized

251
bookwyrm/lists_stream.py Normal file
View file

@ -0,0 +1,251 @@
""" access the list streams stored in redis """
from django.dispatch import receiver
from django.db import transaction
from django.db.models import signals, Count, Q
from bookwyrm import models
from bookwyrm.redis_store import RedisStore
from bookwyrm.tasks import app, MEDIUM, HIGH
class ListsStream(RedisStore):
"""all the lists you can see"""
def stream_id(self, user): # pylint: disable=no-self-use
"""the redis key for this user's instance of this stream"""
if isinstance(user, int):
# allows the function to take an int or an obj
return f"{user}-lists"
return f"{user.id}-lists"
def get_rank(self, obj): # pylint: disable=no-self-use
"""lists are sorted by updated date"""
return obj.updated_date.timestamp()
def add_list(self, book_list):
"""add a list to users' feeds"""
# the pipeline contains all the add-to-stream activities
self.add_object_to_related_stores(book_list)
def add_user_lists(self, viewer, user):
"""add a user's lists to another user's feed"""
# only add the lists that the viewer should be able to see
lists = models.List.privacy_filter(viewer).filter(user=user)
self.bulk_add_objects_to_store(lists, self.stream_id(viewer))
def remove_user_lists(self, viewer, user, exclude_privacy=None):
"""remove a user's list from another user's feed"""
# remove all so that followers only lists are removed
lists = user.list_set
if exclude_privacy:
lists = lists.exclude(privacy=exclude_privacy)
self.bulk_remove_objects_from_store(lists.all(), self.stream_id(viewer))
def get_list_stream(self, user):
"""load the lists to be displayed"""
lists = self.get_store(self.stream_id(user))
return (
models.List.objects.filter(id__in=lists)
.annotate(item_count=Count("listitem", filter=Q(listitem__approved=True)))
# hide lists with no approved books
.filter(item_count__gt=0)
.select_related("user")
.prefetch_related("listitem_set")
.order_by("-updated_date")
.distinct()
)
def populate_lists(self, user):
"""go from zero to a timeline"""
self.populate_store(self.stream_id(user))
def get_audience(self, book_list): # pylint: disable=no-self-use
"""given a list, what users should see it"""
# everybody who could plausibly see this list
audience = models.User.objects.filter(
is_active=True,
local=True, # we only create feeds for users of this instance
).exclude( # not blocked
Q(id__in=book_list.user.blocks.all()) | Q(blocks=book_list.user)
)
group = book_list.group
# only visible to the poster and mentioned users
if book_list.privacy == "direct":
if group:
audience = audience.filter(
Q(id=book_list.user.id) # if the user is the post's author
| ~Q(groups=group.memberships) # if the user is in the group
)
else:
audience = audience.filter(
Q(id=book_list.user.id) # if the user is the post's author
)
# only visible to the poster's followers and tagged users
elif book_list.privacy == "followers":
if group:
audience = audience.filter(
Q(id=book_list.user.id) # if the user is the list's owner
| Q(following=book_list.user) # if the user is following the pwmer
# if a user is in the group
| Q(memberships__group__id=book_list.group.id)
)
else:
audience = audience.filter(
Q(id=book_list.user.id) # if the user is the list's owner
| Q(following=book_list.user) # if the user is following the pwmer
)
return audience.distinct()
def get_stores_for_object(self, obj):
return [self.stream_id(u) for u in self.get_audience(obj)]
def get_lists_for_user(self, user): # pylint: disable=no-self-use
"""given a user, what lists should they see on this stream"""
return models.List.privacy_filter(
user,
privacy_levels=["public", "followers"],
)
def get_objects_for_store(self, store):
user = models.User.objects.get(id=store.split("-")[0])
return self.get_lists_for_user(user)
@receiver(signals.post_save, sender=models.List)
# pylint: disable=unused-argument
def add_list_on_create(sender, instance, created, *args, **kwargs):
"""add newly created lists streamsstreams"""
if not created:
return
# when creating new things, gotta wait on the transaction
transaction.on_commit(lambda: add_list_on_create_command(instance.id))
@receiver(signals.post_delete, sender=models.List)
# pylint: disable=unused-argument
def remove_list_on_delete(sender, instance, *args, **kwargs):
"""remove deleted lists to streams"""
remove_list_task.delay(instance.id)
def add_list_on_create_command(instance_id):
"""runs this code only after the database commit completes"""
add_list_task.delay(instance_id)
@receiver(signals.post_save, sender=models.UserFollows)
# pylint: disable=unused-argument
def add_lists_on_follow(sender, instance, created, *args, **kwargs):
"""add a newly followed user's lists to feeds"""
if not created or not instance.user_subject.local:
return
add_user_lists_task.delay(instance.user_subject.id, instance.user_object.id)
@receiver(signals.post_delete, sender=models.UserFollows)
# pylint: disable=unused-argument
def remove_lists_on_unfollow(sender, instance, *args, **kwargs):
"""remove lists from a feed on unfollow"""
if not instance.user_subject.local:
return
# remove all but public lists
remove_user_lists_task.delay(
instance.user_subject.id, instance.user_object.id, exclude_privacy="public"
)
@receiver(signals.post_save, sender=models.UserBlocks)
# pylint: disable=unused-argument
def remove_lists_on_block(sender, instance, *args, **kwargs):
"""remove lists from all feeds on block"""
# blocks apply ot all feeds
if instance.user_subject.local:
remove_user_lists_task.delay(instance.user_subject.id, instance.user_object.id)
# and in both directions
if instance.user_object.local:
remove_user_lists_task.delay(instance.user_object.id, instance.user_subject.id)
@receiver(signals.post_delete, sender=models.UserBlocks)
# pylint: disable=unused-argument
def add_lists_on_unblock(sender, instance, *args, **kwargs):
"""add lists back to all feeds on unblock"""
# make sure there isn't a block in the other direction
if models.UserBlocks.objects.filter(
user_subject=instance.user_object,
user_object=instance.user_subject,
).exists():
return
# add lists back to streams with lists from anyone
if instance.user_subject.local:
add_user_lists_task.delay(
instance.user_subject.id,
instance.user_object.id,
)
# add lists back to streams with lists from anyone
if instance.user_object.local:
add_user_lists_task.delay(
instance.user_object.id,
instance.user_subject.id,
)
@receiver(signals.post_save, sender=models.User)
# pylint: disable=unused-argument
def populate_lists_on_account_create(sender, instance, created, *args, **kwargs):
"""build a user's feeds when they join"""
if not created or not instance.local:
return
transaction.on_commit(lambda: add_list_on_account_create_command(instance.id))
def add_list_on_account_create_command(user_id):
"""wait for the transaction to complete"""
populate_lists_task.delay(user_id)
# ---- TASKS
@app.task(queue=MEDIUM)
def populate_lists_task(user_id):
"""background task for populating an empty list stream"""
user = models.User.objects.get(id=user_id)
ListsStream().populate_lists(user)
@app.task(queue=MEDIUM)
def remove_list_task(list_id):
"""remove a list from any stream it might be in"""
stores = models.User.objects.filter(local=True, is_active=True).values_list(
"id", flat=True
)
# delete for every store
stores = [ListsStream().stream_id(idx) for idx in stores]
ListsStream().remove_object_from_related_stores(list_id, stores=stores)
@app.task(queue=HIGH)
def add_list_task(list_id):
"""add a list to any stream it should be in"""
book_list = models.List.objects.get(id=list_id)
ListsStream().add_list(book_list)
@app.task(queue=MEDIUM)
def remove_user_lists_task(viewer_id, user_id, exclude_privacy=None):
"""remove all lists by a user from a viewer's stream"""
viewer = models.User.objects.get(id=viewer_id)
user = models.User.objects.get(id=user_id)
ListsStream().remove_user_lists(viewer, user, exclude_privacy=exclude_privacy)
@app.task(queue=MEDIUM)
def add_user_lists_task(viewer_id, user_id):
"""add all lists by a user to a viewer's stream"""
viewer = models.User.objects.get(id=viewer_id)
user = models.User.objects.get(id=user_id)
ListsStream().add_user_lists(viewer, user)

View file

@ -9,7 +9,7 @@ r = redis.Redis(
port=settings.REDIS_ACTIVITY_PORT,
password=settings.REDIS_ACTIVITY_PASSWORD,
unix_socket_path=settings.REDIS_ACTIVITY_SOCKET,
db=0,
db=settings.REDIS_ACTIVITY_DB_INDEX,
)

View file

@ -3,7 +3,7 @@ from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
from bookwyrm import models
def init_groups():
@ -55,7 +55,7 @@ def init_permissions():
},
]
content_type = ContentType.objects.get_for_model(User)
content_type = models.ContentType.objects.get_for_model(User)
for permission in permissions:
permission_obj = Permission.objects.create(
codename=permission["codename"],
@ -72,7 +72,7 @@ def init_permissions():
def init_connectors():
"""access book data sources"""
Connector.objects.create(
models.Connector.objects.create(
identifier="bookwyrm.social",
name="BookWyrm dot Social",
connector_file="bookwyrm_connector",
@ -84,7 +84,7 @@ def init_connectors():
priority=2,
)
Connector.objects.create(
models.Connector.objects.create(
identifier="inventaire.io",
name="Inventaire",
connector_file="inventaire",
@ -96,7 +96,7 @@ def init_connectors():
priority=3,
)
Connector.objects.create(
models.Connector.objects.create(
identifier="openlibrary.org",
name="OpenLibrary",
connector_file="openlibrary",
@ -113,7 +113,7 @@ def init_federated_servers():
"""big no to nazis"""
built_in_blocks = ["gab.ai", "gab.com"]
for server in built_in_blocks:
FederatedServer.objects.create(
models.FederatedServer.objects.create(
server_name=server,
status="blocked",
)
@ -121,18 +121,61 @@ def init_federated_servers():
def init_settings():
"""info about the instance"""
SiteSettings.objects.create(
models.SiteSettings.objects.create(
support_link="https://www.patreon.com/bookwyrm",
support_title="Patreon",
)
def init_link_domains(*_):
"""safe book links"""
domains = [
("standardebooks.org", "Standard EBooks"),
("www.gutenberg.org", "Project Gutenberg"),
("archive.org", "Internet Archive"),
("openlibrary.org", "Open Library"),
("theanarchistlibrary.org", "The Anarchist Library"),
]
for domain, name in domains:
models.LinkDomain.objects.create(
domain=domain,
name=name,
status="approved",
)
class Command(BaseCommand):
help = "Initializes the database with starter data"
def add_arguments(self, parser):
parser.add_argument(
"--limit",
default=None,
help="Limit init to specific table",
)
def handle(self, *args, **options):
init_groups()
init_permissions()
init_connectors()
init_federated_servers()
init_settings()
limit = options.get("limit")
tables = [
"group",
"permission",
"connector",
"federatedserver",
"settings",
"linkdomain",
]
if limit not in tables:
raise Exception("Invalid table limit:", limit)
if not limit or limit == "group":
init_groups()
if not limit or limit == "permission":
init_permissions()
if not limit or limit == "connector":
init_connectors()
if not limit or limit == "federatedserver":
init_federated_servers()
if not limit or limit == "settings":
init_settings()
if not limit or limit == "linkdomain":
init_link_domains()

View file

@ -0,0 +1,28 @@
""" Re-create list streams """
from django.core.management.base import BaseCommand
from bookwyrm import lists_stream, models
def populate_lists_streams():
"""build all the lists streams for all the users"""
print("Populating lists streams")
users = models.User.objects.filter(
local=True,
is_active=True,
).order_by("-last_active_date")
print("This may take a long time! Please be patient.")
for user in users:
print(".", end="")
lists_stream.populate_lists_task.delay(user.id)
print("\nAll done, thank you for your patience!")
class Command(BaseCommand):
"""start all over with lists streams"""
help = "Populate list streams for all users"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run feed builder"""
populate_lists_streams()

View file

@ -1,18 +1,20 @@
""" Re-create user streams """
from django.core.management.base import BaseCommand
from bookwyrm import activitystreams, models
from bookwyrm import activitystreams, lists_stream, models
def populate_streams(stream=None):
"""build all the streams for all the users"""
streams = [stream] if stream else activitystreams.streams.keys()
print("Populations streams", streams)
print("Populating streams", streams)
users = models.User.objects.filter(
local=True,
is_active=True,
).order_by("-last_active_date")
print("This may take a long time! Please be patient.")
for user in users:
print(".", end="")
lists_stream.populate_lists_task.delay(user.id)
for stream_key in streams:
print(".", end="")
activitystreams.populate_stream_task.delay(stream_key, user.id)

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.5 on 2022-01-04 18:59
import bookwyrm.models.user
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0121_user_summary_keys"),
]
operations = [
migrations.AlterField(
model_name="annualgoal",
name="year",
field=models.IntegerField(default=bookwyrm.models.user.get_current_year),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 3.2.5 on 2022-01-04 22:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0122_alter_annualgoal_year"),
]
operations = [
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es-es", "Español (Spanish)"),
("gl-es", "Galego (Galician)"),
("fr-fr", "Français (French)"),
("lt-lt", "Lietuvių (Lithuanian)"),
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
("pt-pt", "Português Europeu (European Portuguese)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 3.2.10 on 2022-01-06 17:59
from django.contrib.auth.models import AbstractUser
from django.db import migrations
def get_admins(apps, schema_editor):
"""add any superusers to the "admin" group"""
db_alias = schema_editor.connection.alias
groups = apps.get_model("auth", "Group")
try:
group = groups.objects.using(db_alias).get(name="admin")
except groups.DoesNotExist:
# for tests
return
users = apps.get_model("bookwyrm", "User")
admins = users.objects.using(db_alias).filter(is_superuser=True)
for admin in admins:
admin.groups.add(group)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0123_alter_user_preferred_language"),
]
operations = [
migrations.RunPython(get_admins, reverse_code=migrations.RunPython.noop),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 3.2.10 on 2022-01-09 01:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0124_auto_20220106_1759"),
]
operations = [
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es-es", "Español (Spanish)"),
("gl-es", "Galego (Galician)"),
("it-it", "Italiano (Italian)"),
("fr-fr", "Français (French)"),
("lt-lt", "Lietuvių (Lithuanian)"),
("no-no", "Norsk (Norwegian)"),
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
("pt-pt", "Português Europeu (European Portuguese)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,55 @@
# Generated by Django 3.2.10 on 2022-01-12 23:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0125_alter_user_preferred_language"),
]
operations = [
migrations.AlterField(
model_name="annualgoal",
name="privacy",
field=models.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Private"),
],
default="public",
max_length=255,
),
),
migrations.AlterField(
model_name="importjob",
name="privacy",
field=models.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Private"),
],
default="public",
max_length=255,
),
),
migrations.AlterField(
model_name="user",
name="default_post_privacy",
field=models.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Private"),
],
default="public",
max_length=255,
),
),
]

View file

@ -0,0 +1,144 @@
# Generated by Django 3.2.10 on 2022-01-10 21:20
import bookwyrm.models.activitypub_mixin
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0125_alter_user_preferred_language"),
]
operations = [
migrations.CreateModel(
name="LinkDomain",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("domain", models.CharField(max_length=255, unique=True)),
(
"status",
models.CharField(
choices=[
("approved", "Approved"),
("blocked", "Blocked"),
("pending", "Pending"),
],
default="pending",
max_length=50,
),
),
("name", models.CharField(max_length=100)),
(
"reported_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Link",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("url", bookwyrm.models.fields.URLField(max_length=255)),
(
"added_by",
bookwyrm.models.fields.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"domain",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="bookwyrm.linkdomain",
),
),
],
options={
"abstract": False,
},
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
),
migrations.CreateModel(
name="FileLink",
fields=[
(
"link_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookwyrm.link",
),
),
("filetype", bookwyrm.models.fields.CharField(max_length=5)),
(
"book",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="file_links",
to="bookwyrm.book",
),
),
],
options={
"abstract": False,
},
bases=("bookwyrm.link",),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 3.2.10 on 2022-01-10 22:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0126_filelink_link_linkdomain"),
]
operations = [
migrations.RemoveConstraint(
model_name="report",
name="self_report",
),
migrations.AddField(
model_name="report",
name="links",
field=models.ManyToManyField(blank=True, to="bookwyrm.Link"),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.10 on 2022-01-13 01:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0126_auto_20220112_2315"),
("bookwyrm", "0127_auto_20220110_2211"),
]
operations = []

View file

@ -0,0 +1,32 @@
# Generated by Django 3.2.10 on 2022-01-17 17:16
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0128_merge_0126_auto_20220112_2315_0127_auto_20220110_2211"),
]
operations = [
migrations.AddField(
model_name="filelink",
name="availability",
field=bookwyrm.models.fields.CharField(
choices=[
("free", "Free"),
("purchase", "Purchasable"),
("loan", "Available for loan"),
],
default="free",
max_length=100,
),
),
migrations.AlterField(
model_name="filelink",
name="filetype",
field=bookwyrm.models.fields.CharField(max_length=50),
),
]

View file

@ -4,6 +4,7 @@ import sys
from .book import Book, Work, Edition, BookDataModel
from .author import Author
from .link import Link, FileLink, LinkDomain
from .connector import Connector
from .shelf import Shelf, ShelfBook

View file

@ -1,6 +1,8 @@
""" database schema for info about authors """
import re
from django.contrib.postgres.indexes import GinIndex
from django.core.cache import cache
from django.core.cache.utils import make_template_fragment_key
from django.db import models
from bookwyrm import activitypub
@ -34,6 +36,17 @@ class Author(BookDataModel):
)
bio = fields.HtmlField(null=True, blank=True)
def save(self, *args, **kwargs):
"""clear related template caches"""
# clear template caches
if self.id:
cache_keys = [
make_template_fragment_key("titleby", [book])
for book in self.book_set.values_list("id", flat=True)
]
cache.delete_many(cache_keys)
return super().save(*args, **kwargs)
@property
def isni_link(self):
"""generate the url from the isni id"""

View file

@ -84,6 +84,7 @@ class BookWyrmModel(models.Model):
# you can see groups of which you are a member
if (
hasattr(self, "memberships")
and viewer.is_authenticated
and self.memberships.filter(user=viewer).exists()
):
return

View file

@ -3,6 +3,8 @@ import re
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.core.cache import cache
from django.core.cache.utils import make_template_fragment_key
from django.db import models, transaction
from django.db.models import Prefetch
from django.dispatch import receiver
@ -185,6 +187,11 @@ class Book(BookDataModel):
"""can't be abstract for query reasons, but you shouldn't USE it"""
if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError("Books should be added as Editions or Works")
# clear template caches
cache_key = make_template_fragment_key("titleby", [self.id])
cache.delete(cache_key)
return super().save(*args, **kwargs)
def get_remote_id(self):
@ -234,8 +241,11 @@ class Work(OrderedCollectionPageMixin, Book):
)
activity_serializer = activitypub.Work
serialize_reverse_fields = [("editions", "editions", "-edition_rank")]
deserialize_reverse_fields = [("editions", "editions")]
serialize_reverse_fields = [
("editions", "editions", "-edition_rank"),
("file_links", "fileLinks", "-created_date"),
]
deserialize_reverse_fields = [("editions", "editions"), ("file_links", "fileLinks")]
# https://schema.org/BookFormatType
@ -289,6 +299,8 @@ class Edition(Book):
activity_serializer = activitypub.Edition
name_field = "title"
serialize_reverse_fields = [("file_links", "fileLinks", "-created_date")]
deserialize_reverse_fields = [("file_links", "fileLinks")]
def get_rank(self):
"""calculate how complete the data is on this edition"""

View file

@ -203,9 +203,12 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
return value.split("@")[0]
PrivacyLevels = models.TextChoices(
"Privacy", ["public", "unlisted", "followers", "direct"]
)
PrivacyLevels = [
("public", _("Public")),
("unlisted", _("Unlisted")),
("followers", _("Followers")),
("direct", _("Private")),
]
class PrivacyField(ActivitypubFieldMixin, models.CharField):
@ -214,9 +217,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
public = "https://www.w3.org/ns/activitystreams#Public"
def __init__(self, *args, **kwargs):
super().__init__(
*args, max_length=255, choices=PrivacyLevels.choices, default="public"
)
super().__init__(*args, max_length=255, choices=PrivacyLevels, default="public")
# pylint: disable=invalid-name
def set_field_from_activity(self, instance, data, overwrite=True):
@ -516,6 +517,10 @@ class CharField(ActivitypubFieldMixin, models.CharField):
"""activitypub-aware char field"""
class URLField(ActivitypubFieldMixin, models.URLField):
"""activitypub-aware url field"""
class TextField(ActivitypubFieldMixin, models.TextField):
"""activitypub-aware text field"""

View file

@ -40,9 +40,7 @@ class ImportJob(models.Model):
mappings = models.JSONField()
complete = models.BooleanField(default=False)
source = models.CharField(max_length=100)
privacy = models.CharField(
max_length=255, default="public", choices=PrivacyLevels.choices
)
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
retry = models.BooleanField(default=False)
@property

95
bookwyrm/models/link.py Normal file
View file

@ -0,0 +1,95 @@
""" outlink data """
from urllib.parse import urlparse
from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
from . import fields
class Link(ActivitypubMixin, BookWyrmModel):
"""a link to a website"""
url = fields.URLField(max_length=255, activitypub_field="href")
added_by = fields.ForeignKey(
"User", on_delete=models.SET_NULL, null=True, activitypub_field="attributedTo"
)
domain = models.ForeignKey(
"LinkDomain",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="links",
)
activity_serializer = activitypub.Link
reverse_unfurl = True
@property
def name(self):
"""link name via the assocaited domain"""
return self.domain.name
def save(self, *args, **kwargs):
"""create a link"""
# get or create the associated domain
if not self.domain:
domain = urlparse(self.url).netloc
self.domain, _ = LinkDomain.objects.get_or_create(domain=domain)
# this is never broadcast, the owning model broadcasts an update
if "broadcast" in kwargs:
del kwargs["broadcast"]
return super().save(*args, **kwargs)
AvailabilityChoices = [
("free", _("Free")),
("purchase", _("Purchasable")),
("loan", _("Available for loan")),
]
class FileLink(Link):
"""a link to a file"""
book = models.ForeignKey(
"Book", on_delete=models.CASCADE, related_name="file_links", null=True
)
filetype = fields.CharField(max_length=50, activitypub_field="mediaType")
availability = fields.CharField(
max_length=100, choices=AvailabilityChoices, default="free"
)
StatusChoices = [
("approved", _("Approved")),
("blocked", _("Blocked")),
("pending", _("Pending")),
]
class LinkDomain(BookWyrmModel):
"""List of domains used in links"""
domain = models.CharField(max_length=255, unique=True)
status = models.CharField(max_length=50, choices=StatusChoices, default="pending")
name = models.CharField(max_length=100)
reported_by = models.ForeignKey(
"User", blank=True, null=True, on_delete=models.SET_NULL
)
def raise_not_editable(self, viewer):
if viewer.has_perm("moderate_post"):
return
raise PermissionDenied()
def save(self, *args, **kwargs):
"""set a default name"""
if not self.name:
self.name = self.domain
super().save(*args, **kwargs)

View file

@ -1,5 +1,6 @@
""" progress in a book """
from django.core import validators
from django.core.cache import cache
from django.db import models
from django.db.models import F, Q
@ -30,6 +31,7 @@ class ReadThrough(BookWyrmModel):
def save(self, *args, **kwargs):
"""update user active time"""
cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}")
self.user.update_active_date()
# an active readthrough must have an unset finish date
if self.finish_date:

View file

@ -1,5 +1,6 @@
""" defines relationships between users """
from django.apps import apps
from django.core.cache import cache
from django.db import models, transaction, IntegrityError
from django.db.models import Q
@ -36,6 +37,17 @@ class UserRelationship(BookWyrmModel):
"""the remote user needs to recieve direct broadcasts"""
return [u for u in [self.user_subject, self.user_object] if not u.local]
def save(self, *args, **kwargs):
"""clear the template cache"""
# invalidate the template cache
cache.delete_many(
[
f"relationship-{self.user_subject.id}-{self.user_object.id}",
f"relationship-{self.user_object.id}-{self.user_subject.id}",
]
)
super().save(*args, **kwargs)
class Meta:
"""relationships should be unique"""

View file

@ -1,6 +1,5 @@
""" flagged for moderation """
from django.db import models
from django.db.models import F, Q
from .base_model import BookWyrmModel
@ -13,14 +12,12 @@ class Report(BookWyrmModel):
note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT)
statuses = models.ManyToManyField("Status", blank=True)
links = models.ManyToManyField("Link", blank=True)
resolved = models.BooleanField(default=False)
class Meta:
"""don't let users report themselves"""
"""set order by default"""
constraints = [
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
]
ordering = ("-created_date",)

View file

@ -82,6 +82,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
if not self.reply_parent:
self.thread_id = self.id
super().save(broadcast=False, update_fields=["thread_id"])
def delete(self, *args, **kwargs): # pylint: disable=unused-argument

View file

@ -129,7 +129,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
related_name="favorite_statuses",
)
default_post_privacy = models.CharField(
max_length=255, default="public", choices=fields.PrivacyLevels.choices
max_length=255, default="public", choices=fields.PrivacyLevels
)
remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id")
created_date = models.DateTimeField(auto_now_add=True)
@ -346,6 +346,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def delete(self, *args, **kwargs):
"""deactivate rather than delete a user"""
# pylint: disable=attribute-defined-outside-init
self.is_active = False
# skip the logic in this class's save()
super().save(*args, **kwargs)
@ -406,13 +407,10 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
def to_activity(self, **kwargs):
"""override default AP serializer to add context object
idk if this is the best way to go about this"""
activity_object = super().to_activity(**kwargs)
del activity_object["@context"]
del activity_object["type"]
return activity_object
def get_current_year():
"""sets default year for annual goal to this year"""
return timezone.now().year
class AnnualGoal(BookWyrmModel):
@ -420,9 +418,9 @@ class AnnualGoal(BookWyrmModel):
user = models.ForeignKey("User", on_delete=models.PROTECT)
goal = models.IntegerField(validators=[MinValueValidator(1)])
year = models.IntegerField(default=timezone.now().year)
year = models.IntegerField(default=get_current_year)
privacy = models.CharField(
max_length=255, default="public", choices=fields.PrivacyLevels.choices
max_length=255, default="public", choices=fields.PrivacyLevels
)
class Meta:

View file

@ -9,7 +9,7 @@ r = redis.Redis(
port=settings.REDIS_ACTIVITY_PORT,
password=settings.REDIS_ACTIVITY_PASSWORD,
unix_socket_path=settings.REDIS_ACTIVITY_SOCKET,
db=0,
db=settings.REDIS_ACTIVITY_DB_INDEX,
)
@ -31,7 +31,8 @@ class RedisStore(ABC):
# add the status to the feed
pipeline.zadd(store, value)
# trim the store
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
if self.max_length:
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
if not execute:
return pipeline
# and go!
@ -39,10 +40,15 @@ class RedisStore(ABC):
def remove_object_from_related_stores(self, obj, stores=None):
"""remove an object from all stores"""
# if the stoers are provided, the object can just be an id
if stores and isinstance(obj, int):
obj_id = obj
else:
obj_id = obj.id
stores = self.get_stores_for_object(obj) if stores is None else stores
pipeline = r.pipeline()
for store in stores:
pipeline.zrem(store, -1, obj.id)
pipeline.zrem(store, -1, obj_id)
pipeline.execute()
def bulk_add_objects_to_store(self, objs, store):
@ -50,7 +56,7 @@ class RedisStore(ABC):
pipeline = r.pipeline()
for obj in objs[: self.max_length]:
pipeline.zadd(store, self.get_value(obj))
if objs:
if objs and self.max_length:
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
pipeline.execute()
@ -74,7 +80,7 @@ class RedisStore(ABC):
pipeline.zadd(store, self.get_value(obj))
# only trim the store if objects were added
if queryset.exists():
if queryset.exists() and self.max_length:
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
pipeline.execute()

View file

@ -9,12 +9,12 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.1.0"
VERSION = "0.2.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "2d3181e1"
JS_CACHE = "76c5ff1f"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -24,7 +24,9 @@ EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
DEFAULT_FROM_EMAIL = f"admin@{DOMAIN}"
EMAIL_SENDER_NAME = env("EMAIL_SENDER_NAME", "admin")
EMAIL_SENDER_DOMAIN = env("EMAIL_SENDER_DOMAIN", DOMAIN)
EMAIL_SENDER = f"{EMAIL_SENDER_NAME}@{EMAIL_SENDER_DOMAIN}"
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -112,6 +114,7 @@ REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost")
REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
REDIS_ACTIVITY_SOCKET = env("REDIS_ACTIVITY_SOCKET", None)
REDIS_ACTIVITY_DB_INDEX = env("REDIS_ACTIVITY_DB_INDEX", 0)
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
@ -120,15 +123,47 @@ STREAMS = [
{"key": "books", "name": _("Books Timeline"), "shortname": _("Books")},
]
# Search configuration
# total time in seconds that the instance will spend searching connectors
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 15))
# timeout for a query to an individual connector
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))
# Redis cache backend
if env("USE_DUMMY_CACHE", False):
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
}
}
else:
# pylint: disable=line-too-long
if (REDIS_ACTIVITY_SOCKET):
redis_activity_url = f"unix://{REDIS_ACTIVITY_SOCKET}?db={REDIS_ACTIVITY_DB_INDEX}"
else:
redis_activity_url = f"redis://:{REDIS_ACTIVITY_PASSWORD}@{REDIS_ACTIVITY_HOST}:{REDIS_ACTIVITY_PORT}/{REDIS_ACTIVITY_DB_INDEX}"
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": redis_activity_url,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": env("POSTGRES_DB", "fedireads"),
"USER": env("POSTGRES_USER", "fedireads"),
"PASSWORD": env("POSTGRES_PASSWORD", ""),
"NAME": env("POSTGRES_DB", "bookwyrm"),
"USER": env("POSTGRES_USER", "bookwyrm"),
"PASSWORD": env("POSTGRES_PASSWORD", "bookwyrm"),
"HOST": env("POSTGRES_HOST", ""),
"PORT": env("PGPORT", 5432),
},
@ -167,9 +202,12 @@ LANGUAGES = [
("de-de", _("Deutsch (German)")),
("es-es", _("Español (Spanish)")),
("gl-es", _("Galego (Galician)")),
("it-it", _("Italiano (Italian)")),
("fr-fr", _("Français (French)")),
("lt-lt", _("Lietuvių (Lithuanian)")),
("pt-br", _("Português - Brasil (Brazilian Portuguese)")),
("no-no", _("Norsk (Norwegian)")),
("pt-br", _("Português do Brasil (Brazilian Portuguese)")),
("pt-pt", _("Português Europeu (European Portuguese)")),
("zh-hans", _("简体中文 (Simplified Chinese)")),
("zh-hant", _("繁體中文 (Traditional Chinese)")),
]
@ -190,6 +228,7 @@ USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
# Imagekit generated thumbnails
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
IMAGEKIT_CACHEFILE_DIR = "thumbnails"
IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = "bookwyrm.thumbnail_generation.Strategy"
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
@ -234,3 +273,7 @@ else:
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)

View file

@ -8,6 +8,44 @@ body {
flex-direction: column;
}
button {
border: none;
margin: 0;
padding: 0;
width: auto;
overflow: visible;
background: transparent;
/* inherit font, color & alignment from ancestor */
color: inherit;
font: inherit;
text-align: inherit;
/* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */
line-height: normal;
/* Corrects font smoothing for webkit */
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
/* Corrects inability to style clickable `input` types in iOS */
-webkit-appearance: none;
/* Generalizes pointer cursor */
cursor: pointer;
}
button::-moz-focus-inner {
/* Remove excess padding and border in Firefox 4+ */
border: 0;
padding: 0;
}
/* Better accessibility for keyboard users */
*:focus-visible {
outline-style: auto !important;
}
.image {
overflow: hidden;
}
@ -29,10 +67,38 @@ body {
overflow-x: auto;
}
.modal-card {
pointer-events: none;
}
.modal-card > * {
pointer-events: all;
}
/* stylelint-disable no-descending-specificity */
.modal-card:focus {
outline-style: auto;
}
.modal-card:focus:not(:focus-visible) {
outline-style: initial;
}
.modal-card:focus-visible {
outline-style: auto;
}
/* stylelint-enable no-descending-specificity */
.modal-card.is-fullwidth {
min-width: 75% !important;
}
@media only screen and (min-width: 769px) {
.modal-card.is-thin {
width: 350px !important;
}
}
.modal-card-body {
max-height: 70vh;
}
@ -69,6 +135,18 @@ body {
border-bottom: 1px solid #ededed;
border-right: 0;
}
.is-flex-direction-row-mobile {
flex-direction: row !important;
}
.is-flex-direction-column-mobile {
flex-direction: column !important;
}
}
.tag.is-small {
height: auto;
}
.button.is-transparent {
@ -93,10 +171,34 @@ body {
display: inline !important;
}
button .button-invisible-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 1rem;
box-sizing: border-box;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
background: rgba(0, 0, 0, 66%);
color: white;
opacity: 0;
transition: opacity 0.2s ease;
}
button:hover .button-invisible-overlay,
button:active .button-invisible-overlay,
button:focus-visible .button-invisible-overlay {
opacity: 1;
}
/** File input styles
******************************************************************************/
input[type=file]::file-selector-button {
input[type="file"]::file-selector-button {
-moz-appearance: none;
-webkit-appearance: none;
background-color: #fff;
@ -117,7 +219,7 @@ input[type=file]::file-selector-button {
white-space: nowrap;
}
input[type=file]::file-selector-button:hover {
input[type="file"]::file-selector-button:hover {
border-color: #b5b5b5;
color: #363636;
}
@ -125,7 +227,7 @@ input[type=file]::file-selector-button:hover {
/** General `details` element styles
******************************************************************************/
summary {
details summary {
cursor: pointer;
}
@ -133,22 +235,22 @@ summary::-webkit-details-marker {
display: none;
}
summary::marker {
details summary::marker {
content: none;
}
.detail-pinned-button summary {
details.detail-pinned-button summary {
position: absolute;
right: 0;
}
.detail-pinned-button form {
details.detail-pinned-button form {
float: left;
width: -webkit-fill-available;
width: 100%;
margin-top: 1em;
}
/** Details dropdown
/** Dropdown w/ Details element
******************************************************************************/
details.dropdown[open] summary.dropdown-trigger::before {
@ -160,11 +262,11 @@ details.dropdown[open] summary.dropdown-trigger::before {
right: 0;
}
details .dropdown-menu {
details.dropdown .dropdown-menu {
display: block !important;
}
details .dropdown-menu button {
details.dropdown .dropdown-menu button {
/* Fix weird Safari defaults */
box-sizing: border-box;
}
@ -177,7 +279,7 @@ details.dropdown .dropdown-menu a:focus-visible {
@media only screen and (max-width: 768px) {
details.dropdown[open] summary.dropdown-trigger::before {
background-color: rgba(0, 0, 0, 0.5);
background-color: rgba(0, 0, 0, 50%);
z-index: 30;
}
@ -199,13 +301,53 @@ details.dropdown .dropdown-menu a:focus-visible {
}
}
/** Details panel
******************************************************************************/
details.details-panel {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 10%);
transition: box-shadow 0.2s ease;
padding: 0.75rem;
}
details[open].details-panel,
details.details-panel:hover {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 20%);
}
details.details-panel summary {
position: relative;
}
details.details-panel summary .details-close {
position: absolute;
right: 0;
top: 0;
transform: rotate(45deg);
transition: transform 0.2s ease;
}
details[open].details-panel summary .details-close {
transform: rotate(0deg);
}
@media only screen and (min-width: 769px) {
.details-panel .filters-field:not(:last-child) {
border-right: 1px solid rgba(0, 0, 0, 10%);
margin-top: 0.75rem;
margin-bottom: 0.75rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
}
/** Shelving
******************************************************************************/
/** @todo Replace icons with SVG symbols.
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
.shelf-option:disabled > *::after {
font-family: "icomoon"; /* stylelint-disable font-family-no-missing-generic-family-keyword */
font-family: icomoon; /* stylelint-disable font-family-no-missing-generic-family-keyword */
content: "\e919"; /* icon-check */
margin-left: 0.5em;
}
@ -213,14 +355,14 @@ details.dropdown .dropdown-menu a:focus-visible {
/** Toggles
******************************************************************************/
.toggle-button[aria-pressed=true],
.toggle-button[aria-pressed=true]:hover {
background-color: hsl(171, 100%, 41%);
.toggle-button[aria-pressed="true"],
.toggle-button[aria-pressed="true"]:hover {
background-color: hsl(171deg, 100%, 41%);
color: white;
}
.hide-active[aria-pressed=true],
.hide-inactive[aria-pressed=false] {
.hide-active[aria-pressed="true"],
.hide-inactive[aria-pressed="false"] {
display: none;
}
@ -277,36 +419,36 @@ details.dropdown .dropdown-menu a:focus-visible {
/* All stars are visually filled by default. */
.form-rate-stars .icon::before {
content: '\e9d9'; /* icon-star-full */
content: "\e9d9"; /* icon-star-full */
}
/* Icons directly following half star inputs are marked as half */
.form-rate-stars input.half:checked ~ .icon::before {
content: '\e9d8'; /* icon-star-half */
content: "\e9d8"; /* icon-star-half */
}
/* stylelint-disable no-descending-specificity */
.form-rate-stars input.half:checked + input + .icon:hover::before {
content: '\e9d8' !important; /* icon-star-half */
content: "\e9d8" !important; /* icon-star-half */
}
/* Icons directly following half check inputs that follow the checked input are emptied. */
.form-rate-stars input.half:checked + input + .icon ~ .icon::before {
content: '\e9d7'; /* icon-star-empty */
content: "\e9d7"; /* icon-star-empty */
}
/* Icons directly following inputs that follow the checked input are emptied. */
.form-rate-stars input:checked ~ input + .icon::before {
content: '\e9d7'; /* icon-star-empty */
content: "\e9d7"; /* icon-star-empty */
}
/* When a label is hovered, repeat the fill-all-then-empty-following pattern. */
.form-rate-stars:hover .icon.icon::before {
content: '\e9d9' !important; /* icon-star-full */
content: "\e9d9" !important; /* icon-star-full */
}
.form-rate-stars .icon:hover ~ .icon::before {
content: '\e9d7' !important; /* icon-star-empty */
content: "\e9d7" !important; /* icon-star-empty */
}
/** Book covers
@ -374,6 +516,8 @@ details.dropdown .dropdown-menu a:focus-visible {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1em;
white-space: initial;
text-align: center;
}
@ -412,7 +556,7 @@ details.dropdown .dropdown-menu a:focus-visible {
.quote > blockquote::before,
.quote > blockquote::after {
font-family: 'icomoon';
font-family: icomoon;
position: absolute;
}
@ -576,6 +720,11 @@ ol.ordered-list li::before {
}
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
min-width: 10em;
}
/* Threads
******************************************************************************/
@ -607,13 +756,20 @@ ol.ordered-list li::before {
padding: 0 0.75em;
}
/* Notifications page
******************************************************************************/
.notification a.icon {
text-decoration: none !important;
}
/* Breadcrumbs
******************************************************************************/
.books-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(10em, 1fr));
gap: 1.5rem;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
align-items: end;
justify-items: stretch;
}
@ -622,7 +778,6 @@ ol.ordered-list li::before {
grid-column: span 2;
grid-row: span 2;
justify-self: stretch;
padding: 1.5rem 1.5rem 0;
}
.books-grid .book-cover {
@ -638,6 +793,13 @@ ol.ordered-list li::before {
min-height: calc(2 * var(--height-basis));
}
@media only screen and (min-width: 769px) {
.books-grid {
gap: 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(8em, 1fr));
}
}
/* Copy
******************************************************************************/
@ -1051,3 +1213,93 @@ ol.ordered-list li::before {
margin-bottom: 0.75rem !important;
}
}
/* Gaps (for Flexbox and Grid)
*
* Those are supplementary rules to Bulmas. They follow the same conventions.
* Add those youll need.
******************************************************************************/
.is-gap-0 {
gap: 0;
}
.is-gap-1 {
gap: 0.25rem;
}
.is-gap-2 {
gap: 0.5rem;
}
.is-gap-3 {
gap: 0.75rem;
}
.is-gap-4 {
gap: 1rem;
}
.is-gap-5 {
gap: 1.5rem;
}
.is-gap-6 {
gap: 3rem;
}
.is-row-gap-0 {
row-gap: 0;
}
.is-row-gap-1 {
row-gap: 0.25rem;
}
.is-row-gap-2 {
row-gap: 0.5rem;
}
.is-row-gap-3 {
row-gap: 0.75rem;
}
.is-row-gap-4 {
row-gap: 1rem;
}
.is-row-gap-5 {
row-gap: 1.5rem;
}
.is-row-gap-6 {
row-gap: 3rem;
}
.is-column-gap-0 {
column-gap: 0;
}
.is-column-gap-1 {
column-gap: 0.25rem;
}
.is-column-gap-2 {
column-gap: 0.5rem;
}
.is-column-gap-3 {
column-gap: 0.75rem;
}
.is-column-gap-4 {
column-gap: 1rem;
}
.is-column-gap-5 {
column-gap: 1.5rem;
}
.is-column-gap-6 {
column-gap: 3rem;
}

View file

@ -25,6 +25,10 @@
-moz-osx-font-smoothing: grayscale;
}
.icon.is-small {
font-size: small;
}
.icon-book:before {
content: "\e901";
}

View file

@ -0,0 +1,184 @@
(function () {
"use strict";
/**
* Suggest a completion as a user types
*
* Use `data-autocomplete="<completions set identifier>"`on the input field.
* specifying the trie to be used for autocomplete
*
* @example
* <input
* type="input"
* data-autocomplete="mimetype"
* >
* @param {Event} event
* @return {undefined}
*/
function autocomplete(event) {
const input = event.target;
// Get suggestions
let trie = tries[input.getAttribute("data-autocomplete")];
let suggestions = getSuggestions(input.value, trie);
const boxId = input.getAttribute("list");
// Create suggestion box, if needed
let suggestionsBox = document.getElementById(boxId);
// Clear existing suggestions
suggestionsBox.innerHTML = "";
// Populate suggestions box
suggestions.forEach((suggestion) => {
const suggestionItem = document.createElement("option");
suggestionItem.textContent = suggestion;
suggestionsBox.appendChild(suggestionItem);
});
}
function getSuggestions(input, trie) {
// Follow the trie through the provided input
input = input.toLowerCase();
input.split("").forEach((letter) => {
if (!trie) {
return;
}
trie = trie[letter];
});
if (!trie) {
return [];
}
return searchTrie(trie);
}
function searchTrie(trie) {
const options = Object.values(trie);
if (typeof trie == "string") {
return [trie];
}
return options
.map((option) => {
const newTrie = option;
if (typeof newTrie == "string") {
return [newTrie];
}
return searchTrie(newTrie);
})
.reduce((prev, next) => prev.concat(next));
}
document.querySelectorAll("[data-autocomplete]").forEach((input) => {
input.addEventListener("input", autocomplete);
});
})();
const tries = {
mimetype: {
a: {
a: {
c: "AAC",
},
z: {
w: "AZW",
},
},
d: {
a: {
i: {
s: {
y: "Daisy",
},
},
},
},
e: {
p: {
u: {
b: "ePub",
},
},
},
f: {
l: {
a: {
c: "FLAC",
},
},
},
h: {
t: {
m: {
l: "HTML",
},
},
},
m: {
4: {
a: "M4A",
b: "M4B",
},
o: {
b: {
i: "MOBI",
},
},
p: {
3: "MP3",
},
},
o: {
g: {
g: "OGG",
},
},
p: {
d: {
f: "PDF",
},
l: {
a: {
i: {
n: {
t: {
e: {
x: {
t: "Plaintext",
},
},
},
},
},
},
},
r: {
i: {
n: {
t: {
" ": {
b: {
o: {
o: {
k: "Print book",
},
},
},
},
},
},
},
},
},
},
};

View file

@ -1,21 +0,0 @@
/* exported BlockHref */
let BlockHref = new class {
constructor() {
document.querySelectorAll('[data-href]')
.forEach(t => t.addEventListener('click', this.followLink.bind(this)));
}
/**
* Follow a fake link
*
* @param {Event} event
* @return {undefined}
*/
followLink(event) {
const url = event.currentTarget.dataset.href;
window.location.href = url;
}
}();

View file

@ -1,7 +1,7 @@
/* exported BookWyrm */
/* globals TabGroup */
let BookWyrm = new class {
let BookWyrm = new (class {
constructor() {
this.MAX_FILE_SIZE_BYTES = 10 * 1000000;
this.initOnDOMLoaded();
@ -10,54 +10,43 @@ let BookWyrm = new class {
}
initEventListeners() {
document.querySelectorAll('[data-controls]')
.forEach(button => button.addEventListener(
'click',
this.toggleAction.bind(this))
);
document
.querySelectorAll("[data-controls]")
.forEach((button) => button.addEventListener("click", this.toggleAction.bind(this)));
document.querySelectorAll('.interaction')
.forEach(button => button.addEventListener(
'submit',
this.interact.bind(this))
);
document
.querySelectorAll(".interaction")
.forEach((button) => button.addEventListener("submit", this.interact.bind(this)));
document.querySelectorAll('.hidden-form input')
.forEach(button => button.addEventListener(
'change',
this.revealForm.bind(this))
);
document
.querySelectorAll(".hidden-form input")
.forEach((button) => button.addEventListener("change", this.revealForm.bind(this)));
document.querySelectorAll('[data-hides]')
.forEach(button => button.addEventListener(
'change',
this.hideForm.bind(this))
);
document
.querySelectorAll("[data-hides]")
.forEach((button) => button.addEventListener("change", this.hideForm.bind(this)));
document.querySelectorAll('[data-back]')
.forEach(button => button.addEventListener(
'click',
this.back)
);
document
.querySelectorAll("[data-back]")
.forEach((button) => button.addEventListener("click", this.back));
document.querySelectorAll('input[type="file"]')
.forEach(node => node.addEventListener(
'change',
this.disableIfTooLarge.bind(this)
));
document.querySelectorAll('[data-duplicate]')
.forEach(node => node.addEventListener(
'click',
this.duplicateInput.bind(this)
))
document.querySelectorAll('details.dropdown')
.forEach(node => node.addEventListener(
'toggle',
this.handleDetailsDropdown.bind(this)
))
document
.querySelectorAll('input[type="file"]')
.forEach((node) => node.addEventListener("change", this.disableIfTooLarge.bind(this)));
document
.querySelectorAll("[data-modal-open]")
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
document
.querySelectorAll("[data-duplicate]")
.forEach((node) => node.addEventListener("click", this.duplicateInput.bind(this)));
document
.querySelectorAll("details.dropdown")
.forEach((node) =>
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
);
}
/**
@ -66,15 +55,15 @@ let BookWyrm = new class {
initOnDOMLoaded() {
const bookwyrm = this;
window.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.tab-group')
.forEach(tabs => new TabGroup(tabs));
document.querySelectorAll('input[type="file"]').forEach(
bookwyrm.disableIfTooLarge.bind(bookwyrm)
);
document.querySelectorAll('[data-copytext]').forEach(
bookwyrm.copyText.bind(bookwyrm)
);
window.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".tab-group").forEach((tabs) => new TabGroup(tabs));
document
.querySelectorAll('input[type="file"]')
.forEach(bookwyrm.disableIfTooLarge.bind(bookwyrm));
document.querySelectorAll("[data-copytext]").forEach(bookwyrm.copyText.bind(bookwyrm));
document
.querySelectorAll(".modal.is-active")
.forEach(bookwyrm.handleActiveModal.bind(bookwyrm));
});
}
@ -83,8 +72,7 @@ let BookWyrm = new class {
*/
initReccuringTasks() {
// Polling
document.querySelectorAll('[data-poll]')
.forEach(liveArea => this.polling(liveArea));
document.querySelectorAll("[data-poll]").forEach((liveArea) => this.polling(liveArea));
}
/**
@ -110,15 +98,19 @@ let BookWyrm = new class {
const bookwyrm = this;
delay = delay || 10000;
delay += (Math.random() * 1000);
delay += Math.random() * 1000;
setTimeout(function() {
fetch('/api/updates/' + counter.dataset.poll)
.then(response => response.json())
.then(data => bookwyrm.updateCountElement(counter, data));
setTimeout(
function () {
fetch("/api/updates/" + counter.dataset.poll)
.then((response) => response.json())
.then((data) => bookwyrm.updateCountElement(counter, data));
bookwyrm.polling(counter, delay * 1.25);
}, delay, counter);
bookwyrm.polling(counter, delay * 1.25);
},
delay,
counter
);
}
/**
@ -133,60 +125,56 @@ let BookWyrm = new class {
const count_by_type = data.count_by_type;
const currentCount = counter.innerText;
const hasMentions = data.has_mentions;
const allowedStatusTypesEl = document.getElementById('unread-notifications-wrapper');
const allowedStatusTypesEl = document.getElementById("unread-notifications-wrapper");
// If we're on the right counter element
if (counter.closest('[data-poll-wrapper]').contains(allowedStatusTypesEl)) {
if (counter.closest("[data-poll-wrapper]").contains(allowedStatusTypesEl)) {
const allowedStatusTypes = JSON.parse(allowedStatusTypesEl.textContent);
// For keys in common between allowedStatusTypes and count_by_type
// This concerns 'review', 'quotation', 'comment'
count = allowedStatusTypes.reduce(function(prev, currentKey) {
count = allowedStatusTypes.reduce(function (prev, currentKey) {
const currentValue = count_by_type[currentKey] | 0;
return prev + currentValue;
}, 0);
// Add all the "other" in count_by_type if 'everything' is allowed
if (allowedStatusTypes.includes('everything')) {
if (allowedStatusTypes.includes("everything")) {
// Clone count_by_type with 0 for reviews/quotations/comments
const count_by_everything_else = Object.assign(
{},
count_by_type,
{review: 0, quotation: 0, comment: 0}
);
const count_by_everything_else = Object.assign({}, count_by_type, {
review: 0,
quotation: 0,
comment: 0,
});
count = Object.keys(count_by_everything_else).reduce(
function(prev, currentKey) {
const currentValue =
count_by_everything_else[currentKey] | 0
count = Object.keys(count_by_everything_else).reduce(function (prev, currentKey) {
const currentValue = count_by_everything_else[currentKey] | 0;
return prev + currentValue;
},
count
);
return prev + currentValue;
}, count);
}
}
if (count != currentCount) {
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
this.addRemoveClass(counter.closest("[data-poll-wrapper]"), "is-hidden", count < 1);
counter.innerText = count;
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-danger', hasMentions);
this.addRemoveClass(counter.closest("[data-poll-wrapper]"), "is-danger", hasMentions);
}
}
/**
* Show form.
*
*
* @param {Event} event
* @return {undefined}
*/
revealForm(event) {
let trigger = event.currentTarget;
let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0];
let hidden = trigger.closest(".hidden-form").querySelectorAll(".is-hidden")[0];
if (hidden) {
this.addRemoveClass(hidden, 'is-hidden', !hidden);
this.addRemoveClass(hidden, "is-hidden", !hidden);
}
}
@ -198,10 +186,10 @@ let BookWyrm = new class {
*/
hideForm(event) {
let trigger = event.currentTarget;
let targetId = trigger.dataset.hides
let visible = document.getElementById(targetId)
let targetId = trigger.dataset.hides;
let visible = document.getElementById(targetId);
this.addRemoveClass(visible, 'is-hidden', true);
this.addRemoveClass(visible, "is-hidden", true);
}
/**
@ -216,31 +204,34 @@ let BookWyrm = new class {
if (!trigger.dataset.allowDefault || event.currentTarget == event.target) {
event.preventDefault();
}
let pressed = trigger.getAttribute('aria-pressed') === 'false';
let pressed = trigger.getAttribute("aria-pressed") === "false";
let targetId = trigger.dataset.controls;
// Toggle pressed status on all triggers controlling the same target.
document.querySelectorAll('[data-controls="' + targetId + '"]')
.forEach(otherTrigger => otherTrigger.setAttribute(
'aria-pressed',
otherTrigger.getAttribute('aria-pressed') === 'false'
));
document
.querySelectorAll('[data-controls="' + targetId + '"]')
.forEach((otherTrigger) =>
otherTrigger.setAttribute(
"aria-pressed",
otherTrigger.getAttribute("aria-pressed") === "false"
)
);
// @todo Find a better way to handle the exception.
if (targetId && ! trigger.classList.contains('pulldown-menu')) {
if (targetId && !trigger.classList.contains("pulldown-menu")) {
let target = document.getElementById(targetId);
this.addRemoveClass(target, 'is-hidden', !pressed);
this.addRemoveClass(target, 'is-active', pressed);
this.addRemoveClass(target, "is-hidden", !pressed);
this.addRemoveClass(target, "is-active", pressed);
}
// Show/hide pulldown-menus.
if (trigger.classList.contains('pulldown-menu')) {
if (trigger.classList.contains("pulldown-menu")) {
this.toggleMenu(trigger, targetId);
}
// Show/hide container.
let container = document.getElementById('hide_' + targetId);
let container = document.getElementById("hide_" + targetId);
if (container) {
this.toggleContainer(container, pressed);
@ -277,14 +268,14 @@ let BookWyrm = new class {
* @return {undefined}
*/
toggleMenu(trigger, targetId) {
let expanded = trigger.getAttribute('aria-expanded') == 'false';
let expanded = trigger.getAttribute("aria-expanded") == "false";
trigger.setAttribute('aria-expanded', expanded);
trigger.setAttribute("aria-expanded", expanded);
if (targetId) {
let target = document.getElementById(targetId);
this.addRemoveClass(target, 'is-active', expanded);
this.addRemoveClass(target, "is-active", expanded);
}
}
@ -296,7 +287,7 @@ let BookWyrm = new class {
* @return {undefined}
*/
toggleContainer(container, pressed) {
this.addRemoveClass(container, 'is-hidden', pressed);
this.addRemoveClass(container, "is-hidden", pressed);
}
/**
@ -333,7 +324,7 @@ let BookWyrm = new class {
node.focus();
setTimeout(function() {
setTimeout(function () {
node.selectionStart = node.selectionEnd = 10000;
}, 0);
}
@ -353,15 +344,17 @@ let BookWyrm = new class {
const relatedforms = document.querySelectorAll(`.${form.dataset.id}`);
// Toggle class on all related forms.
relatedforms.forEach(relatedForm => bookwyrm.addRemoveClass(
relatedForm,
'is-hidden',
relatedForm.className.indexOf('is-hidden') == -1
));
relatedforms.forEach((relatedForm) =>
bookwyrm.addRemoveClass(
relatedForm,
"is-hidden",
relatedForm.className.indexOf("is-hidden") == -1
)
);
this.ajaxPost(form).catch(error => {
this.ajaxPost(form).catch((error) => {
// @todo Display a notification in the UI instead.
console.warn('Request failed:', error);
console.warn("Request failed:", error);
});
}
@ -373,11 +366,11 @@ let BookWyrm = new class {
*/
ajaxPost(form) {
return fetch(form.action, {
method : "POST",
method: "POST",
body: new FormData(form),
headers: {
'Accept': 'application/json',
}
Accept: "application/json",
},
});
}
@ -402,21 +395,112 @@ let BookWyrm = new class {
const element = eventOrElement.currentTarget || eventOrElement;
const submits = element.form.querySelectorAll('[type="submit"]');
const warns = element.parentElement.querySelectorAll('.file-too-big');
const isTooBig = element.files &&
element.files[0] &&
element.files[0].size > MAX_FILE_SIZE_BYTES;
const warns = element.parentElement.querySelectorAll(".file-too-big");
const isTooBig =
element.files && element.files[0] && element.files[0].size > MAX_FILE_SIZE_BYTES;
if (isTooBig) {
submits.forEach(submitter => submitter.disabled = true);
warns.forEach(
sib => addRemoveClass(sib, 'is-hidden', false)
);
submits.forEach((submitter) => (submitter.disabled = true));
warns.forEach((sib) => addRemoveClass(sib, "is-hidden", false));
} else {
submits.forEach(submitter => submitter.disabled = false);
warns.forEach(
sib => addRemoveClass(sib, 'is-hidden', true)
);
submits.forEach((submitter) => (submitter.disabled = false));
warns.forEach((sib) => addRemoveClass(sib, "is-hidden", true));
}
}
/**
* Handle the modal component with a button trigger.
*
* @param {Event} event - Event fired by an element
* with the `data-modal-open` attribute
* pointing to a modal by its id.
* @return {undefined}
*
* See https://github.com/bookwyrm-social/bookwyrm/pull/1633
* for information about using the modal.
*/
handleModalButton(event) {
const { handleFocusTrap } = this;
const modalButton = event.currentTarget;
const targetModalId = modalButton.dataset.modalOpen;
const htmlElement = document.querySelector("html");
const modal = document.getElementById(targetModalId);
if (!modal) {
return;
}
// Helper functions
function handleModalOpen(modalElement) {
event.preventDefault();
htmlElement.classList.add("is-clipped");
modalElement.classList.add("is-active");
modalElement.getElementsByClassName("modal-card")[0].focus();
const closeButtons = modalElement.querySelectorAll("[data-modal-close]");
closeButtons.forEach((button) => {
button.addEventListener("click", function () {
handleModalClose(modalElement);
});
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
handleModalClose(modalElement);
}
});
modalElement.addEventListener("keydown", handleFocusTrap);
}
function handleModalClose(modalElement) {
modalElement.removeEventListener("keydown", handleFocusTrap);
htmlElement.classList.remove("is-clipped");
modalElement.classList.remove("is-active");
modalButton.focus();
}
// Open modal
handleModalOpen(modal);
}
/**
* Handle the modal component when opened at page load.
*
* @param {Element} modalElement - Active modal element
* @return {undefined}
*
*/
handleActiveModal(modalElement) {
if (!modalElement) {
return;
}
const { handleFocusTrap } = this;
modalElement.getElementsByClassName("modal-card")[0].focus();
const closeButtons = modalElement.querySelectorAll("[data-modal-close]");
closeButtons.forEach((button) => {
button.addEventListener("click", function () {
handleModalClose(modalElement);
});
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
handleModalClose(modalElement);
}
});
modalElement.addEventListener("keydown", handleFocusTrap);
function handleModalClose(modalElement) {
modalElement.removeEventListener("keydown", handleFocusTrap);
history.back();
}
}
@ -428,31 +512,27 @@ let BookWyrm = new class {
* @return {undefined}
*/
displayPopUp(url, windowName) {
window.open(
url,
windowName,
"left=100,top=100,width=430,height=600"
);
window.open(url, windowName, "left=100,top=100,width=430,height=600");
}
duplicateInput (event ) {
duplicateInput(event) {
const trigger = event.currentTarget;
const input_id = trigger.dataset['duplicate']
const input_id = trigger.dataset["duplicate"];
const orig = document.getElementById(input_id);
const parent = orig.parentNode;
const new_count = parent.querySelectorAll("input").length + 1
const new_count = parent.querySelectorAll("input").length + 1;
let input = orig.cloneNode();
input.id += ("-" + (new_count))
input.value = ""
input.id += "-" + new_count;
input.value = "";
let label = parent.querySelector("label").cloneNode();
label.setAttribute("for", input.id)
label.setAttribute("for", input.id);
parent.appendChild(label)
parent.appendChild(input)
parent.appendChild(label);
parent.appendChild(input);
}
/**
@ -467,24 +547,19 @@ let BookWyrm = new class {
copyText(textareaEl) {
const text = textareaEl.textContent;
const copyButtonEl = document.createElement('button');
const copyButtonEl = document.createElement("button");
copyButtonEl.textContent = textareaEl.dataset.copytextLabel;
copyButtonEl.classList.add(
"button",
"is-small",
"is-primary",
"is-light"
);
copyButtonEl.addEventListener('click', () => {
navigator.clipboard.writeText(text).then(function() {
textareaEl.classList.add('is-success');
copyButtonEl.classList.replace('is-primary', 'is-success');
copyButtonEl.classList.add("button", "is-small", "is-primary", "is-light");
copyButtonEl.addEventListener("click", () => {
navigator.clipboard.writeText(text).then(function () {
textareaEl.classList.add("is-success");
copyButtonEl.classList.replace("is-primary", "is-success");
copyButtonEl.textContent = textareaEl.dataset.copytextSuccess;
});
});
textareaEl.parentNode.appendChild(copyButtonEl)
textareaEl.parentNode.appendChild(copyButtonEl);
}
/**
@ -496,41 +571,41 @@ let BookWyrm = new class {
*/
handleDetailsDropdown(event) {
const detailsElement = event.target;
const summaryElement = detailsElement.querySelector('summary');
const menuElement = detailsElement.querySelector('.dropdown-menu');
const htmlElement = document.querySelector('html');
const summaryElement = detailsElement.querySelector("summary");
const menuElement = detailsElement.querySelector(".dropdown-menu");
const htmlElement = document.querySelector("html");
if (detailsElement.open) {
// Focus first menu element
menuElement.querySelectorAll(
'a[href]:not([disabled]), button:not([disabled])'
)[0].focus();
menuElement
.querySelectorAll("a[href]:not([disabled]), button:not([disabled])")[0]
.focus();
// Enable focus trap
menuElement.addEventListener('keydown', this.handleFocusTrap);
menuElement.addEventListener("keydown", this.handleFocusTrap);
// Close on Esc
detailsElement.addEventListener('keydown', handleEscKey);
detailsElement.addEventListener("keydown", handleEscKey);
// Clip page if Mobile
if (this.isMobile()) {
htmlElement.classList.add('is-clipped');
htmlElement.classList.add("is-clipped");
}
} else {
summaryElement.focus();
// Disable focus trap
menuElement.removeEventListener('keydown', this.handleFocusTrap);
menuElement.removeEventListener("keydown", this.handleFocusTrap);
// Unclip page
if (this.isMobile()) {
htmlElement.classList.remove('is-clipped');
htmlElement.classList.remove("is-clipped");
}
}
function handleEscKey(event) {
if (event.key !== 'Escape') {
return;
if (event.key !== "Escape") {
return;
}
summaryElement.click();
@ -553,34 +628,34 @@ let BookWyrm = new class {
* @return {undefined}
*/
handleFocusTrap(event) {
if (event.key !== 'Tab') {
return;
if (event.key !== "Tab") {
return;
}
const focusableEls = event.currentTarget.querySelectorAll(
[
'a[href]:not([disabled])',
'button:not([disabled])',
'textarea:not([disabled])',
"a[href]:not([disabled])",
"button:not([disabled])",
"textarea:not([disabled])",
'input:not([type="hidden"]):not([disabled])',
'select:not([disabled])',
'details:not([disabled])',
'[tabindex]:not([tabindex="-1"]):not([disabled])'
].join(',')
"select:not([disabled])",
"details:not([disabled])",
'[tabindex]:not([tabindex="-1"]):not([disabled])',
].join(",")
);
const firstFocusableEl = focusableEls[0];
const firstFocusableEl = focusableEls[0];
const lastFocusableEl = focusableEls[focusableEls.length - 1];
if (event.shiftKey ) /* Shift + tab */ {
if (document.activeElement === firstFocusableEl) {
if (event.shiftKey) {
/* Shift + tab */ if (document.activeElement === firstFocusableEl) {
lastFocusableEl.focus();
event.preventDefault();
}
} else /* Tab */ {
} /* Tab */ else {
if (document.activeElement === lastFocusableEl) {
firstFocusableEl.focus();
event.preventDefault();
}
}
}
}();
})();

View file

@ -1,13 +1,13 @@
/* exported LocalStorageTools */
/* globals BookWyrm */
let LocalStorageTools = new class {
let LocalStorageTools = new (class {
constructor() {
document.querySelectorAll('[data-hide]')
.forEach(t => this.setDisplay(t));
document.querySelectorAll("[data-hide]").forEach((t) => this.setDisplay(t));
document.querySelectorAll('.set-display')
.forEach(t => t.addEventListener('click', this.updateDisplay.bind(this)));
document
.querySelectorAll(".set-display")
.forEach((t) => t.addEventListener("click", this.updateDisplay.bind(this)));
}
/**
@ -23,8 +23,9 @@ let LocalStorageTools = new class {
window.localStorage.setItem(key, value);
document.querySelectorAll('[data-hide="' + key + '"]')
.forEach(node => this.setDisplay(node));
document
.querySelectorAll('[data-hide="' + key + '"]')
.forEach((node) => this.setDisplay(node));
}
/**
@ -38,6 +39,6 @@ let LocalStorageTools = new class {
let key = node.dataset.hide;
let value = window.localStorage.getItem(key);
BookWyrm.addRemoveClass(node, 'is-hidden', value);
BookWyrm.addRemoveClass(node, "is-hidden", value);
}
}();
})();

View file

@ -1,22 +1,21 @@
/* exported StatusCache */
/* globals BookWyrm */
let StatusCache = new class {
let StatusCache = new (class {
constructor() {
document.querySelectorAll('[data-cache-draft]')
.forEach(t => t.addEventListener('change', this.updateDraft.bind(this)));
document
.querySelectorAll("[data-cache-draft]")
.forEach((t) => t.addEventListener("change", this.updateDraft.bind(this)));
document.querySelectorAll('[data-cache-draft]')
.forEach(t => this.populateDraft(t));
document.querySelectorAll("[data-cache-draft]").forEach((t) => this.populateDraft(t));
document.querySelectorAll('.submit-status')
.forEach(button => button.addEventListener(
'submit',
this.submitStatus.bind(this))
);
document
.querySelectorAll(".submit-status")
.forEach((button) => button.addEventListener("submit", this.submitStatus.bind(this)));
document.querySelectorAll('.form-rate-stars label.icon')
.forEach(button => button.addEventListener('click', this.toggleStar.bind(this)));
document
.querySelectorAll(".form-rate-stars label.icon")
.forEach((button) => button.addEventListener("click", this.toggleStar.bind(this)));
}
/**
@ -80,25 +79,26 @@ let StatusCache = new class {
event.preventDefault();
BookWyrm.addRemoveClass(form, 'is-processing', true);
trigger.setAttribute('disabled', null);
BookWyrm.addRemoveClass(form, "is-processing", true);
trigger.setAttribute("disabled", null);
BookWyrm.ajaxPost(form).finally(() => {
// Change icon to remove ongoing activity on the current UI.
// Enable back the element used to submit the form.
BookWyrm.addRemoveClass(form, 'is-processing', false);
trigger.removeAttribute('disabled');
})
.then(response => {
if (!response.ok) {
throw new Error();
}
this.submitStatusSuccess(form);
})
.catch(error => {
console.warn(error);
this.announceMessage('status-error-message');
});
BookWyrm.ajaxPost(form)
.finally(() => {
// Change icon to remove ongoing activity on the current UI.
// Enable back the element used to submit the form.
BookWyrm.addRemoveClass(form, "is-processing", false);
trigger.removeAttribute("disabled");
})
.then((response) => {
if (!response.ok) {
throw new Error();
}
this.submitStatusSuccess(form);
})
.catch((error) => {
console.warn(error);
this.announceMessage("status-error-message");
});
}
/**
@ -112,12 +112,16 @@ let StatusCache = new class {
let copy = element.cloneNode(true);
copy.id = null;
element.insertAdjacentElement('beforebegin', copy);
element.insertAdjacentElement("beforebegin", copy);
BookWyrm.addRemoveClass(copy, 'is-hidden', false);
setTimeout(function() {
copy.remove();
}, 10000, copy);
BookWyrm.addRemoveClass(copy, "is-hidden", false);
setTimeout(
function () {
copy.remove();
},
10000,
copy
);
}
/**
@ -131,8 +135,9 @@ let StatusCache = new class {
form.reset();
// Clear localstorage
form.querySelectorAll('[data-cache-draft]')
.forEach(node => window.localStorage.removeItem(node.dataset.cacheDraft));
form.querySelectorAll("[data-cache-draft]").forEach((node) =>
window.localStorage.removeItem(node.dataset.cacheDraft)
);
// Close modals
let modal = form.closest(".modal.is-active");
@ -142,8 +147,11 @@ let StatusCache = new class {
// Update shelve buttons
if (form.reading_status) {
document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']")
.forEach(button => this.cycleShelveButtons(button, form.reading_status.value));
document
.querySelectorAll("[data-shelve-button-book='" + form.book.value + "']")
.forEach((button) =>
this.cycleShelveButtons(button, form.reading_status.value)
);
}
return;
@ -156,7 +164,7 @@ let StatusCache = new class {
document.querySelector("[data-controls=" + reply.id + "]").click();
}
this.announceMessage('status-success-message');
this.announceMessage("status-success-message");
}
/**
@ -172,8 +180,9 @@ let StatusCache = new class {
let next_identifier = shelf.dataset.shelfNext;
// Set all buttons to hidden
button.querySelectorAll("[data-shelf-identifier]")
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
button
.querySelectorAll("[data-shelf-identifier]")
.forEach((item) => BookWyrm.addRemoveClass(item, "is-hidden", true));
// Button that should be visible now
let next = button.querySelector("[data-shelf-identifier=" + next_identifier + "]");
@ -183,15 +192,17 @@ let StatusCache = new class {
// ------ update the dropdown buttons
// Remove existing hidden class
button.querySelectorAll("[data-shelf-dropdown-identifier]")
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false));
button
.querySelectorAll("[data-shelf-dropdown-identifier]")
.forEach((item) => BookWyrm.addRemoveClass(item, "is-hidden", false));
// Remove existing disabled states
button.querySelectorAll("[data-shelf-dropdown-identifier] button")
.forEach(item => item.disabled = false);
button
.querySelectorAll("[data-shelf-dropdown-identifier] button")
.forEach((item) => (item.disabled = false));
next_identifier = next_identifier == 'complete' ? 'read' : next_identifier;
next_identifier = next_identifier == "complete" ? "read" : next_identifier;
// Disable the current state
button.querySelector(
@ -206,8 +217,9 @@ let StatusCache = new class {
BookWyrm.addRemoveClass(main_button, "is-hidden", true);
// Just hide the other two menu options, idk what to do with them
button.querySelectorAll("[data-extra-options]")
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
button
.querySelectorAll("[data-extra-options]")
.forEach((item) => BookWyrm.addRemoveClass(item, "is-hidden", true));
// Close menu
let menu = button.querySelector("details[open]");
@ -235,5 +247,4 @@ let StatusCache = new class {
halfStar.checked = "checked";
}
}
}();
})();

View file

@ -2,7 +2,8 @@
import math
import logging
from django.dispatch import receiver
from django.db.models import signals, Count, Q
from django.db import transaction
from django.db.models import signals, Count, Q, Case, When, IntegerField
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
@ -29,6 +30,7 @@ class SuggestedUsers(RedisStore):
def get_counts_from_rank(self, rank): # pylint: disable=no-self-use
"""calculate mutuals count and shared books count from rank"""
# pylint: disable=c-extension-no-member
return {
"mutuals": math.floor(rank),
# "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1,
@ -84,24 +86,17 @@ class SuggestedUsers(RedisStore):
def get_suggestions(self, user, local=False):
"""get suggestions"""
values = self.get_store(self.store_id(user), withscores=True)
results = []
annotations = [
When(pk=int(pk), then=self.get_counts_from_rank(score)["mutuals"])
for (pk, score) in values
]
# annotate users with mutuals and shared book counts
for user_id, rank in values:
counts = self.get_counts_from_rank(rank)
try:
user = models.User.objects.get(
id=user_id, is_active=True, bookwyrm_user=True
)
except models.User.DoesNotExist as err:
# if this happens, the suggestions are janked way up
logger.exception(err)
continue
user.mutuals = counts["mutuals"]
if (local and user.local) or not local:
results.append(user)
if len(results) >= 5:
break
return results
users = models.User.objects.filter(
is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values]
).annotate(mutuals=Case(*annotations, output_field=IntegerField(), default=0))
if local:
users = users.filter(local=True)
return users.order_by("-mutuals")[:5]
def get_annotated_users(viewer, *args, **kwargs):
@ -119,16 +114,17 @@ def get_annotated_users(viewer, *args, **kwargs):
),
distinct=True,
),
# shared_books=Count(
# "shelfbook",
# filter=Q(
# ~Q(id=viewer.id),
# shelfbook__book__parent_work__in=[
# s.book.parent_work for s in viewer.shelfbook_set.all()
# ],
# ),
# distinct=True,
# ),
# pylint: disable=line-too-long
# shared_books=Count(
# "shelfbook",
# filter=Q(
# ~Q(id=viewer.id),
# shelfbook__book__parent_work__in=[
# s.book.parent_work for s in viewer.shelfbook_set.all()
# ],
# ),
# distinct=True,
# ),
)
)
@ -197,7 +193,7 @@ def update_user(sender, instance, created, update_fields=None, **kwargs):
"""an updated user, neat"""
# a new user is found, create suggestions for them
if created and instance.local:
rerank_suggestions_task.delay(instance.id)
transaction.on_commit(lambda: update_new_user_command(instance.id))
# we know what fields were updated and discoverability didn't change
if not instance.bookwyrm_user or (
@ -217,6 +213,11 @@ def update_user(sender, instance, created, update_fields=None, **kwargs):
remove_user_task.delay(instance.id)
def update_new_user_command(instance_id):
"""wait for transaction to complete"""
rerank_suggestions_task.delay(instance_id)
@receiver(signals.post_save, sender=models.FederatedServer)
def domain_level_update(sender, instance, created, update_fields=None, **kwargs):
"""remove users on a domain block"""

View file

@ -0,0 +1,22 @@
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
def instrumentDjango():
from opentelemetry.instrumentation.django import DjangoInstrumentor
DjangoInstrumentor().instrument()
def instrumentCelery():
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from celery.signals import worker_process_init
@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs):
CeleryInstrumentor().instrument()

View file

@ -0,0 +1,141 @@
{% extends 'about/layout.html' %}
{% load humanize %}
{% load i18n %}
{% load utilities %}
{% load bookwyrm_tags %}
{% load cache %}
{% block title %}
{% trans "About" %}
{% endblock %}
{% block about_content %}
{# seven day cache #}
{% cache 604800 about_page %}
{% get_book_superlatives as superlatives %}
<section class="content pb-4">
<h2>
{% blocktrans with site_name=site.name %}Welcome to {{ site_name }}!{% endblocktrans %}
</h2>
<p class="subtitle notification has-background-primary-light">
{% blocktrans trimmed with site_name=site.name %}
{{ site_name }} is part of <em>BookWyrm</em>, a network of independent, self-directed communities for readers.
While you can interact seamlessly with users anywhere in the <a href="https://joinbookwyrm.com/instances/" target="_blank">BookWyrm network</a>, this community is unique.
{% endblocktrans %}
</p>
<div class="columns">
{% if top_rated %}
{% with book=superlatives.top_rated.default_edition rating=top_rated.rating %}
<div class="column is-one-third is-flex">
<div class="media notification">
<div class="media-left">
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-m' size='medium' aria='show' %}
</a>
</div>
<div class="media-content">
{% blocktrans trimmed with title=book|book_title book_path=book.local_path site_name=site.name rating=rating|floatformat:1 %}
<a href="{{ book_path }}"><em>{{ title }}</em></a> is {{ site_name }}'s most beloved book, with an average rating of {{ rating }} out of 5.
{% endblocktrans %}
</div>
</div>
</div>
{% endwith %}
{% endif %}
{% if wanted %}
{% with book=superlatives.wanted.default_edition %}
<div class="column is-one-third is-flex">
<div class="media notification">
<div class="media-left">
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-m' size='medium' aria='show' %}
</a>
</div>
<div class="media-content">
{% blocktrans trimmed with title=book|book_title book_path=book.local_path site_name=site.name %}
More {{ site_name }} users want to read <a href="{{ book_path }}"><em>{{ title }}</em></a> than any other book.
{% endblocktrans %}
</div>
</div>
</div>
{% endwith %}
{% endif %}
{% if controversial %}
{% with book=superlatives.controversial.default_edition %}
<div class="column is-one-third is-flex">
<div class="media notification">
<div class="media-left">
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-m' size='medium' aria='show' %}
</a>
</div>
<div class="media-content">
{% blocktrans trimmed with title=book|book_title book_path=book.local_path site_name=site.name %}
<a href="{{ book_path }}"><em>{{ title }}</em></a> has the most divisive ratings of any book on {{ site_name }}.
{% endblocktrans %}
</div>
</div>
</div>
{% endwith %}
{% endif %}
</div>
<p>
{% trans "Track your reading, talk about books, write reviews, and discover what to read next. Always ad-free, anti-corporate, and community-oriented, BookWyrm is human-scale software, designed to stay small and personal. If you have feature requests, bug reports, or grand dreams, <a href='https://joinbookwyrm.com/get-involved' target='_blank'>reach out</a> and make yourself heard." %}
</p>
</section>
<section class="block">
<header class="content">
<h2 class="title is-3">{% trans "Meet your admins" %}</h2>
<p>
{% url "conduct" as coc_path %}
{% blocktrans with site_name=site.name %}
{{ site_name }}'s moderators and administrators keep the site up and running, enforce the <a href="coc_path">code of conduct</a>, and respond when users report spam and bad behavior.
{% endblocktrans %}
</p>
</header>
<div class="columns is-multiline">
{% for user in admins %}
<div class="column">
<div class="card is-stretchable">
{% with role=user.groups.first.name %}
<div class="card-header {% if role == "moderator" %}has-background-info-light{% else %}has-background-success-light{% endif %}">
<span class="card-header-title is-size-7 pt-1 pb-1">
{% if role == "moderator" %}
{% trans "Moderator" %}
{% else %}
{% trans "Admin" %}
{% endif %}
</span>
</div>
{% endwith %}
<div class="cord-content p-5">
{% include 'user/user_preview.html' with user=user %}
</div>
{% if request.user.is_authenticated and user.id != request.user.id %}
<div class="has-background-white-bis card-footer">
<div class="card-footer-item">
{% include 'snippets/follow_button.html' with user=user minimal=True %}
</div>
<div class="card-footer-item">
<a href="{% url 'direct-messages-user' user|username %}">{% trans "Send direct message" %}</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endcache %}
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends 'about/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Code of Conduct" %}{% endblock %}
{% block about_content %}
<div class="block content">
<h2>{% trans "Code of Conduct" %}</h2>
<div class="content">
{{ site.code_of_conduct | safe }}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,57 @@
{% extends 'landing/layout.html' %}
{% load humanize %}
{% load i18n %}
{% block about_panel %}
<div class="box">
{% include "snippets/about.html" with size="m" %}
{% if active_users %}
<ul>
<li class="tag is-size-6">
<span class="mr-1">{% trans "Active users:" %}</span>
<strong>{{ active_users|intcomma }}</strong>
</li>
<li class="tag is-size-6">
<span class="mr-1">{% trans "Statuses posted:" %}</span>
<strong>{{ status_count|intcomma }}</strong>
</li>
<li class="tag is-size-6">
<span class="mr-1">{% trans "Software version:" %}</span>
<strong>{{ version }}</strong>
</li>
</ul>
{% endif %}
</div>
{% endblock %}
{% block panel %}
<div class="block columns pt-4">
<nav class="menu column is-one-quarter">
<h2 class="menu-label">{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}</h2>
<ul class="menu-list">
<li>
{% url 'about' as path %}
<a href="{{ path }}" {% if request.path in path %}class="is-active"{% endif %}>
{% trans "About" %}
</a>
</li>
<li>
{% url 'conduct' as path %}
<a href="{{ path }}" {% if request.path in path %}class="is-active"{% endif %}>
{% trans "Code of Conduct" %}
</a>
</li>
<li>
{% url 'privacy' as path %}
<a href="{{ path }}" {% if request.path in path %}class="is-active"{% endif %}>
{% trans "Privacy Policy" %}
</a>
</li>
</ul>
</nav>
<div class="column">
{% block about_content %}{% endblock %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends 'about/layout.html' %}
{% load i18n %}
{% block title %}{% trans "Privacy Policy" %}{% endblock %}
{% block about_content %}
<div class="block">
<h2 class="title">{% trans "Privacy Policy" %}</h2>
<div class="content">
{{ site.privacy_policy | safe }}
</div>
</div>
{% endblock %}

View file

@ -58,7 +58,15 @@
{% if year_key %}
<div class="horizontal-copy mb-5">
<textarea rows="1" readonly class="textarea is-small" aria-labelledby="embed-label" data-copytext data-copytext-label="{% trans 'Copy address' %}" data-copytext-success="{% trans 'Copied!' %}">{{ request.scheme|add:"://"|add:request.get_host|add:request.path }}?key={{ year_key }}</textarea>
<textarea
rows="1"
readonly
class="textarea is-small"
aria-labelledby="embed-label"
data-copytext
data-copytext-label="{% trans 'Copy address' %}"
data-copytext-success="{% trans 'Copied!' %}"
>{{ request.scheme|add:"://"|add:request.get_host|add:request.path }}?key={{ year_key }}</textarea>
</div>
{% endif %}
@ -101,13 +109,17 @@
</div>
{% if not books %}
<p class="has-text-centered is-size-5">{% blocktrans %}Sadly {{ display_name }} didnt finish any book in {{ year }}{% endblocktrans %}</p>
<p class="has-text-centered is-size-5">{% blocktrans %}Sadly {{ display_name }} didnt finish any books in {{ year }}{% endblocktrans %}</p>
{% else %}
<div class="columns is-mobile">
<div class="column is-8 is-offset-2 has-text-centered">
<h2 class="title is-3 is-serif">
{% blocktrans with pages_total=pages_total|intcomma %}In {{ year }}, {{ display_name }} read {{ books_total }} books<br />for a total of {{ pages_total }} pages!{% endblocktrans %}
{% blocktrans trimmed count counter=books_total with pages_total=pages_total|intcomma %}
In {{ year }}, {{ display_name }} read {{ books_total }} book<br />for a total of {{ pages_total }} pages!
{% plural %}
In {{ year }}, {{ display_name }} read {{ books_total }} books<br />for a total of {{ pages_total }} pages!
{% endblocktrans %}
</h2>
<p class="subtitle is-5">{% trans "Thats great!" %}</p>
@ -130,7 +142,7 @@
{% if book_pages_lowest and book_pages_highest %}
<div class="columns is-align-items-center mt-5">
<div class="column is-2 is-offset-1">
<a href="{{ book_pages_lowest.local_path }}">{% include 'snippets/book_cover.html' with book=book_pages_lowest cover_class='is-w-auto-tablet is-h-l-mobile' %}</a>
<a href="{{ book_pages_lowest.local_path }}">{% include 'snippets/book_cover.html' with book=book_pages_lowest cover_class='is-w-auto-tablet is-h-l-mobile' size='xxlarge' %}</a>
</div>
<div class="column is-3">
{% trans "Their shortest read this year…" %}
@ -151,12 +163,12 @@
</p>
</div>
<div class="column is-2">
<a href="{{ book_pages_highest.local_path }}">{% include 'snippets/book_cover.html' with book=book_pages_highest cover_class='is-w-auto-tablet is-h-l-mobile' %}</a>
<a href="{{ book_pages_highest.local_path }}">{% include 'snippets/book_cover.html' with book=book_pages_highest cover_class='is-w-auto-tablet is-h-l-mobile' size='xxlarge' %}</a>
</div>
<div class="column is-3">
{% trans "…and the longest" %}
<p class="title is-4 is-serif is-italic">
<a href="{{ book_pages_lowest.local_path }}" class="has-text-success-dark">
<a href="{{ book_pages_highest.local_path }}" class="has-text-success-dark">
{{ book_pages_highest.title }}
</a>
</p>
@ -180,17 +192,46 @@
</div>
</div>
{% if goal_status and goal_status.percent >= 100 %}
<div class="columns">
<div class="column has-text-centered">
<h2 class="title is-3 is-serif">
{% with goal=goal_status.goal goal_percent=goal_status.percent %}
{% blocktrans trimmed count counter=goal %}
{{ display_name }} set a goal of reading {{ goal }} book in {{ year }},<br />
and achieved {{ goal_percent }}% of that goal
{% plural %}
{{ display_name }} set a goal of reading {{ goal }} books in {{ year }},<br />
and achieved {{ goal_percent }}% of that goal
{% endblocktrans %}
{% endwith %}
</h2>
<p class="subtitle is-5">{% trans "Way to go!" %}</p>
</div>
</div>
<div class="columns">
<div class="column is-one-fifth is-offset-two-fifths">
<hr />
</div>
</div>
{% endif %}
{% if ratings_total > 0 %}
<div class="columns">
<div class="column has-text-centered">
<h2 class="title is-3 is-serif">
{% blocktrans %}{{ display_name }} left {{ ratings_total }} ratings, <br />their average rating is {{ rating_average }}{% endblocktrans %}
{% blocktrans trimmed count counter=ratings_total %}
{{ display_name }} left {{ ratings_total }} rating, <br />their average rating is {{ rating_average }}
{% plural %}
{{ display_name }} left {{ ratings_total }} ratings, <br />their average rating is {{ rating_average }}
{% endblocktrans %}
</h2>
</div>
</div>
<div class="columns is-align-items-center">
<div class="column is-3 is-offset-3">
<a href="{{ book_rating_highest.book.local_path }}">{% include 'snippets/book_cover.html' with book=book_rating_highest.book cover_class='is-w-auto-tablet is-h-l-mobile' %}</a>
<div class="column is-2 is-offset-4">
<a href="{{ book_rating_highest.book.local_path }}">{% include 'snippets/book_cover.html' with book=book_rating_highest.book cover_class='is-w-xl-tablet is-h-l-mobile' size='xxlarge' %}</a>
</div>
{% if book_rating_highest %}
<div class="column is-4">
@ -224,7 +265,7 @@
<div class="columns">
<div class="column has-text-centered">
<h2 class="title is-3 is-serif">
{% blocktrans %}All the books {{ display_name }} read in 2021{% endblocktrans %}
{% blocktrans %}All the books {{ display_name }} read in {{ year }}{% endblocktrans %}
</h2>
</div>
</div>
@ -233,7 +274,7 @@
<div class="column is-10 is-offset-1">
<div class="books-grid">
{% for book in books %}
{% if book.id in best_ratings_books_ids %}
{% if books_total > 12 and book.id in best_ratings_books_ids %}
<a href="{{ book.local_path }}" class="has-text-centered is-big has-text-success-dark">
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto' size='xxlarge' %}
<span class="book-title is-serif is-size-5">
@ -242,7 +283,7 @@
</a>
{% else %}
<a href="{{ book.local_path }}" class="has-text-centered has-text-success-dark">
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto' size='large' %}
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto' size='xlarge' %}
<span class="book-title is-serif is-size-6">
{{ book.title }}
</span>

View file

@ -23,18 +23,13 @@
</div>
</div>
<div class="block columns is-flex-direction-row-reverse" itemscope itemtype="https://schema.org/Person">
<div class="block columns" itemscope itemtype="https://schema.org/Person">
<meta itemprop="name" content="{{ author.name }}">
{% if author.bio %}
<div class="column">
{% include "snippets/trimmed_text.html" with full=author.bio trim_length=200 %}
</div>
{% endif %}
{% firstof author.aliases author.born author.died as details %}
{% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni as links %}
{% if details or links %}
<div class="column is-two-fifths">
<div class="column is-3">
{% if details %}
<section class="block content">
<h2 class="title is-4">{% trans "Author details" %}</h2>
@ -92,10 +87,11 @@
{% trans "View on OpenLibrary" %}
</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="ol_sync" controls_uid=author.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %}
{% include "author/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %}
{% endwith %}
<button class="button is-small" type="button" data-modal-open="openlibrary_sync">
<span class="icon icon-download" title="{{ button_text }}"></span>
<span class="is-sr-only-mobile">{{ button_text }}</span>
</button>
{% include "author/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" id="openlibrary_sync" %}
{% endif %}
</div>
{% endif %}
@ -107,10 +103,11 @@
</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="iv_sync" controls_uid=author.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %}
{% include "author/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %}
{% endwith %}
<button class="button is-small" type="button" data-modal-open="inventaire_sync">
<span class="icon icon-download" title="{{ button_text }}"></span>
<span class="is-sr-only-mobile">{{ button_text }}</span>
</button>
{% include "author/sync_modal.html" with source="inventaire.io" source_name="Inventaire" id="inventaire_sync" %}
{% endif %}
</div>
{% endif %}
@ -135,26 +132,28 @@
{% endif %}
</div>
{% endif %}
</div>
<hr aria-hidden="true">
<div class="column">
{% if author.bio %}
{% include "snippets/trimmed_text.html" with full=author.bio trim_length=200 %}
{% endif %}
<div class="block">
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
<div class="columns is-multiline is-mobile">
{% for book in books %}
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
<div class="is-flex-grow-1">
{% include 'landing/small-book.html' with book=book %}
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
<div class="columns is-multiline is-mobile">
{% for book in books %}
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
<div class="is-flex-grow-1">
{% include 'landing/small-book.html' with book=book %}
</div>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
{% endfor %}
</div>
<div>
{% include 'snippets/pagination.html' with page=books %}
</div>
{% endfor %}
</div>
</div>
<div>
{% include 'snippets/pagination.html' with page=books %}
</div>
{% endblock %}

View file

@ -19,12 +19,8 @@
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">
<span>{% trans "Confirm" %}</span>
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -61,24 +61,53 @@
<div class="columns">
<div class="column is-one-fifth">
{% include 'snippets/book_cover.html' with size='xxlarge' size_mobile='medium' book=book cover_class='is-h-m-mobile' %}
{% if not book.cover %}
{% if user_authenticated %}
<button
type="button"
class="cover-container no-cover is-h-m-mobile"
data-modal-open="add_cover_{{ book.id }}"
>
<img
class="book-cover"
src="{% static "images/no_cover.jpg" %}"
alt=""
aria-hidden="true"
>
<span class="cover-caption">
<span>{{ book.alt_text }}</span>
<span>{% trans "Click to add cover" %}</span>
</span>
<span class="button-invisible-overlay has-text-centered">
{% trans "Click to add cover" %}
</span>
</button>
{% join "add_cover" book.id as modal_id %}
{% include 'book/cover_add_modal.html' with id=modal_id %}
{% if request.GET.cover_error %}
<p class="help is-danger">{% trans "Failed to load cover" %}</p>
{% endif %}
{% else %}
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-m-mobile' %}
{% endif %}
{% endif %}
{% if book.cover %}
<button type="button" data-modal-open="cover_show_modal" class="cover-container is-h-m-mobile is-relative">
{% include 'snippets/book_cover.html' with size='xxlarge' size_mobile='medium' book=book cover_class='is-h-m-mobile' %}
<span class="button-invisible-overlay has-text-centered">
{% trans "Click to enlarge" %}
</span>
</button>
{% include 'book/cover_show_modal.html' with book=book id="cover_show_modal" %}
{% endif %}
{% include 'snippets/rate_action.html' with user=request.user book=book %}
<div class="mb-3">
{% include 'snippets/shelve_button/shelve_button.html' %}
</div>
{% if user_authenticated and not book.cover %}
<div class="block">
{% trans "Add cover" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add_cover" controls_uid=book.id focus="modal_title_add_cover" class="is-small" %}
{% include 'book/cover_modal.html' with book=book controls_text="add_cover" controls_uid=book.id %}
{% if request.GET.cover_error %}
<p class="help is-danger">{% trans "Failed to load cover" %}</p>
{% endif %}
</div>
{% endif %}
<section class="is-clipped">
{% with book=book %}
<div class="content">
@ -93,23 +122,30 @@
{% trans "Load data" as button_text %}
{% if book.openlibrary_key %}
<p>
<a href="{{ book.openlibrary_link }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a>
<a href="{{ book.openlibrary_link }}" target="_blank" rel="noopener">
{% trans "View on OpenLibrary" %}
</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="ol_sync" controls_uid=book.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %}
{% include "book/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %}
{% endwith %}
<button class="button is-small" type="button" data-modal-open="openlibrary_sync">
<span class="icon icon-download" title="{{ button_text }}"></span>
<span class="is-sr-only-mobile">{{ button_text }}</span>
</button>
{% include "book/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" id="openlibrary_sync" %}
{% endif %}
</p>
{% endif %}
{% if book.inventaire_id %}
<p>
<a href="{{ book.inventaire_link }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a>
<a href="{{ book.inventaire_link }}" target="_blank" rel="noopener">
{% trans "View on Inventaire" %}
</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="iv_sync" controls_uid=book.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %}
{% include "book/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %}
{% endwith %}
<button class="button is-small" type="button" data-modal-open="inventaire_sync">
<span class="icon icon-download" title="{{ button_text }}"></span>
<span class="is-sr-only-mobile">{{ button_text }}</span>
</button>
{% include "book/sync_modal.html" with source="inventaire.io" source_name="Inventaire" id="inventaire_sync" %}
{% endif %}
</p>
{% endif %}
@ -147,7 +183,7 @@
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %}
<div class="box is-hidden" id="add_description_{{ book.id }}">
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
<form name="add-description" method="POST" action="{% url "add-description" book.id %}">
{% csrf_token %}
<p class="fields is-grouped">
<label class="label" for="id_description_{{ book.id }}">{% trans "Description:" %}</label>
@ -201,29 +237,21 @@
<h2 class="title is-5">{% trans "Your reading activity" %}</h2>
</div>
<div class="column is-narrow">
{% trans "Add read dates" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" class="is-small" controls_text="add_readthrough" focus="add_readthrough_focus_" %}
<button class="button is-small" data-modal-open="add-readthrough">
<span class="icon icon-plus m-mobile-0" aria-hidden="true"></span>
<span class="is-sr-only-mobile">
{% trans "Add read dates" %}
</span>
</button>
</div>
</header>
<section class="is-hidden box" id="add_readthrough">
<form name="add-readthrough" action="/create-readthrough" method="post">
{% include 'snippets/readthrough_form.html' with readthrough=None %}
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">{% trans "Create" %}</button>
</div>
<div class="control">
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="add_readthrough" %}
</div>
</div>
</form>
</section>
{% include "readthrough/readthrough_modal.html" with id="add-readthrough" %}
{% if not readthroughs.exists %}
<p>{% trans "You don't have any reading activity for this book." %}</p>
{% endif %}
{% for readthrough in readthroughs %}
{% include 'book/readthrough.html' with readthrough=readthrough %}
{% include 'readthrough/readthrough_list.html' with readthrough=readthrough %}
{% endfor %}
</section>
<hr aria-hidden="true">
@ -291,7 +319,7 @@
</div>
</div>
</div>
<div class="column is-one-fifth">
<div class="column is-one-fifth is-clipped">
{% if book.subjects %}
<section class="content block">
<h2 class="title is-5">{% trans "Subjects" %}</h2>
@ -316,11 +344,11 @@
{% endif %}
{% if lists.exists or request.user.list_set.exists %}
<section class="content block">
<section class="content block is-clipped">
<h2 class="title is-5">{% trans "Lists" %}</h2>
<ul>
{% for list in lists %}
<li><a href="{{ list.local_path }}">{{ list.name }}</a></li>
<li><a href="{{ list.local_path }}">{{ list.name }}</a></li>
{% endfor %}
</ul>
@ -330,7 +358,7 @@
<input type="hidden" name="book" value="{{ book.id }}">
<label class="label" for="id_list">{% trans "Add to list" %}</label>
<div class="field has-addons">
<div class="select control">
<div class="select control is-clipped">
<select name="list" id="id_list">
{% for list in user.list_set.all %}
<option value="{{ list.id }}">{{ list.name }}</option>
@ -345,6 +373,10 @@
{% endif %}
</section>
{% endif %}
<section class="content block">
{% include "book/file_links/links.html" %}
</section>
</div>
</div>
@ -354,4 +386,5 @@
{% block scripts %}
<script src="{% static "js/vendor/tabs.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/autocomplete.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -29,8 +29,7 @@
{% block modal-footer %}
<button class="button is-primary" type="submit">{% trans "Add" %}</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -0,0 +1,12 @@
{% load i18n %}
{% load static %}
<div class="modal" id="{{ id }}">
<div class="modal-background" data-modal-close></div><!-- modal background -->
<div class="modal-card is-align-items-center" role="dialog" aria-modal="true" tabindex="-1" aria-label="{% trans 'Book cover preview' %}">
<div class="cover-container">
<img class="book-cover" src="{% get_media_prefix %}{{ book.cover }}" itemprop="thumbnailUrl" alt="">
</div>
</div>
<button type="button" data-modal-close class="modal-close is-large" aria-label="{% trans 'Close' %}"></button>
</div>

View file

@ -0,0 +1,64 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% load static %}
{% block modal-title %}
{% trans "Add file link" %}
{% endblock %}
{% block modal-form-open %}
<form name="add-link" method="POST" action="{% url 'file-link-add' book.id %}">
{% endblock %}
{% block modal-body %}
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="added_by" value="{{ request.user.id }}">
<p class="notification">
{% trans "Links from unknown domains will need to be approved by a moderator before they are added." %}
</p>
<div class="columns">
<div class="column is-four-fifths">
<label class="label" for="id_url">{% trans "URL:" %}</label>
<input type="url" name="url" maxlength="255" class="input" required="" id="id_url" value="{% firstof file_link_form.url.value "" %}" placeholder="https://..." aria-describedby="desc_name">
{% include 'snippets/form_errors.html' with errors_list=file_link_form.url.errors id="desc_url" %}
</div>
<div class="column is-one-fifth">
<label class="label" for="id_filetype">{% trans "File type:" %}</label>
<input
type="text"
name="filetype"
maxlength="50"
class="input"
required=""
id="id_filetype"
value="{% firstof file_link_form.filetype.value '' %}"
placeholder="ePub"
list="mimetypes-list"
data-autocomplete="mimetype"
>
<datalist id="mimetypes-list"></datalist>
{% include 'snippets/form_errors.html' with errors_list=file_link_form.filetype.errors id="desc_filetype" %}
</div>
</div>
<div>
<label class="label" for="id_availability">
{% trans "Availability:" %}
</label>
<div class="select">
{{ file_link_form.availability }}
</div>
</div>
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
{% if not static %}
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% endif %}
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -0,0 +1,114 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load utilities %}
{% block title %}{% trans "Edit links" %}{% endblock %}
{% block content %}
<header class="block content">
<h1 class="title">
{% blocktrans with title=book|book_title %}
Links for "<em>{{ title }}</em>"
{% endblocktrans %}
</h1>
</header>
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'book' book.id %}">{{ book|book_title }}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{% trans "Edit links" %}
</a>
</li>
</ul>
</nav>
<section class="block content">
<div class="table-container">
<table class="is-striped is-fullwidth">
<tr>
<th>{% trans "URL" %}</th>
<th>{% trans "Added by" %}</th>
<th>{% trans "Filetype" %}</th>
<th>{% trans "Domain" %}</th>
<th>{% trans "Status" %}</th>
<th colspan="2">{% trans "Actions" %}</th>
</tr>
{% for link in links %}
<tr>
<td class="overflow-wrap-anywhere">
<a href="{{ link.url }}" target="_blank" rel="noopener">{{ link.url }}</a>
</td>
<td>
<a href="{% url 'user-feed' link.added_by.id %}">{{ link.added_by.display_name }}</a>
</td>
<td>
{{ link.filelink.filetype }}
</td>
<td>
{{ link.domain.name }}
<p>
<a href="{% url 'report-link' link.added_by.id link.id %}">{% trans "Report spam" %}</a>
</p>
</td>
<td>
{% with status=link.domain.status %}
<span class="tag {% if status == 'blocked' %}has-background-danger{% elif status == 'approved' %}has-background-primary{% endif %}">
<span class="icon" aria-hidden="true">
<span class="icon-{% if status == 'blocked' %}x{% elif status == 'approved' %}check{% else %}lock{% endif %}"></span>
</span>
<span>
{{ link.domain.get_status_display }}
</span>
</span>
{% endwith %}
</td>
<td>
<form name="edit-link" class="control" method="post" action="{% url 'file-link' book.id link.id %}">
{% csrf_token %}
<input type="hidden" name="url" value="{{ link.form.url.value }}">
<input type="hidden" name="filetype" value="{{ link.form.filetype.value }}">
<input type="hidden" name="added_by" value="{{ link.form.added_by.value }}">
<input type="hidden" name="book" value="{{ link.form.book.value }}">
<div class="field has-addons">
<div class="control">
<div class="select">
{{ link.form.availability }}
</div>
</div>
<div class="control">
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
</div>
</div>
</form>
</td>
<td>
<form name="delete-link-{{ link.id }}" class="control" method="post" action="{% url 'file-link-delete' book.id link.id %}">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">Delete link</button>
</form>
</td>
</tr>
{% endfor %}
{% if not book.file_links.exists %}
<tr>
<td colspan="5"><em>{% trans "No links available for this book." %}</em></td>
</tr>
{% endif %}
</table>
</div>
{% url 'file-link-add' book.id as fallback_url %}
<form name="add-link" method="get" action="{{ fallback_url }}">
<button class="button" type="submit" data-modal-open="add-links">
<span class="icon icon-plus m-0-mobile" aria-hidden="true"></span>
<span class="is-sr-only-mobile">
{% trans "Add link to file" %}
</span>
</button>
</form>
</section>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load static %}
{% block title %}
{% trans "File Links" %}
{% endblock %}
{% block content %}
{% include "book/file_links/add_link_modal.html" with book=book active=True static=True id="file-link" %}
{% endblock %}
{% block scripts %}
<script src="{% static "js/autocomplete.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% load i18n %}
{% load bookwyrm_tags %}
{% load utilities %}
{% get_book_file_links book as links %}
{% if links.exists or request.user.is_authenticated %}
<header class="columns is-mobile mb-0">
<div class="column">
<h2 class="title is-5">{% trans "Get a copy" %}</h2>
</div>
{% if can_edit_book %}
<div class="column is-narrow">
{% url 'file-link-add' book.id as fallback_url %}
<form name="add-link" method="get" action="{{ fallback_url }}">
<button class="button is-small" type="submit" data-modal-open="add-links">
<span class="icon icon-plus">
<span class="is-sr-only">
{% trans "Add link to file" %}
</span>
</span>
</button>
</form>
</div>
{% endif %}
</header>
{% if links %}
<ul class="mt-0">
{% for link in links.all %}
{% join "verify" link.id as verify_modal %}
<li>
<a href="{{ link.url }}" rel="noopener" target="_blank" title="{{ link.url }}" data-modal-open="{{ verify_modal }}">{{ link.name }}</a>
({{ link.filetype }})
{% if link.availability != "free" %}
<p class="help">
{{ link.get_availability_display }}
</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% for link in links.all %}
{% join "verify" link.id as verify_modal %}
{% include "book/file_links/verification_modal.html" with id=verify_modal %}
{% endfor %}
{% else %}
<em>{% trans "No links available" %}</em>
{% endif %}
{% if can_edit_book and links.exists %}
<a href="{% url 'file-link' book.id %}" class="is-pulled-right">
<span class="icon icon-pencil" aria-hidden="true"></span>
<span>{% trans "Edit links" %}</span>
</a>
{% endif %}
{% include 'book/file_links/add_link_modal.html' with book=book id="add-links" %}
{% endif %}

View file

@ -0,0 +1,29 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% trans "Leaving BookWyrm" %}
{% endblock %}
{% block modal-body %}
{% blocktrans trimmed with link_url=link.url %}
This link is taking you to: <code>{{ link_url }}</code>.<br>
Is that where you'd like to go?
{% endblocktrans %}
{% endblock %}
{% block modal-footer %}
<a href="{{ link.url }}" target="_blank" rel="noopener" class="button is-primary">{% trans "Continue" %}</a>
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% if request.user.is_authenticated %}
<div class="has-text-right is-flex-grow-1">
<a href="{% url 'report-link' link.added_by.id link.id %}">{% trans "Report spam" %}</a>
</div>
{% endif %}
{% endblock %}

View file

@ -19,12 +19,8 @@
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">
<span>{% trans "Confirm" %}</span>
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -1,40 +1,33 @@
{% load i18n %}
<div
role="dialog"
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"
>
{# @todo Implement focus traps to prevent tabbing out of the modal. #}
<div class="modal-background"></div>
{% 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 is-flex-shrink-1" id="modal_card_title_{{ controls_text }}_{{ controls_uid }}">
<div class="modal {% if active %}is-active{% endif %}" id="{{ id }}">
<div class="modal-background" data-modal-close></div>
<div class="modal-card" role="dialog" aria-modal="true" tabindex="-1" aria-described-by="{{ id }}_header">
<header class="modal-card-head">
<h2 class="modal-card-title mb-0" id="{{ id }}_header">
{% block modal-title %}{% endblock %}
</h2>
{% if static %}
<a href="/" class="delete">{{ label }}</a>
{% else %}
{% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %}
{% endif %}
<button
type="button"
class="delete"
aria-label="{% trans 'Close' %}"
data-modal-close
></button>
</header>
{% block modal-form-open %}{% endblock %}
{% if not no_body %}
<section class="modal-card-body">
{% block modal-body %}{% endblock %}
</section>
{% endif %}
<footer class="modal-card-foot">
{% block modal-footer %}{% endblock %}
</footer>
{% block modal-form-close %}{% endblock %}
</div>
{% if static %}
<a href="/" class="modal-close is-large">{{ label }}</a>
{% else %}
{% include 'snippets/toggle/toggle_button.html' with label=label class="modal-close is-large" nonbutton=True %}
{% endif %}
<button
type="button"
class="modal-close is-large"
aria-label="{% trans 'Close' %}"
data-modal-close
></button>
</div>

View file

@ -3,10 +3,12 @@
{% block filter %}
<label class="label" for="id_sort">{% trans "Order by" %}</label>
<div class="select">
<select name="sort" id="id_sort">
<option value="recent" {% if request.GET.sort == "recent" %}selected{% endif %}>{% trans "Recently active" %}</option>
<option value="suggested" {% if request.GET.sort == "suggested" %}selected{% endif %}>{% trans "Suggested" %}</option>
</select>
<div class="control">
<div class="select">
<select name="sort" id="id_sort">
<option value="recent" {% if request.GET.sort == "recent" %}selected{% endif %}>{% trans "Recently active" %}</option>
<option value="suggested" {% if request.GET.sort == "suggested" %}selected{% endif %}>{% trans "Suggested" %}</option>
</select>
</div>
</div>
{% endblock %}

View file

@ -6,57 +6,22 @@
<h1 class="title">
{{ tab.name }}
</h1>
<div class="block is-clipped">
<div class="is-pulled-left">
<div class="tabs">
<ul>
{% for stream in streams %}
<li class="{% if tab.key == stream.key %}is-active{% endif %}"{% if tab.key == stream.key %} aria-current="page"{% endif %}>
<a href="/{{ stream.key }}#feed">{{ stream.shortname }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
{# feed settings #}
<details class="detail-pinned-button" {% if settings_saved %}open{% endif %}>
<summary class="control">
<span class="button">
<span class="icon icon-dots-three m-0-mobile" aria-hidden="true"></span>
<span class="is-sr-only-mobile">{{ _("Feed settings") }}</span>
</span>
</summary>
<form class="notification level is-align-items-flex-end" method="post" action="/{{ tab.key }}#feed">
{% csrf_token %}
<div class="level-left">
<div class="field">
<div class="control">
<span class="is-flex is-align-items-baseline">
<label class="label mt-2 mb-1">Status types</label>
{% if settings_saved %}
<span class="tag is-success is-light ml-2">{{ _("Saved!") }}</span>
{% endif %}
</span>
{% for name, value in feed_status_types_options %}
<label class="mr-2">
<input type="checkbox" name="feed_status_types" value="{{ name }}" {% if name in user.feed_status_types %}checked=""{% endif %}/>
{{ value }}
</label>
{% endfor %}
</div>
</div>
</div>
<div class="level-right control">
<button class="button is-small is-primary is-outlined" type="submit">
{{ _("Save settings") }}
</button>
</div>
</form>
</details>
<div class="tabs">
<ul>
{% for stream in streams %}
<li class="{% if tab.key == stream.key %}is-active{% endif %}"{% if tab.key == stream.key %} aria-current="page"{% endif %}>
<a href="/{{ stream.key }}#feed">{{ stream.shortname }}</a>
</li>
{% endfor %}
</ul>
</div>
{# feed settings #}
{% with "/"|add:tab.key|add:"#feed" as action %}
{% include 'feed/feed_filters.html' with size="small" method="post" action=action %}
{% endwith %}
{# announcements and system messages #}
{% if not activities.number > 1 %}
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
@ -73,7 +38,7 @@
{% endif %}
{% if annual_summary_year and tab.key == 'home' %}
<section class="block" data-hide="hide_annual_summary_{{ annual_summary_year }}">
<section class="block is-hidden" data-hide="hide_annual_summary_{{ annual_summary_year }}">
{% include 'feed/summary_card.html' with year=annual_summary_year %}
<hr>
</section>

View file

@ -0,0 +1,5 @@
{% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %}
{% include 'feed/status_types_filter.html' %}
{% endblock %}

View file

@ -8,82 +8,7 @@
<div class="columns">
{% if user.is_authenticated %}
<div class="column is-one-third">
<section class="block">
<h2 class="title is-4">{% trans "Your Books" %}</h2>
{% if not suggested_books %}
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
{% else %}
{% with active_book=request.GET.book %}
<div class="tab-group">
<div class="tabs is-small">
<ul role="tablist">
{% for shelf in suggested_books %}
{% if shelf.books %}
{% with shelf_counter=forloop.counter %}
<li>
<p>
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
{% else %}{{ shelf.name }}{% endif %}
</p>
<div class="tabs is-small is-toggle">
<ul>
{% for book in shelf.books %}
<li class="{% if active_book == book.id|stringformat:'d' %}is-active{% elif not active_book and shelf_counter == 1 and forloop.first %}is-active{% endif %}">
<a
href="{{ request.path }}?book={{ book.id }}"
id="tab_book_{{ book.id }}"
role="tab"
aria-label="{{ book.title }}"
aria-selected="{% if active_book == book.id|stringformat:'d' %}true{% elif shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}"
aria-controls="book_{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-m' %}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% endwith %}
{% endif %}
{% endfor %}
</ul>
</div>
{% for shelf in suggested_books %}
{% with shelf_counter=forloop.counter %}
{% for book in shelf.books %}
<div
class="suggested-tabs card"
role="tabpanel"
id="book_{{ book.id }}"
{% if active_book and active_book == book.id|stringformat:'d' %}{% elif not active_book and shelf_counter == 1 and forloop.first %}{% else %} hidden{% endif %}
aria-labelledby="tab_book_{{ book.id }}">
<div class="card-header">
<div class="card-header-title">
<div>
<p class="mb-2">{% include 'snippets/book_titleby.html' with book=book %}</p>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
<div class="card-header-icon is-hidden-tablet">
{% trans "Close" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with label=button_text controls_text="book" controls_uid=book.id class="delete" nonbutton=True pressed=True %}
</div>
</div>
<div class="card-content">
{% include 'snippets/create_status.html' with book=book %}
</div>
</div>
{% endfor %}
{% endwith %}
{% endfor %}
</div>
{% endwith %}
{% endif %}
</section>
{% include "feed/suggested_books.html" %}
{% if goal %}
<section class="block">
<div class="block">

View file

@ -31,7 +31,7 @@
</div>
{% endif %}
{% endfor %}
<div class="is-main block" id="anchor-{{ status.id }}">
<div class="is-main block">
{% include 'snippets/status/status.html' with status=status main=True %}
</div>

View file

@ -0,0 +1,16 @@
{% extends 'snippets/filters_panel/filter_field.html' %}
{% load i18n %}
{% block filter %}
<label class="label mt-2 mb-1">Status types</label>
<div class="is-flex is-flex-direction-row is-flex-direction-column-mobile">
{% for name, value in feed_status_types_options %}
<label class="mr-2">
<input type="checkbox" name="feed_status_types" value="{{ name }}" {% if name in user.feed_status_types %}checked=""{% endif %}/>
{{ value }}
</label>
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,76 @@
{% load i18n %}
{% load bookwyrm_tags %}
{% suggested_books as suggested_books %}
<section class="block">
<h2 class="title is-4">{% trans "Your Books" %}</h2>
{% if not suggested_books %}
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
{% else %}
{% with active_book=request.GET.book %}
<div class="tab-group">
<div class="tabs is-small">
<ul role="tablist">
{% for shelf in suggested_books %}
{% if shelf.books %}
{% with shelf_counter=forloop.counter %}
<li>
<p>
{% include "snippets/translated_shelf_name.html" with shelf=shelf %}
</p>
<div class="tabs is-small is-toggle">
<ul>
{% for book in shelf.books %}
<li class="{% if active_book == book.id|stringformat:'d' %}is-active{% elif not active_book and shelf_counter == 1 and forloop.first %}is-active{% endif %}">
<a
href="{{ request.path }}?book={{ book.id }}"
id="tab_book_{{ book.id }}"
role="tab"
aria-label="{{ book.title }}"
aria-selected="{% if active_book == book.id|stringformat:'d' %}true{% elif shelf_counter == 1 and forloop.first %}true{% else %}false{% endif %}"
aria-controls="book_{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book cover_class='is-h-m' %}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% endwith %}
{% endif %}
{% endfor %}
</ul>
</div>
{% for shelf in suggested_books %}
{% with shelf_counter=forloop.counter %}
{% for book in shelf.books %}
<div
class="suggested-tabs card"
role="tabpanel"
id="book_{{ book.id }}"
{% if active_book and active_book == book.id|stringformat:'d' %}{% elif not active_book and shelf_counter == 1 and forloop.first %}{% else %} hidden{% endif %}
aria-labelledby="tab_book_{{ book.id }}">
<div class="card-header">
<div class="card-header-title">
<div>
<p class="mb-2">{% include 'snippets/book_titleby.html' with book=book %}</p>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
<div class="card-header-icon is-hidden-tablet">
{% trans "Close" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with label=button_text controls_text="book" controls_uid=book.id class="delete" nonbutton=True pressed=True %}
</div>
</div>
<div class="card-content">
{% include 'snippets/create_status.html' with book=book %}
</div>
</div>
{% endfor %}
{% endwith %}
{% endfor %}
</div>
{% endwith %}
{% endif %}
</section>

View file

@ -1,29 +1,31 @@
{% extends 'components/card.html' %}
{% load i18n %}
{% block card-header %}
<h3 class="card-header-title has-background-success-dark has-text-white">
<span class="icon is-size-3 mr-2" aria-hidden="true">📚</span>
<span class="icon is-size-3 mr-2" aria-hidden="true"></span>
{% blocktrans %}{{ year }} in the books{% endblocktrans %}
</h3>
<article class="card">
<header class="card-header has-background-success-dark">
<h3 class="card-header-title has-text-white">
<span class="icon is-size-3 mr-2" aria-hidden="true">📚</span>
<span class="icon is-size-3 mr-2" aria-hidden="true"></span>
{% blocktrans %}{{ year }} in the books{% endblocktrans %}
</h3>
<div class="card-header-icon has-background-success-dark has-text-white">
{% trans "Dismiss message" as button_text %}
<button class="delete set-display" type="button" data-id="hide_annual_summary_{{ year }}" data-value="true">
<span>{% trans "Dismiss message" %}</span>
</button>
</div>
{% endblock %}
<div class="card-header-icon has-text-white">
{% trans "Dismiss message" as button_text %}
<button class="delete set-display" type="button" data-id="hide_annual_summary_{{ year }}" data-value="true">
<span>{% trans "Dismiss message" %}</span>
</button>
</div>
</header>
{% block card-content %}
<p class="mb-3">
{% blocktrans %}The end of the year is the best moment to take stock of all the books read during the last 12 months. How many pages have you read? Which book is your best-rated of the year? We compiled these stats, and more!{% endblocktrans %}
</p>
<section class="card-content">
<p class="mb-3">
{% blocktrans %}The end of the year is the best moment to take stock of all the books read during the last 12 months. How many pages have you read? Which book is your best-rated of the year? We compiled these stats, and more!{% endblocktrans %}
</p>
<p>
<a href="{% url 'annual-summary' request.user.localname year %}" class="button is-success has-background-success-dark">
{% blocktrans %}Discover your stats for {{ year }}!{% endblocktrans %}
</a>
</p>
{% endblock %}
<p>
<a href="{% url 'annual-summary' request.user.localname year %}" class="button is-success has-background-success-dark">
{% blocktrans %}Discover your stats for {{ year }}!{% endblocktrans %}
</a>
</p>
</section>
</article>

View file

@ -10,7 +10,12 @@
<div class="modal-background"></div>
<div class="modal-card is-fullwidth">
<header class="modal-card-head">
<img class="image logo mr-2" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" aria-hidden="true">
<img
class="image logo mr-2"
src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}"
aria-hidden="true"
alt="{{ site.name }}"
>
<h1 class="modal-card-title" id="get_started_header">
{% blocktrans %}Welcome to {{ site_name }}!{% endblocktrans %}
<span class="subtitle is-block">

View file

@ -13,13 +13,26 @@
<div class="column is-two-thirds">
<div class="block">
<label class="label" for="id_name">{% trans "Display name:" %}</label>
<input type="text" name="name" maxlength="100" class="input" id="id_name" placeholder="{{ user.localname }}" value="{% if request.user.name %}{{ request.user.name }}{% endif %}">
<input
type="text"
name="name"
maxlength="100"
class="input"
id="id_name"
placeholder="{{ user.localname }}"
value="{% if request.user.name %}{{ request.user.name }}{% endif %}"
>
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
</div>
<div class="block">
<label class="label" for="id_summary">{% trans "Summary:" %}</label>
<textarea name="summary" cols="None" rows="None" class="textarea" id="id_summary" placeholder="{% trans 'A little bit about you' %}">{% if request.user.summary %}{{ request.user.summary }}{% endif %}</textarea>
<textarea
name="summary"
class="textarea"
id="id_summary"
placeholder="{% trans 'A little bit about you' %}"
>{% if request.user.summary %}{{ request.user.summary }}{% endif %}</textarea>
{% include 'snippets/form_errors.html' with errors_list=form.summary.errors id="desc_summary" %}
</div>

View file

@ -14,8 +14,7 @@
<button class="button is-danger" type="submit">
{% trans "Delete" %}
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="delete_group" controls_uid=group.id %}
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
</form>
{% endblock %}

View file

@ -9,5 +9,5 @@
<form name="edit-group" method="post" action="{% url 'group' group.id %}">
{% include 'groups/form.html' %}
</form>
{% include "groups/delete_group_modal.html" with controls_text="delete_group" controls_uid=group.id %}
{% include "groups/delete_group_modal.html" with id="delete_group" %}
{% endblock %}

View file

@ -2,8 +2,5 @@
{% load i18n %}
{% block searchresults %}
<h2 class="title is-5">
{% trans "Add new members!" %}
</h2>
{% include 'groups/suggested_users.html' with suggested_users=suggested_users %}
{% endblock %}

View file

@ -5,30 +5,29 @@
<div class="column is-two-thirds">
<input type="hidden" name="user" value="{{ request.user.id }}" />
<div class="field">
<label class="label" for="id_name">{% trans "Group Name:" %}</label>
<label class="label" for="group_form_id_name">{% trans "Group Name:" %}</label>
{{ group_form.name }}
</div>
<div class="field">
<label class="label" for="id_description">{% trans "Group Description:" %}</label>
<label class="label" for="group_form_id_description">{% trans "Group Description:" %}</label>
{{ group_form.description }}
</div>
</div>
</div>
<div class="columns is-mobile">
<div class="column">
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select_no_followers.html' with current=group.privacy %}
</div>
<div class="control">
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
</div>
</div>
</div>
<div class="is-flex">
{% if group.id %}
<div class="column is-narrow">
{% trans "Delete group" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_group" controls_uid=group.id focus="modal_title_delete_group" %}
<div class="is-flex-grow-1">
<button type="button" data-modal-open="delete_group" class="button is-danger">
{% trans "Delete group" %}
</button>
</div>
{% endif %}
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select_no_followers.html' with current=group.privacy %}
</div>
<div class="control">
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
</div>
</div>
</div>

View file

@ -1,37 +1,41 @@
{% extends 'groups/layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load bookwyrm_group_tags %}
{% load markdown %}
{% block panel %}
<div class="columns mt-3">
<section class="column is-three-quarters">
{% if group.user == request.user %}
<div class="block">
<form class="field has-addons" method="get" action="{% url 'group-find-users' group.id %}">
<div class="control">
<input type="text" name="user_query" value="{{ request.GET.user_query }}" class="input" placeholder="{% trans 'Search to add a user' %}" aria-label="{% trans 'Search to add a user' %}">
</div>
<div class="control">
<button class="button" type="submit">
<span class="icon icon-search" title="{% trans 'Search' %}">
<span class="is-sr-only">{% trans "Search" %}</span>
</span>
</button>
</div>
</form>
</div>
{% endif %}
{% block searchresults %}
{% endblock %}
<div class="mb-2">
{% include "groups/members.html" with group=group %}
</div>
<h2 class="title is-5">Lists</h2>
</section>
</div>
<header class="columns content is-mobile">
<div class="column">
<h2 class="title is-5">{% trans "Lists" %}</h2>
<p class="subtitle is-6">
{% trans "Members of this group can create group-curated lists." %}
</p>
</div>
{% if request.user.is_authenticated and group|is_member:request.user %}
<div class="column is-narrow is-flex">
{% trans "Create List" as button_text %}
{% include 'snippets/toggle/open_button.html' with controls_text="create_list" icon_with_text="plus" text=button_text focus="create_list_header" %}
</div>
{% endif %}
</header>
{% if request.user.is_authenticated and group|is_member:request.user %}
<div class="block">
{% include 'lists/create_form.html' with controls_text="create_list" curation_group=group %}
</div>
{% endif %}
<div class="columns mt-3">
<section class="column is-three-quarters">
{% if not lists %}
<p>{% trans "This group has no lists" %}</p>
{% else %}
@ -45,19 +49,17 @@
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h4>
</header>
{% with list_books=list.listitem_set.all|slice:5 %}
{% if list_books %}
<div class="card-image columns is-mobile is-gapless is-clipped">
{% for book in list_books %}
<a class="column is-cover" href="{{ book.book.local_path }}">
{% include 'snippets/book_cover.html' with book=book.book cover_class='is-h-s' size='small' aria='show' %}
</a>
{% endfor %}
</div>
{% endif %}
{% if list_books %}
<div class="card-image columns is-mobile is-gapless is-clipped">
{% for book in list_books %}
<a class="column is-cover" href="{{ book.book.local_path }}">
{% include 'snippets/book_cover.html' with book=book.book cover_class='is-h-s' size='small' aria='show' %}
</a>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="card-content is-flex-grow-0">
<div class="is-clipped" {% if list.description %}title="{{ list.description }}"{% endif %}>
{% if list.description %}
@ -74,9 +76,8 @@
</div>
{% endfor %}
</div>
{% endif %}
{% include "snippets/pagination.html" with page=items %}
</section>
</div>
{% endblock %}

View file

@ -1,5 +1,6 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load bookwyrm_group_tags %}
{% block title %}{{ group.name }}{% endblock %}
@ -11,12 +12,12 @@
{% include 'groups/created_text.html' with group=group %}
</p>
</div>
{% if request.user == group.user %}
<div class="column is-narrow is-flex">
{% if request.user == group.user %}
{% trans "Edit group" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_group" focus="edit_group_header" %}
{% endif %}
{% trans "Edit group" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_group" focus="edit_group_header" %}
</div>
{% endif %}
</header>
<div class="block content">

View file

@ -5,8 +5,22 @@
{% load bookwyrm_group_tags %}
<h2 class="title is-5">Group Members</h2>
<p class="subtitle is-6">{% trans "Members can add and remove books on a group's book lists" %}</p>
{% if group.user == request.user %}
<div class="block">
<form class="field has-addons" method="get" action="{% url 'group-find-users' group.id %}">
<div class="control">
<input type="text" name="user_query" value="{{ request.GET.user_query }}" class="input" placeholder="{% trans 'Search to add a user' %}" aria-label="{% trans 'Search to add a user' %}">
</div>
<div class="control">
<button class="button" type="submit">
<span class="icon icon-search" title="{% trans 'Search' %}">
<span class="is-sr-only">{% trans "Search" %}</span>
</span>
</button>
</div>
</form>
</div>
{% endif %}
{% if group.user != request.user and group|is_member:request.user %}
<form action="{% url 'remove-group-member' %}" method="POST" class="my-4">
{% csrf_token %}

View file

@ -3,6 +3,9 @@
{% load humanize %}
{% if suggested_users %}
<h2 class="title is-5">
{% trans "Add new members!" %}
</h2>
<div class="column is-flex is-flex-grow-0">
{% for user in suggested_users %}
<div class="box has-text-centered is-shadowless has-background-white-bis m-2">

View file

@ -1,37 +0,0 @@
{% extends 'landing/layout.html' %}
{% load i18n %}
{% block panel %}
<div class="block columns mt-4">
<nav class="menu column is-one-quarter">
<h2 class="menu-label">{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}</h2>
<ul class="menu-list">
<li>
<a href="#coc">{% trans "Code of Conduct" %}</a>
</li>
<li>
<a href="#privacy">{% trans "Privacy Policy" %}</a>
</li>
</ul>
</nav>
<div class="column content">
<div class="block" id="coc">
<h2 class="title">{% trans "Code of Conduct" %}</h2>
<div class="content">
{{ site.code_of_conduct | safe }}
</div>
</div>
<hr aria-hidden="true">
<div class="block" id="privacy">
<h2 class="title">{% trans "Privacy Policy" %}</h2>
<div class="content">
{{ site.privacy_policy | safe }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,11 +1,17 @@
{% extends 'landing/layout.html' %}
{% load i18n %}
{% load cache %}
{% load bookwyrm_tags %}
{% block panel %}
<div class="block is-hidden-tablet">
<h2 class="title has-text-centered">{% trans "Recent Books" %}</h2>
</div>
{% get_current_language as LANGUAGE_CODE %}
{% cache 60 * 60 LANGUAGE_CODE %}
{% get_landing_books as books %}
<section class="tile is-ancestor">
<div class="tile is-vertical is-6">
<div class="tile is-parent">
@ -46,5 +52,5 @@
</div>
</div>
</section>
{% endcache %}
{% endblock %}

View file

@ -31,6 +31,7 @@
</div>
</section>
{% block about_panel %}
<section class="tile is-ancestor">
<div class="tile is-7 is-parent">
<div class="tile is-child box">
@ -89,6 +90,7 @@
{% endif %}
</div>
</section>
{% endblock %}
{% block panel %}{% endblock %}

View file

@ -38,7 +38,7 @@
<a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
</a>
<form class="navbar-item column" action="{% url 'search' %}">
<form class="navbar-item column is-align-items-start pt-5" action="{% url 'search' %}">
<div class="field has-addons">
<div class="control">
{% if user.is_authenticated %}
@ -58,25 +58,22 @@
</div>
</form>
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main_nav" aria-expanded="false">
<div class="navbar-item mt-3">
<div class="icon icon-dots-three-vertical" title="{% trans 'Main navigation menu' %}">
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
</div>
</div>
</div>
<button type="button" tabindex="0" class="navbar-burger pulldown-menu my-4" data-controls="main_nav" aria-expanded="false">
<i class="icon icon-dots-three-vertical" aria-hidden="true"></i>
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
</button>
</div>
<div class="navbar-menu" id="main_nav">
<div class="navbar-start">
{% if request.user.is_authenticated %}
<a href="/#feed" class="navbar-item">
<a href="/#feed" class="navbar-item mt-3 py-0">
{% trans "Feed" %}
</a>
<a href="{% url 'lists' %}" class="navbar-item">
<a href="{% url 'lists' %}" class="navbar-item mt-3 py-0">
{% trans "Lists" %}
</a>
<a href="{% url 'discover' %}" class="navbar-item">
<a href="{% url 'discover' %}" class="navbar-item mt-3 py-0">
{% trans "Discover" %}
</a>
{% endif %}
@ -84,7 +81,7 @@
<div class="navbar-end">
{% if request.user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<div class="navbar-item mt-3 py-0 has-dropdown is-hoverable">
<a
href="{{ request.user.local_path }}"
class="navbar-link pulldown-menu"
@ -143,7 +140,7 @@
</li>
</ul>
</div>
<div class="navbar-item">
<div class="navbar-item mt-3 py-0">
<a href="{% url 'notifications' %}" class="tags has-addons">
<span class="tag is-medium">
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
@ -161,7 +158,7 @@
</a>
</div>
{% else %}
<div class="navbar-item">
<div class="navbar-item pt-5 pb-0">
{% if request.path != '/login' and request.path != '/login/' %}
<div class="columns">
<div class="column">

View file

@ -7,6 +7,6 @@
{% block form %}
<form name="create-list" method="post" action="{% url 'lists' %}">
{% include 'lists/form.html' %}
{% include 'lists/form.html' with curation_group=group %}
</form>
{% endblock %}

View file

@ -1,72 +1,74 @@
{% extends 'lists/layout.html' %}
{% load i18n %}
{% block breadcrumbs %}
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'lists' %}">{% trans "Lists" %}</a></li>
<li><a href="{% url 'list' list.id %}">{{ list.name|truncatechars:30 }}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{% trans "Curate" %}
</a>
</li>
</ul>
</nav>
{% endblock %}
{% block panel %}
<section class="block">
<div class="columns is-mobile is-multiline is-align-items-baseline">
<div class="column is-narrow">
<h2 class="title is-4">{% trans "Pending Books" %}</h2>
</div>
<p class="column is-narrow"><a href="{% url 'list' list.id %}">{% trans "Go to list" %}</a></p>
</div>
<h2 class="title is-4">{% trans "Pending Books" %}</h2>
{% if not pending.exists %}
<p>{% trans "You're all set!" %}</p>
<p><em>{% trans "You're all set!" %}</em></p>
{% else %}
<dl>
<div class="columns">
{% for item in pending %}
{% with book=item.book %}
<div
class="
columns is-gapless
is-vcentered is-justify-content-space-between
mb-6
"
>
<dt class="column mr-auto">
<div class="columns is-mobile is-gapless is-vcentered">
<a
class="column is-cover"
href="{{ book.local_path }}"
aria-hidden="true"
>
{% include 'snippets/book_cover.html' with cover_class='is-w-xs-mobile is-w-s is-h-xs-mobile is-h-s' size_mobile='xsmall' size='small' %}
{% with book=item.book %}
<div class="column">
<div class="columns is-mobile">
<a
class="column is-cover"
href="{{ book.local_path }}"
aria-hidden="true"
>
{% include 'snippets/book_cover.html' with cover_class='is-w-xs-mobile is-w-s is-h-xs-mobile is-h-s' size_mobile='xsmall' size='small' %}
</a>
<div class="column ml-3">
{% include 'snippets/book_titleby.html' %}
<p>
{% trans "Suggested by" %}
<a href="{{ item.user.local_path }}">
{{ item.user.display_name }}
</a>
<div class="column ml-3">
{% include 'snippets/book_titleby.html' %}
</div>
</div>
</dt>
<dd class="column is-4-tablet mx-3-tablet my-3-mobile">
{% trans "Suggested by" %}
<a href="{{ item.user.local_path }}">
{{ item.user.display_name }}
</a>
</dd>
<dd class="column is-narrow field has-addons">
<form class="control" method="POST" action="{% url 'list-curate' list.id %}">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
<input type="hidden" name="approved" value="true">
<button class="button">{% trans "Approve" %}</button>
</form>
<form class="control" method="POST" action="{% url 'list-curate' list.id %}">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
<input type="hidden" name="approved" value="false">
<button class="button is-danger is-light">{% trans "Discard" %}</button>
</form>
</dd>
</p>
</div>
</div>
{% endwith %}
</div>
<div class="column is-narrow">
<div class="field has-addons">
<form class="control" method="POST" action="{% url 'list-curate' list.id %}">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
<input type="hidden" name="approved" value="true">
<button type="submit" class="button">{% trans "Approve" %}</button>
</form>
<form class="control" method="POST" action="{% url 'list-curate' list.id %}">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
<input type="hidden" name="approved" value="false">
<button type="submit" class="button is-danger is-light">{% trans "Discard" %}</button>
</form>
</div>
</div>
{% endwith %}
{% endfor %}
</dl>
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -14,8 +14,9 @@
<button class="button is-danger" type="submit">
{% trans "Delete" %}
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="delete_list" controls_uid=list.id %}
<button type="button" class="button" data-modal-close>
{% trans "Cancel" %}
</button>
</form>
{% endblock %}

View file

@ -9,5 +9,5 @@
<form name="edit-list" method="post" action="{% url 'list' list.id %}">
{% include 'lists/form.html' %}
</form>
{% include "lists/delete_list_modal.html" with controls_text="delete_list" controls_uid=list.id %}
{% include "lists/delete_list_modal.html" with id="delete_list" %}
{% endblock %}

View file

@ -18,33 +18,82 @@
<fieldset class="field">
<legend class="label">{% trans "List curation:" %}</legend>
<label class="field" data-hides="list_group_selector">
<input type="radio" name="curation" value="closed"{% if not list or list.curation == 'closed' %} checked{% endif %}> {% trans "Closed" %}
<p class="help mb-2">{% trans "Only you can add and remove books to this list" %}</p>
</label>
<div class="field" data-hides="list_group_selector">
<input
type="radio"
name="curation"
value="closed"
aria-described-by="id_curation_closed_help"
id="id_curation_closed"
{% if not curation_group.exists or not list or list.curation == 'closed' %}checked{% endif %}
>
<label for="id_curation_closed">
{% trans "Closed" %}
</label>
<p class="help mb-2" id="id_curation_closed_help">
{% trans "Only you can add and remove books to this list" %}
</p>
</div>
<label class="field" data-hides="list_group_selector">
<input type="radio" name="curation" value="curated"{% if list.curation == 'curated' %} checked{% endif %}> {% trans "Curated" %}
<p class="help mb-2">{% trans "Anyone can suggest books, subject to your approval" %}</p>
</label>
<div class="field" data-hides="list_group_selector">
<input
type="radio"
name="curation"
value="curated"
aria-described-by="id_curation_curated_help"
id="id_curation_curated"
{% if list.curation == 'curated' %} checked{% endif %}
>
<label for="id_curation_curated">
{% trans "Curated" %}
</label>
<p class="help mb-2" id="id_curation_curated_help">
{% trans "Anyone can suggest books, subject to your approval" %}
</p>
</div>
<label class="field" data-hides="list_group_selector">
<input type="radio" name="curation" value="open"{% if list.curation == 'open' %} checked{% endif %}> {% trans "Open" context "curation type" %}
<p class="help mb-2">{% trans "Anyone can add books to this list" %}</p>
</label>
<div class="field" data-hides="list_group_selector">
<input
type="radio"
name="curation"
value="open"
aria-described-by="id_curation_open_help"
id="id_curation_open"
{% if list.curation == 'open' %} checked{% endif %}
>
<label for="id_curation_open">
{% trans "Open" context "curation type" %}
</label>
<p class="help mb-2" id="id_curation_open_help">
{% trans "Anyone can add books to this list" %}
</p>
</div>
<label class="field hidden-form">
<input type="radio" name="curation" value="group"{% if list.curation == 'group' %} checked{% endif %} > {% trans "Group" %}
<p class="help mb-2">{% trans "Group members can add to and remove from this list" %}</p>
<fieldset class="{% if list.curation != 'group' %}is-hidden{% endif %}" id="list_group_selector">
<div class="field hidden-form">
<input
type="radio"
name="curation"
value="group"
aria-described-by="id_curation_group_help"
id="id_curation_group"
{% if curation_group.id or list.curation == 'group' %}checked{% endif %}
>
<label for="id_curation_group">
{% trans "Group" %}
</label>
<p class="help mb-2" id="id_curation_group_help">
{% trans "Group members can add to and remove from this list" %}
</p>
<fieldset class="{% if list.curation != 'group' and not curation_group %}is-hidden{% endif %}" id="list_group_selector">
{% if user.memberships.exists %}
<label class="label" for="id_group" id="group">{% trans "Select Group" %}</label>
<div class="field has-addons">
<div class="select control">
<select name="group" id="id_group">
<option value="" disabled {% if not list.group %} selected{% endif %}>{% trans "Select a group" %}</option>
<option value="" disabled {% if not list.group and not curation_group %} selected{% endif %}>{% trans "Select a group" %}</option>
{% for membership in user.memberships.all %}
<option value="{{ membership.group.id }}" {% if list.group.id == membership.group.id %} selected{% endif %}>{{ membership.group.name }}</option>
<option value="{{ membership.group.id }}" {% if list.group.id == membership.group.id or curation_group.id == membership.group.id %} selected{% endif %}>{{ membership.group.name }}</option>
{% endfor %}
</select>
</div>
@ -61,25 +110,24 @@
{% endwith %}
{% endif %}
</fieldset>
</label>
</div>
</fieldset>
</div>
</div>
<div class="columns is-mobile">
<div class="column">
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select.html' with current=list.privacy %}
</div>
<div class="control">
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
</div>
</div>
</div>
<div class="is-flex">
{% if list.id %}
<div class="column is-narrow">
{% trans "Delete list" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class="is-danger" text=button_text icon_with_text="x" controls_text="delete_list" controls_uid=list.id focus="modal_title_delete_list" %}
<div class="is-flex-grow-1">
<button type="button" data-modal-open="delete_list" class="button is-danger">
{% trans "Delete list" %}
</button>
</div>
{% endif %}
<div class="field has-addons">
<div class="control">
{% include 'snippets/privacy_select.html' with current=list.privacy %}
</div>
<div class="control">
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
</div>
</div>
</div>

View file

@ -11,6 +11,7 @@
{% include 'lists/created_text.html' with list=list %}
</p>
</div>
<div class="column is-narrow is-flex">
{% if request.user == list.user %}
{% trans "Edit List" as button_text %}
@ -20,6 +21,8 @@
</div>
</header>
{% block breadcrumbs %}{% endblock %}
<div class="block content">
{% include 'snippets/trimmed_text.html' with full=list.description %}
</div>

View file

@ -4,6 +4,19 @@
{% load bookwyrm_group_tags %}
{% load markdown %}
{% block breadcrumbs %}
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'lists' %}">{% trans "Lists" %}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{{ list.name|truncatechars:30 }}
</a>
</li>
</ul>
</nav>
{% endblock %}
{% block panel %}
{% if request.user == list.user and pending_count %}
<div class="block content">
@ -56,37 +69,48 @@
<div>
{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:20 }}
</div>
{% include 'snippets/shelve_button/shelve_button.html' %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
{% endwith %}
<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=item.user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
</div>
<p>
{% blocktrans trimmed with username=item.user.display_name user_path=item.user.local_path %}
Added by <a href="{{ user_path }}">{{ username }}</a>
{% endblocktrans %}
</p>
</div>
{% if list.user == request.user or list.group|is_member:request.user %}
<div class="card-footer-item">
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
{% csrf_token %}
<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>
<div class="control">
<input id="input_list_position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
</div>
<div class="control">
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
</div>
<form
name="set-position-{{ item.id }}"
method="post"
action="{% url 'list-set-book-position' item.id %}"
class="card-footer-item"
>
{% csrf_token %}
<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>
</form>
</div>
<div class="control">
<input id="input_list_position_{{ item.id }}" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
</div>
<div class="control">
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
</div>
</div>
</form>
{% endif %}
{% if list.user == request.user or list.curation == 'open' and item.user == request.user or list.group|is_member:request.user %}
<form name="remove-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
<form
name="remove-book-{{ item.id }}"
method="post"
action="{% url 'list-remove-book' list.id %}"
class="card-footer-item"
>
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
<button type="submit" class="button is-small is-danger">{% trans "Remove" %}</button>
@ -172,14 +196,20 @@
<form
class="mt-1"
name="add-book"
name="add-book-{{ book.id }}"
method="post"
action="{% url 'list-add-book' %}{% if query %}?q={{ query }}{% endif %}"
>
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="list" value="{{ list.id }}">
<button type="submit" class="button is-small is-link">{% if list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}{% trans "Add" %}{% else %}{% trans "Suggest" %}{% endif %}</button>
<button type="submit" class="button is-small is-link">
{% if list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
{% trans "Add" %}
{% else %}
{% trans "Suggest" %}
{% endif %}
</button>
</form>
</div>
</div>
@ -191,7 +221,16 @@
{% trans "Embed this list on a website" %}
</h2>
<div class="vertical-copy">
<textarea readonly class="textarea is-small" aria-labelledby="embed-label" data-copytext data-copytext-label="{% trans 'Copy embed code' %}" data-copytext-success="{% trans 'Copied!' %}"><iframe style="border-width:0;" id="bookwyrm_list_embed" width="400" height="600" title="{% blocktrans with list_name=list.name site_name=site.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}} on {{ site_name }}{% endblocktrans %}" src="{{ embed_url }}"></iframe></textarea>
<textarea
readonly
class="textarea is-small"
aria-labelledby="embed-label"
data-copytext
data-copytext-label="{% trans 'Copy embed code' %}"
data-copytext-success="{% trans 'Copied!' %}"
>&lt;iframe style="border-width:0;" id="bookwyrm_list_embed" width="400" height="600" title="{% blocktrans trimmed with list_name=list.name site_name=site.name owner=list.user.display_name %}
{{ list_name }}, a list by {{owner}} on {{ site_name }}
{% endblocktrans %}" src="{{ embed_url }}"&gt;&lt;/iframe&gt;</textarea>
</div>
</div>

View file

@ -1,4 +1,4 @@
{% extends 'notifications/items/item_layout.html' %}
{% extends 'notifications/items/layout.html' %}
{% load i18n %}
{% load utilities %}

View file

@ -1,4 +1,4 @@
{% extends 'notifications/items/item_layout.html' %}
{% extends 'notifications/items/layout.html' %}
{% load i18n %}
{% load utilities %}

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