Merge pull request #336 from mouse-reeve/user-shelves

User-created shelves
This commit is contained in:
Mouse Reeve 2020-11-10 22:06:40 -08:00 committed by GitHub
commit 56850b9574
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 330 additions and 98 deletions

View file

@ -159,3 +159,8 @@ class CreateInviteForm(CustomForm):
choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]]
+ [(None, 'Unlimited')])
}
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ['user', 'name', 'privacy']

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-11-10 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0008_work_default_edition'),
]
operations = [
migrations.AddField(
model_name='shelf',
name='privacy',
field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
),
]

View file

@ -1,8 +1,9 @@
''' puttin' books on shelves '''
import re
from django.db import models
from bookwyrm import activitypub
from .base_model import BookWyrmModel, OrderedCollectionMixin
from .base_model import BookWyrmModel, OrderedCollectionMixin, PrivacyLevels
class Shelf(OrderedCollectionMixin, BookWyrmModel):
@ -11,6 +12,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT)
editable = models.BooleanField(default=True)
privacy = models.CharField(
max_length=255,
default='public',
choices=PrivacyLevels.choices
)
books = models.ManyToManyField(
'Edition',
symmetrical=False,
@ -18,6 +24,15 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
through_fields=('shelf', 'book')
)
def save(self, *args, **kwargs):
''' set the identifier '''
saved = super().save(*args, **kwargs)
if not self.identifier:
slug = re.sub(r'[^\w]', '', self.name).lower()
self.identifier = '%s-%d' % (slug, self.id)
return super().save(*args, **kwargs)
return saved
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''

View file

