commit d219921bb0f736aabcb7f38d792aa8ef2e2c0480 Author: doubaniux Date: Fri May 1 22:46:15 2020 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b46c6c3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# VS Code configuration files +.vscode/ + +# flake8 +tox.ini + +# ignore migrations for now +migrations/ + +# docs/ + +# Local sqlite3 db +*.sqlite3 \ No newline at end of file diff --git a/boofilsic/__init__.py b/boofilsic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/boofilsic/asgi.py b/boofilsic/asgi.py new file mode 100644 index 00000000..dbaccb32 --- /dev/null +++ b/boofilsic/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for boofilsic project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boofilsic.settings') + +application = get_asgi_application() diff --git a/boofilsic/settings.py b/boofilsic/settings.py new file mode 100644 index 00000000..2708bbbe --- /dev/null +++ b/boofilsic/settings.py @@ -0,0 +1,169 @@ +""" +Django settings for boofilsic project. + +Generated by 'django-admin startproject' using Django 3.0.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +import os +import psycopg2.extensions + + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = '***REMOVED***' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'markdownx', + 'users.apps.UsersConfig', + 'common.apps.CommonConfig', + 'books.apps.BooksConfig', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'boofilsic.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'boofilsic.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +if DEBUG: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'test', + 'USER': 'donotban', + 'PASSWORD': 'donotbansilvousplait', + 'HOST': '192.168.136.5', + 'OPTIONS': { + 'client_encoding': 'UTF8', + # 'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_DEFAULT, + } + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'boofilsic', + 'USER': 'doubaniux', + 'PASSWORD': 'password', + 'HOST': 'localhost', + 'OPTIONS': { + 'client_encoding': 'UTF8', + # 'isolation_level': psycopg2.extensions.ISOLATION_LEVEL_DEFAULT, + } + } + } + +# Customized auth backend, glue OAuth2 and Django User model together +# https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#authentication-backends + +AUTHENTICATION_BACKENDS = [ + 'users.auth.OAuth2Backend', + # for admin to login admin site + # 'django.contrib.auth.backends.ModelBackend' + ] + + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = '' + +AUTH_USER_MODEL = 'users.User' + +MEDIA_URL = '/media/' +MEDIA_ROOT = 'E:\\temp' + +CLIENT_ID = '3U57sjR7uvCu8suyFlp-fiBVj9-pKt3-jd7F2gLF6EE' +CLIENT_SECRET = 'HZohdI-xR8lUyTs_bM0G3l9Na0W6bZ6DfMK3b84_E0g' + +# Path to save report related images, ends without slash +REPORT_MEDIA_PATH_ROOT = 'report/' +MARKDOWNX_MEDIA_PATH = 'review/' +BOOK_MEDIA_PATH_ROOT = 'book/' +DEFAULT_BOOK_IMAGE = os.path.join(MEDIA_ROOT, BOOK_MEDIA_PATH_ROOT, 'default.jpg') + +# Mastodon domain name +MASTODON_DOMAIN_NAME = 'cmx-im.work' + +# Default password for each user. since assword is not used any way, +# any string that is not empty is ok +DEFAULT_PASSWORD = 'eBRM1DETkYgiqPgq' + +# Default redirect loaction when access login required view +LOGIN_URL = '/users/login/' + +# Admin site root url +ADMIN_URL = 'lpLuTqX72Bt2hLfxxRYKeTZdE59Y2hLfpLuTqX72BtxxResXulIui1ayY2hLfpLuTqX72BtxxRejYKej1aNejYKeTZdE59sXuljYKej1aN1ZdE59sXulINSGMXTY9IIui1ayY2hLfxxRejYKej1aN1ZdE59sXulINSGMXTY9ID4tYEmjrHd' + +# https://django-debug-toolbar.readthedocs.io/en/latest/ +# maybe benchmarking before deployment \ No newline at end of file diff --git a/boofilsic/urls.py b/boofilsic/urls.py new file mode 100644 index 00000000..31ad0a73 --- /dev/null +++ b/boofilsic/urls.py @@ -0,0 +1,29 @@ +"""boofilsic URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from users.views import login + +urlpatterns = [ + path(settings.ADMIN_URL + '/', admin.site.urls), + path('login/', login), + path('markdownx/', include('markdownx.urls')), + path('users/', include('users.urls')), + path('books/', include('books.urls')), + path('', include('common.urls')), + +] diff --git a/boofilsic/wsgi.py b/boofilsic/wsgi.py new file mode 100644 index 00000000..f0f67843 --- /dev/null +++ b/boofilsic/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for boofilsic project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boofilsic.settings') + +application = get_wsgi_application() diff --git a/books/__init__.py b/books/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/books/admin.py b/books/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/books/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/books/apps.py b/books/apps.py new file mode 100644 index 00000000..f716137a --- /dev/null +++ b/books/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BooksConfig(AppConfig): + name = 'books' diff --git a/books/forms.py b/books/forms.py new file mode 100644 index 00000000..2e07a45e --- /dev/null +++ b/books/forms.py @@ -0,0 +1,76 @@ +from django import forms +from common.forms import KeyValueInput +from django.contrib.postgres.forms import SimpleArrayField +from django.utils.translation import gettext_lazy as _ +from .models import Book, BookMark, BookReview + + +class BookForm(forms.ModelForm): + pub_year = forms.IntegerField(required=False, max_value=9999, min_value=0, label=_("出版年份")) + pub_month = forms.IntegerField(required=False, max_value=12, min_value=1, label=_("出版月份")) + class Meta: + model = Book + fields = [ + 'title', + 'isbn', + 'author', + 'pub_house', + 'subtitle', + 'translator', + 'orig_title', + 'language', + 'pub_month', + 'pub_year', + 'binding', + 'price', + 'pages', + 'cover', + 'brief', + 'other_info', + ] + labels = { + 'title': _("书名"), + 'isbn': _("ISBN"), + 'author': _("作者"), + 'pub_house': _("出版社"), + 'subtitle': _("副标题"), + 'translator': _("译者"), + 'orig_title': _("原作名"), + 'language': _("语言"), + 'pub_month': _("出版月份"), + 'pub_year': _("出版年份"), + 'binding': _("装帧"), + 'price': _("定价"), + 'pages': _("页数"), + 'cover': _("封面"), + 'brief': _("简介"), + 'other_info': _("其他信息"), + } + widgets = { + 'author': forms.TextInput(attrs={'placeholder': _("多个作者使用英文逗号分隔")}), + 'translator': forms.TextInput(attrs={'placeholder': _("多个译者使用英文逗号分隔")}), + 'other_info': KeyValueInput(), + } + + +class BookMarkForm(forms.ModelForm): + class Meta: + model = BookMark + fields = [ + 'book', + 'status', + 'rating', + 'text', + 'is_private', + ] + + +class BookReviewForm(forms.ModelForm): + class Meta: + model = BookReview + fields = [ + 'book', + 'title', + 'content', + 'is_private' + ] \ No newline at end of file diff --git a/books/models.py b/books/models.py new file mode 100644 index 00000000..ab699bd4 --- /dev/null +++ b/books/models.py @@ -0,0 +1,82 @@ +import django.contrib.postgres.fields as postgres +from django.utils.translation import ugettext_lazy as _ +from django.db import models +from django.core.serializers.json import DjangoJSONEncoder +from common.models import Resource, Mark, Review +from boofilsic.settings import BOOK_MEDIA_PATH_ROOT, DEFAULT_BOOK_IMAGE +from datetime import datetime + + +def book_cover_path(instance, filename): + raise NotImplementedError("UUID!!!!!!!!!!!") + root = '' + if BOOK_MEDIA_PATH_ROOT.endswith('/'): + root = BOOK_MEDIA_PATH_ROOT + else: + root = BOOK_MEDIA_PATH_ROOT + '/' + return root + datetime.now().strftime('%Y/%m/%d') + f'{filename}' + + +class Book(Resource): + # widely recognized name, usually in Chinese + title = models.CharField(_("title"), max_length=200) + subtitle = models.CharField(_("subtitle"), blank=True, default='', max_length=200) + # original name, for books in foreign language + orig_title = models.CharField(_("original title"), blank=True, default='', max_length=200) + + author = postgres.ArrayField( + models.CharField(_("author"), blank=True, default='', max_length=100), + null=True, + blank=True, + default=list, + ) + translator = postgres.ArrayField( + models.CharField(_("translator"), blank=True, default='', max_length=100), + null=True, + blank=True, + default=list, + ) + language = models.CharField(_("language"), blank=True, default='', max_length=10) + pub_house = models.CharField(_("publishing house"), blank=True, default='', max_length=200) + pub_year = models.IntegerField(_("published year"), null=True, blank=True) + pub_month = models.IntegerField(_("published month"), null=True, blank=True) + binding = models.CharField(_("binding"), blank=True, default='', max_length=50) + # since data origin is not formatted and might be CNY USD or other currency, use char instead + price = models.CharField(_("pricing"), blank=True, default='', max_length=50) + pages = models.PositiveIntegerField(_("pages"), null=True, blank=True) + isbn = models.CharField(_("ISBN"), blank=True, max_length=20, unique=True, db_index=True) + # to store previously scrapped data + img_url = models.URLField(max_length=300) + cover = models.ImageField(_("cover picture"), upload_to=book_cover_path, default=DEFAULT_BOOK_IMAGE, blank=True) + + class Meta: + # more info: https://docs.djangoproject.com/en/2.2/ref/models/options/ + # set managed=False if the model represents an existing table or + # a database view that has been created by some other means. + # check the link above for further info + # managed = True + # db_table = 'book' + constraints = [ + models.CheckConstraint(check=models.Q(pub_year__gte=0), name='pub_year_lowerbound'), + models.CheckConstraint(check=models.Q(pub_month__lte=12), name='pub_month_upperbound'), + models.CheckConstraint(check=models.Q(pub_month__gte=1), name='pub_month_lowerbound'), + ] + + def __str__(self): + return self.title + + +class BookMark(Mark): + book = models.ForeignKey(Book, on_delete=models.SET_NULL, related_name='book_marks', null=True) + class Meta: + constraints = [ + models.UniqueConstraint(fields=['owner', 'book'], name="unique_book_mark") + ] + + +class BookReview(Review): + book = models.ForeignKey(Book, on_delete=models.SET_NULL, related_name='book_reviews', null=True) + class Meta: + constraints = [ + models.UniqueConstraint(fields=['owner', 'book'], name="unique_book_review") + ] diff --git a/books/templates/books/create_update.html b/books/templates/books/create_update.html new file mode 100644 index 00000000..776e0663 --- /dev/null +++ b/books/templates/books/create_update.html @@ -0,0 +1,76 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 添加图书' %} + + + + + + + +
+
+ + +
+
+
+
+ {% csrf_token %} + {{ form }} + +
+
+ +
+
+ +
+ + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/books/templates/books/delete.html b/books/templates/books/delete.html new file mode 100644 index 00000000..e69de29b diff --git a/books/templates/books/detail.html b/books/templates/books/detail.html new file mode 100644 index 00000000..a26a814c --- /dev/null +++ b/books/templates/books/detail.html @@ -0,0 +1,140 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 书籍详情' %} | {{ book.title }} + + + + + + + + + +
+
+ + +
+
+
+
+ +
+
+ {{ book.title }} +
+ +
+
{% if book.isbn %}{% trans 'ISBN:' %}{{ book.isbn }}{% endif %}
+
{% if book.author %}{% trans '作者:' %} + {% for author in book.author %} + {{ author }} + {% endfor %} + {% endif %}
+
{% if book.pub_house %}{% trans '出版社:' %}{{ book.pub_house }}{% endif %}
+
{% if book.subtitle %}{% trans '副标题:' %}{{ book.subtitle }}{% endif %}
+
{% if book.translator %}{% trans '译者:' %} + {% for translator in book.translator %} + {{ translator }} + {% endfor %} + {% endif %}
+
{% if book.orig_title %}{% trans '原作名:' %}{{ book.orig_title }}{% endif %}
+
{% if book.language %}{% trans '语言:' %}{{ book.language }}{% endif %}
+
{%if book.pub_year %}{% trans '出版时间:' %}{{ book.pub_year }}{% trans '年' %}{% if book.pub_month %}{{ book.pub_month }}{% trans '月' %}{% endif %}{% endif %}
+
+
+ {% if book.rating %} + + + + {{ book.rating }} + {% else %} + {% trans '评分:暂无评分' %} + {% endif %} +
{% if book.binding %}{% trans '装帧:' %}{{ book.binding }}{% endif %}
+
{% if book.price %}{% trans '定价:' %}{{ book.price }}{% endif %}
+
{% if book.pages %}{% trans '页数' %}{{ book.pages }}{% endif %}
+ {% if book.other_info %} + {% for k, v in book.other_info.items %} +
+ {{k}}:{{v}} +
+ {% endfor %} + {% endif %} + + {% comment %} + {% url 'users:home' book.last_editor %} + {% endcomment %} + +
{% trans '最近编辑者:' %}someone
+
+ +
+
+
+
+
{% trans '简介' %}
+ + {% if book.brief %} +

