1
0
Fork 0

Merge branch 'main' into remove-tags

This commit is contained in:
Mouse Reeve 2021-04-22 18:18:24 -07:00
commit 563623616c
158 changed files with 5668 additions and 2066 deletions

View file

@ -5,7 +5,7 @@ import sys
from .base_activity import ActivityEncoder, Signature, naive_parse
from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError, resolve_remote_id
from .image import Image
from .image import Document, Image
from .note import Note, GeneratedNote, Article, Comment, Quotation
from .note import Review, Rating
from .note import Tombstone

View file

@ -52,10 +52,14 @@ def naive_parse(activity_objects, activity_json, serializer=None):
if activity_json.get("publicKeyPem"):
# ugh
activity_json["type"] = "PublicKey"
activity_type = activity_json.get("type")
try:
activity_type = activity_json["type"]
serializer = activity_objects[activity_type]
except KeyError as e:
# we know this exists and that we can't handle it
if activity_type in ["Question"]:
return None
raise ActivitySerializerError(e)
return serializer(activity_objects=activity_objects, **activity_json)
@ -111,7 +115,7 @@ class ActivityObject:
and hasattr(model, "ignore_activity")
and model.ignore_activity(self)
):
raise ActivitySerializerError()
return None
# check for an existing instance
instance = instance or model.find_existing(self.serialize())

View file

@ -3,7 +3,7 @@ from dataclasses import dataclass, field
from typing import List
from .base_activity import ActivityObject
from .image import Image
from .image import Document
@dataclass(init=False)
@ -11,6 +11,7 @@ class Book(ActivityObject):
""" serializes an edition or work, abstract """
title: str
lastEditedBy: str = None
sortTitle: str = ""
subtitle: str = ""
description: str = ""
@ -28,7 +29,7 @@ class Book(ActivityObject):
librarythingKey: str = ""
goodreadsKey: str = ""
cover: Image = None
cover: Document = None
type: str = "Book"
@ -64,6 +65,7 @@ class Author(ActivityObject):
""" author of a book """
name: str
lastEditedBy: str = None
born: str = None
died: str = None
aliases: List[str] = field(default_factory=lambda: [])

View file

@ -4,10 +4,17 @@ from .base_activity import ActivityObject
@dataclass(init=False)
class Image(ActivityObject):
""" image block """
class Document(ActivityObject):
""" a document """
url: str
name: str = ""
type: str = "Document"
id: str = None
@dataclass(init=False)
class Image(Document):
""" an image """
type: str = "Image"

View file

@ -4,7 +4,7 @@ from typing import Dict, List
from django.apps import apps
from .base_activity import ActivityObject, Link
from .image import Image
from .image import Document
@dataclass(init=False)
@ -32,7 +32,7 @@ class Note(ActivityObject):
inReplyTo: str = ""
summary: str = ""
tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default_factory=lambda: [])
attachment: List[Document] = field(default_factory=lambda: [])
sensitive: bool = False
type: str = "Note"

View file

@ -1,4 +1,4 @@
""" undo wrapper activity """
""" activities that do things """
from dataclasses import dataclass, field
from typing import List
from django.apps import apps
@ -9,23 +9,25 @@ from .ordered_collection import CollectionItem
@dataclass(init=False)
class Verb(ActivityObject):
"""generic fields for activities - maybe an unecessary level of
abstraction but w/e"""
"""generic fields for activities """
actor: str
object: ActivityObject
def action(self):
""" usually we just want to save, this can be overridden as needed """
self.object.to_model()
""" usually we just want to update and save """
# self.object may return None if the object is invalid in an expected way
# ie, Question type
if self.object:
self.object.to_model()
@dataclass(init=False)
class Create(Verb):
""" Create activity """
to: List
cc: List
to: List[str]
cc: List[str] = field(default_factory=lambda: [])
signature: Signature = None
type: str = "Create"
@ -34,26 +36,38 @@ class Create(Verb):
class Delete(Verb):
""" Create activity """
to: List
cc: List
to: List[str]
cc: List[str] = field(default_factory=lambda: [])
type: str = "Delete"
def action(self):
""" find and delete the activity object """
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
if not self.object:
return
if isinstance(self.object, str):
# Deleted users are passed as strings. Not wild about this fix
model = apps.get_model("bookwyrm.User")
obj = model.find_existing_by_remote_id(self.object)
else:
obj = self.object.to_model(save=False, allow_create=False)
if obj:
obj.delete()
# if we can't find it, we don't need to delete it because we don't have it
@dataclass(init=False)
class Update(Verb):
""" Update activity """
to: List
to: List[str]
type: str = "Update"
def action(self):
""" update a model instance from the dataclass """
self.object.to_model(allow_create=False)
if self.object:
self.object.to_model(allow_create=False)
@dataclass(init=False)
@ -162,7 +176,8 @@ class Remove(Add):
def action(self):
""" find and remove the activity object """
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
if obj:
obj.delete()
@dataclass(init=False)

View file

@ -219,6 +219,12 @@ def dict_from_mappings(data, mappings):
def get_data(url, params=None):
""" wrapper for request.get """
# check if the url is blocked
if models.FederatedServer.is_blocked(url):
raise ConnectorException(
"Attempting to load data from blocked url: {:s}".format(url)
)
try:
resp = requests.get(
url,

View file

@ -3,7 +3,7 @@ import datetime
from collections import defaultdict
from django import forms
from django.forms import ModelForm, PasswordInput, widgets
from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
from django.forms.widgets import Textarea
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@ -150,6 +150,12 @@ class LimitedEditUserForm(CustomForm):
help_texts = {f: None for f in fields}
class UserGroupForm(CustomForm):
class Meta:
model = models.User
fields = ["groups"]
class TagForm(CustomForm):
class Meta:
model = models.Tag
@ -281,3 +287,26 @@ class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "statuses", "note"]
class ServerForm(CustomForm):
class Meta:
model = models.FederatedServer
exclude = ["remote_id"]
class SortListForm(forms.Form):
sort_by = ChoiceField(
choices=(
("order", _("List Order")),
("title", _("Book Title")),
("rating", _("Rating")),
),
label=_("Sort By"),
)
direction = ChoiceField(
choices=(
("ascending", _("Ascending")),
("descending", _("Descending")),
),
)

View file

@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from bookwyrm.models import Connector, SiteSettings, User
from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
from bookwyrm.settings import DOMAIN
@ -107,6 +107,16 @@ def init_connectors():
)
def init_federated_servers():
""" big no to nazis """
built_in_blocks = ["gab.ai", "gab.com"]
for server in built_in_blocks:
FederatedServer.objects.create(
server_name=server,
status="blocked",
)
def init_settings():
SiteSettings.objects.create()
@ -118,4 +128,5 @@ class Command(BaseCommand):
init_groups()
init_permissions()
init_connectors()
init_federated_servers()
init_settings()

View file

@ -17,7 +17,7 @@ def populate_streams():
)
for user in users:
for stream in activitystreams.streams.values():
stream.populate_stream(user)
stream.populate_streams(user)
class Command(BaseCommand):

View file

@ -0,0 +1,37 @@
# Generated by Django 3.1.6 on 2021-04-07 18:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0062_auto_20210407_1545"),
]
operations = [
migrations.AddField(
model_name="federatedserver",
name="notes",
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name="federatedserver",
name="application_type",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="federatedserver",
name="application_version",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="federatedserver",
name="status",
field=models.CharField(
choices=[("federated", "Federated"), ("blocked", "Blocked")],
default="federated",
max_length=255,
),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.1.8 on 2021-04-10 16:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0063_auto_20210408_1556"),
("bookwyrm", "0063_auto_20210407_1827"),
]
operations = []

View file

@ -0,0 +1,13 @@
# Generated by Django 3.1.8 on 2021-04-11 17:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0064_auto_20210408_2208"),
("bookwyrm", "0064_merge_20210410_1633"),
]
operations = []

View file