@ -37,4 +37,5 @@
<glyph unicode="&#xe9d8;" glyph-name="star-half" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-0.942-0.496 0.942 570.768 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
<glyph unicode="&#xe9d9;" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" />
<glyph unicode="&#xe9da;" glyph-name="heart" d="M755.188 896c-107.63 0-200.258-87.554-243.164-179-42.938 91.444-135.578 179-243.216 179-148.382 0-268.808-120.44-268.808-268.832 0-301.846 304.5-380.994 512.022-679.418 196.154 296.576 511.978 387.206 511.978 679.418 0 148.392-120.43 268.832-268.812 268.832z" />
<glyph unicode="&#xea0a;" glyph-name="plus" d="M992 576h-352v352c0 17.672-14.328 32-32 32h-192c-17.672 0-32-14.328-32-32v-352h-352c-17.672 0-32-14.328-32-32v-192c0-17.672 14.328-32 32-32h352v-352c0-17.672 14.328-32 32-32h192c17.672 0 32 14.328 32 32v352h352c17.672 0 32 14.328 32 32v192c0 17.672-14.328 32-32 32z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?jhaogg');
src: url('fonts/icomoon.eot?jhaogg#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?jhaogg') format('truetype'),
url('fonts/icomoon.woff?jhaogg') format('woff'),
url('fonts/icomoon.svg?jhaogg#icomoon') format('svg');
src: url('fonts/icomoon.eot?rd4abb');
src: url('fonts/icomoon.eot?rd4abb#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?rd4abb') format('truetype'),
url('fonts/icomoon.woff?rd4abb') format('woff'),
url('fonts/icomoon.svg?rd4abb#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -115,3 +115,6 @@
.icon-heart:before {
content: "\e9da";
}
.icon-plus:before {
content: "\ea0a";
}

View file

@ -1,6 +1,16 @@
{% extends 'layout.html' %}
{% load fr_display %}
{% block content %}
<div class="block">
<h1 class="title">
{% if is_self %}Your
{% else %}
{% include 'snippets/username.html' with user=user possessive=True %}
{% endif %}
followers
</h1>
</div>
{% include 'snippets/user_header.html' with user=user %}
<div class="block">

View file

@ -1,6 +1,16 @@
{% extends 'layout.html' %}
{% load fr_display %}
{% block content %}
<div class="block">
<h1 class="title">
Users following
{% if is_self %}you
{% else %}
{% include 'snippets/username.html' with user=user %}
{% endif %}
</h1>
</div>
{% include 'snippets/user_header.html' with user=user %}
<div class="block">

View file

@ -1,17 +1,122 @@
{% extends 'layout.html' %}
{% load fr_display %}
{% block content %}
<div class="columns">
<div class="column">
<h1 class="title">
{% if is_self %}Your
{% else %}
{% include 'snippets/username.html' with user=user possessive=True %}
{% endif %}
shelves
</h1>
</div>
</div>
{% include 'snippets/user_header.html' with user=user %}
<div class="block">
<div class="tabs">
<ul>
{% for shelf_tab in shelves %}
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}">
<a href="/user/{{ user | username }}/shelf/{{ shelf_tab.identifier }}">{{ shelf_tab.name }}</a>
</li>
{% endfor %}
</ul>
<div class="block columns">
<div class="column">
<div class="tabs" role="tablist">
<ul>
{% for shelf_tab in shelves %}
<li class="{% if shelf_tab.identifier == shelf.identifier %}is-active{% endif %}">
<a href="/user/{{ user | username }}/shelf/{{ shelf_tab.identifier }}" role="tab" aria-selected="{% if shelf_tab.identifier == shelf.identifier %}true{% else %}false{% endif %}">{{ shelf_tab.name }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
{% if is_self %}
<div class="column is-narrow">
<input type="radio" id="create-shelf-form-hide" name="create-shelf-form" class="toggle-control" checked>
<div class="toggle-content hidden">
<label for="create-shelf-form-show">
<div role="button" tabindex="0">
<span class="icon icon-plus">
<span class="is-sr-only">Create new shelf</span>
</span>
</div>
</label>
</div>
</div>
{% endif %}
</div>
<input type="radio" id="create-shelf-form-show" name="create-shelf-form" class="toggle-control">
<div class="toggle-content hidden">
<div class="box mb-5">
<h2 class="title is-4">Create new shelf</h2>
<form name="create-shelf" action="/create-shelf/" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="field">
<label class="label" for="id_name_create">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" id="id_name_create">
</div>
<label class="label">
<p>Shelf privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True %}
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Create shelf</button>
<label role="button" class="button" for="create-shelf-form-hide" tabindex="0">Cancel<label>
</div>
</form>
</div>
</div>
<div class="block columns">
<div class="column">
<h2 class="title is-3">
{{ shelf.name }}
<span class="subtitle">
{% include 'snippets/privacy-icons.html' with item=shelf %}
</span>
</h2>
</div>
{% if is_self %}
<div class="column is-narrow">
<input type="radio" id="edit-shelf-form-hide" name="edit-shelf-form" class="toggle-control" checked>
<div class="toggle-content hidden">
<label for="edit-shelf-form-show">
<div role="button" tabindex="0">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit shelf</span>
</span>
</div>
</label>
</div>
</div>
{% endif %}
</div>
<input type="radio" id="edit-shelf-form-show" name="edit-shelf-form" class="toggle-control">
<div class="toggle-content hidden">
<div class="box mb-5">
<h2 class="title is-4">Edit shelf</h2>
<form name="create-shelf" action="/edit-shelf/{{ shelf.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
{% if shelf.editable %}
<div class="field">
<label class="label" for="id_name">Name:</label>
<input type="text" name="name" maxlength="100" class="input" required="true" value="{{ shelf.name }}" id="id_name">
</div>
{% endif %}
<label class="label">
<p>Shelf privacy:</p>
{% include 'snippets/privacy_select.html' with no_label=True current=shelf.privacy %}
</label>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Update shelf</button>
<label role="button" class="button" for="edit-shelf-form-hide" tabindex="0">Cancel<label>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,18 @@
{% if item.privacy == 'public' %}
<span class="icon icon-globe">
<span class="is-sr-only">Public post</span>
</span>
{% elif item.privacy == 'unlisted' %}
<span class="icon icon-unlock">
<span class="is-sr-only">Unlisted post</span>
</span>
{% elif item.privacy == 'followers' %}
<span class="icon icon-lock">
<span class="is-sr-only">Followers-only post</span>
</span>
{% else %}
<span class="icon icon-envelope">
<span class="is-sr-only">Private post</span>
</span>
{% endif %}

View file