{{ book.brief }}

+ {% else %} +

{% trans '暂无简介' %}

+ {% endif %} + +
+
+ +
+
+

+ + +
+ + +
+
+ +
+ +
+ + + + + + + diff --git a/books/tests.py b/books/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/books/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/books/urls.py b/books/urls.py new file mode 100644 index 00000000..620c3989 --- /dev/null +++ b/books/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from .views import * + + +app_name = 'books' +urlpatterns = [ + path('create/', create, name='create'), + path('/', retrieve, name='retrieve'), +] diff --git a/books/views.py b/books/views.py new file mode 100644 index 00000000..574d21d2 --- /dev/null +++ b/books/views.py @@ -0,0 +1,46 @@ +from django.shortcuts import render, get_object_or_404, redirect, reverse +from django.contrib.auth.decorators import login_required +from django.utils.translation import gettext_lazy as _ +from django.http import HttpResponseBadRequest +from .models import * +from .forms import * + + +@login_required +def create(request): + if request.method == 'GET': + form = BookForm() + return render( + request, + 'books/create_update.html', + { + 'form': form, + 'title': _('添加书籍') + } + ) + elif request.method == 'POST': + # check user credential in post data, must be the login user + pass + form = BookForm(request.POST) + if form.is_valid(): + form.instance.last_editor = request.user + form.save() + + return redirect(reverse("books:retrieve", args=[form.instance.id])) + else: + return HttpResponseBadRequest() + + +@login_required +def retrieve(request, id): + if request.method == 'GET': + book = get_object_or_404(Book, pk=id) + return render( + request, + 'books/detail.html', + { + 'book': book, + } + ) + else: + return HttpResponseBadRequest() \ No newline at end of file diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/common/admin.py b/common/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/common/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/common/apps.py b/common/apps.py new file mode 100644 index 00000000..5f2f0784 --- /dev/null +++ b/common/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CommonConfig(AppConfig): + name = 'common' diff --git a/common/forms.py b/common/forms.py new file mode 100644 index 00000000..e19899c0 --- /dev/null +++ b/common/forms.py @@ -0,0 +1,30 @@ +from django import forms +from django.contrib.postgres.forms import JSONField +import json + + +class KeyValueInput(forms.Widget): + """ jQeury required """ + template_name = 'widgets/key_value.html' + + def get_context(self, name, value, attrs): + """ called when rendering """ + context = {} + context['widget'] = { + 'name': name, + 'is_hidden': self.is_hidden, + 'required': self.is_required, + 'value': self.format_value(value), + 'attrs': self.build_attrs(self.attrs, attrs), + 'template_name': self.template_name, + 'keyvalue_pairs': {}, + } + if context['widget']['value']: + key_value_pairs = json.loads(context['widget']['value']) + # for kv in key_value_pairs: + + context['widget']['keyvalue_pairs'] = { + + } + return context + diff --git a/common/mastodon/__init__.py b/common/mastodon/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/common/mastodon/api.py b/common/mastodon/api.py new file mode 100644 index 00000000..dc5f5e5e --- /dev/null +++ b/common/mastodon/api.py @@ -0,0 +1,22 @@ +# See https://docs.joinmastodon.org/methods/accounts/ + +# returns user info +# retruns the same info as verify account credentials +# GET +ACCOUNT = '/api/v1/accounts/:id' + +# returns user info if valid, 401 if invalid +# GET +VERIFY_ACCOUNT_CREDENTIALS = '/api/v1/accounts/verify_credentials' + +# obtain token +# GET +OAUTH_TOKEN = '/oauth/token' + +# obatin auth code +# GET +OAUTH_AUTHORIZE = '/oauth/authorize' + +# revoke token +# POST +REVOKE_TOKEN = '/oauth/revoke' \ No newline at end of file diff --git a/common/models.py b/common/models.py new file mode 100644 index 00000000..833d4479 --- /dev/null +++ b/common/models.py @@ -0,0 +1,76 @@ +import django.contrib.postgres.fields as postgres +from decimal import * +from django.utils.translation import ugettext_lazy as _ +from django.db import models +from django.core.serializers.json import DjangoJSONEncoder +from markdownx.models import MarkdownxField +from users.models import User + + +# abstract base classes +################################### +class Resource(models.Model): + + rating_total_score = models.PositiveIntegerField(null=True, blank=True) + rating_number = models.PositiveIntegerField(null=True, blank=True) + rating = models.DecimalField(null=True, blank=True, max_digits=2, decimal_places=1) + created_time = models.DateTimeField(auto_now_add=True) + edited_time = models.DateTimeField(auto_now_add=True) + last_editor = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='%(class)s_last_editor', null=True, blank=False) + brief = models.TextField(blank=True, default="") + other_info = postgres.JSONField(blank=True, null=True, encoder=DjangoJSONEncoder, default=dict) + + class Meta: + abstract = True + constraints = [ + models.CheckConstraint(check=models.Q(rating__gte=0), name='%(class)s_rating_lowerbound'), + models.CheckConstraint(check=models.Q(rating__lte=10), name='%(class)s_rating_upperbound'), + ] + + def save(self, *args, **kwargs): + """ update rating before save to db """ + if self.rating_number and self.rating_total_score: + self.rating = Decimal(str(round(self.rating_total_score / self.rating_number ), 1)) + super().save(*args, **kwargs) + + +class UserOwnedEntity(models.Model): + is_private = models.BooleanField() + owner = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='user_%(class)ss', null=True) + created_time = models.DateTimeField(auto_now_add=True) + edited_time = models.DateTimeField(auto_now_add=True) + + class Meta: + abstract = True + + +# commonly used entity classes +################################### +class MarkStatusEnum(models.IntegerChoices): + DO = 1, _('Do') + WISH = 2, _('Wish') + COLLECT = 3, _('Collect') + + +class Mark(UserOwnedEntity): + status = models.SmallIntegerField(choices=MarkStatusEnum.choices) + rating = models.PositiveSmallIntegerField(blank=True, null=True) + text = models.CharField(max_length=150, blank=True, default='') + + class Meta: + abstract = True + constraints = [ + models.CheckConstraint(check=models.Q(rating__gte=0), name='mark_rating_lowerbound'), + models.CheckConstraint(check=models.Q(rating__lte=10), name='mark_rating_upperbound'), + ] + + +class Review(UserOwnedEntity): + title = models.CharField(max_length=120) + content = MarkdownxField() + + def __str__(self): + return self.title + + class Meta: + abstract = True \ No newline at end of file diff --git a/common/static/css/boofilsic_browse.css b/common/static/css/boofilsic_browse.css new file mode 100644 index 00000000..0630379e --- /dev/null +++ b/common/static/css/boofilsic_browse.css @@ -0,0 +1,416 @@ +/* Global */ + +:root { + --primary: #9b4dca; + --secondary: #606c76; + --light: #ccc; + --bright: rgb(250, 250, 250); +} + +html { + /* background-color: #eee; */ + font-size: 55%; + height: 100%; +} + +body { + height: 100%; + margin: 0; +} + +#page-wrapper { + position: relative; + min-height: 100vh; +} + +#content-wrapper { + padding-bottom: 160px; +} + + +input[type=text]::-ms-clear { display: none; width : 0; height: 0; } +input[type=text]::-ms-reveal { display: none; width : 0; height: 0; } +input[type="search"]::-webkit-search-decoration, +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-results-button, +input[type="search"]::-webkit-search-results-decoration { display: none; } + +footer { + text-align: center; + + margin-bottom: 4px !important; + + border-top: var(--bright) solid 2px; + + position: absolute !important; + left: 50%; + transform: translateX(-50%); + bottom: 0; +} + +footer a { + margin: 0 15px; + font-size: small; +} + +img.emoji { + height: 20px !important; + width: 20px !important; + box-sizing: border-box; + object-fit: contain; + position: relative; + top: 3px; +} + +.more-link { + font-size: small; + margin-left: 5px; +} + +.highlight { + font-weight: bold; +} + +div.dividing-line { + height: 0; + width: 100%; + margin: 40px 0 15px 0; + border-top: solid 1px var(--light); +} + + +/* Nav Bar */ + +section#navbar { + background-color: var(--bright); + box-sizing: border-box; + padding: 18px 0; + margin-bottom: 50px; + border-bottom: var(--light) 0.5px solid; + +} + +.navbar img.logo { + width: 100px; + float: left; +} + +.navbar .search-box { + height: 32px; + margin: 5px 0 0 16px; + width: 50%; + float: left; + background-color: white; +} + +.navbar .search-box::placeholder { + color: var(--light); +} + +.navbar a.nav-link, .navbar a.nav-link:visited { + font-size: 80%; + float: right; + margin-top: 10px; + color: var(--secondary); + margin-left: 20px; +} + +.navbar a.nav-link:hover, .navbar a.nav-link:hover:visited { + color: var(--primary); +} + +/* Main Content Section */ + +section#content div#main { + padding: 32px 35px; + background-color: var(--bright); + margin-right: 40px; + width: 75%; +} + +/* Aside Content Section */ + +section#content div#aside { + width: 25%; +} + +.set .set-title { + display: inline-block; +} +.set .set-empty { + font-size: small; + margin: 0; + padding-left: 20px; +} + +.set .set-item-list { + padding: 0 25px 0 10px; +} + +.set .set-item { + text-align: center; +} + +.set .set-item-image { + max-width: 90px; + height: 110px; + object-fit: contain; +} + +.set .set-item-title, .set .set-item-title:visited { + width: 80%; + margin: auto; + line-height: 1.3em; + font-size: small; + display: block; + color: var(--secondary); + +} + +.set .set-item-title:hover, .set .set-item-title:visited:hover { + color: var(--primary); +} + +.set .set-content { + font-size: small; +} + + /* Info Card */ + +.aside-card { + background-color: var(--bright); + padding: 20px 24px 15px 24px; + margin-bottom: 30px; +} + +.aside-card .clearfix { + margin-bottom: 15px; +} + +.aside-card .info-avatar { + width: 72px; + object-fit: contain; + float: left; +} + +.aside-card .info-name { + margin: 0; + max-width: 120px; + margin-bottom: 12px; + padding-left: 8px; + float: left; +} + +.aside-card .info-brief { + line-height: 2rem; + font-size: small; + overflow-wrap: break-word; + hyphens: auto; +} + +.aside-card a.report { + color: var(--light); + font-size: small; +} + +.aside-card a.follow { + font-size: small; + color: var(--secondary); + margin-right: 10px; +} + +.aside-card .button.add-button { + margin-top: 10px; +} + +/* Relation Card */ + +.relation-card { + padding: 2px; + margin-bottom: 10px; +} + +.relation-card .relation-label { + margin: 0; + margin-bottom: 12px; + display: inline-block; +} + +.relation-card .relation-user .relation-avatar { + width: 50px; +} + +.relation-card .relation-user .relation-name { + display: block; + font-size: small; + line-height: 1.8rem; + /* white-space: nowrap; */ + text-overflow: ellipsis; + /* max-width: 100px; */ + overflow: hidden; +} + +.relation-card .relation-user-list { + margin-bottom: 15px; +} + +.relation-card .relation-user-list .relation-user { + list-style-type: none; + text-align: center; + margin-bottom: 0px !important; + justify-items: center; + padding: 0 3px !important; +} + +/* Report Card */ + +.report-card { + padding: 2px; + font-size: small; +} + +.report-card .report-label { + margin: 0; +} + +.report-card .report-user-link { + padding: 0 5px; +} + +.report-card .report-message { + list-style-type: none; +} + +/* Search Result */ + +.result-item { + list-style: none; + margin-bottom: 30px; +} + + +.result-item .result-info { + margin-left: 20px; + float: left; +} + +.result-item img.result-book-cover { + float: left; + object-fit: contain; + height: 150px; + width: 100px; +} + +.result-item .result-info .rating-empty { + font-size: small; +} + +.result-item .result-info .rating-star{ + cursor: unset; + display: inline; + position: relative; + top: 3px; + left: -3px; +} + +/* overwrite rating-star plugin */ +.result-item .result-info .rating-star .jq-star { + cursor: unset; +} + +.result-item .result-info .result-book-info { + font-size: 80%; + overflow: hidden; + width: 420px; + display: inline-block; + white-space: nowrap; + text-overflow: ellipsis; + position: relative; + top: .55rem; +} + +.result-item .result-info .rating-score { + font-size: 80%; + display: inline; + margin-right: 20px; +} + +.result-item .result-info .result-book-title { + white-space: nowrap; + text-overflow: ellipsis; + max-width: 500px; + display: block; + overflow: hidden +} + +.result-item .result-info .result-book-brief { + margin-top: 10px; + margin-bottom: 0; + font-size: small; + width: 565px; + display: block; + text-overflow: ellipsis; + overflow: hidden; + /* white-space: nowrap; */ + height: 90px; +} + +/* Pagination */ +.pagination { + margin-top: 50px; + text-align: center; +} + +.pagination a { + display: inline-block; +} + +.pagination .button { + padding: 0 5px; + height: 2rem; +} + +.pagination .page-index { + font-size: small; +} + +/* Display Page */ + +img.display-image { + height: 210px; + object-fit: contain; + float: left; +} + +.display-info { + max-width: 530px; + float: left; + margin-left: 20px; + + overflow: hidden; + text-overflow: ellipsis; +} + +.display-title { + display: -webkit-box; + -webkit-line-clamp: 1; /* number of lines to show */ + -webkit-box-orient: vertical; + font-weight: bold; +} + +.display-info-detail { + font-size: small; + width: 250px; + display: inline-block; + vertical-align: top; +} + +.display-info-detail .rating-star { + position: relative; + left: -5px; +} + +.display-info-detail .rating-score { + position: relative; + top: -2.5px; +} \ No newline at end of file diff --git a/common/static/css/boofilsic_edit.css b/common/static/css/boofilsic_edit.css new file mode 100644 index 00000000..3a0353e7 --- /dev/null +++ b/common/static/css/boofilsic_edit.css @@ -0,0 +1,329 @@ +/* Global */ + +:root { + --primary: #9b4dca; + --secondary: #606c76; + --light: #ccc; + --bright: rgb(250, 250, 250); +} + +html { + /* background-color: #eee; */ + font-size: 55%; + height: 100%; +} + +body { + height: 100%; + margin: 0; +} + +#page-wrapper { + position: relative; + min-height: 100vh; +} + +#content-wrapper { + padding-bottom: 160px; +} + + +input[type=text]::-ms-clear { display: none; width : 0; height: 0; } +input[type=text]::-ms-reveal { display: none; width : 0; height: 0; } +input[type="search"]::-webkit-search-decoration, +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-results-button, +input[type="search"]::-webkit-search-results-decoration { display: none; } + +footer { + text-align: center; + + margin-bottom: 4px !important; + + border-top: var(--bright) solid 2px; + + position: absolute !important; + left: 50%; + transform: translateX(-50%); + bottom: 0; +} + +footer a { + margin: 0 15px; + font-size: small; +} + +img.emoji { + height: 20px !important; + width: 20px !important; + box-sizing: border-box; + object-fit: contain; + position: relative; + top: 3px; +} + +.more-link { + font-size: small; + margin-left: 5px; +} + + +/* Nav Bar */ + +section#navbar { + background-color: var(--bright); + box-sizing: border-box; + padding: 18px 0; + margin-bottom: 50px; + border-bottom: var(--light) 0.5px solid; + +} + +.navbar img.logo { + width: 100px; + float: left; +} + +.navbar h4.nav-title { + display: inline; + margin-left: 16px; + position: relative; + top: 7px; +} + +.navbar a.nav-link, .navbar a.nav-link:visited { + font-size: 80%; + float: right; + margin-top: 10px; + color: var(--secondary); + margin-left: 20px; +} + +.navbar a.nav-link:hover, .navbar a.nav-link:hover:visited { + color: var(--primary); +} + +/* Main Content Section */ + +section#content div#main { + padding: 32px 35px; + background-color: var(--bright); + width: 100%; +} + +div#main form label { + font-size: small; + font-weight: normal; +} + +div#main form input::placeholder { + color: var(--light); +} + +div#main form button[type="submit"] { + margin-top: 20px; +} + +div#main form input[type="file"] { + display: block; + margin-bottom: 0; +} + + /* Info Card */ + +.aside-card { + background-color: var(--bright); + padding: 20px 24px 15px 24px; + margin-bottom: 30px; +} + +.aside-card .clearfix { + margin-bottom: 15px; +} + +.aside-card .info-avatar { + width: 72px; + object-fit: contain; + float: left; +} + +.aside-card .info-name { + margin: 0; + max-width: 120px; + margin-bottom: 12px; + padding-left: 8px; + float: left; +} + +.aside-card .info-brief { + line-height: 2rem; + font-size: small; + overflow-wrap: break-word; + hyphens: auto; +} + +.aside-card a.report { + color: var(--light); + font-size: small; +} + +.aside-card a.follow { + font-size: small; + color: var(--secondary); + margin-right: 10px; +} + +.aside-card .button.add-button { + margin-top: 10px; +} + +/* Relation Card */ + +.relation-card { + padding: 2px; + margin-bottom: 10px; +} + +.relation-card .relation-label { + margin: 0; + margin-bottom: 12px; + display: inline-block; +} + +.relation-card .relation-user .relation-avatar { + width: 50px; +} + +.relation-card .relation-user .relation-name { + display: block; + font-size: small; + line-height: 1.8rem; + /* white-space: nowrap; */ + text-overflow: ellipsis; + /* max-width: 100px; */ + overflow: hidden; +} + +.relation-card .relation-user-list { + margin-bottom: 15px; +} + +.relation-card .relation-user-list .relation-user { + list-style-type: none; + text-align: center; + margin-bottom: 0px !important; + justify-items: center; + padding: 0 3px !important; +} + +/* Report Card */ + +.report-card { + padding: 2px; + font-size: small; +} + +.report-card .report-label { + margin: 0; +} + +.report-card .report-user-link { + padding: 0 5px; +} + +.report-card .report-message { + list-style-type: none; +} + +/* Search Result */ + +.result-item { + list-style: none; + margin-bottom: 30px; +} + + +.result-item .result-info { + margin-left: 20px; + float: left; +} + +.result-item img.result-book-cover { + float: left; + object-fit: contain; + height: 150px; + width: 100px; +} + +.result-item .result-info .rating-empty { + font-size: small; +} + +.result-item .result-info .rating-star{ + cursor: unset; + display: inline; + position: relative; + top: 3px; + left: -3px; +} + +/* overwrite rating-star plugin */ +.result-item .result-info .rating-star .jq-star { + cursor: unset; +} + +.result-item .result-info .result-book-info { + font-size: 80%; + overflow: hidden; + width: 420px; + display: inline-block; + white-space: nowrap; + text-overflow: ellipsis; + position: relative; + top: .55rem; +} + +.result-item .result-info .rating-score { + font-size: 80%; + display: inline; + margin-right: 20px; +} + +.result-item .result-info .result-book-title { + white-space: nowrap; + text-overflow: ellipsis; + max-width: 500px; + display: block; + overflow: hidden +} + +.result-item .result-info .result-book-brief { + margin-top: 10px; + margin-bottom: 0; + font-size: small; + width: 565px; + display: block; + text-overflow: ellipsis; + overflow: hidden; + /* white-space: nowrap; */ + height: 90px; +} + +/* Pagination */ +.pagination { + margin-top: 50px; + text-align: center; +} + +.pagination a { + display: inline-block; +} + +.pagination .button { + padding: 0 5px; + height: 2rem; +} + +.pagination .page-index { + font-size: small; +} \ No newline at end of file diff --git a/common/static/img/default.jpg b/common/static/img/default.jpg new file mode 100644 index 00000000..16f522c1 Binary files /dev/null and b/common/static/img/default.jpg differ diff --git a/common/static/img/default_avatar.jpg b/common/static/img/default_avatar.jpg new file mode 100644 index 00000000..48ab2cb2 Binary files /dev/null and b/common/static/img/default_avatar.jpg differ diff --git a/common/static/img/logo.svg b/common/static/img/logo.svg new file mode 100644 index 00000000..e911380a --- /dev/null +++ b/common/static/img/logo.svg @@ -0,0 +1 @@ +logo \ No newline at end of file diff --git a/common/static/js/create_update.js b/common/static/js/create_update.js new file mode 100644 index 00000000..fe407674 --- /dev/null +++ b/common/static/js/create_update.js @@ -0,0 +1,23 @@ +$(document).ready( function() { + // assume there is only one input[file] on page + $("input[type='file']").each(function() { + $(this).after(''); + }) + + // mark required + $("input[required]").each(function() { + $(this).prev().prepend("*"); + }) + + $("input[type='file']").change(function() { + if (this.files && this.files[0]) { + var reader = new FileReader(); + + reader.onload = function (e) { + $('#previewImage').attr('src', e.target.result); + } + + reader.readAsDataURL(this.files[0]); + } + }); +}); \ No newline at end of file diff --git a/common/static/js/detail.js b/common/static/js/detail.js new file mode 100644 index 00000000..0beaa427 --- /dev/null +++ b/common/static/js/detail.js @@ -0,0 +1,12 @@ +$(document).ready( function() { + // readonly star rating + let ratingLabels = $(".rating-star"); + $(ratingLabels).each( function(index, value) { + let ratingScore = $(this).data("rating-score") / 2; + $(this).starRating({ + initialRating: ratingScore, + readOnly: true + }); + }); + +}); \ No newline at end of file diff --git a/common/static/js/home.js b/common/static/js/home.js new file mode 100644 index 00000000..7606e621 --- /dev/null +++ b/common/static/js/home.js @@ -0,0 +1,83 @@ + +$(document).ready( function() { + let token = $("#oauth2Token").text(); + let mast_uri = $("#mastodonURI").text(); + let id = $("#userMastodonID").text(); + + $(".mast-following-more").hide(); + $(".mast-followers-more").hide(); + + getUserInfo( + id, + mast_uri, + token, + function(userData) { + let userName; + if (userData.display_name) { + userName = translateEmojis(userData.display_name, userData.emojis); + } else { + userName = userData.username; + } + $(".mast-user .mast-avatar").attr("src", userData.avatar); + $(".mast-user .mast-displayname").html(userName); + $(".mast-user .mast-brief").text($(userData.note).text()); + } + ); + + getFollowers( + id, + mast_uri, + token, + function(userList) { + if (userList.length == 0) { + $(".mast-followers").hide(); + } else { + if (userList.length > 4){ + userList = userList.slice(0, 4); + $(".mast-followers-more").show(); + } + let template = $(".mast-followers li").clone(); + $(".mast-followers").html(""); + userList.forEach(data => { + temp = $(template).clone(); + temp.find("img").attr("src", data.avatar); + if (data.display_name) { + temp.find("a").text(data.display_name); + } else { + temp.find("a").text(data.username); + } + $(".mast-followers").append(temp); + }); + } + } + ); + + getFollowing( + id, + mast_uri, + token, + function(userList) { + if (userList.length == 0) { + $(".mast-following").hide(); + } else { + if (userList.length > 4){ + userList = userList.slice(0, 4); + $(".mast-following-more").show(); + } + let template = $(".mast-following li").clone(); + $(".mast-following").html(""); + userList.forEach(data => { + temp = $(template).clone() + temp.find("img").attr("src", data.avatar); + if (data.display_name) { + temp.find("a").text(data.display_name); + } else { + temp.find("a").text(data.username); + } + $(".mast-following").append(temp); + }); + } + } + ); + +}); \ No newline at end of file diff --git a/common/static/js/mastodon.js b/common/static/js/mastodon.js new file mode 100644 index 00000000..24cdef70 --- /dev/null +++ b/common/static/js/mastodon.js @@ -0,0 +1,144 @@ +// .replace(":id", "") + +// GET +const API_FOLLOWERS = "/api/v1/accounts/:id/followers" +// GET +const API_FOLLOWING = "/api/v1/accounts/:id/following" +// GET +const API_ACCOUNT = '/api/v1/accounts/:id' + + +// [ +// { +// "id": "1020382", +// "username": "atul13061987", +// "acct": "atul13061987", +// "display_name": "", +// "locked": false, +// "bot": false, +// "created_at": "2019-12-04T07:17:02.745Z", +// "note": "