@ -0,0 +1,27 @@
# Generated by Django 3.1.8 on 2021-04-12 15:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0065_merge_20210411_1702"),
]
operations = [
migrations.AddField(
model_name="user",
name="deactivation_reason",
field=models.CharField(
blank=True,
choices=[
("self_deletion", "Self Deletion"),
("moderator_deletion", "Moderator Deletion"),
("domain_block", "Domain Block"),
],
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,30 @@
from django.db import migrations
def forwards_func(apps, schema_editor):
# Set all values for ListItem.order
BookList = apps.get_model("bookwyrm", "List")
db_alias = schema_editor.connection.alias
for book_list in BookList.objects.using(db_alias).all():
for i, item in enumerate(book_list.listitem_set.order_by("id"), 1):
item.order = i
item.save()
def reverse_func(apps, schema_editor):
# null all values for ListItem.order
BookList = apps.get_model("bookwyrm", "List")
db_alias = schema_editor.connection.alias
for book_list in BookList.objects.using(db_alias).all():
for item in book_list.listitem_set.order_by("id"):
item.order = None
item.save()
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0066_user_deactivation_reason"),
]
operations = [migrations.RunPython(forwards_func, reverse_func)]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.1.6 on 2021-04-08 16:15
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0067_denullify_list_item_order"),
]
operations = [
migrations.AlterField(
model_name="listitem",
name="order",
field=bookwyrm.models.fields.IntegerField(),
),
migrations.AlterUniqueTogether(
name="listitem",
unique_together={("order", "book_list"), ("book", "book_list")},
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 3.1.8 on 2021-04-22 16:04
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0068_ordering_for_list_items"),
]
operations = [
migrations.AlterField(
model_name="author",
name="last_edited_by",
field=bookwyrm.models.fields.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name="book",
name="last_edited_by",
field=bookwyrm.models.fields.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -148,13 +148,17 @@ class ActivitypubMixin:
mentions = self.recipients if hasattr(self, "recipients") else []
# we always send activities to explicitly mentioned users' inboxes
recipients = [u.inbox for u in mentions or []]
recipients = [u.inbox for u in mentions or [] if not u.local]
# unless it's a dm, all the followers should receive the activity
if privacy != "direct":
# we will send this out to a subset of all remote users
queryset = user_model.objects.filter(
local=False,
queryset = (
user_model.viewer_aware_objects(user)
.filter(
local=False,
)
.distinct()
)
# filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers
@ -175,7 +179,7 @@ class ActivitypubMixin:
"inbox", flat=True
)
recipients += list(shared_inboxes) + list(inboxes)
return recipients
return list(set(recipients))
def to_activity_dataclass(self):
""" convert from a model to an activity """
@ -193,7 +197,7 @@ class ObjectMixin(ActivitypubMixin):
def save(self, *args, created=None, **kwargs):
""" broadcast created/updated/deleted objects as appropriate """
broadcast = kwargs.get("broadcast", True)
# this bonus kwarg woul cause an error in the base save method
# this bonus kwarg would cause an error in the base save method
if "broadcast" in kwargs:
del kwargs["broadcast"]
@ -359,6 +363,10 @@ class CollectionItemMixin(ActivitypubMixin):
activity_serializer = activitypub.CollectionItem
def broadcast(self, activity, sender, software="bookwyrm"):
""" only send book collection updates to other bookwyrm instances """
super().broadcast(activity, sender, software=software)
@property
def privacy(self):
""" inherit the privacy of the list, or direct if pending """
@ -371,6 +379,9 @@ class CollectionItemMixin(ActivitypubMixin):
def recipients(self):
""" the owner of the list is a direct recipient """
collection_field = getattr(self, self.collection_field)
if collection_field.user.local:
# don't broadcast to yourself
return []
return [collection_field.user]
def save(self, *args, broadcast=True, **kwargs):
@ -386,11 +397,11 @@ class CollectionItemMixin(ActivitypubMixin):
activity = self.to_add_activity(self.user)
self.broadcast(activity, self.user)
def delete(self, *args, **kwargs):
def delete(self, *args, broadcast=True, **kwargs):
""" broadcast a remove activity """
activity = self.to_remove_activity(self.user)
super().delete(*args, **kwargs)
if self.user.local:
if self.user.local and broadcast:
self.broadcast(activity, self.user)
def to_add_activity(self, user):
@ -524,7 +535,7 @@ def to_ordered_collection_page(
""" serialize and pagiante a queryset """
paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.page(page)
activity_page = paginated.get_page(page)
if id_only:
items = [s.remote_id for s in activity_page.object_list]
else:

View file

@ -33,4 +33,4 @@ class Image(Attachment):
)
caption = fields.TextField(null=True, blank=True, activitypub_field="name")
activity_serializer = activitypub.Image
activity_serializer = activitypub.Document

View file

@ -31,6 +31,36 @@ class BookWyrmModel(models.Model):
""" how to link to this object in the local app """
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
def visible_to_user(self, viewer):
""" is a user authorized to view an object? """
# make sure this is an object with privacy owned by a user
if not hasattr(self, "user") or not hasattr(self, "privacy"):
return None
# viewer can't see it if the object's owner blocked them
if viewer in self.user.blocks.all():
return False
# you can see your own posts and any public or unlisted posts
if viewer == self.user or self.privacy in ["public", "unlisted"]:
return True
# you can see the followers only posts of people you follow
if (
self.privacy == "followers"
and self.user.followers.filter(id=viewer.id).first()
):
return True
# you can see dms you are tagged in
if hasattr(self, "mention_users"):
if (
self.privacy == "direct"
and self.mention_users.filter(id=viewer.id).first()
):
return True
return False
@receiver(models.signals.post_save)
# pylint: disable=unused-argument

View file

@ -26,7 +26,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
max_length=255, blank=True, null=True, deduplication_field=True
)
last_edited_by = models.ForeignKey("User", on_delete=models.PROTECT, null=True)
last_edited_by = fields.ForeignKey(
"User",
on_delete=models.PROTECT,
null=True,
)
class Meta:
""" can't initialize this model, that wouldn't make sense """

View file

@ -1,17 +1,51 @@
""" connections to external ActivityPub servers """
from urllib.parse import urlparse
from django.db import models
from .base_model import BookWyrmModel
FederationStatus = models.TextChoices(
"Status",
[
"federated",
"blocked",
],
)
class FederatedServer(BookWyrmModel):
""" store which servers we federate with """
server_name = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else
status = models.CharField(max_length=255, default="federated")
status = models.CharField(
max_length=255, default="federated", choices=FederationStatus.choices
)
# is it mastodon, bookwyrm, etc
application_type = models.CharField(max_length=255, null=True)
application_version = models.CharField(max_length=255, null=True)
application_type = models.CharField(max_length=255, null=True, blank=True)
application_version = models.CharField(max_length=255, null=True, blank=True)
notes = models.TextField(null=True, blank=True)
def block(self):
""" block a server """
self.status = "blocked"
self.save()
# TODO: blocked servers
# deactivate all associated users
self.user_set.filter(is_active=True).update(
is_active=False, deactivation_reason="domain_block"
)
def unblock(self):
""" unblock a server """
self.status = "federated"
self.save()
self.user_set.filter(deactivation_reason="domain_block").update(
is_active=True, deactivation_reason=None
)
@classmethod
def is_blocked(cls, url):
""" look up if a domain is blocked """
url = urlparse(url)
domain = url.netloc
return cls.objects.filter(server_name=domain, status="blocked").exists()

View file

@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
return [i.remote_id for i in value.all()]
def field_from_activity(self, value):
items = []
if value is None or value is MISSING:
return []
return None
if not isinstance(value, list):
# If this is a link, we currently aren't doing anything with it
return None
items = []
for remote_id in value:
try:
validate_remote_id(remote_id)
@ -336,7 +339,7 @@ def image_serializer(value, alt):
else:
return None
url = "https://%s%s" % (DOMAIN, url)
return activitypub.Image(url=url, name=alt)
return activitypub.Document(url=url, name=alt)
class ImageField(ActivitypubFieldMixin, models.ImageField):

View file

@ -47,7 +47,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
@property
def collection_queryset(self):
""" list of books for this shelf, overrides OrderedCollectionMixin """
return self.books.filter(listitem__approved=True).all().order_by("listitem")
return self.books.filter(listitem__approved=True).order_by("listitem")
class Meta:
""" default sorting """
@ -67,7 +67,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
)
notes = fields.TextField(blank=True, null=True)
approved = models.BooleanField(default=True)
order = fields.IntegerField(blank=True, null=True)
order = fields.IntegerField()
endorsement = models.ManyToManyField("User", related_name="endorsers")
activity_serializer = activitypub.ListItem
@ -93,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
)
class Meta:
""" an opinionated constraint! you can't put a book on a list twice """
unique_together = ("book", "book_list")
# A book may only be placed into a list once, and each order in the list may be used only
# once
unique_together = (("book", "book_list"), ("order", "book_list"))
ordering = ("-created_date",)

View file

@ -50,11 +50,10 @@ class UserRelationship(BookWyrmModel):
),
]
def get_remote_id(self, status=None): # pylint: disable=arguments-differ
def get_remote_id(self):
""" use shelf identifier in remote_id """
status = status or "follows"
base_path = self.user_subject.remote_id
return "%s#%s/%d" % (base_path, status, self.id)
return "%s#follows/%d" % (base_path, self.id)
class UserFollows(ActivityMixin, UserRelationship):
@ -102,12 +101,15 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
def save(self, *args, broadcast=True, **kwargs):
""" make sure the follow or block relationship doesn't already exist """
# don't create a request if a follow already exists
# if there's a request for a follow that already exists, accept it
# without changing the local database state
if UserFollows.objects.filter(
user_subject=self.user_subject,
user_object=self.user_object,
).exists():
raise IntegrityError()
self.accept(broadcast_only=True)
return
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
@ -138,16 +140,25 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
notification_type=notification_type,
)
def accept(self):
def get_accept_reject_id(self, status):
""" get id for sending an accept or reject of a local user """
base_path = self.user_object.remote_id
return "%s#%s/%d" % (base_path, status, self.id or 0)
def accept(self, broadcast_only=False):
""" turn this request into the real deal"""
user = self.user_object
if not self.user_subject.local:
activity = activitypub.Accept(
id=self.get_remote_id(status="accepts"),
id=self.get_accept_reject_id(status="accepts"),
actor=self.user_object.remote_id,
object=self.to_activity(),
).serialize()
self.broadcast(activity, user)
if broadcast_only:
return
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
@ -156,7 +167,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
""" generate a Reject for this follow request """
if self.user_object.local:
activity = activitypub.Reject(
id=self.get_remote_id(status="rejects"),
id=self.get_accept_reject_id(status="rejects"),
actor=self.user_object.remote_id,
object=self.to_activity(),
).serialize()

View file

@ -48,7 +48,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
@property
def collection_queryset(self):
""" list of books for this shelf, overrides OrderedCollectionMixin """
return self.books.all().order_by("shelfbook")
return self.books.order_by("shelfbook")
def get_remote_id(self):
""" shelf identifier instead of id """

View file

@ -24,6 +24,16 @@ from .federated_server import FederatedServer
from . import fields, Review
DeactivationReason = models.TextChoices(
"DeactivationReason",
[
"self_deletion",
"moderator_deletion",
"domain_block",
],
)
class User(OrderedCollectionPageMixin, AbstractUser):
""" a user who wants to read books """
@ -111,6 +121,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
default=str(pytz.utc),
max_length=255,
)
deactivation_reason = models.CharField(
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
)
name_field = "username"
property_fields = [("following_link", "following")]
@ -132,13 +145,18 @@ class User(OrderedCollectionPageMixin, AbstractUser):
return self.name
return self.localname or self.username
@property
def deleted(self):
""" for consistent naming """
return not self.is_active
activity_serializer = activitypub.Person
@classmethod
def viewer_aware_objects(cls, viewer):
""" the user queryset filtered for the context of the logged in user """
queryset = cls.objects.filter(is_active=True)
if viewer.is_authenticated:
if viewer and viewer.is_authenticated:
queryset = queryset.exclude(blocks=viewer)
return queryset
@ -192,6 +210,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def to_activity(self, **kwargs):
"""override default AP serializer to add context object
idk if this is the best way to go about this"""
if not self.is_active:
return self.remote_id
activity_object = super().to_activity(**kwargs)
activity_object["@context"] = [
"https://www.w3.org/ns/activitystreams",
@ -270,6 +291,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
editable=False,
).save(broadcast=False)
def delete(self, *args, **kwargs):
""" deactivate rather than delete a user """
self.is_active = False
# skip the logic in this class's save()
super().save(*args, **kwargs)
@property
def local_path(self):
""" this model doesn't inherit bookwyrm model, so here we are """

View file

@ -24,7 +24,8 @@ EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = env("EMAIL_PORT", 587)
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env("EMAIL_USE_TLS", True)
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@ -97,6 +98,7 @@ WSGI_APPLICATION = "bookwyrm.wsgi.application"
# redis/activity streams settings
REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost")
REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
STREAMS = ["home", "local", "federated"]
@ -165,7 +167,7 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
# https://docs.djangoproject.com/en/3.1/howto/static-files/
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_URL = "/static/"

View file