@ -5,10 +5,18 @@
<label class="is-sr-only" for="privacy-{{ uuid }}">Post privacy</label>
{% endif %}
<select name="privacy" id="privacy-{{ uuid }}">
<option value="public" selected>Public</option>
<option value="unlisted">Unlisted</option>
<option value="followers">Followers only</option>
<option value="direct">Private</option>
<option value="public" {% if not current or current == 'public' %}selected{% endif %}>
Public
</option>
<option value="unlisted" {% if current == 'unlisted' %}selected{% endif %}>
Unlisted
</option>
<option value="followers" {% if current == 'followers' %}selected{% endif %}>
Followers only
</option>
<option value="direct" {% if current == 'direct' %}selected{% endif %}>
Private
</option>
</select>
{% endwith %}
</div>

View file

@ -76,5 +76,15 @@
</table>
{% else %}
<p>This shelf is empty.</p>
{% if shelf.editable %}
<form name="delete-shelf" action="/delete-shelf/{{ shelf.id }}" method="post">
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<button class="button is-danger is-light" type="submit">
Delete shelf
</button>
</form>
{% endif %}
{% endif %}

View file

@ -60,23 +60,7 @@
</div>
<div class="card-footer-item">
{% if status.privacy == 'public' %}
<span class="icon icon-globe">
<span class="is-sr-only">Public post</span>
</span>
{% elif status.privacy == 'unlisted' %}
<span class="icon icon-unlock">
<span class="is-sr-only">Unlisted post</span>
</span>
{% elif status.privacy == 'followers' %}
<span class="icon icon-lock">
<span class="is-sr-only">Followers-only post</span>
</span>
{% else %}
<span class="icon icon-envelope">
<span class="is-sr-only">Private post</span>
</span>
{% endif %}
{% include 'snippets/privacy-icons.html' with item=status %}
</div>
<div class="card-footer-item">

View file

@ -1,19 +1,6 @@
{% load humanize %}
{% load fr_display %}
<div class="block">
<div class="level">
<h2 class="title">User Profile</h2>
{% if is_self %}
<div class="level-right">
<a href="/user-edit/" class="edit-link">edit
<span class="icon icon-pencil">
<span class="is-sr-only">Edit profile</span>
</span>
</a>
</div>
{% endif %}
</div>
<div class="columns">
<div class="column is-narrow">
<div class="media">

View file

@ -1,6 +1,21 @@
{% extends 'layout.html' %}
{% block content %}
<div class="columns">
<div class="column">
<h1 class="title">User profile</h1>
</div>
{% if is_self %}
<div class="column is-narrow">
<a href="/user-edit/">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit profile</span>
</span>
</a>
</div>
{% endif %}
</div>
{% include 'snippets/user_header.html' with user=user %}
<div class="block">

View file

@ -1,25 +0,0 @@
{% extends 'layout.html' %}
{% load fr_display %}
{% block content %}
{% include 'snippets/user_header.html' with user=user %}
<div class="block">
<div class="tabs">
<ul>
{% for shelf in shelves %}
<li class="{% if true %}is-active{% endif %}">
<a href="/user/{{ user | username }}/shelves/{{ shelf.identifier }}">{{ shelf.name }}</a>
</li>
{% endfor %}
</ul>
<h2 class="title">{{ shelf.name }}</h2>
</div>
{% for shelf in shelves %}
<div class="block">
{% include 'snippets/shelf.html' with shelf=shelf ratings=ratings %}
</div>
{% endfor %}
{% endblock %}

View file