", +// "url": "https://mastodon.social/@atul13061987", +// "avatar": "https://mastodon.social/avatars/original/missing.png", +// "avatar_static": "https://mastodon.social/avatars/original/missing.png", +// "header": "https://mastodon.social/headers/original/missing.png", +// "header_static": "https://mastodon.social/headers/original/missing.png", +// "followers_count": 0, +// "following_count": 2, +// "statuses_count": 0, +// "last_status_at": null, +// "emojis": [], +// "fields": [] +// }, +// { +// "id": "1020381", +// "username": "linuxliner", +// "acct": "linuxliner", +// "display_name": "", +// "locked": false, +// "bot": false, +// "created_at": "2019-12-04T07:15:56.426Z", +// "note": "

", +// "url": "https://mastodon.social/@linuxliner", +// "avatar": "https://mastodon.social/avatars/original/missing.png", +// "avatar_static": "https://mastodon.social/avatars/original/missing.png", +// "header": "https://mastodon.social/headers/original/missing.png", +// "header_static": "https://mastodon.social/headers/original/missing.png", +// "followers_count": 0, +// "following_count": 2, +// "statuses_count": 0, +// "last_status_at": null, +// "emojis": [], +// "fields": [] +// } +// ] +function getFollowers(id, mastodonURI, token, callback) { + let url = mastodonURI + API_FOLLOWERS.replace(":id", id); + $.ajax({ + url: url, + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + token, + }, + success: function(data){ + callback(data); + }, + }); +} + +function getFollowing(id, mastodonURI, token, callback) { + let url = mastodonURI + API_FOLLOWING.replace(":id", id); + $.ajax({ + url: url, + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + token, + }, + success: function(data){ + callback(data); + }, + }); +} + +// { +// "id": "1", +// "username": "Gargron", +// "acct": "Gargron", +// "display_name": "Eugen", +// "locked": false, +// "bot": false, +// "created_at": "2016-03-16T14:34:26.392Z", +// "note": "

Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.