@ -1,9 +1,13 @@
html {
scroll-behavior: smooth;
scroll-padding-top: 20%;
}
/* --- --- */
body {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.image {
overflow: hidden;
}
@ -25,17 +29,8 @@ html {
min-width: 75% !important;
}
/* --- "disabled" for non-buttons --- */
.is-disabled {
background-color: #dbdbdb;
border-color: #dbdbdb;
box-shadow: none;
color: #7a7a7a;
opacity: 0.5;
cursor: not-allowed;
}
/* --- SHELVING --- */
/** Shelving
******************************************************************************/
/** @todo Replace icons with SVG symbols.
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
@ -45,7 +40,9 @@ html {
margin-left: 0.5em;
}
/* --- TOGGLES --- */
/** Toggles
******************************************************************************/
.toggle-button[aria-pressed=true],
.toggle-button[aria-pressed=true]:hover {
background-color: hsl(171, 100%, 41%);
@ -57,12 +54,8 @@ html {
display: none;
}
.hidden {
display: none !important;
}
.hidden.transition-y,
.hidden.transition-x {
.transition-x.is-hidden,
.transition-y.is-hidden {
display: block !important;
visibility: hidden !important;
height: 0;
@ -71,16 +64,18 @@ html {
padding: 0;
}
.transition-x,
.transition-y {
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
transition-duration: 0.5s;
transition-timing-function: ease;
}
.transition-x {
transition-property: width, margin-left, margin-right, padding-left, padding-right;
transition-duration: 0.5s;
transition-timing-function: ease;
}
.transition-y {
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
}
@media (prefers-reduced-motion: reduce) {
@ -121,7 +116,9 @@ html {
content: '\e9d7';
}
/* --- BOOK COVERS --- */
/** Book covers
******************************************************************************/
.cover-container {
height: 250px;
width: max-content;
@ -186,7 +183,9 @@ html {
padding: 0.1em;
}
/* --- AVATAR --- */
/** Avatars
******************************************************************************/
.avatar {
vertical-align: middle;
display: inline;
@ -202,25 +201,57 @@ html {
min-height: 96px;
}
/* --- QUOTES --- */
.quote blockquote {
/** Statuses: Quotes
*
* \e906: icon-quote-open
* \e905: icon-quote-close
*
* The `content` class on the blockquote allows to apply styles to markdown
* generated HTML in the quote: https://bulma.io/documentation/elements/content/
*
* ```html
* <div class="quote block">
* <blockquote dir="auto" class="content mb-2">
* User generated quote in markdown
* </blockquote>
*
* <p> <a>Book Title</a> by <aclass="author">Author</a></p>
* </div>
* ```
******************************************************************************/
.quote > blockquote {
position: relative;
padding-left: 2em;
}
.quote blockquote::before,
.quote blockquote::after {
.quote > blockquote::before,
.quote > blockquote::after {
font-family: 'icomoon';
position: absolute;
}
.quote blockquote::before {
.quote > blockquote::before {
content: "\e906";
top: 0;
left: 0;
}
.quote blockquote::after {
.quote > blockquote::after {
content: "\e905";
right: 0;
}
/* States
******************************************************************************/
/* "disabled" for non-buttons */
.is-disabled {
background-color: #dbdbdb;
border-color: #dbdbdb;
box-shadow: none;
color: #7a7a7a;
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -1,10 +1,13 @@
/** @todo Replace icons with SVG symbols.
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?n5x55');
src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?n5x55') format('truetype'),
url('fonts/icomoon.woff?n5x55') format('woff'),
url('fonts/icomoon.svg?n5x55#icomoon') format('svg');
src: url('../fonts/icomoon.eot?n5x55');
src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?n5x55') format('truetype'),
url('../fonts/icomoon.woff?n5x55') format('woff'),
url('../fonts/icomoon.svg?n5x55#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;

View file

@ -0,0 +1,285 @@
/* exported BookWyrm */
/* globals TabGroup */
let BookWyrm = new class {
constructor() {
this.initOnDOMLoaded();
this.initReccuringTasks();
this.initEventListeners();
}
initEventListeners() {
document.querySelectorAll('[data-controls]')
.forEach(button => button.addEventListener(
'click',
this.toggleAction.bind(this))
);
document.querySelectorAll('.interaction')
.forEach(button => button.addEventListener(
'submit',
this.interact.bind(this))
);
document.querySelectorAll('.hidden-form input')
.forEach(button => button.addEventListener(
'change',
this.revealForm.bind(this))
);
document.querySelectorAll('[data-back]')
.forEach(button => button.addEventListener(
'click',
this.back)
);
}
/**
* Execute code once the DOM is loaded.
*/
initOnDOMLoaded() {
window.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.tab-group')
.forEach(tabs => new TabGroup(tabs));
});
}
/**
* Execute recurring tasks.
*/
initReccuringTasks() {
// Polling
document.querySelectorAll('[data-poll]')
.forEach(liveArea => this.polling(liveArea));
}
/**
* Go back in browser history.
*
* @param {Event} event
* @return {undefined}
*/
back(event) {
event.preventDefault();
history.back();
}
/**
* Update a counter with recurring requests to the API
* The delay is slightly randomized and increased on each cycle.
*
* @param {Object} counter - DOM node
* @param {int} delay - frequency for polling in ms
* @return {undefined}
*/
polling(counter, delay) {
const bookwyrm = this;
delay = delay || 10000;
delay += (Math.random() * 1000);
setTimeout(function() {
fetch('/api/updates/' + counter.dataset.poll)
.then(response => response.json())
.then(data => bookwyrm.updateCountElement(counter, data));
bookwyrm.polling(counter, delay * 1.25);
}, delay, counter);
}
/**
* Update a counter.
*
* @param {object} counter - DOM node
* @param {object} data - json formatted response from a fetch
* @return {undefined}
*/
updateCountElement(counter, data) {
const currentCount = counter.innerText;
const count = data.count;
if (count != currentCount) {
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
counter.innerText = count;
}
}
/**
* Toggle form.
*
* @param {Event} event
* @return {undefined}
*/
revealForm(event) {
let trigger = event.currentTarget;
let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0];
this.addRemoveClass(hidden, 'is-hidden', !hidden);
}
/**
* Execute actions on targets based on triggers.
*
* @param {Event} event
* @return {undefined}
*/
toggleAction(event) {
let trigger = event.currentTarget;
let pressed = trigger.getAttribute('aria-pressed') === 'false';
let targetId = trigger.dataset.controls;
// Toggle pressed status on all triggers controlling the same target.
document.querySelectorAll('[data-controls="' + targetId + '"]')
.forEach(otherTrigger => otherTrigger.setAttribute(
'aria-pressed',
otherTrigger.getAttribute('aria-pressed') === 'false'
));
// @todo Find a better way to handle the exception.
if (targetId && ! trigger.classList.contains('pulldown-menu')) {
let target = document.getElementById(targetId);
this.addRemoveClass(target, 'is-hidden', !pressed);
this.addRemoveClass(target, 'is-active', pressed);
}
// Show/hide pulldown-menus.
if (trigger.classList.contains('pulldown-menu')) {
this.toggleMenu(trigger, targetId);
}
// Show/hide container.
let container = document.getElementById('hide-' + targetId);
if (container) {
this.toggleContainer(container, pressed);
}
// Check checkbox, if appropriate.
let checkbox = trigger.dataset.controlsCheckbox;
if (checkbox) {
this.toggleCheckbox(checkbox, pressed);
}
// Set focus, if appropriate.
let focus = trigger.dataset.focusTarget;
if (focus) {
this.toggleFocus(focus);
}
}
/**
* Show or hide menus.
*
* @param {Event} event
* @return {undefined}
*/
toggleMenu(trigger, targetId) {
let expanded = trigger.getAttribute('aria-expanded') == 'false';
trigger.setAttribute('aria-expanded', expanded);
if (targetId) {
let target = document.getElementById(targetId);
this.addRemoveClass(target, 'is-active', expanded);
}
}
/**
* Show or hide generic containers.
*
* @param {object} container - DOM node
* @param {boolean} pressed - Is the trigger pressed?
* @return {undefined}
*/
toggleContainer(container, pressed) {
this.addRemoveClass(container, 'is-hidden', pressed);
}
/**
* Check or uncheck a checbox.
*
* @param {object} checkbox - DOM node
* @param {boolean} pressed - Is the trigger pressed?
* @return {undefined}
*/
toggleCheckbox(checkbox, pressed) {
document.getElementById(checkbox).checked = !!pressed;
}
/**
* Give the focus to an element.
* Only move the focus based on user interactions.
*
* @param {string} nodeId - ID of the DOM node to focus (button, link)
* @return {undefined}
*/
toggleFocus(nodeId) {
let node = document.getElementById(nodeId);
node.focus();
setTimeout(function() {
node.selectionStart = node.selectionEnd = 10000;
}, 0);
}
/**
* Make a request and update the UI accordingly.
* This function is used for boosts, favourites, follows and unfollows.
*
* @param {Event} event
* @return {undefined}
*/
interact(event) {
event.preventDefault();
const bookwyrm = this;
const form = event.currentTarget;
const relatedforms = document.querySelectorAll(`.${form.dataset.id}`);
// Toggle class on all related forms.
relatedforms.forEach(relatedForm => bookwyrm.addRemoveClass(
relatedForm,
'is-hidden',
relatedForm.className.indexOf('is-hidden') == -1
));
this.ajaxPost(form).catch(error => {
// @todo Display a notification in the UI instead.
console.warn('Request failed:', error);
});
}
/**
* Submit a form using POST.
*
* @param {object} form - Form to be submitted
* @return {Promise}
*/
ajaxPost(form) {
return fetch(form.action, {
method : "POST",
body: new FormData(form)
});
}
/**
* Add or remove a class based on a boolean condition.
*
* @param {object} node - DOM node to change class on
* @param {string} classname - Name of the class
* @param {boolean} add - Add?
* @return {undefined}
*/
addRemoveClass(node, classname, add) {
if (add) {
node.classList.add(classname);
} else {
node.classList.remove(classname);
}
}
}

View file

@ -1,17 +1,34 @@
/* exported toggleAllCheckboxes */
/**
* Toggle all descendant checkboxes of a target.
*
* Use `data-target="ID_OF_TARGET"` on the node being listened to.
*
* @param {Event} event - change Event
* @return {undefined}
*/
function toggleAllCheckboxes(event) {
const mainCheckbox = event.target;
(function() {
'use strict';
/**
* Toggle all descendant checkboxes of a target.
*
* Use `data-target="ID_OF_TARGET"` on the node on which the event is listened
* to (checkbox, button, link), where_ID_OF_TARGET_ should be the ID of an
* ancestor for the checkboxes.
*
* @example
* <input
* type="checkbox"
* data-action="toggle-all"
* data-target="failed-imports"
* >
* @param {Event} event
* @return {undefined}
*/
function toggleAllCheckboxes(event) {
const mainCheckbox = event.target;
document
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
.forEach(checkbox => checkbox.checked = mainCheckbox.checked);
}
document
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;});
}
.querySelectorAll('[data-action="toggle-all"]')
.forEach(input => {
input.addEventListener('change', toggleAllCheckboxes);
});
})();

View file

@ -1,20 +1,43 @@
/* exported updateDisplay */
/* globals addRemoveClass */
/* exported LocalStorageTools */
/* globals BookWyrm */
// set javascript listeners
function updateDisplay(e) {
// used in set reading goal
var key = e.target.getAttribute('data-id');
var value = e.target.getAttribute('data-value');
window.localStorage.setItem(key, value);
let LocalStorageTools = new class {
constructor() {
document.querySelectorAll('[data-hide]')
.forEach(t => this.setDisplay(t));
document.querySelectorAll('[data-hide="' + key + '"]')
.forEach(t => setDisplay(t));
}
function setDisplay(el) {
// used in set reading goal
var key = el.getAttribute('data-hide');
var value = window.localStorage.getItem(key);
addRemoveClass(el, 'hidden', value);
document.querySelectorAll('.set-display')
.forEach(t => t.addEventListener('click', this.updateDisplay.bind(this)));
}
/**
* Update localStorage, then display content based on keys in localStorage.
*
* @param {Event} event
* @return {undefined}
*/
updateDisplay(event) {
// used in set reading goal
let key = event.target.dataset.id;
let value = event.target.dataset.value;
window.localStorage.setItem(key, value);
document.querySelectorAll('[data-hide="' + key + '"]')
.forEach(node => this.setDisplay(node));
}
/**
* Toggle display of a DOM node based on its value in the localStorage.
*
* @param {object} node - DOM node to toggle.
* @return {undefined}
*/
setDisplay(node) {
// used in set reading goal
let key = node.dataset.hide;
let value = window.localStorage.getItem(key);
BookWyrm.addRemoveClass(node, 'is-hidden', value);
}
}

View file

@ -1,169 +0,0 @@
/* globals setDisplay TabGroup toggleAllCheckboxes updateDisplay */
// set up javascript listeners
window.onload = function() {
// buttons that display or hide content
document.querySelectorAll('[data-controls]')
.forEach(t => t.onclick = toggleAction);
// javascript interactions (boost/fav)
Array.from(document.getElementsByClassName('interaction'))
.forEach(t => t.onsubmit = interact);
// handle aria settings on menus
Array.from(document.getElementsByClassName('pulldown-menu'))
.forEach(t => t.onclick = toggleMenu);
// hidden submit button in a form
document.querySelectorAll('.hidden-form input')
.forEach(t => t.onchange = revealForm);
// polling
document.querySelectorAll('[data-poll]')
.forEach(el => polling(el));
// browser back behavior
document.querySelectorAll('[data-back]')
.forEach(t => t.onclick = back);
Array.from(document.getElementsByClassName('tab-group'))
.forEach(t => new TabGroup(t));
// display based on localstorage vars
document.querySelectorAll('[data-hide]')
.forEach(t => setDisplay(t));
// update localstorage
Array.from(document.getElementsByClassName('set-display'))
.forEach(t => t.onclick = updateDisplay);
// Toggle all checkboxes.
document
.querySelectorAll('[data-action="toggle-all"]')
.forEach(input => {
input.addEventListener('change', toggleAllCheckboxes);
});
};
function back(e) {
e.preventDefault();
history.back();
}
function polling(el, delay) {
delay = delay || 10000;
delay += (Math.random() * 1000);
setTimeout(function() {
fetch('/api/updates/' + el.getAttribute('data-poll'))
.then(response => response.json())
.then(data => updateCountElement(el, data));
polling(el, delay * 1.25);
}, delay, el);
}
function updateCountElement(el, data) {
const currentCount = el.innerText;
const count = data.count;
if (count != currentCount) {
addRemoveClass(el.closest('[data-poll-wrapper]'), 'hidden', count < 1);
el.innerText = count;
}
}
function revealForm(e) {
var hidden = e.currentTarget.closest('.hidden-form').getElementsByClassName('hidden')[0];
if (hidden) {
removeClass(hidden, 'hidden');
}
}
function toggleAction(e) {
var el = e.currentTarget;
var pressed = el.getAttribute('aria-pressed') == 'false';
var targetId = el.getAttribute('data-controls');
document.querySelectorAll('[data-controls="' + targetId + '"]')
.forEach(t => t.setAttribute('aria-pressed', (t.getAttribute('aria-pressed') == 'false')));
if (targetId) {
var target = document.getElementById(targetId);
addRemoveClass(target, 'hidden', !pressed);
addRemoveClass(target, 'is-active', pressed);
}
// show/hide container
var container = document.getElementById('hide-' + targetId);
if (container) {
addRemoveClass(container, 'hidden', pressed);
}
// set checkbox, if appropriate
var checkbox = el.getAttribute('data-controls-checkbox');
if (checkbox) {
document.getElementById(checkbox).checked = !!pressed;
}
// set focus, if appropriate
var focus = el.getAttribute('data-focus-target');
if (focus) {
var focusEl = document.getElementById(focus);
focusEl.focus();
setTimeout(function(){ focusEl.selectionStart = focusEl.selectionEnd = 10000; }, 0);
}
}
function interact(e) {
e.preventDefault();
ajaxPost(e.target);
var identifier = e.target.getAttribute('data-id');
Array.from(document.getElementsByClassName(identifier))
.forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1));
}
function toggleMenu(e) {
var el = e.currentTarget;
var expanded = el.getAttribute('aria-expanded') == 'false';
el.setAttribute('aria-expanded', expanded);
var targetId = el.getAttribute('data-controls');
if (targetId) {
var target = document.getElementById(targetId);
addRemoveClass(target, 'is-active', expanded);
}
}
function ajaxPost(form) {
fetch(form.action, {
method : "POST",
body: new FormData(form)
});
}
function addRemoveClass(el, classname, bool) {
if (bool) {
addClass(el, classname);
} else {
removeClass(el, classname);
}
}
function addClass(el, classname) {
var classes = el.className.split(' ');
if (classes.indexOf(classname) > -1) {
return;
}
el.className = classes.concat(classname).join(' ');
}
function removeClass(el, className) {
var classes = [];
if (el.className) {
classes = el.className.split(' ');
}
const idx = classes.indexOf(className);
if (idx > -1) {
classes.splice(idx, 1);
}
el.className = classes.join(' ');
}

View file

@ -6,24 +6,36 @@
{% block title %}{{ book.title }}{% endblock %}
{% block content %}
<div class="block">
{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
<div class="block" itemscope itemtype="https://schema.org/Book">
<div class="columns is-mobile">
<div class="column">
<h1 class="title">
{{ book.title }}{% if book.subtitle %}:
<small>{{ book.subtitle }}</small>{% endif %}
<span itemprop="name">
{{ book.title }}{% if book.subtitle %}:
<small>{{ book.subtitle }}</small>
{% endif %}
</span>
{% if book.series %}
<small class="has-text-grey-dark">({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})</small><br>
<meta itemprop="isPartOf" content="{{ book.series }}">
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
<small class="has-text-grey-dark">
({{ book.series }}
{% if book.series_number %} #{{ book.series_number }}{% endif %})
</small>
<br>
{% endif %}
</h1>
{% if book.authors %}
<h2 class="subtitle">
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
</h2>
{% endif %}
</div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% if user_authenticated and can_edit_book %}
<div class="column is-narrow">
<a href="{{ book.id }}/edit">
<span class="icon icon-pencil" title="{% trans "Edit Book" %}">
@ -44,7 +56,7 @@
{% include 'snippets/shelve_button/shelve_button.html' %}
</div>
{% if request.user.is_authenticated and not book.cover %}
{% 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" %}
@ -55,31 +67,16 @@
</div>
{% endif %}
<section class="content is-clipped">
<dl>
{% if book.isbn_13 %}
<div class="is-flex is-justify-content-space-between is-align-items-center">
<dt>{% trans "ISBN:" %}</dt>
<dd>{{ book.isbn_13 }}</dd>
<section class="is-clipped">
{% with book=book %}
<div class="content">
{% include 'book/publisher_info.html' %}
</div>
{% endif %}
{% if book.oclc_number %}
<div class="is-flex is-justify-content-space-between is-align-items-center">
<dt>{% trans "OCLC Number:" %}</dt>
<dd>{{ book.oclc_number }}</dd>
<div class="my-3">
{% include 'book/book_identifiers.html' %}
</div>
{% endif %}
{% if book.asin %}
<div class="is-flex is-justify-content-space-between is-align-items-center">
<dt>{% trans "ASIN:" %}</dt>
<dd>{{ book.asin }}</dd>
</div>
{% endif %}
</dl>
{% include 'book/publisher_info.html' with book=book %}
{% endwith %}
{% if book.openlibrary_key %}
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
@ -89,18 +86,35 @@
<div class="column is-three-fifths">
<div class="block">
<h3 class="field is-grouped">
<h3
class="field is-grouped"
itemprop="aggregateRating"
itemscope
itemtype="https://schema.org/AggregateRating"
>
<meta itemprop="ratingValue" content="{{ rating|floatformat }}">
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
<meta itemprop="reviewCount" content="{{ review_count }}">
{% include 'snippets/stars.html' with rating=rating %}
{% blocktrans count counter=review_count %}({{ review_count }} review){% plural %}({{ review_count }} reviews){% endblocktrans %}
{% blocktrans count counter=review_count trimmed %}
({{ review_count }} review)
{% plural %}
({{ review_count }} reviews)
{% endblocktrans %}
</h3>
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
{% with full=book|book_description itemprop='abstract' %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
{% if user_authenticated and can_edit_book and not book|book_description %}
{% trans 'Add Description' as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
<div class="box hidden" id="add-description-{{ book.id }}">
<div class="box is-hidden" id="add-description-{{ book.id }}">
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
{% csrf_token %}
<p class="fields is-grouped">
@ -138,7 +152,7 @@
{% endfor %}
</div>
{% if request.user.is_authenticated %}
{% if user_authenticated %}
<section class="block">
<header class="columns">
<h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2>
@ -150,7 +164,7 @@
{% if not readthroughs.exists %}
<p>{% trans "You don't have any reading activity for this book." %}</p>
{% endif %}
<section class="hidden box" id="add-readthrough">
<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">
@ -176,14 +190,15 @@
</div>
<div class="column is-one-fifth">
{% if book.subjects %}
<section class="content block">
<h2 class="title is-5">{% trans "Subjects" %}</h2>
<ul>
{% for subject in book.subjects %}
<li>{{ subject }}</li>
{% endfor %}
</ul>
</section>
<section class="content block">
<h2 class="title is-5">{% trans "Subjects" %}</h2>
<ul>
{% for subject in book.subjects %}
<li itemprop="about">{{ subject }}</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if book.subject_places %}
@ -229,43 +244,56 @@
{% endif %}
</div>
</div>
</div>
<div class="block" id="reviews">
{% for review in reviews %}
<div class="block">
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
</div>
{% endfor %}
<div class="block is-flex is-flex-wrap-wrap">
{% for rating in ratings %}
<div class="block mr-5">
<div class="media">
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
<div class="media-content">
<div>
<a href="{{ rating.user.local_path }}">{{ rating.user.display_name }}</a>
</div>
<div class="is-flex">
<p class="mr-1">{% trans "rated it" %}</p>
{% include 'snippets/stars.html' with rating=rating.rating %}
</div>
<div>
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
</div>
</div>
<div class="block" id="reviews">
{% for review in reviews %}
<div
class="block"
itemprop="review"
itemscope
itemtype="https://schema.org/Review"
>
{% with status=review hide_book=True depth=1 %}
{% include 'snippets/status/status.html' %}
{% endwith %}
</div>
</div>
{% endfor %}
</div>
<div class="block">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
<div class="block is-flex is-flex-wrap-wrap">
{% for rating in ratings %}
{% with user=rating.user %}
<div class="block mr-5">
<div class="media">
<div class="media-left">
{% include 'snippets/avatar.html' %}
</div>
<div class="media-content">
<div>
<a href="{{ user.local_path }}">{{ user.display_name }}</a>
</div>
<div class="is-flex">
<p class="mr-1">{% trans "rated it" %}</p>
{% include 'snippets/stars.html' with rating=rating.rating %}
</div>
<div>
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
</div>
</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
<div class="block">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
</div>
</div>
</div>
{% endwith %}
{% endblock %}
{% block scripts %}
<script src="/static/js/tabs.js"></script>
<script src="/static/js/vendor/tabs.js"></script>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% spaceless %}
{% load i18n %}
<dl>
{% if book.isbn_13 %}
<div class="is-flex">
<dt class="mr-1">{% trans "ISBN:" %}</dt>
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
</div>
{% endif %}
{% if book.oclc_number %}
<div class="is-flex">
<dt class="mr-1">{% trans "OCLC Number:" %}</dt>
<dd>{{ book.oclc_number }}</dd>
</div>
{% endif %}
{% if book.asin %}
<div class="is-flex">
<dt class="mr-1">{% trans "ASIN:" %}</dt>
<dd>{{ book.asin }}</dd>
</div>
{% endif %}
</dl>
{% endspaceless %}

View file

@ -98,7 +98,7 @@
<p class="mb-2">
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" required="" id="id_subtitle">
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
</p>
{% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p>
@ -109,7 +109,10 @@
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<p class="mb-2"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p>
<p class="mb-2">
<label class="label" for="id_series">{% trans "Series:" %}</label>
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
</p>
{% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}

View file

@ -25,7 +25,18 @@
{{ book.title }}
</a>
</h2>
{% include 'book/publisher_info.html' with book=book %}
{% with book=book %}
<div class="columns is-multiline">
<div class="column is-half">
{% include 'book/publisher_info.html' %}
</div>
<div class="column is-half ">
{% include 'book/book_identifiers.html' %}
</div>
</div>
{% endwith %}
</div>
<div class="column is-3">
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}

View file

@ -1,24 +1,70 @@
{% spaceless %}
{% load i18n %}
{% load humanize %}
<p>
{% if book.physical_format and not book.pages %}
{{ book.physical_format | title }}
{% elif book.physical_format and book.pages %}
{% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %}
{% elif book.pages %}
{% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %}
{% endif %}
{% with format=book.physical_format pages=book.pages %}
{% if format %}
{% comment %}
@todo The bookFormat property is limited to a list of values whereas the book edition is free text.
@see https://schema.org/bookFormat
{% endcomment %}
<meta itemprop="bookFormat" content="{{ format }}">
{% endif %}
{% if pages %}
<meta itemprop="numberOfPages" content="{{ pages }}">
{% endif %}
{% if format and not pages %}
{% blocktrans %}{{ format }}{% endblocktrans %}
{% elif format and pages %}
{% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
{% elif pages %}
{% blocktrans %}{{ pages }} pages{% endblocktrans %}
{% endif %}
{% endwith %}
</p>
{% if book.languages %}
<p>
{% blocktrans with languages=book.languages|join:", " %}{{ languages }} language{% endblocktrans %}
</p>
{% for language in book.languages %}
<meta itemprop="inLanguage" content="{{ language }}">
{% endfor %}
<p>
{% with languages=book.languages|join:", " %}
{% blocktrans %}{{ languages }} language{% endblocktrans %}
{% endwith %}
</p>
{% endif %}
<p>
{% if book.published_date and book.publishers %}
{% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
{% elif book.published_date %}
{% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %}
{% elif book.publishers %}
{% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %}
{% endif %}
{% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
{% if date or book.first_published_date %}
<meta
itemprop="datePublished"
content="{{ book.first_published_date|default:book.published_date|date:'Y-m-d' }}"
>
{% endif %}
{% comment %}
@todo The publisher property needs to be an Organization or a Person. Well be using Thing which is the more generic ancestor.
@see https://schema.org/Publisher
{% endcomment %}
{% if book.publishers %}
{% for publisher in book.publishers %}
<meta itemprop="publisher" content="{{ publisher }}">
{% endfor %}
{% endif %}
{% if date and publisher %}
{% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
{% elif date %}
{% blocktrans %}Published {{ date }}{% endblocktrans %}
{% elif publisher %}
{% blocktrans %}Published by {{ publisher }}.{% endblocktrans %}
{% endif %}
{% endwith %}
</p>
{% endspaceless %}

View file

@ -1,13 +1,34 @@
{% spaceless %}
{% load bookwyrm_tags %}
{% with 0|uuid as uuid %}
<div class="dropdown control{% if right %} is-right{% endif %}" id="menu-{{ uuid }}">
<button type="button" class="button dropdown-trigger pulldown-menu {{ class }}" aria-expanded="false" class="pulldown-menu" aria-haspopup="true" aria-controls="menu-options-{{ uuid }}" data-controls="menu-{{ uuid }}">
<div
id="menu-{{ uuid }}"
class="
dropdown control
{% if right %}is-right{% endif %}
"
>
<button
class="button dropdown-trigger pulldown-menu {{ class }}"
type="button"
aria-expanded="false"
aria-haspopup="true"
aria-controls="menu-options-{{ uuid }}"
data-controls="menu-{{ uuid }}"
>
{% block dropdown-trigger %}{% endblock %}
</button>
<div class="dropdown-menu">
<ul class="dropdown-content" role="menu" id="menu-options-{{ uuid }}">
<ul
id="menu-options-{{ uuid }}"
class="dropdown-content p-0 is-clipped"
role="menu"
>
{% block dropdown-list %}{% endblock %}
</ul>
</div>
</div>
{% endwith %}
{% endspaceless %}

View file

@ -1,5 +1,5 @@
{% load i18n %}
<section class="card hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
<section class="card is-hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
<header class="card-header has-background-white-ter">
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
{% block header %}{% endblock %}

View file

@ -1,7 +1,7 @@
{% load i18n %}
<div
role="dialog"
class="modal hidden"
class="modal is-hidden"
id="{{ controls_text }}-{{ controls_uid }}"
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
aria-modal="true"

View file

@ -29,7 +29,7 @@
{# announcements and system messages #}
{% if not activities.number > 1 %}
<a href="{{ request.path }}" class="transition-y hidden notification is-primary is-block" data-poll-wrapper>
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
{% blocktrans %}load <span data-poll="stream/{{ tab }}">0</span> unread status(es){% endblocktrans %}
</a>

View file

@ -104,5 +104,5 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/tabs.js"></script>
<script src="/static/js/vendor/tabs.js"></script>
{% endblock %}

View file

@ -20,7 +20,7 @@
{% if user == request.user %}
<div class="block">
{% now 'Y' as year %}
<section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal">
<section class="card {% if goal %}is-hidden{% endif %}" id="show-edit-goal">
<header class="card-header">
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {% blocktrans %}{{ year }} Reading Goal{% endblocktrans %}

View file

@ -26,7 +26,7 @@
</select>
</div>
<div class="field">
<label class="label" for="id_csv_field">{% trans "Data file:" %}</label>
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
{{ import_form.csv_file }}
</div>
</div>

View file

@ -1,13 +1,13 @@
{% load bookwyrm_tags %}
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<html lang="{% get_lang %}">
<head>
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link type="text/css" rel="stylesheet" href="/static/css/bulma.min.css">
<link type="text/css" rel="stylesheet" href="/static/css/format.css">
<link type="text/css" rel="stylesheet" href="/static/css/icons.css">
<link rel="stylesheet" href="/static/css/vendor/bulma.min.css">
<link rel="stylesheet" href="/static/css/vendor/icons.css">
<link rel="stylesheet" href="/static/css/bookwyrm.css">
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}/images/{{ site.favicon }}{% else %}/static/images/favicon.ico{% endif %}">
@ -22,165 +22,169 @@
<meta name="twitter:image:alt" content="BookWyrm Logo">
</head>
<body>
<nav class="navbar container" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
</a>
<form class="navbar-item column" action="/search/">
<div class="field has-addons">
<div class="control">
<input aria-label="{% trans 'Search for a book or user' %}" id="search-input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}">
</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>
</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>
</div>
<div class="navbar-menu" id="main-nav">
<div class="navbar-start">
{% if request.user.is_authenticated %}
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
{% trans "Your books" %}
<nav class="navbar" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
</a>
<a href="/#feed" class="navbar-item">
{% trans "Feed" %}
</a>
<a href="{% url 'lists' %}" class="navbar-item">
{% trans "Lists" %}
</a>
{% endif %}
</div>
<div class="navbar-end">
{% if request.user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<a
href="{{ request.user.local_path }}"
class="navbar-link pulldown-menu"
role="button"
aria-expanded="false"
tabindex="0"
aria-haspopup="true"
aria-controls="navbar-dropdown"
>
{% include 'snippets/avatar.html' with user=request.user %}
<span class="ml-2">{{ request.user.display_name }}</span>
</a>
<ul class="navbar-dropdown" id="navbar-dropdown">
<li>
<a href="{% url 'direct-messages' %}" class="navbar-item">
{% trans "Direct Messages" %}
</a>
</li>
<li>
<a href="{% url 'directory' %}" class="navbar-item">
{% trans 'Directory' %}
</a>
</li>
<li>
<a href="/import" class="navbar-item">
{% trans 'Import Books' %}
</a>
</li>
<li>
<a href="/preferences/profile" class="navbar-item">
{% trans 'Settings' %}
</a>
</li>
{% if perms.bookwyrm.create_invites or perms.moderate_users %}
<li class="navbar-divider" role="presentation"></li>
{% endif %}
{% if perms.bookwyrm.create_invites %}
<li>
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
{% trans 'Invites' %}
</a>
</li>
{% endif %}
{% if perms.bookwyrm.moderate_users %}
<li>
<a href="{% url 'settings-users' %}" class="navbar-item">
{% trans 'Admin' %}
</a>
</li>
{% endif %}
<li class="navbar-divider" role="presentation"></li>
<li>
<a href="/logout" class="navbar-item">
{% trans 'Log out' %}
</a>
</li>
</ul>
</div>
<div class="navbar-item">
<a href="/notifications" class="tags has-addons">
<span class="tag is-medium">
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
<span class="is-sr-only">{% trans "Notifications" %}</span>
</span>
</span>
<span class="{% if not request.user|notification_count %}hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
<span data-poll="notifications">{{ request.user | notification_count }}</span>
</span>
</a>
</div>
{% else %}
<div class="navbar-item">
{% if request.path != '/login' and request.path != '/login/' %}
<div class="columns">
<div class="column">
<form name="login" method="post" action="/login">
{% csrf_token %}
<div class="columns is-variable is-1">
<div class="column">
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
</div>
<div class="column">
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
</div>
<div class="column is-narrow">
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
</div>
</div>
</form>
<form class="navbar-item column" action="/search/">
<div class="field has-addons">
<div class="control">
<input aria-label="{% trans 'Search for a book or user' %}" id="search-input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}">
</div>
{% if site.allow_registration and request.path != '' and request.path != '/' %}
<div class="column is-narrow">
<a href="/" class="button is-link">
{% trans "Join" %}
</a>
<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>
</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>
</div>
<div class="navbar-menu" id="main-nav">
<div class="navbar-start">
{% if request.user.is_authenticated %}
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
{% trans "Your books" %}
</a>
<a href="/#feed" class="navbar-item">
{% trans "Feed" %}
</a>
<a href="{% url 'lists' %}" class="navbar-item">
{% trans "Lists" %}
</a>
{% endif %}
</div>
<div class="navbar-end">
{% if request.user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<a
href="{{ request.user.local_path }}"
class="navbar-link pulldown-menu"
role="button"
aria-expanded="false"
tabindex="0"
aria-haspopup="true"
aria-controls="navbar-dropdown"
>
{% include 'snippets/avatar.html' with user=request.user %}
<span class="ml-2">{{ request.user.display_name }}</span>
</a>
<ul class="navbar-dropdown" id="navbar-dropdown">
<li>
<a href="{% url 'direct-messages' %}" class="navbar-item">
{% trans "Direct Messages" %}
</a>
</li>
<li>
<a href="{% url 'directory' %}" class="navbar-item">
{% trans 'Directory' %}
</a>
</li>
<li>
<a href="/import" class="navbar-item">
{% trans 'Import Books' %}
</a>
</li>
<li>
<a href="/preferences/profile" class="navbar-item">
{% trans 'Settings' %}
</a>
</li>
{% if perms.bookwyrm.create_invites or perms.moderate_users %}
<li class="navbar-divider" role="presentation"></li>
{% endif %}
{% if perms.bookwyrm.create_invites %}
<li>
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
{% trans 'Invites' %}
</a>
</li>
{% endif %}
{% if perms.bookwyrm.moderate_users %}
<li>
<a href="{% url 'settings-users' %}" class="navbar-item">
{% trans 'Admin' %}
</a>
</li>
{% endif %}
<li class="navbar-divider" role="presentation"></li>
<li>
<a href="/logout" class="navbar-item">
{% trans 'Log out' %}
</a>
</li>
</ul>
</div>
<div class="navbar-item">
<a href="/notifications" class="tags has-addons">
<span class="tag is-medium">
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
<span class="is-sr-only">{% trans "Notifications" %}</span>
</span>
</span>
<span class="{% if not request.user|notification_count %}is-hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
<span data-poll="notifications">{{ request.user | notification_count }}</span>
</span>
</a>
</div>
{% else %}
<div class="navbar-item">
{% if request.path != '/login' and request.path != '/login/' %}
<div class="columns">
<div class="column">
<form name="login" method="post" action="/login">
{% csrf_token %}
<div class="columns is-variable is-1">
<div class="column">
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
</div>
<div class="column">
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
</div>
<div class="column is-narrow">
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
</div>
</div>
</form>
</div>
{% if site.allow_registration and request.path != '' and request.path != '/' %}
<div class="column is-narrow">
<a href="/" class="button is-link">
{% trans "Join" %}
</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</nav>
<div class="section container">
{% block content %}
{% endblock %}
<div class="section is-flex-grow-1">
<div class="container">
{% block content %}
{% endblock %}
</div>
</div>
<footer class="footer">
@ -212,7 +216,7 @@
<script>
var csrf_token = '{{ csrf_token }}';
</script>
<script src="/static/js/shared.js"></script>
<script src="/static/js/bookwyrm.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -13,10 +13,10 @@
<div class="columns mt-3">
<section class="column is-three-quarters">
{% if not items.exists %}
{% if not items.object_list.exists %}
<p>{% trans "This list is currently empty" %}</p>
{% else %}
<ol>
<ol start="{{ items.start_index }}">
{% for item in items %}
<li class="block pb-3">
<div class="card">
@ -30,11 +30,27 @@
{% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}
</div>
</div>
<div class="card-footer has-background-white-bis">
<div class="card-footer has-background-white-bis is-align-items-baseline">
<div class="card-footer-item">
<div>
<p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
</div>
</div>
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
<div class="card-footer-item">
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
<div class="field has-addons mb-0">
{% csrf_token %}
<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>
</div>
<label for="input-list-position" class="help">{% trans "List position" %}</label>
</form>
</div>
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
{% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}">
@ -47,10 +63,27 @@
{% endfor %}
</ol>
{% endif %}
{% include "snippets/pagination.html" with page=items %}
</section>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<section class="column is-one-quarter content">
<h2>{% trans "Sort List" %}</h2>
<form name="sort" action="{% url 'list' list.id %}" method="GET" class="block">
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
<div class="select is-fullwidth">
{{ sort_form.sort_by }}
</div>
<label class="label" for="id_direction">{% trans "Direction" %}</label>
<div class="select is-fullwidth">
{{ sort_form.direction }}
</div>
<div>
<button class="button is-primary is-fullwidth" type="submit">
{% trans "Sort List" %}
</button>
</div>
</form>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2>
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
<div class="field has-addons">
@ -93,7 +126,7 @@
</div>
{% endif %}
{% endfor %}
</section>
{% endif %}
</section>
</div>
{% endblock %}

View file

@ -15,10 +15,12 @@
{% endif %}
</h1>
</div>
{% if request.user.is_authenticated %}
<div class="column is-narrow">
{% trans "Create List" as button_text %}
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text=button_text focus="create-list-header" %}
</div>
{% endif %}
</header>
<div class="block">

View file

@ -1,5 +1,6 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
@ -14,23 +15,9 @@
{% include 'moderation/report_preview.html' with report=report %}
</div>
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<p><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' report.user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="deactivate" method="post" action="{% url 'settings-report-deactivate' report.id %}">
{% csrf_token %}
{% if report.user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Deactivate user" %}</button>
{% else %}
<button class="button">{% trans "Reactivate user" %}</button>
{% endif %}
</form>
</div>
</div>
{% include 'user_admin/user_info.html' with user=report.user %}
{% include 'user_admin/user_moderation_actions.html' with user=report.user %}
<div class="block">
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3>

View file

@ -15,7 +15,9 @@
{% csrf_token %}
<input type="hidden" name="reporter" value="{{ reporter.id }}">
<input type="hidden" name="user" value="{{ user.id }}">
{% if status %}
<input type="hidden" name="statuses" value="{{ status.id }}">
{% endif %}
<section class="content">
<p>{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}</p>

View file

@ -8,6 +8,7 @@
{% trans "Reports" %}
{% endif %}
{% endblock %}
{% block header %}
{% if server %}
{% blocktrans with server_name=server.server_name %}Reports: <small>{{ server_name }}</small>{% endblocktrans %}
@ -29,6 +30,8 @@
</ul>
</div>
{% include 'user_admin/user_admin_filters.html' %}
<div class="block">
{% if not reports %}
<em>{% trans "No reports found." %}</em>

View file

@ -123,7 +123,7 @@
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
{{ related_status.published_date | post_date }}
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>
</div>

View file

@ -37,7 +37,7 @@
</div>
{% endif %}
<div class="{% if local_results.results %}hidden{% endif %}" id="more-results">
<div class="{% if local_results.results %}is-hidden{% endif %}" id="more-results">
{% for result_set in book_results|slice:"1:" %}
{% if result_set.results %}
<section class="block">

View file

@ -6,7 +6,14 @@
{% block content %}
<header class="block column is-offset-one-quarter pl-1">
<h1 class="title">{% block header %}{% endblock %}</h1>
<div class="columns is-mobile">
<div class="column">
<h1 class="title">{% block header %}{% endblock %}</h1>
</div>
<div class="column is-narrow">
{% block edit-button %}{% endblock %}
</div>
</div>
</header>
<div class="block columns">

View file

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

View file

@ -1,67 +1,116 @@
{% extends 'settings/admin_layout.html' %}
{% block title %}{{ server.server_name }}{% endblock %}
{% load i18n %}
{% load bookwyrm_tags %}
{% block header %}
{{ server.server_name }}
{% if server.status == "blocked" %}<span class="icon icon-x has-text-danger is-size-5" title="{% trans 'Blocked' %}"><span class="is-sr-only">{% trans "Blocked" %}</span></span>
{% endif %}
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
{% endblock %}
{% block panel %}
<div class="columns">
<section class="column is-half content">
<h2 class="title is-4">{% trans "Details" %}</h2>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
</section>
<section class="column is-half content">
<h2 class="title is-4">{% trans "Activity" %}</h2>
<dl>
<div class="is-flex">
<dt>{% trans "Users:" %}</dt>
<dd>
{{ users.count }}
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Reports:" %}</dt>
<dd>
{{ reports.count }}
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Followed by us:" %}</dt>
<dd>
{{ followed_by_us.count }}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Followed by them:" %}</dt>
<dd>
{{ followed_by_them.count }}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Blocked by us:" %}</dt>
<dd>
{{ blocked_by_us.count }}
</dd>
</div>
</dl>
</section>
</div>
<section class="block content">
<h2 class="title is-4">{% trans "Details" %}</h2>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
<header class="columns is-mobile">
<div class="column">
<h2 class="title is-4 mb-0">{% trans "Notes" %}</h2>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
<div class="column is-narrow">
{% trans "Edit" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-notes" %}
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>Federated</dd>
</div>
</dl>
</header>
{% if server.notes %}
<p id="hide-edit-notes">{{ server.notes|to_markdown|safe }}</p>
{% endif %}
<form class="box is-hidden" method="POST" action="{% url 'settings-federated-server' server.id %}" id="edit-notes">
{% csrf_token %}
<p>
<label class="is-sr-only" for="id_notes">Notes:</label>
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ server.notes|default:"" }}</textarea>
</p>
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="edit-notes" %}
</form>
</section>
<section class="block content">
<h2 class="title is-4">{% trans "Activity" %}</h2>
<dl>
<div class="is-flex">
<dt>{% trans "Users:" %}</dt>
<dd>
{{ users.count }}
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Reports:" %}</dt>
<dd>
{{ reports.count }}
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Followed by us:" %}</dt>
<dd>
{{ followed_by_us.count }}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Followed by them:" %}</dt>
<dd>
{{ followed_by_them.count }}
</dd>
</div>
<div class="is-flex">
<dt>{% trans "Blocked by us:" %}</dt>
<dd>
{{ blocked_by_us.count }}
</dd>
</div>
</dl>
<h2 class="title is-4">{% trans "Actions" %}</h2>
{% if server.status != 'blocked' %}
<form class="block" method="post" action="{% url 'settings-federated-server-block' server.id %}">
{% csrf_token %}
<button class="button is-danger">{% trans "Block" %}</button>
<p class="help">{% trans "All users from this instance will be deactivated." %}</p>
</form>
{% else %}
<form class="block" method="post" action="{% url 'settings-federated-server-unblock' server.id %}">
{% csrf_token %}
<button class="button">{% trans "Un-block" %}</button>
<p class="help">{% trans "All users from this instance will be re-activated." %}</p>
</form>
{% endif %}
</section>
{% endblock %}

View file

@ -4,8 +4,15 @@
{% block header %}{% trans "Federated Servers" %}{% endblock %}
{% block panel %}
{% block edit-button %}
<a href="{% url 'settings-import-blocklist' %}">
<span class="icon icon-plus" title="{% trans 'Add server' %}">
<span class="is-sr-only">{% trans "Add server" %}</span>
</span>
</a>
{% endblock %}
{% block panel %}
<table class="table is-striped">
<tr>
{% url 'settings-federation' as url %}

View file

@ -0,0 +1,67 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% block title %}{% trans "Add server" %}{% endblock %}
{% block header %}
{% trans "Import Blocklist" %}
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
{% endblock %}
{% block panel %}
<div class="tabs">
<ul>
{% url 'settings-import-blocklist' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Import block list" %}</a>
</li>
{% url 'settings-add-federated-server' as url %}
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
<a href="{{ url }}">{% trans "Add server" %}</a>
</li>
</ul>
</div>
{% if succeeded and not failed %}
<p class="notification is-primary">{% trans "Success!" %}</p>
{% elif succeeded or failed %}
<div class="block content">
{% if succeeded %}
<p>{% trans "Successfully blocked:" %} {{ succeeded }}</p>
{% endif %}
<p>{% trans "Failed:" %}</p>
<ul>
{% for item in failed %}
<li>
<pre>
{{ item }}
</pre>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form method="POST" action="{% url 'settings-import-blocklist' %}" enctype="multipart/form-data">
{% csrf_token %}
<div class="field">
<label class="label" for="id_file">JSON data:</label>
<aside class="help">
Expects a json file in the format provided by <a href="https://fediblock.org/" target="_blank" rel=”noopener”>FediBlock</a>, with a list of entries that have <code>instance</code> and <code>url</code> fields. For example:
<pre>
[
{
"instance": "example.server.com",
"url": "https://link.to.more/info"
},
...
]
</pre>
</aside>
<input type="file" name="json_file" required="" id="id_file">
</div>
<button type="submit" class="button is-primary">{% trans "Import" %}</button>
</form>
{% endblock %}

View file

@ -1 +1,17 @@
{% for author in book.authors.all %}<a href="/author/{{ author.id }}" class="author">{{ author.name }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}
{% spaceless %}
{% comment %}
@todo The author property needs to be an Organization or a Person. Well be using Thing which is the more generic ancestor.
@see https://schema.org/Author
{% endcomment %}
{% for author in book.authors.all %}
<a
href="/author/{{ author.id }}"
class="author"
itemprop="author"
itemscope
itemtype="https://schema.org/Thing"
><span
itemprop="name"
>{{ author.name }}<span></a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% endspaceless %}

View file

@ -1,13 +1,29 @@
{% spaceless %}
{% load bookwyrm_tags %}
{% load i18n %}
<div class="cover-container is-{{ size }}">
{% if book.cover %}
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}">
{% else %}
<div class="no-cover book-cover">
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">
<div>
<p>{{ book.alt_text }}</p>
{% if book.cover %}
<img
class="book-cover"
src="/images/{{ book.cover }}"
alt="{{ book.alt_text }}"
title="{{ book.alt_text }}"
itemprop="thumbnailUrl"
>
{% else %}
<div class="no-cover book-cover">
<img
class="book-cover"
src="/static/images/no_cover.jpg"
alt="{% trans "No cover" %}"
>
<div>
<p>{{ book.alt_text }}</p>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endspaceless %}

View file

@ -2,7 +2,7 @@
{% load i18n %}
{% with status.id|uuid as uuid %}
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
@ -10,7 +10,7 @@
</span>
</button>
</form>
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small is-primary" type="submit">
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}">

View file

@ -1,5 +1,5 @@
{% load i18n %}
<div class="control{% if not parent_status.content_warning and not draft.content_warning %} hidden{% endif %}" id="spoilers-{{ uuid }}">
<div class="control{% if not parent_status.content_warning and not draft.content_warning %} is-hidden{% endif %}" id="spoilers-{{ uuid }}">
<label class="is-sr-only" for="id_content_warning-{{ uuid }}">{% trans "Spoiler alert:" %}</label>
<input
type="text"

View file

@ -6,14 +6,16 @@
<input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}">
{% if type == 'review' %}
<div class="control">
<div class="field">
<label class="label" for="id_name_{{ book.id }}_{{ type }}">{% trans "Title" %}:</label>
<input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'" value="{% firstof draft.name ''%}">
<div class="control">
<input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'" value="{% firstof draft.name ''%}">
</div>
</div>
{% endif %}
<div class="control">
<div class="field">
{% if type != 'reply' and type != 'direct' %}
<label class="label" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}">
<label class="label{% if type == 'review' %} mb-0{% endif %}" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}">
{% if type == 'comment' %}
{% trans "Comment:" %}
{% elif type == 'quotation' %}
@ -25,28 +27,37 @@
{% endif %}
{% if type == 'review' %}
<fieldset>
<fieldset class="mb-1">
<legend class="is-sr-only">{% trans "Rating" %}</legend>
{% include 'snippets/form_rate_stars.html' with book=book type=type|default:'summary' default_rating=draft.rating %}
</fieldset>
{% endif %}
{% if type == 'quotation' %}
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
{% else %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" {% if type == 'reply' %} aria-label="Reply"{% endif %} required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
{% endif %}
<div class="control">
{% if type == 'quotation' %}
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
{% elif type == 'reply' %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" aria-label="{% trans 'Reply' %}" required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
{% else %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.content|default:'' }}</textarea>
{% endif %}
</div>
</div>
{# Supplemental fields #}
{% if type == 'quotation' %}
<div class="control">
<div class="field">
<label class="label" for="id_content_quote-{{ book.id }}">{% trans "Comment" %}:</label>
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea is-small" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea>
<div class="control">
<textarea name="content" class="textarea" rows="3" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea>
</div>
</div>
{% elif type == 'comment' %}
<div class="control">
<div>
{% active_shelf book as active_shelf %}
{% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %}
@ -58,11 +69,13 @@
<div class="control">
<input aria-label="{% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}" class="input" type="number" min="0" name="progress" size="3" value="{% firstof draft.progress readthrough.progress '' %}" id="progress-{{ uuid }}">
</div>
<div class="control select">
<select name="progress_mode" aria-label="Progress mode">
<option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
<option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
</select>
<div class="control">
<div class="select">
<select name="progress_mode" aria-label="Progress mode">
<option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
<option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
</select>
</div>
</div>
</div>
{% if readthrough.progress_mode == 'PG' and book.pages %}
@ -73,9 +86,12 @@
{% endif %}
</div>
{% endif %}
<input type="checkbox" class="hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
{# bottom bar #}
<div class="columns pt-1">
<input type="checkbox" class="is-hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
<div class="columns mt-1">
<div class="field has-addons column">
<div class="control">
{% trans "Include spoiler alert" as button_text %}

View file

@ -1,7 +1,7 @@
{% load bookwyrm_tags %}
{% load i18n %}
{% with status.id|uuid as uuid %}
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small" type="submit">
<span class="icon icon-heart" title="{% trans 'Like status' %}">
@ -9,7 +9,7 @@
</span>
</button>
</form>
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-primary is-small" type="submit">
<span class="icon icon-heart" title="{% trans 'Un-like status' %}">

View file

@ -11,7 +11,7 @@
</span>
</h2>
<form class="hidden mt-3" id="filters" method="get" action="{{ request.path }}" tabindex="0">
<form class="is-hidden mt-3" id="filters" method="get" action="{{ request.path }}" tabindex="0">
{% if sort %}
<input type="hidden" name="sort" value="{{ sort }}">
{% endif %}

View file

@ -6,12 +6,12 @@
<div class="field{% if not minimal %} has-addons{% else %} mb-0{% endif %}">
<div class="control">
<form action="{% url 'follow' %}" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all or request.user in user.follower_requests.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
<form action="{% url 'follow' %}" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all or request.user in user.follower_requests.all %}is-hidden{%endif %}" data-id="follow-{{ user.id }}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-small{% if not minimal %} is-link{% endif %}" type="submit">{% trans "Follow" %}</button>
</form>
<form action="{% url 'unfollow' %}" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all and not request.user in user.follower_requests.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
<form action="{% url 'unfollow' %}" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all and not request.user in user.follower_requests.all %}is-hidden{%endif %}" data-id="follow-{{ user.id }}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
{% if user.manually_approves_followers and request.user not in user.followers.all %}

View file

@ -11,7 +11,7 @@
{% include 'snippets/form_rate_stars.html' with book=book classes='mb-1 has-text-warning-dark' default_rating=book|user_rating:request.user %}
<div class="field has-addons hidden">
<div class="field has-addons is-hidden">
<div class="control">
{% include 'snippets/privacy_select.html' with class="is-small" %}
</div>

View file

@ -24,7 +24,7 @@
{% if readthrough.progress %}
{% trans "Show all updates" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="updates" controls_uid=readthrough.id class="is-small" %}
<ul id="updates-{{ readthrough.id }}" class="hidden">
<ul id="updates-{{ readthrough.id }}" class="is-hidden">
{% for progress_update in readthrough.progress_updates %}
<li>
<form name="delete-update" action="/delete-progressupdate" method="POST">
@ -67,7 +67,7 @@
</div>
</div>
<div class="box hidden" id="edit-readthrough-{{ readthrough.id }}" tabindex="0">
<div class="box is-hidden" id="edit-readthrough-{{ readthrough.id }}" tabindex="0">
<h3 class="title is-5">{% trans "Edit read dates" %}</h3>
<form name="edit-readthrough" action="/edit-readthrough" method="post">
{% include 'snippets/readthrough_form.html' with readthrough=readthrough %}

View file

@ -7,23 +7,23 @@
{% block dropdown-list %}
{% for shelf in request.user.shelf_set.all %}
<li role="menuitem">
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post">
<li role="menuitem" class="dropdown-item p-0">
<form name="shelve" action="/shelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="change-shelf-from" value="{{ current.identifier }}">
<input type="hidden" name="shelf" value="{{ shelf.identifier }}">
<button class="button is-fullwidth is-small shelf-option" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}><span>{{ shelf.name }}</span></button>
<button class="button is-fullwidth is-small shelf-option is-radiusless is-white" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}><span>{{ shelf.name }}</span></button>
</form>
</li>
{% endfor %}
<li class="navbar-divider" role="presentation"></li>
<li>
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/unshelve/" method="post">
<li class="navbar-divider" role="separator"></li>
<li role="menuitem" class="dropdown-item p-0">
<form name="shelve" action="/unshelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="shelf" value="{{ current.id }}">
<button class="button is-fullwidth is-small is-danger is-light" type="submit">{% trans "Remove" %}</button>
<button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove" %}</button>
</form>
</li>
{% endblock %}

View file

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

View file

@ -2,8 +2,8 @@
{% load i18n %}
{% for shelf in shelves %}
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
{% if dropdown %}<li role="menuitem">{% endif %}
<div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}hidden{% endif %}">
{% if dropdown %}<li role="menuitem" class="dropdown-item p-0">{% endif %}
<div class="{% if not dropdown and active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}">
{% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% trans "Start reading" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %}
@ -30,24 +30,20 @@
{% if dropdown %}
{% if readthrough and active_shelf.shelf.identifier != 'read' %}
<li role="menuitem">
<div class="dropdown-item pt-0 pb-0">
{% trans "Update progress" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="progress-update" controls_uid=button_uuid focus="modal-title-progress-update" %}
</div>
<li role="menuitem" class="dropdown-item p-0">
{% trans "Update progress" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="progress-update" controls_uid=button_uuid focus="modal-title-progress-update" %}
</li>
{% endif %}
{% if active_shelf.shelf %}
<li role="menuitem">
<div class="dropdown-item pt-0 pb-0">
<form name="shelve" action="/unshelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}">
<button class="button is-fullwidth is-small is-danger is-light" type="submit">{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %}</button>
</form>
</div>
<li role="menuitem" class="dropdown-item p-0">
<form name="shelve" action="/unshelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}">
<button class="button is-fullwidth is-small{% if dropdown %} is-radiusless{% endif %} is-danger is-light" type="submit">{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %}</button>
</form>
</li>
{% endif %}

View file

@ -1,7 +1,7 @@
{% spaceless %}
{% load i18n %}
<p class="stars">
<span class="stars">
<span class="is-sr-only">
{% if rating %}
{% blocktranslate trimmed with rating=rating|floatformat count counter=rating|length %}
@ -23,5 +23,5 @@
aria-hidden="true"
></span>
{% endfor %}
</p>
</span>
{% endspaceless %}

View file

@ -1,14 +0,0 @@
{% load bookwyrm_tags %}
<div class="columns">
<div class="column is-narrow">
<div>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
<div class="column">
<h3 class="title is-6">{% include 'snippets/book_titleby.html' with book=book %}</h3>
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
</div>
</div>

View file

@ -0,0 +1,128 @@
{% load bookwyrm_tags %}
{% load i18n %}
{% with status_type=status.status_type %}
<div
class="block"
{% if status_type == "Review" %}
itemprop="rating"
itemtype="https://schema.org/Rating"
{% endif %}
>
<div class="columns">
{% if not hide_book %}
{% with book=status.book|default:status.mention_books.first %}
{% if book %}
<div class="column is-narrow is-hidden-mobile">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
{% endif %}
{% endwith %}
{% endif %}
<article class="column">
{% if status_type == 'Review' %}
<header class="mb-2">
<h3
class="title is-5 has-subtitle"
dir="auto"
itemprop="name"
>
{{ status.name|escape }}
</h3>
<h4 class="subtitle is-6">
<span
class="is-hidden"
{% if status_type == "Review" %}
itemprop="reviewRating"
itemscope
itemtype="https://schema.org/Rating"
{% endif %}
>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
</h4>
</header>
{% endif %}
{% if status.content_warning %}
<div>
<p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/open_button.html' %}
{% endwith %}
</div>
{% endif %}
<div
{% if status.content_warning %}
id="show-status-cw-{{ status.id }}"
class="is-hidden"
{% endif %}
>
{% if status.content_warning %}
{% trans "Show less" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/close_button.html' %}
{% endwith %}
{% endif %}
{% if status.quote %}
<div class="quote block">
<blockquote dir="auto" class="content mb-2">{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
{% with full=status.content|safe no_trim=status.content_warning itemprop="reviewBody" %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% endif %}
{% if status.attachments.exists %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a
href="/images/{{ attachment.image }}"
target="_blank"
aria-label="{% trans 'Open image in new window' %}"
>
<img
src="/images/{{ attachment.image }}"
{% if attachment.caption %}
alt="{{ attachment.caption }}"
title="{{ attachment.caption }}"
{% endif %}
>
</a>
</figure>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</article>
</div>
</div>
{% endwith %}

View file

@ -0,0 +1,23 @@
{% spaceless %}
{% load bookwyrm_tags %}
{% load i18n %}
{% if not hide_book %}
{% with book=status.book|default:status.mention_books.first %}
<div class="columns is-mobile">
<div class="column is-narrow">
<div>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</div>
</div>
<div class="column">
<h3 class="title is-6 mb-1">{% include 'snippets/book_titleby.html' with book=book %}</h3>
<p>{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:20 }}</p>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
{% endwith %}
{% endif %}
{% endspaceless %}

View file

@ -0,0 +1,85 @@
{% extends 'components/card.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block card-header %}
<h3 class="card-header-title has-background-white-ter is-block">
{% include 'snippets/status/status_header.html' with status=status %}
</h3>
{% endblock %}
{% block card-content %}{% endblock %}
{% block card-footer %}
<div class="card-footer-item">
{% if moderation_mode and perms.bookwyrm.moderate_post %}
{# moderation options #}
<form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}
</button>
</form>
{% elif no_interact %}
{# nothing here #}
{% elif request.user.is_authenticated %}
<div class="field has-addons">
<div class="control">
{% trans "Reply" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text=button_text icon="comment" class="is-small toggle-button" focus="id_content_reply" %}
</div>
<div class="control">
{% include 'snippets/boost_button.html' with status=status %}
</div>
<div class="control">
{% include 'snippets/fav_button.html' with status=status %}
</div>
</div>
{% else %}
<a href="/login">
<span class="icon icon-comment" title="{% trans 'Reply' %}">
<span class="is-sr-only">{% trans "Reply" %}</span>
</span>
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
<span class="is-sr-only">{% trans "Boost status" %}</span>
</span>
<span class="icon icon-heart" title="{% trans 'Like status' %}">
<span class="is-sr-only">{% trans "Like status" %}</span>
</span>
</a>
{% endif %}
</div>
<div class="card-footer-item">
{% include 'snippets/privacy-icons.html' with item=status %}
</div>
<div class="card-footer-item">
<a href="{{ status.remote_id }}">{{ status.published_date|timesince }}</a>
</div>
{% if not moderation_mode %}
<div class="card-footer-item">
{% include 'snippets/status/status_options.html' with class="is-small" right=True %}
</div>
{% endif %}
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %}
{% with status.id|uuid as uuid %}
<section class="is-hidden" id="show-comment-{{ status.id }}">
<div class="card-footer">
<div class="card-footer-item">
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}
</div>
</div>
</section>
{% endwith %}
{% endif %}
{% endblock %}

View file

@ -1,90 +1,14 @@
{% extends 'components/card.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block card-header %}
<h3 class="card-header-title has-background-white-ter is-block">
{% include 'snippets/status/status_header.html' with status=status %}
</h3>
{% endblock %}
{% extends 'snippets/status/layout.html' %}
{% block card-content %}
{% include 'snippets/status/status_content.html' with status=status %}
{% endblock %}
{% with status_type=status.status_type %}
{% block card-footer %}
<div class="card-footer-item">
{% if moderation_mode and perms.bookwyrm.moderate_post %}
{# moderation options #}
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}
</button>
</form>
{% elif no_interact %}
{# nothing here #}
{% elif request.user.is_authenticated %}
<div class="field has-addons">
<div class="control">
{% trans "Reply" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text=button_text icon="comment" class="is-small toggle-button" focus="id_content_reply" %}
</div>
<div class="control">
{% include 'snippets/boost_button.html' with status=status %}
</div>
<div class="control">
{% include 'snippets/fav_button.html' with status=status %}
</div>
</div>
{% else %}
<a href="/login">
<span class="icon icon-comment" title="{% trans 'Reply' %}">
<span class="is-sr-only">{% trans "Reply" %}</span>
</span>
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
<span class="is-sr-only">{% trans "Boost status" %}</span>
</span>
<span class="icon icon-heart" title="{% trans 'Like status' %}">
<span class="is-sr-only">{% trans "Like status" %}</span>
</span>
</a>
{% endif %}
</div>
<div class="card-footer-item">
{% include 'snippets/privacy-icons.html' with item=status %}
</div>
<div class="card-footer-item">
<a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a>
</div>
{% if not moderation_mode %}
<div class="card-footer-item">
{% include 'snippets/status/status_options.html' with class="is-small" right=True %}
</div>
{% if status_type == 'GeneratedNote' or status_type == 'Rating' %}
{% include 'snippets/status/generated_status.html' with status=status %}
{% else %}
{% include 'snippets/status/content_status.html' with status=status %}
{% endif %}
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %}
{% with status.id|uuid as uuid %}
<section class="hidden" id="show-comment-{{ status.id }}">
<div class="card-footer">
<div class="card-footer-item">
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}
</div>
</div>
</section>
{% endwith %}
{% endif %}
{% endblock %}

View file

@ -1,68 +1,134 @@
{% load bookwyrm_tags %}
{% load i18n %}
<div class="block">
{% if status.status_type == 'Review' or status.status_type == 'Rating' %}
<div>
{% if status.name %}
<h3 class="title is-5 has-subtitle" dir="auto">
{{ status.name|escape }}
</h3>
{% endif %}
{% include 'snippets/stars.html' with rating=status.rating %}
</div>
{% with status_type=status.status_type %}
<div
class="block"
{% if status_type == 'Review' %}
{% firstof "reviewBody" as body_prop %}
{% firstof 'itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating"' as rating_type %}
{% endif %}
{% if status_type == 'Rating' %}
itemprop="rating"
itemtype="https://schema.org/Rating"
{% endif %}
>
{% if status_type == 'Review' or status_type == 'Rating' %}
<div>
{% if status.name %}
<h3
class="title is-5 has-subtitle"
dir="auto"
itemprop="name"
>
{{ status.name|escape }}
</h3>
{% endif %}
<span
class="is-sr-only"
{{ rating_type }}
>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{% if status_type == 'Rating' %}
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
{% endif %}
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
</div>
{% endif %}
{% if status.content_warning %}
<div>
<p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
</div>
<div>
<p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/open_button.html' %}
{% endwith %}
</div>
{% endif %}
<div{% if status.content_warning %} class="hidden" id="show-status-cw-{{ status.id }}"{% endif %}>
<div
{% if status.content_warning %}
{% trans "Show less" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
id="show-status-cw-{{ status.id }}"
class="is-hidden"
{% endif %}
>
{% if status.content_warning %}
{% trans "Show less" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/close_button.html' %}
{% endwith %}
{% endif %}
{% if status.quote %}
<div class="quote block">
<blockquote dir="auto" class="mb-2">{{ status.quote | safe }}</blockquote>
<div class="quote block">
<blockquote dir="auto" class="content mb-2">{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Announce' %}
{% include 'snippets/trimmed_text.html' with full=status.content|safe no_trim=status.content_warning %}
{% endif %}
{% if status.attachments.exists %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="{% trans 'Open image in new window' %}">
<img src="/images/{{ attachment.image }}"{% if attachment.caption %} alt="{{ attachment.caption }}" title="{{ attachment.caption }}"{% endif %}>
</a>
</figure>
</div>
{% endfor %}
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
{% with full=status.content|safe no_trim=status.content_warning itemprop=body_prop %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% endif %}
{% if status.attachments.exists %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a
href="/images/{{ attachment.image }}"
target="_blank"
aria-label="{% trans 'Open image in new window' %}"
>
<img
src="/images/{{ attachment.image }}"
{% if attachment.caption %}
alt="{{ attachment.caption }}"
title="{{ attachment.caption }}"
{% endif %}
>
</a>
</figure>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% if not hide_book %}
{% if status.book or status.mention_books.count %}
<div class="{% if status.status_type != 'GeneratedNote' %}box has-background-white-bis{% endif %}">
{% if status.book %}
{% include 'snippets/status/book_preview.html' with book=status.book %}
{% elif status.mention_books.count %}
{% include 'snippets/status/book_preview.html' with book=status.mention_books.first %}
{% if status.book or status.mention_books.count %}
<div
{% if status_type != 'GeneratedNote' %}
class="box has-background-white-bis"
{% endif %}
>
{% if status.book %}
{% with book=status.book %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% elif status.mention_books.count %}
{% with book=status.mention_books.first %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
{% endwith %}

View file

@ -1,9 +1,19 @@
{% load bookwyrm_tags %}
{% load i18n %}
<a href="{{ status.user.local_path }}">
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
{{ status.user.display_name }}
</a>
<span
itemprop="author"
itemscope
itemtype="https://schema.org/Person"
>
<a
href="{{ status.user.local_path }}"
itemprop="url"
>
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
<span itemprop="name">{{ status.user.display_name }}</span>
</a>
</span>
{% if status.status_type == 'GeneratedNote' %}
{{ status.content | safe }}
@ -30,10 +40,29 @@
{% endwith %}
{% endif %}
{% if status.book %}
<a href="/book/{{ status.book.id }}">{{ status.book.title }}</a>
{% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
<a href="/book/{{ status.book.id }}">{{ status.book.title }}</a>{% if status.status_type == 'Rating' %}:
<span
itemprop="reviewRating"
itemscope
itemtype="https://schema.org/Rating"
>
<span class="is-hidden" {{ rating_type }}>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
{% endif %}
{% else %}
{% include 'snippets/book_titleby.html' with book=status.book %}
{% endif %}
{% elif status.mention_books %}
<a href="/book/{{ status.mention_books.first.id }}">{{ status.mention_books.first.title }}</a>
<a href="/book/{{ status.mention_books.first.id }}">{{ status.mention_books.first.title }}</a>
{% endif %}
{% if status.progress %}

View file

@ -11,19 +11,19 @@
{% block dropdown-list %}
{% if status.user == request.user %}
{# things you can do to your own statuses #}
<li role="menuitem">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
<li role="menuitem" class="dropdown-item p-0">
<form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light is-fullwidth is-small" type="submit">
<button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete status" %}
</button>
</form>
</li>
{% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %}
<li role="menuitem">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post">
<li role="menuitem" class="dropdown-item p-0">
<form class="" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post">
{% csrf_token %}
<button class="button is-danger is-light is-fullwidth is-small" type="submit">
<button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete & re-draft" %}
</button>
</form>
@ -31,13 +31,15 @@
{% endif %}
{% else %}
{# things you can do to other people's statuses #}
<li role="menuitem">
<a href="{% url 'direct-messages-user' status.user|username %}" class="button is-small is-fullwidth">{% trans "Send direct message" %}</a>
<li role="menuitem" class="dropdown-item p-0">
<a href="{% url 'direct-messages-user' status.user|username %}" class="button is-small is-white is-radiusless is-fullwidth">
{% trans "Send direct message" %}
</a>
</li>
<li role="menuitem">
<li role="menuitem" class="dropdown-item p-0">
{% include 'snippets/report_button.html' with user=status.user status=status %}
</li>
<li role="menuitem">
<li role="menuitem" class="dropdown-item p-0">
{% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %}
</li>
{% endif %}

View file

@ -2,39 +2,46 @@
{% load i18n %}
{% with 0|uuid as uuid %}
{% if full %}
{% with full|to_markdown|safe as full %}
{% if full %}
{% with full|to_markdown|safe as full %}
{% with full|to_markdown|safe|truncatewords_html:150 as trimmed %}
{% if not no_trim and trimmed != full %}
<div id="hide-full-{{ uuid }}">
<div class="content" id="trimmed-{{ uuid }}">
<div dir="auto">{{ trimmed }}</div>
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %}
{% if not no_trim and trimmed != full %}
<div id="hide-full-{{ uuid }}">
<div class="content" id="trimmed-{{ uuid }}">
<div dir="auto">{{ trimmed }}</div>
<div>
{% trans "Show more" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
</div>
</div>
</div>
<div id="full-{{ uuid }}" class="is-hidden">
<div class="content">
<div
dir="auto"
{% if itemprop %}itemprop="{{ itemprop }}{% endif %}"
>
{{ full }}
</div>
<div>
{% trans "Show more" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
</div>
</div>
</div>
<div id="full-{{ uuid }}" class="hidden">
<div class="content">
<div dir="auto">{{ full }}</div>
<div>
{% trans "Show less" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
</div>
</div>
</div>
{% else %}
<div class="content">
<div dir="auto">{{ full }}</div>
</div>
{% endif %}
<div>
{% trans "Show less" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
</div>
</div>
</div>
{% else %}
<div class="content">
<div
dir="auto"
{% if itemprop %}itemprop="{{ itemprop }}{% endif %}"
>
{{ full }}
</div>
</div>
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
{% endwith %}

View file

@ -13,7 +13,7 @@
<a href="/direct-messages/{{ user|username }}" class="button is-fullwidth is-small">{% trans "Send direct message" %}</a>
</li>
<li role="menuitem">
{% include 'snippets/report_button.html' with user=status.user class="is-fullwidth" %}
{% include 'snippets/report_button.html' with user=user class="is-fullwidth" %}
</li>
<li role="menuitem">
{% include 'snippets/block_button.html' with user=user class="is-fullwidth" %}

View file

@ -24,7 +24,7 @@
{% block panel %}
<section class="block content">
<form name="create-list" method="post" action="{% url 'lists' %}" class="box hidden" id="create-list">
<form name="create-list" method="post" action="{% url 'lists' %}" class="box is-hidden" id="create-list">
<header class="columns">
<h3 class="title column">{% trans "Create list" %}</h3>
<div class="column is-narrow">

View file

@ -0,0 +1,7 @@
{% extends 'snippets/filters_panel/filter_field.html' %}
{% load i18n %}
{% block filter %}
<label class="label" for="id_server">{% trans "Server name" %}</label>
<input type="text" class="input" name="server" value="{{ request.GET.server|default:'' }}" id="id_server" placeholder="example.server.com">
{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block title %}{{ user.username }}{% endblock %}
{% block header %}{{ user.username }}{% endblock %}
{% block panel %}
<div class="block">
<a href="{% url 'settings-users' %}">{% trans "Back to users" %}</a>
</div>
{% include 'user_admin/user_info.html' with user=user %}
{% include 'user_admin/user_moderation_actions.html' with user=user %}
{% endblock %}

View file

@ -13,6 +13,8 @@
{% block panel %}
{% include 'user_admin/user_admin_filters.html' %}
<table class="table is-striped">
<tr>
{% url 'settings-users' as url %}
@ -39,7 +41,7 @@
</tr>
{% for user in users %}
<tr>
<td>{{ user.username }}</td>
<td><a href="{% url 'settings-user' user.id %}">{{ user.username }}</a></td>
<td>{{ user.created_date }}</td>
<td>{{ user.last_active_date }}</td>
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td>

View file

@ -0,0 +1,6 @@
{% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %}
{% include 'user_admin/server_filter.html' %}
{% include 'user_admin/username_filter.html' %}
{% endblock %}

View file

@ -0,0 +1,56 @@
{% load i18n %}
{% load bookwyrm_tags %}
<div class="block columns">
<div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "User details" %}</h4>
<div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=user %}
{% if user.summary %}
<div class="box content has-background-white-ter is-shadowless">
{{ user.summary | to_markdown | safe }}
</div>
{% endif %}
<p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
</div>
</div>
{% if not user.local %}
{% with server=user.federated_server %}
<div class="column is-half is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Instance details" %}</h4>
<div class="box content is-flex-grow-1">
{% if server %}
<h5>{{ server.server_name }}</h5>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
{% if server.notes %}
<h5>{% trans "Notes" %}</h5>
<div class="box content has-background-white-ter is-shadowless">
{{ server.notes }}
</div>
{% endif %}
<p>
<a href="{% url 'settings-federated-server' server.id %}">{% trans "View instance" %}</a>
</p>
{% else %}
<em>{% trans "Not set" %}</em>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</div>

View file

@ -0,0 +1,42 @@
{% load i18n %}
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id %}">
{% csrf_token %}
{% if user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
{% else %}
<button class="button">{% trans "Un-suspend user" %}</button>
{% endif %}
</form>
</div>
{% if user.local %}
<div>
<form name="permission" method="post" action="{% url 'settings-user' user.id %}">
{% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}
{{ group_form.non_field_errors }}
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>{{ name|title }}</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>User</option>
</select>
</div>
{% for error in group_form.groups.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% endwith %}
<button class="button">{% trans "Save" %}</button>
</form>
</div>
{% endif %}
</div>

View file

@ -0,0 +1,8 @@
{% extends 'snippets/filters_panel/filter_field.html' %}
{% load i18n %}
{% block filter %}
<label class="label" for="id_username">{% trans "Username" %}</label>
<input type="text" class="input" name="username" value="{{ request.GET.username|default:'' }}" id="id_username" placeholder="user@domain.com">
{% endblock %}

View file

@ -1,11 +1,8 @@
""" template filters """
from uuid import uuid4
from datetime import datetime
from dateutil.relativedelta import relativedelta
from django import template
from django import template, utils
from django.db.models import Avg
from django.utils import timezone
from bookwyrm import models, views
from bookwyrm.views.status import to_markdown
@ -62,14 +59,10 @@ def get_notification_count(user):
def get_replies(status):
""" get all direct replies to a status """
# TODO: this limit could cause problems
return (
models.Status.objects.filter(
reply_parent=status,
deleted=False,
)
.select_subclasses()
.all()[:10]
)
return models.Status.objects.filter(
reply_parent=status,
deleted=False,
).select_subclasses()[:10]
@register.filter(name="parent")
@ -133,28 +126,6 @@ def get_uuid(identifier):
return "%s%s" % (identifier, uuid4())
@register.filter(name="post_date")
def time_since(date):
""" concise time ago function """
if not isinstance(date, datetime):
return ""
now = timezone.now()
if date < (now - relativedelta(weeks=1)):
formatter = "%b %-d"
if date.year != now.year:
formatter += " %Y"
return date.strftime(formatter)
delta = relativedelta(now, date)
if delta.days:
return "%dd" % delta.days
if delta.hours:
return "%dh" % delta.hours
if delta.minutes:
return "%dm" % delta.minutes
return "%ds" % delta.seconds
@register.filter(name="to_markdown")
def get_markdown(content):
""" convert markdown to html """
@ -246,3 +217,10 @@ def active_read_through(book, user):
def comparison_bool(str1, str2):
""" idk why I need to write a tag for this, it reutrns a bool """
return str1 == str2
@register.simple_tag(takes_context=False)
def get_lang():
""" get current language, strip to the first two letters """
language = utils.translation.get_language()
return language[0 : language.find("-")]

View file

@ -0,0 +1,39 @@
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value"
}
],
"id": "https://example.com/users/rat",
"type": "Person",
"preferredUsername": "rat",
"name": "RAT???",
"inbox": "https://example.com/users/rat/inbox",
"outbox": "https://example.com/users/rat/outbox",
"followers": "https://example.com/users/rat/followers",
"following": "https://example.com/users/rat/following",
"summary": "",
"publicKey": {
"id": "https://example.com/users/rat/#main-key",
"owner": "https://example.com/users/rat",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC6QisDrjOQvkRo/MqNmSYPwqtt\nCxg/8rCW+9jKbFUKvqjTeKVotEE85122v/DCvobCCdfQuYIFdVMk+dB1xJ0iPGPg\nyU79QHY22NdV9mFKA2qtXVVxb5cxpA4PlwOHM6PM/k8B+H09OUrop2aPUAYwy+vg\n+MXyz8bAXrIS1kq6fQIDAQAB\n-----END PUBLIC KEY-----"
},
"endpoints": {
"sharedInbox": "https://example.com/inbox"
},
"bookwyrmUser": true,
"manuallyApprovesFollowers": false,
"discoverable": true,
"devices": "https://friend.camp/users/tripofmice/collections/devices",
"tag": [],
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/images/avatars/AL-2-crop-50.png"
}
}

View file

@ -1,5 +1,6 @@
{
"id": "https://bookwyrm.social/book/5989",
"lastEditedBy": "https://example.com/users/rat",
"type": "Edition",
"authors": [
"https://bookwyrm.social/author/417"

View file

@ -0,0 +1 @@
from . import *

View file

@ -0,0 +1,44 @@
""" test populating user streams """
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models
from bookwyrm.management.commands.populate_streams import populate_streams
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay")
class Activitystreams(TestCase):
""" using redis to build activity streams """
def setUp(self):
""" we need some stuff """
self.local_user = models.User.objects.create_user(
"mouse", "mouse@mouse.mouse", "password", local=True, localname="mouse"
)
self.another_user = models.User.objects.create_user(
"nutria", "nutria@nutria.nutria", "password", local=True, localname="nutria"
)
with patch("bookwyrm.models.user.set_remote_server.delay"):
self.remote_user = models.User.objects.create_user(
"rat",
"rat@rat.com",
"ratword",
local=False,
remote_id="https://example.com/users/rat",
inbox="https://example.com/users/rat/inbox",
outbox="https://example.com/users/rat/outbox",
)
self.book = models.Edition.objects.create(title="test book")
def test_populate_streams(self, _):
""" make sure the function on the redis manager gets called """
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
models.Comment.objects.create(
user=self.local_user, content="hi", book=self.book
)
with patch(
"bookwyrm.activitystreams.ActivityStream.populate_store"
) as redis_mock:
populate_streams()
self.assertEqual(redis_mock.call_count, 6) # 2 users x 3 streams

View file

@ -155,8 +155,8 @@ class ActivitypubMixins(TestCase):
recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 2)
self.assertEqual(recipients[0], another_remote_user.inbox)
self.assertEqual(recipients[1], self.remote_user.inbox)
self.assertTrue(another_remote_user.inbox in recipients)
self.assertTrue(self.remote_user.inbox in recipients)
def test_get_recipients_direct(self, _):
""" determines the recipients for a user's object broadcast """

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