@ -120,6 +120,9 @@ urlpatterns = [
re_path(r'^delete-status/?$', actions.delete_status),
re_path(r'^create-shelf/?$', actions.create_shelf),
re_path(r'^edit-shelf/(?P<shelf_id>\d+)?$', actions.edit_shelf),
re_path(r'^delete-shelf/(?P<shelf_id>\d+)?$', actions.delete_shelf),
re_path(r'^shelve/?$', actions.shelve),
re_path(r'^unshelve/?$', actions.unshelve),
re_path(r'^start-reading/?$', actions.start_reading),

View file

@ -11,7 +11,7 @@ from django.contrib.auth.decorators import login_required, permission_required
from django.core.exceptions import PermissionDenied
from django.core.files.base import ContentFile
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils import timezone
@ -272,6 +272,44 @@ def upload_cover(request, book_id):
return redirect('/book/%s' % book.id)
@login_required
def create_shelf(request):
''' user generated shelves '''
form = forms.ShelfForm(request.POST)
if not form.is_valid():
return redirect(request.headers.get('Referer', '/'))
shelf = form.save()
return redirect('/user/%s/shelf/%s' % \
(request.user.localname, shelf.identifier))
@login_required
def edit_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user:
return HttpResponseBadRequest()
form = forms.ShelfForm(request.POST, instance=shelf)
if not form.is_valid():
return redirect(request.headers.get('Referer', '/'))
shelf = form.save()
return redirect('/user/%s/shelf/%s' % \
(request.user.localname, shelf.identifier))
@login_required
def delete_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user or not shelf.editable:
return HttpResponseBadRequest()
shelf.delete()
return redirect('/user/%s/shelves' % request.user.localname)
@login_required
def shelve(request):
''' put a on a user's shelf '''

View file

@ -311,8 +311,9 @@ def notifications_page(request):
notifications.update(read=True)
return TemplateResponse(request, 'notifications.html', data)
@csrf_exempt
def user_page(request, username, subpage=None, shelf=None):
def user_page(request, username):
''' profile page for a user '''
try:
user = get_user_from_username(username)
@ -329,19 +330,6 @@ def user_page(request, username, subpage=None, shelf=None):
'user': user,
'is_self': request.user.id == user.id,
}
if subpage == 'followers':
data['followers'] = user.followers.all()
return TemplateResponse(request, 'followers.html', data)
if subpage == 'following':
data['following'] = user.following.all()
return TemplateResponse(request, 'following.html', data)
if subpage == 'shelves':
data['shelves'] = user.shelf_set.all()
if shelf:
data['shelf'] = user.shelf_set.get(identifier=shelf)
else:
data['shelf'] = user.shelf_set.first()
return TemplateResponse(request, 'shelf.html', data)
data['shelf_count'] = user.shelf_set.count()
shelves = []
@ -376,7 +364,13 @@ def followers_page(request, username):
if is_api_request(request):
return JsonResponse(user.to_followers_activity(**request.GET))
return user_page(request, username, subpage='followers')
data = {
'title': '%s: followers' % user.name,
'user': user,
'is_self': request.user.id == user.id,
'followers': user.followers.all(),
}
return TemplateResponse(request, 'followers.html', data)
@csrf_exempt
@ -393,16 +387,19 @@ def following_page(request, username):
if is_api_request(request):
return JsonResponse(user.to_following_activity(**request.GET))
return user_page(request, username, subpage='following')
data = {
'title': '%s: following' % user.name,
'user': user,
'is_self': request.user.id == user.id,
'following': user.following.all(),
}
return TemplateResponse(request, 'following.html', data)
@csrf_exempt
def user_shelves_page(request, username):
''' list of followers '''
if request.method != 'GET':
return HttpResponseBadRequest()
return user_page(request, username, subpage='shelves')
return shelf_page(request, username, None)
@csrf_exempt
@ -629,10 +626,40 @@ def shelf_page(request, username, shelf_identifier):
except models.User.DoesNotExist:
return HttpResponseNotFound()
shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier)
if shelf_identifier:
shelf = user.shelf_set.get(identifier=shelf_identifier)
else:
shelf = user.shelf_set.first()
is_self = request.user == user
shelves = user.shelf_set
if not is_self:
follower = user.followers.filter(id=request.user.id).exists()
# make sure the user has permission to view the shelf
if shelf.privacy == 'direct' or \
(shelf.privacy == 'followers' and not follower):
return HttpResponseNotFound()
# only show other shelves that should be visible
if follower:
shelves = shelves.filter(privacy__in=['public', 'followers'])
else:
print('hi')
shelves = shelves.filter(privacy='public')
if is_api_request(request):
return JsonResponse(shelf.to_activity(**request.GET))
return user_page(
request, username, subpage='shelves', shelf=shelf_identifier)
data = {
'title': user.name,
'user': user,
'is_self': is_self,
'shelves': shelves.all(),
'shelf': shelf,
'create_form': forms.ShelfForm(),
'edit_form': forms.ShelfForm(shelf),
}
return TemplateResponse(request, 'shelf.html', data)