", +// "url": "https://mastodon.social/@Gargron", +// "avatar": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg", +// "avatar_static": "https://files.mastodon.social/accounts/avatars/000/000/001/original/d96d39a0abb45b92.jpg", +// "header": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png", +// "header_static": "https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png", +// "followers_count": 318699, +// "following_count": 453, +// "statuses_count": 61013, +// "last_status_at": "2019-11-30T20:02:08.277Z", +// "emojis": [], +// "fields": [ +// { +// "name": "Patreon", +// "value": "https://www.patreon.com/mastodon", +// "verified_at": null +// }, +// { +// "name": "Homepage", +// "value": "https://zeonfederated.com", +// "verified_at": "2019-07-15T18:29:57.191+00:00" +// } +// ] +// } +function getUserInfo(id, mastodonURI, token, callback) { + let url = mastodonURI + API_ACCOUNT.replace(":id", id); + $.ajax({ + url: url, + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + token, + }, + success: function(data){ + callback(data); + }, + }); +} + +function getEmojiDict(emoji_list) { + let dict = new Object; + emoji_list.forEach(pair => { + dict[":" + pair.shortcode + ":"] = pair.url; + }); + return dict; +} + +function translateEmojis(text, emoji_list) { + let dict = getEmojiDict(emoji_list); + let regex = /:(.*?):/g; + let translation = text.replace(regex, function (match) { + return " + match + "; + }); + return translation; +} diff --git a/common/static/js/search_result.js b/common/static/js/search_result.js new file mode 100644 index 00000000..da61b68d --- /dev/null +++ b/common/static/js/search_result.js @@ -0,0 +1,11 @@ +$(document).ready( function() { + let ratingLabels = $(".rating-star"); + $(ratingLabels).each( function(index, value) { + let ratingScore = $(this).data("rating-score") / 2; + $(this).starRating({ + initialRating: ratingScore, + readOnly: true + }); + }); + +}); \ No newline at end of file diff --git a/common/static/lib/css/milligram.css b/common/static/lib/css/milligram.css new file mode 100644 index 00000000..d253355e --- /dev/null +++ b/common/static/lib/css/milligram.css @@ -0,0 +1,602 @@ +/*! + * Milligram v1.3.0 + * https://milligram.github.io + * + * Copyright (c) 2017 CJ Patoilo + * Licensed under the MIT license + */ + +*, +*:after, +*:before { + box-sizing: inherit; +} + +html { + box-sizing: border-box; + font-size: 62.5%; +} + +body { + color: #606c76; + font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; + font-size: 1.6em; + font-weight: 300; + letter-spacing: .01em; + line-height: 1.6; +} + +blockquote { + border-left: 0.3rem solid #d1d1d1; + margin-left: 0; + margin-right: 0; + padding: 1rem 1.5rem; +} + +blockquote *:last-child { + margin-bottom: 0; +} + +.button, +button, +input[type='button'], +input[type='reset'], +input[type='submit'] { + background-color: #9b4dca; + border: 0.1rem solid #9b4dca; + border-radius: .4rem; + color: #fff; + cursor: pointer; + display: inline-block; + font-size: 1.1rem; + font-weight: 700; + height: 3.8rem; + letter-spacing: .1rem; + line-height: 3.8rem; + padding: 0 3.0rem; + text-align: center; + text-decoration: none; + text-transform: uppercase; + white-space: nowrap; +} + +.button:focus, .button:hover, +button:focus, +button:hover, +input[type='button']:focus, +input[type='button']:hover, +input[type='reset']:focus, +input[type='reset']:hover, +input[type='submit']:focus, +input[type='submit']:hover { + background-color: #606c76; + border-color: #606c76; + color: #fff; + outline: 0; +} + +.button[disabled], +button[disabled], +input[type='button'][disabled], +input[type='reset'][disabled], +input[type='submit'][disabled] { + cursor: default; + opacity: .5; +} + +.button[disabled]:focus, .button[disabled]:hover, +button[disabled]:focus, +button[disabled]:hover, +input[type='button'][disabled]:focus, +input[type='button'][disabled]:hover, +input[type='reset'][disabled]:focus, +input[type='reset'][disabled]:hover, +input[type='submit'][disabled]:focus, +input[type='submit'][disabled]:hover { + background-color: #9b4dca; + border-color: #9b4dca; +} + +.button.button-outline, +button.button-outline, +input[type='button'].button-outline, +input[type='reset'].button-outline, +input[type='submit'].button-outline { + background-color: transparent; + color: #9b4dca; +} + +.button.button-outline:focus, .button.button-outline:hover, +button.button-outline:focus, +button.button-outline:hover, +input[type='button'].button-outline:focus, +input[type='button'].button-outline:hover, +input[type='reset'].button-outline:focus, +input[type='reset'].button-outline:hover, +input[type='submit'].button-outline:focus, +input[type='submit'].button-outline:hover { + background-color: transparent; + border-color: #606c76; + color: #606c76; +} + +.button.button-outline[disabled]:focus, .button.button-outline[disabled]:hover, +button.button-outline[disabled]:focus, +button.button-outline[disabled]:hover, +input[type='button'].button-outline[disabled]:focus, +input[type='button'].button-outline[disabled]:hover, +input[type='reset'].button-outline[disabled]:focus, +input[type='reset'].button-outline[disabled]:hover, +input[type='submit'].button-outline[disabled]:focus, +input[type='submit'].button-outline[disabled]:hover { + border-color: inherit; + color: #9b4dca; +} + +.button.button-clear, +button.button-clear, +input[type='button'].button-clear, +input[type='reset'].button-clear, +input[type='submit'].button-clear { + background-color: transparent; + border-color: transparent; + color: #9b4dca; +} + +.button.button-clear:focus, .button.button-clear:hover, +button.button-clear:focus, +button.button-clear:hover, +input[type='button'].button-clear:focus, +input[type='button'].button-clear:hover, +input[type='reset'].button-clear:focus, +input[type='reset'].button-clear:hover, +input[type='submit'].button-clear:focus, +input[type='submit'].button-clear:hover { + background-color: transparent; + border-color: transparent; + color: #606c76; +} + +.button.button-clear[disabled]:focus, .button.button-clear[disabled]:hover, +button.button-clear[disabled]:focus, +button.button-clear[disabled]:hover, +input[type='button'].button-clear[disabled]:focus, +input[type='button'].button-clear[disabled]:hover, +input[type='reset'].button-clear[disabled]:focus, +input[type='reset'].button-clear[disabled]:hover, +input[type='submit'].button-clear[disabled]:focus, +input[type='submit'].button-clear[disabled]:hover { + color: #9b4dca; +} + +code { + background: #f4f5f6; + border-radius: .4rem; + font-size: 86%; + margin: 0 .2rem; + padding: .2rem .5rem; + white-space: nowrap; +} + +pre { + background: #f4f5f6; + border-left: 0.3rem solid #9b4dca; + overflow-y: hidden; +} + +pre > code { + border-radius: 0; + display: block; + padding: 1rem 1.5rem; + white-space: pre; +} + +hr { + border: 0; + border-top: 0.1rem solid #f4f5f6; + margin: 3.0rem 0; +} + +input[type='email'], +input[type='number'], +input[type='password'], +input[type='search'], +input[type='tel'], +input[type='text'], +input[type='url'], +textarea, +select { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: transparent; + border: 0.1rem solid #d1d1d1; + border-radius: .4rem; + box-shadow: none; + box-sizing: inherit; + height: 3.8rem; + padding: .6rem 1.0rem; + width: 100%; +} + +input[type='email']:focus, +input[type='number']:focus, +input[type='password']:focus, +input[type='search']:focus, +input[type='tel']:focus, +input[type='text']:focus, +input[type='url']:focus, +textarea:focus, +select:focus { + border-color: #9b4dca; + outline: 0; +} + +select { + background: url('data:image/svg+xml;utf8,') center right no-repeat; + padding-right: 3.0rem; +} + +select:focus { + background-image: url('data:image/svg+xml;utf8,'); +} + +textarea { + min-height: 6.5rem; +} + +label, +legend { + display: block; + font-size: 1.6rem; + font-weight: 700; + margin-bottom: .5rem; +} + +fieldset { + border-width: 0; + padding: 0; +} + +input[type='checkbox'], +input[type='radio'] { + display: inline; +} + +.label-inline { + display: inline-block; + font-weight: normal; + margin-left: .5rem; +} + +.container { + margin: 0 auto; + max-width: 112.0rem; + padding: 0 2.0rem; + position: relative; + width: 100%; +} + +.row { + display: flex; + flex-direction: column; + padding: 0; + width: 100%; +} + +.row.row-no-padding { + padding: 0; +} + +.row.row-no-padding > .column { + padding: 0; +} + +.row.row-wrap { + flex-wrap: wrap; +} + +.row.row-top { + align-items: flex-start; +} + +.row.row-bottom { + align-items: flex-end; +} + +.row.row-center { + align-items: center; +} + +.row.row-stretch { + align-items: stretch; +} + +.row.row-baseline { + align-items: baseline; +} + +.row .column { + display: block; + flex: 1 1 auto; + margin-left: 0; + max-width: 100%; + width: 100%; +} + +.row .column.column-offset-10 { + margin-left: 10%; +} + +.row .column.column-offset-20 { + margin-left: 20%; +} + +.row .column.column-offset-25 { + margin-left: 25%; +} + +.row .column.column-offset-33, .row .column.column-offset-34 { + margin-left: 33.3333%; +} + +.row .column.column-offset-50 { + margin-left: 50%; +} + +.row .column.column-offset-66, .row .column.column-offset-67 { + margin-left: 66.6666%; +} + +.row .column.column-offset-75 { + margin-left: 75%; +} + +.row .column.column-offset-80 { + margin-left: 80%; +} + +.row .column.column-offset-90 { + margin-left: 90%; +} + +.row .column.column-10 { + flex: 0 0 10%; + max-width: 10%; +} + +.row .column.column-20 { + flex: 0 0 20%; + max-width: 20%; +} + +.row .column.column-25 { + flex: 0 0 25%; + max-width: 25%; +} + +.row .column.column-33, .row .column.column-34 { + flex: 0 0 33.3333%; + max-width: 33.3333%; +} + +.row .column.column-40 { + flex: 0 0 40%; + max-width: 40%; +} + +.row .column.column-50 { + flex: 0 0 50%; + max-width: 50%; +} + +.row .column.column-60 { + flex: 0 0 60%; + max-width: 60%; +} + +.row .column.column-66, .row .column.column-67 { + flex: 0 0 66.6666%; + max-width: 66.6666%; +} + +.row .column.column-75 { + flex: 0 0 75%; + max-width: 75%; +} + +.row .column.column-80 { + flex: 0 0 80%; + max-width: 80%; +} + +.row .column.column-90 { + flex: 0 0 90%; + max-width: 90%; +} + +.row .column .column-top { + align-self: flex-start; +} + +.row .column .column-bottom { + align-self: flex-end; +} + +.row .column .column-center { + -ms-grid-row-align: center; + align-self: center; +} + +@media (min-width: 40rem) { + .row { + flex-direction: row; + margin-left: -1.0rem; + width: calc(100% + 2.0rem); + } + .row .column { + margin-bottom: inherit; + padding: 0 1.0rem; + } +} + +a { + color: #9b4dca; + text-decoration: none; +} + +a:focus, a:hover { + color: #606c76; +} + +dl, +ol, +ul { + list-style: none; + margin-top: 0; + padding-left: 0; +} + +dl dl, +dl ol, +dl ul, +ol dl, +ol ol, +ol ul, +ul dl, +ul ol, +ul ul { + font-size: 90%; + margin: 1.5rem 0 1.5rem 3.0rem; +} + +ol { + list-style: decimal inside; +} + +ul { + list-style: circle inside; +} + +.button, +button, +dd, +dt, +li { + margin-bottom: 1.0rem; +} + +fieldset, +input, +select, +textarea { + margin-bottom: 1.5rem; +} + +blockquote, +dl, +figure, +form, +ol, +p, +pre, +table, +ul { + margin-bottom: 2.5rem; +} + +table { + border-spacing: 0; + width: 100%; +} + +td, +th { + border-bottom: 0.1rem solid #e1e1e1; + padding: 1.2rem 1.5rem; + text-align: left; +} + +td:first-child, +th:first-child { + padding-left: 0; +} + +td:last-child, +th:last-child { + padding-right: 0; +} + +b, +strong { + font-weight: bold; +} + +p { + margin-top: 0; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-weight: 300; + letter-spacing: -.1rem; + margin-bottom: 2.0rem; + margin-top: 0; +} + +h1 { + font-size: 4.6rem; + line-height: 1.2; +} + +h2 { + font-size: 3.6rem; + line-height: 1.25; +} + +h3 { + font-size: 2.8rem; + line-height: 1.3; +} + +h4 { + font-size: 2.2rem; + letter-spacing: -.08rem; + line-height: 1.35; +} + +h5 { + font-size: 1.8rem; + letter-spacing: -.05rem; + line-height: 1.5; +} + +h6 { + font-size: 1.6rem; + letter-spacing: 0; + line-height: 1.4; +} + +img { + max-width: 100%; +} + +.clearfix:after { + clear: both; + content: ' '; + display: table; +} + +.float-left { + float: left; +} + +.float-right { + float: right; +} + +/*# sourceMappingURL=milligram.css.map */ \ No newline at end of file diff --git a/common/static/lib/css/rating-star.css b/common/static/lib/css/rating-star.css new file mode 100644 index 00000000..0fd1fcbe --- /dev/null +++ b/common/static/lib/css/rating-star.css @@ -0,0 +1,38 @@ +.jq-stars { + display: inline-block; +} + +.jq-rating-label { + font-size: 22px; + display: inline-block; + position: relative; + vertical-align: top; + font-family: helvetica, arial, verdana; +} + +.jq-star { + width: 100px; + height: 100px; + display: inline-block; + cursor: pointer; +} + +.jq-star-svg { + padding-left: 3px; + width: 100%; + height: 100% ; +} + +.jq-star:hover .fs-star-svg path { +} + +.jq-star-svg path { + /* stroke: #000; */ + stroke-linejoin: round; +} + +/* un-used */ +.jq-shadow { + -webkit-filter: drop-shadow( -2px -2px 2px #888 ); + filter: drop-shadow( -2px -2px 2px #888 ); +} diff --git a/common/static/lib/js/rating-star.js b/common/static/lib/js/rating-star.js new file mode 100644 index 00000000..fb9bd8da --- /dev/null +++ b/common/static/lib/js/rating-star.js @@ -0,0 +1,313 @@ +/* + * jQuery StarRatingSvg v1.2.0 + * + * http://github.com/nashio/star-rating-svg + * http://nashio.github.io/star-rating-svg/demo/ + * Author: Ignacio Chavez + * hello@ignaciochavez.com + * Licensed under MIT + */ + +;(function ( $, window, document, undefined ) { + + 'use strict'; + + // Create the defaults once + var pluginName = 'starRating'; + var noop = function(){}; + var defaults = { + totalStars: 5, + useFullStars: false, + starShape: 'straight', + emptyColor: 'lightgray', + hoverColor: 'gold', + activeColor: 'gold', + ratedColor: 'gold', + useGradient: false, + readOnly: false, + disableAfterRate: false, + baseUrl: false, + starGradient: { + start: '#FEF7CD', + end: '#FF9511' + }, + strokeWidth: 0, + strokeColor: 'black', + initialRating: 0, + starSize: 18, + callback: noop, + onHover: noop, + onLeave: noop + }; + + // The actual plugin constructor + var Plugin = function( element, options ) { + var _rating; + this.element = element; + this.$el = $(element); + this.settings = $.extend( {}, defaults, options ); + + // grab rating if defined on the element + _rating = this.$el.data('rating') || this.settings.initialRating; + this._state = { + // round to the nearest half + rating: (Math.round( _rating * 2 ) / 2).toFixed(1) + }; + + // create unique id for stars + this._uid = Math.floor( Math.random() * 999 ); + + // override gradient if not used + if( !options.starGradient && !this.settings.useGradient ){ + this.settings.starGradient.start = this.settings.starGradient.end = this.settings.activeColor; + } + + this._defaults = defaults; + this._name = pluginName; + this.init(); + }; + + var methods = { + init: function () { + this.renderMarkup(); + this.addListeners(); + this.initRating(); + }, + + addListeners: function(){ + if( this.settings.readOnly ){ return; } + this.$stars.on('mouseover', this.hoverRating.bind(this)); + this.$stars.on('mouseout', this.restoreState.bind(this)); + this.$stars.on('click', this.handleRating.bind(this)); + }, + + // apply styles to hovered stars + hoverRating: function(e){ + var index = this.getIndex(e); + this.paintStars(index, 'hovered'); + this.settings.onHover(index + 1, this._state.rating, this.$el); + }, + + // clicked on a rate, apply style and state + handleRating: function(e){ + var index = this.getIndex(e); + var rating = index + 1; + + this.applyRating(rating, this.$el); + this.executeCallback( rating, this.$el ); + + if(this.settings.disableAfterRate){ + this.$stars.off(); + } + }, + + applyRating: function(rating){ + var index = rating - 1; + // paint selected and remove hovered color + this.paintStars(index, 'rated'); + this._state.rating = index + 1; + this._state.rated = true; + }, + + restoreState: function(e){ + var index = this.getIndex(e); + var rating = this._state.rating || -1; + // determine star color depending on manually rated + var colorType = this._state.rated ? 'rated' : 'active'; + this.paintStars(rating - 1, colorType); + this.settings.onLeave(index + 1, this._state.rating, this.$el); + }, + + getIndex: function(e){ + var $target = $(e.currentTarget); + var width = $target.width(); + var side = $(e.target).attr('data-side'); + + // hovered outside the star, calculate by pixel instead + side = (!side) ? this.getOffsetByPixel(e, $target, width) : side; + side = (this.settings.useFullStars) ? 'right' : side ; + + // get index for half or whole star + var index = $target.index() - ((side === 'left') ? 0.5 : 0); + + // pointer is way to the left, rating should be none + index = ( index < 0.5 && (e.offsetX < width / 4) ) ? -1 : index; + return index; + }, + + getOffsetByPixel: function(e, $target, width){ + var leftX = e.pageX - $target.offset().left; + return ( leftX <= (width / 2) && !this.settings.useFullStars) ? 'left' : 'right'; + }, + + initRating: function(){ + this.paintStars(this._state.rating - 1, 'active'); + }, + + paintStars: function(endIndex, stateClass){ + var $polygonLeft; + var $polygonRight; + var leftClass; + var rightClass; + + $.each(this.$stars, function(index, star){ + $polygonLeft = $(star).find('[data-side="left"]'); + $polygonRight = $(star).find('[data-side="right"]'); + leftClass = rightClass = (index <= endIndex) ? stateClass : 'empty'; + + // has another half rating, add half star + leftClass = ( index - endIndex === 0.5 ) ? stateClass : leftClass; + + $polygonLeft.attr('class', 'svg-' + leftClass + '-' + this._uid); + $polygonRight.attr('class', 'svg-' + rightClass + '-' + this._uid); + + }.bind(this)); + }, + + renderMarkup: function () { + var s = this.settings; + var baseUrl = s.baseUrl ? location.href.split('#')[0] : ''; + + // inject an svg manually to have control over attributes + var star = '
' + + + this.getLinearGradient(this._uid + '_SVGID_1_', s.emptyColor, s.emptyColor, s.starShape) + + this.getLinearGradient(this._uid + '_SVGID_2_', s.hoverColor, s.hoverColor, s.starShape) + + this.getLinearGradient(this._uid + '_SVGID_3_', s.starGradient.start, s.starGradient.end, s.starShape) + + this.getVectorPath(this._uid, { + starShape: s.starShape, + strokeWidth: s.strokeWidth, + strokeColor: s.strokeColor + } ) + + '
'; + + // inject svg markup + var starsMarkup = ''; + for( var i = 0; i < s.totalStars; i++){ + starsMarkup += star; + } + this.$el.append(starsMarkup); + this.$stars = this.$el.find('.jq-star'); + }, + + getVectorPath: function(id, attrs){ + return (attrs.starShape === 'rounded') ? + this.getRoundedVectorPath(id, attrs) : this.getSpikeVectorPath(id, attrs); + }, + + getSpikeVectorPath: function(id, attrs){ + return '' + + '' + + ''; + }, + + getRoundedVectorPath: function(id, attrs){ + var fullPoints = 'M520.9,336.5c-3.8-11.8-14.2-20.5-26.5-22.2l-140.9-20.5l-63-127.7 c-5.5-11.2-16.8-18.2-29.3-18.2c-12.5,0-23.8,7-29.3,18.2l-63,127.7L28,314.2C15.7,316,5.4,324.7,1.6,336.5S1,361.3,9.9,370 l102,99.4l-24,140.3c-2.1,12.3,2.9,24.6,13,32c5.7,4.2,12.4,6.2,19.2,6.2c5.2,0,10.5-1.2,15.2-3.8l126-66.3l126,66.2 c4.8,2.6,10,3.8,15.2,3.8c6.8,0,13.5-2.1,19.2-6.2c10.1-7.3,15.1-19.7,13-32l-24-140.3l102-99.4 C521.6,361.3,524.8,348.3,520.9,336.5z'; + + return ''; + }, + + getSvgDimensions: function(starShape){ + return (starShape === 'rounded') ? 'width="550px" height="500.2px" viewBox="0 146.8 550 500.2" style="enable-background:new 0 0 550 500.2;' : 'x="0px" y="0px" width="305px" height="305px" viewBox="60 -62 309 309" style="enable-background:new 64 -59 305 305;'; + }, + + getLinearGradient: function(id, startColor, endColor, starShape){ + var height = (starShape === 'rounded') ? 500 : 250; + return ' '; + }, + + executeCallback: function(rating, $el){ + var callback = this.settings.callback; + callback(rating, $el); + } + + }; + + var publicMethods = { + + unload: function() { + var _name = 'plugin_' + pluginName; + var $el = $(this); + var $starSet = $el.data(_name).$stars; + $starSet.off(); + $el.removeData(_name).remove(); + }, + + setRating: function(rating, round) { + var _name = 'plugin_' + pluginName; + var $el = $(this); + var $plugin = $el.data(_name); + if( rating > $plugin.settings.totalStars || rating < 0 ) { return; } + if( round ){ + rating = Math.round(rating); + } + $plugin.applyRating(rating); + }, + + getRating: function() { + var _name = 'plugin_' + pluginName; + var $el = $(this); + var $starSet = $el.data(_name); + return $starSet._state.rating; + }, + + resize: function(newSize) { + var _name = 'plugin_' + pluginName; + var $el = $(this); + var $starSet = $el.data(_name); + var $stars = $starSet.$stars; + + if(newSize <= 1 || newSize > 200) { + console.log('star size out of bounds'); + return; + } + + $stars = Array.prototype.slice.call($stars); + $stars.forEach(function(star){ + $(star).css({ + 'width': newSize + 'px', + 'height': newSize + 'px' + }); + }); + }, + + setReadOnly: function(flag) { + var _name = 'plugin_' + pluginName; + var $el = $(this); + var $plugin = $el.data(_name); + if(flag === true){ + $plugin.$stars.off('mouseover mouseout click'); + } else { + $plugin.settings.readOnly = false; + $plugin.addListeners(); + } + } + + }; + + + // Avoid Plugin.prototype conflicts + $.extend(Plugin.prototype, methods); + + $.fn[ pluginName ] = function ( options ) { + + // if options is a public method + if( !$.isPlainObject(options) ){ + if( publicMethods.hasOwnProperty(options) ){ + return publicMethods[options].apply(this, Array.prototype.slice.call(arguments, 1)); + } else { + $.error('Method '+ options +' does not exist on ' + pluginName + '.js'); + } + } + + return this.each(function() { + // preventing against multiple instantiations + if ( !$.data( this, 'plugin_' + pluginName ) ) { + $.data( this, 'plugin_' + pluginName, new Plugin( this, options ) ); + } + }); + }; + +})( jQuery, window, document ); + + diff --git a/common/templates/common/home.html b/common/templates/common/home.html new file mode 100644 index 00000000..00a8fa92 --- /dev/null +++ b/common/templates/common/home.html @@ -0,0 +1,187 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} + + + + + + + {% trans 'Boofilsic - 主页' %} + + + + + + + + +
+
+ + +
+
+
+ +
+
+ {% trans '我想看的书' %} +
+ {% if wish_books_more %} + {% trans '更多' %} + {% endif %} + + +
+
+
+ {% trans '我在看的书' %} +
+ {% if do_books_more %} + {% trans '更多' %} + {% endif %} + + +
+
+
+ {% trans '我看过的书' %} +
+ {% if collect_books_more %} + {% trans '更多' %} + {% endif %} + + +
+
+ +
+
+
+ {{ user.username }} + +
+
+
+ + +

+ + +
+
+
+ {% trans '我关注的人' %} +
+ {% trans '更多' %} +
    +
  • + + +
  • +
+
+ {% trans '关注我的人' %} +
+ {% trans '更多' %} +
    +
  • + + +
  • +
+
+ + {% if user.is_staff %} +
+
{% trans '举报信息' %}
+ +
+ {% endif %} + +
+
+ +
+ +
+ + + + + + + + + + diff --git a/common/templates/common/search_result.html b/common/templates/common/search_result.html new file mode 100644 index 00000000..3c81b070 --- /dev/null +++ b/common/templates/common/search_result.html @@ -0,0 +1,189 @@ +{% load static %} +{% load i18n %} +{% load admin_url %} +{% load mastodon %} +{% load oauth_token %} +{% load truncate %} +{% load highlight %} + + + + + + + {% trans 'Boofilsic - 搜索结果' %} + + + + + + + + + +
+
+ + +
+
+
+
    + + {% for book in items %} + +
  • + +
    + + + {% if request.GET.keywords %} + {{ book.title | highlight:request.GET.keywords }} + {% else %} + {{ book.title }} + {% endif %} + + {% if book.rating %} + +
    + + {{ book.rating }} + + {% else %} + {% trans '暂无评分' %} + {% endif %} + + {% if book.pub_year %} + {{ book.pub_year }}{% trans '年' %} / + {% if book.pub_month %} + {{book.pub_month }}{% trans '月' %} / + {% endif %} + {% endif %} + + {% if book.author %} + {% trans '作者' %} + {% for author in book.author %} + {{ author }}{% if not forloop.last %},{% endif %} + {% endfor %}/ + {% endif %} + + {% if book.translator %} + {% trans '译者' %} + {% for translator in book.translator %} + {{ translator }}{% if not forloop.last %},{% endif %} + {% endfor %}/ + {% endif %} + + {% if book.orig_title %} +  {% trans '原名' %} + {{ book.orig_title }} + {% endif %} + +

    + {{ book.brief | truncate:170 }} +

    +
    +
  • + {% empty %} + {% trans '无结果' %} + {% endfor %} + +
+ +
+ +
+
+ +
+ + {% trans '没有想要的结果?' %} +
+ {% trans '添加一个条目' %} +
+
+
+
+ +
+ + + {% comment %} + + + + + {% endcomment %} + + + + + + diff --git a/common/templates/widgets/key_value.html b/common/templates/widgets/key_value.html new file mode 100644 index 00000000..512102bb --- /dev/null +++ b/common/templates/widgets/key_value.html @@ -0,0 +1,63 @@ + +
+ + \ No newline at end of file diff --git a/common/templatetags/__init__.py b/common/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/common/templatetags/admin_url.py b/common/templatetags/admin_url.py new file mode 100644 index 00000000..ce1bc765 --- /dev/null +++ b/common/templatetags/admin_url.py @@ -0,0 +1,16 @@ +from django import template +from django.conf import settings +from django.utils.html import format_html + + +register = template.Library() + + +@register.simple_tag +def admin_url(): + url = settings.ADMIN_URL + if not url.startswith('/'): + url = '/' + url + if not url.endswith('/'): + url += '/' + return format_html(url) \ No newline at end of file diff --git a/common/templatetags/highlight.py b/common/templatetags/highlight.py new file mode 100644 index 00000000..32af3aa2 --- /dev/null +++ b/common/templatetags/highlight.py @@ -0,0 +1,13 @@ +from django import template +from django.utils.safestring import mark_safe +from django.template.defaultfilters import stringfilter +from django.utils.html import format_html + + +register = template.Library() + +@register.filter +@stringfilter +def highlight(text, search): + highlighted = text.replace(search, '{}'.format(search)) + return mark_safe(highlighted) \ No newline at end of file diff --git a/common/templatetags/mastodon.py b/common/templatetags/mastodon.py new file mode 100644 index 00000000..169b77fa --- /dev/null +++ b/common/templatetags/mastodon.py @@ -0,0 +1,12 @@ +from django import template +from django.conf import settings +from django.utils.html import format_html + + +register = template.Library() + + +@register.simple_tag +def mastodon(): + url = 'https://' + settings.MASTODON_DOMAIN_NAME + return format_html(url) \ No newline at end of file diff --git a/common/templatetags/oauth_token.py b/common/templatetags/oauth_token.py new file mode 100644 index 00000000..7aac83a1 --- /dev/null +++ b/common/templatetags/oauth_token.py @@ -0,0 +1,16 @@ +from django import template +from django.conf import settings +from django.utils.html import format_html + +register = template.Library() + +class OAuthTokenNode(template.Node): + def render(self, context): + request = context.get('request') + oauth_token = request.session.get('oauth_token', default='') + return format_html(oauth_token) + + +@register.tag +def oauth_token(parser, token): + return OAuthTokenNode() \ No newline at end of file diff --git a/common/templatetags/truncate.py b/common/templatetags/truncate.py new file mode 100644 index 00000000..558ec5c0 --- /dev/null +++ b/common/templatetags/truncate.py @@ -0,0 +1,17 @@ +from django import template +from django.template.defaultfilters import stringfilter +from django.utils.text import Truncator + + +register = template.Library() + + +@register.filter(is_safe=True) +@stringfilter +def truncate(value, arg): + """Truncate a string after `arg` number of characters.""" + try: + length = int(arg) + except ValueError: # Invalid literal for int(). + return value # Fail silently. + return Truncator(value).chars(length, truncate="...") \ No newline at end of file diff --git a/common/tests.py b/common/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/common/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/common/urls.py b/common/urls.py new file mode 100644 index 00000000..22843ee7 --- /dev/null +++ b/common/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import * + + +app_name = 'common' +urlpatterns = [ + path('', home), + path('home/', home, name='home'), + path('search/', search, name='search'), +] diff --git a/common/views.py b/common/views.py new file mode 100644 index 00000000..85b184d5 --- /dev/null +++ b/common/views.py @@ -0,0 +1,77 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from books.models import Book +from common.models import MarkStatusEnum +from users.models import Report, User +from django.core.paginator import Paginator +from django.db.models import Q +from django.http import HttpResponseBadRequest + + +# how many books have in each set at the home page +BOOKS_PER_SET = 5 + +# how many items are showed in one search result page +ITEMS_PER_PAGE = 20 + + +@login_required +def home(request): + if request.method == 'GET': + books = Book.objects.filter(book_marks__owner=request.user) + + do_books = books.filter(book_marks__status=MarkStatusEnum.DO) + do_books_more = True if do_books.count() > BOOKS_PER_SET else False + wish_books = books.filter(book_marks__status=MarkStatusEnum.WISH) + wish_books_more = True if wish_books.count() > BOOKS_PER_SET else False + collect_books = books.filter(book_marks__status=MarkStatusEnum.COLLECT) + collect_books_more = True if collect_books.count() > BOOKS_PER_SET else False + + reports = Report.objects.order_by('-submitted_time').filter(is_read=False) + # reports = Report.objects.latest('submitted_time').filter(is_read=False) + + return render( + request, + 'common/home.html', + { + 'do_books': do_books[:BOOKS_PER_SET], + 'wish_books': wish_books[:BOOKS_PER_SET], + 'collect_books': collect_books[:BOOKS_PER_SET], + 'do_books_more': do_books_more, + 'wish_books_more': wish_books_more, + 'collect_books_more': collect_books_more, + 'reports': reports, + } + ) + else: + return HttpResponseBadRequest() + + +def search(request): + if request.method == 'GET': + # in the future when more modules are added... + # category = request.GET.get("category") + q = Q() + keywords = request.GET.get("keywords", default='').split() + query_args = [] + for keyword in keywords: + q = q | Q(title__icontains=keyword) + q = q | Q(subtitle__istartswith=keyword) + q = q | Q(orig_title__icontains=keyword) + query_args.append(q) + queryset = Book.objects.filter(*query_args) + + paginator = Paginator(queryset, ITEMS_PER_PAGE) + page_number = request.GET.get('page', default=1) + items = paginator.get_page(page_number) + + return render( + request, + "common/search_result.html", + { + "items": items, + } + ) + + else: + return HttpResponseBadRequest() \ No newline at end of file diff --git a/docs/ui_layout/ui (1).jpg b/docs/ui_layout/ui (1).jpg new file mode 100644 index 00000000..24a126a0 Binary files /dev/null and b/docs/ui_layout/ui (1).jpg differ diff --git a/docs/ui_layout/ui (10).jpg b/docs/ui_layout/ui (10).jpg new file mode 100644 index 00000000..4262d521 Binary files /dev/null and b/docs/ui_layout/ui (10).jpg differ diff --git a/docs/ui_layout/ui (11).jpg b/docs/ui_layout/ui (11).jpg new file mode 100644 index 00000000..ff28ea1d Binary files /dev/null and b/docs/ui_layout/ui (11).jpg differ diff --git a/docs/ui_layout/ui (12).jpg b/docs/ui_layout/ui (12).jpg new file mode 100644 index 00000000..cd528847 Binary files /dev/null and b/docs/ui_layout/ui (12).jpg differ diff --git a/docs/ui_layout/ui (2).jpg b/docs/ui_layout/ui (2).jpg new file mode 100644 index 00000000..5340e155 Binary files /dev/null and b/docs/ui_layout/ui (2).jpg differ diff --git a/docs/ui_layout/ui (3).jpg b/docs/ui_layout/ui (3).jpg new file mode 100644 index 00000000..6d9021ec Binary files /dev/null and b/docs/ui_layout/ui (3).jpg differ diff --git a/docs/ui_layout/ui (4).jpg b/docs/ui_layout/ui (4).jpg new file mode 100644 index 00000000..91f718c8 Binary files /dev/null and b/docs/ui_layout/ui (4).jpg differ diff --git a/docs/ui_layout/ui (5).jpg b/docs/ui_layout/ui (5).jpg new file mode 100644 index 00000000..421175e3 Binary files /dev/null and b/docs/ui_layout/ui (5).jpg differ diff --git a/docs/ui_layout/ui (6).jpg b/docs/ui_layout/ui (6).jpg new file mode 100644 index 00000000..6ab6f00f Binary files /dev/null and b/docs/ui_layout/ui (6).jpg differ diff --git a/docs/ui_layout/ui (7).jpg b/docs/ui_layout/ui (7).jpg new file mode 100644 index 00000000..e7d8370f Binary files /dev/null and b/docs/ui_layout/ui (7).jpg differ diff --git a/docs/ui_layout/ui (8).jpg b/docs/ui_layout/ui (8).jpg new file mode 100644 index 00000000..cd7ca76f Binary files /dev/null and b/docs/ui_layout/ui (8).jpg differ diff --git a/docs/ui_layout/ui (9).jpg b/docs/ui_layout/ui (9).jpg new file mode 100644 index 00000000..7b0eecc4 Binary files /dev/null and b/docs/ui_layout/ui (9).jpg differ diff --git a/manage.py b/manage.py new file mode 100644 index 00000000..871459c8 --- /dev/null +++ b/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'boofilsic.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/users/admin.py b/users/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/users/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/users/apps.py b/users/apps.py new file mode 100644 index 00000000..4ce1fabc --- /dev/null +++ b/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'users' diff --git a/users/auth.py b/users/auth.py new file mode 100644 index 00000000..c1e2689f --- /dev/null +++ b/users/auth.py @@ -0,0 +1,88 @@ +import requests +from django.shortcuts import reverse +from django.contrib.auth.backends import ModelBackend, UserModel +from boofilsic.settings import MASTODON_DOMAIN_NAME, CLIENT_ID, CLIENT_SECRET +from common.mastodon.api import * + + +class OAuth2Backend(ModelBackend): + """ Used to glue OAuth2 and Django User model """ + # "authenticate() should check the credentials it gets and returns + # a user object that matches those credentials." + # arg request is an interface specification, not used in this implementation + def authenticate(self, request, token=None, username=None, **kwargs): + """ when username is provided, assume that token is newly obtained and valid """ + if token is None: + return + + if username is None: + user_data = get_user_data(token) + if user_data: + username = user_data['username'] + else: + # aquiring user data fail means token is invalid thus auth fail + return None + + # when username is provided, assume that token is newly obtained and valid + try: + user = UserModel._default_manager.get_by_natural_key(user_data['username']) + except UserModel.DoesNotExist: + return None + else: + if self.user_can_authenticate(user): + return user + return None + + +def obtain_token(request, code): + """ Returns token if success else None. """ + payload = { + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'redirect_uri': f"http://{request.get_host()}{reverse('users:OAuth2_login')}", + 'grant_type': 'authorization_code', + 'code': code, + 'scope': 'read write' + } + url = 'https://' + MASTODON_DOMAIN_NAME + OAUTH_TOKEN + response = requests.post(url, data=payload) + if response.status_code != 200: + return + data = response.json() + return data.get('access_token') + + +def get_user_data(token): + url = 'https://' + MASTODON_DOMAIN_NAME + VERIFY_ACCOUNT_CREDENTIALS + headers = { + 'Authorization': f'Bearer {token}' + } + response = requests.get(url, headers=headers) + if response.status_code != 200: + return None + return response.json() + + +def revoke_token(token): + payload = { + 'client_id': CLIENT_ID, + 'client_secret': CLIENT_SECRET, + 'scope': token + } + url = 'https://' + MASTODON_DOMAIN_NAME + REVOKE_TOKEN + response = requests.post(url, data=payload) + + +def verify_token(token): + """ Check if the token is valid and is of local instance. """ + url = 'https://' + MASTODON_DOMAIN_NAME + VERIFY_ACCOUNT_CREDENTIALS + headers = { + 'Authorization': f'Bearer {token}' + } + response = requests.get(url, headers=headers) + if response.status_code == 200: + res_data = response.json() + # check if is local instance user + if res_data['acct'] == res_data['username']: + return True + return False \ No newline at end of file diff --git a/users/models.py b/users/models.py new file mode 100644 index 00000000..e46e4a3f --- /dev/null +++ b/users/models.py @@ -0,0 +1,34 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser +from datetime import datetime +from boofilsic.settings import REPORT_MEDIA_PATH_ROOT, DEFAULT_PASSWORD + + +def report_image_path(instance, filename): + raise NotImplementedError("UUID!!!!!!!!!!!") + root = '' + if REPORT_MEDIA_PATH_ROOT.endswith('/'): + root = REPORT_MEDIA_PATH_ROOT + else: + root = REPORT_MEDIA_PATH_ROOT + '/' + return root + datetime.now().strftime('%Y/%m/%d') + f'{filename}' + + +class User(AbstractUser): + mastodon_id = models.IntegerField() + + def save(self, *args, **kwargs): + """ Automatically populate password field with DEFAULT_PASSWORD before saving.""" + self.set_password(DEFAULT_PASSWORD) + return super().save(*args, **kwargs) + + +class Report(models.Model): + submit_user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='sumbitted_reports', null=True) + reported_user = models.ForeignKey(User, on_delete=models.SET_NULL, related_name='accused_reports', null=True) + image = models.ImageField(upload_to=report_image_path, height_field=None, width_field=None, max_length=None, blank=True, default='') + is_read = models.BooleanField(default=False) + submitted_time = models.DateTimeField(auto_now_add=True) + + + diff --git a/users/templates/users/login.html b/users/templates/users/login.html new file mode 100644 index 00000000..d7bebb9c --- /dev/null +++ b/users/templates/users/login.html @@ -0,0 +1,27 @@ + +{% load i18n %} + + + + + + + Document + + +
+ +
+ + {% if user.is_authenticated %} + {% trans '前往我的主页' %} + {% else %} + {% trans '使用长毛象授权登录' %} + {% endif %} + +
+
+ + \ No newline at end of file diff --git a/users/templates/users/register.html b/users/templates/users/register.html new file mode 100644 index 00000000..d4c9a4b9 --- /dev/null +++ b/users/templates/users/register.html @@ -0,0 +1,16 @@ + + + + + + Register + + + Are you sure you wanna join? +
+ {% csrf_token %} + + +
+ + \ No newline at end of file diff --git a/users/tests.py b/users/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 00000000..04f2891a --- /dev/null +++ b/users/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from .views import * + +app_name = 'users' +urlpatterns = [ + path('login/', login, name='login'), + path('register/', register, name='register'), + path('logout/', logout, name='logout'), + path('delete/', delete, name='delete'), + path('OAuth2_login/', OAuth2_login, name='OAuth2_login'), +] diff --git a/users/views.py b/users/views.py new file mode 100644 index 00000000..27746a87 --- /dev/null +++ b/users/views.py @@ -0,0 +1,110 @@ +from django.shortcuts import reverse, redirect, render +from django.http import HttpResponseBadRequest, HttpResponse +from django.contrib import auth +from django.contrib.auth import authenticate +from .models import User +from .auth import * +from boofilsic.settings import MASTODON_DOMAIN_NAME, CLIENT_ID, CLIENT_SECRET +from common.mastodon.api import * + + +# Views +######################################## + +# no page rendered +def OAuth2_login(request): + """ oauth authentication and logging user into django system """ + if request.method == 'GET': + code = request.GET.get('code') + # Network IO + token = obtain_token(request, code) + if token: + # oauth is completed when token aquired + user = authenticate(request, token=token) + if user: + auth_login(request, user, token) + return redirect(reverse('common:home')) + else: + # will be passed to register page + request.session['new_user_token'] = token + return redirect(reverse('users:register')) + else: + # TODO better fail result page + return HttpResponse(content="Authentication failed.") + else: + return HttpResponseBadRequest() + + +# the 'login' page that user can see +def login(request): + if request.method == 'GET': + # TODO NOTE replace http with https!!!! + auth_url = f"https://{MASTODON_DOMAIN_NAME}{OAUTH_AUTHORIZE}?" +\ + f"client_id={CLIENT_ID}&scope=read+write&" +\ + f"redirect_uri=http://{request.get_host()}{reverse('users:OAuth2_login')}" +\ + "&response_type=code" + + return render( + request, + 'users/login.html', + { + 'oauth_auth_url': auth_url + } + ) + else: + return HttpResponseBadRequest() + + +def logout(request): + if request.method == 'GET': + revoke_token(request.session['oauth_token']) + auth_logout(request) + return redirect(reverse("users:login")) + else: + return HttpResponseBadRequest() + + +def register(request): + """ register confirm page """ + if request.method == 'GET': + if request.session.get('oauth_token'): + return redirect(reverse('common:home')) + elif request.session.get('new_user_token'): + return render( + request, + 'users/register.html' + ) + else: + return HttpResponseBadRequest() + elif request.method == 'POST': + token = request.session['new_user_token'] + user_data = get_user_data(token) + new_user = User( + username=user_data['username'], + mastodon_id=user_data['id'] + ) + new_user.save() + del request.session['new_user_token'] + auth_login(request, new_user, token) + return redirect(reverse('common:home')) + else: + return HttpResponseBadRequest() + + +def delete(request): + raise NotImplementedError + + +# Utils +######################################## + +def auth_login(request, user, token): + """ Decorates django ``login()``. Attach token to session.""" + request.session['oauth_token'] = token + auth.login(request, user) + + +def auth_logout(request): + """ Decorates django ``logout()``. Release token in session.""" + del request.session['oauth_token'] + auth.logout(request) \ No newline at end of file