first commit

This commit is contained in:
doubaniux 2020-05-01 22:46:15 +08:00
commit d219921bb0
72 changed files with 3717 additions and 0 deletions

18
.gitignore vendored Normal file
View file

@ -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

0
boofilsic/__init__.py Normal file
View file

16
boofilsic/asgi.py Normal file
View file

@ -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()

169
boofilsic/settings.py Normal file
View file

@ -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

29
boofilsic/urls.py Normal file
View file

@ -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')),
]

16
boofilsic/wsgi.py Normal file
View file

@ -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()

0
books/__init__.py Normal file
View file

3
books/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
books/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BooksConfig(AppConfig):
name = 'books'

76
books/forms.py Normal file
View file

@ -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'
]

82
books/models.py Normal file
View file

@ -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")
]

View file

@ -0,0 +1,76 @@
{% load static %}
{% load i18n %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans 'Boofilsic - 添加图书' %}</title>
<script src="https://cdn.staticfile.org/jquery/3.5.0/jquery.min.js"></script>
<script src="{% static 'js/create_update.js' %}"></script>
<link rel="stylesheet" href="{% static 'lib/css/milligram.css' %}">
<link rel="stylesheet" href="{% static 'css/boofilsic_edit.css' %}">
</head>
<body>
<div id="page-wrapper">
<div id="content-wrapper">
<section id="navbar" class="navbar">
<div class="container">
<nav class="clearfix">
<a href="{% url 'common:home' %}">
<img src="{% static 'img/logo.svg' %}" alt="" class="logo">
</a>
<h4 class="nav-title">{{ title }}</h4>
<a class="nav-link" id="logoutLink" href="{% url 'users:logout' %}">{% trans '登出' %}</a>
<a class="nav-link" href="{% url 'common:home' %}">{% trans '主页' %}</a>
<a class="nav-link" href="{% admin_url %}">{% trans '后台' %}</a>
</nav>
</div>
</section>
<section id="content" class="container">
<div class="row">
<div id="main">
<form action="{% url 'books:create' %}" method="post">
{% csrf_token %}
{{ form }}
<button type="submit">{% trans '提交' %}</button>
</form>
</div>
</section>
</div>
<footer class="container">
<a href="">whitiewhite@donotban.com</a>
<a href="" id="githubLink">Github</a>
</footer>
</div>
{% comment %}
<div id="oauth2Token" hidden="true">{% oauth_token %}</div>
<div id="mastodonURI" hidden="true">{% mastodon %}</div>
<!--current user mastodon id-->
<div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div>
{% endcomment %}
<script>
$("#searchInput").on('keyup', function (e) {
if (e.keyCode === 13) {
let keywords = $(this).val();
if (keywords)
location.href = "{% url 'common:search' %}" + "?keywords=" + keywords;
}
});
</script>
</body>
</html>

View file

View file

@ -0,0 +1,140 @@
{% load static %}
{% load i18n %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans 'Boofilsic - 书籍详情' %} | {{ book.title }}</title>
<script src="https://cdn.staticfile.org/jquery/3.5.0/jquery.min.js"></script>
<script src="{% static 'lib/js/rating-star.js' %}"></script>
<script src="{% static 'js/detail.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/boofilsic_browse.css' %}">
<link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}">
<link rel="stylesheet" href="{% static 'lib/css/milligram.css' %}">
</head>
<body>
<div id="page-wrapper">
<div id="content-wrapper">
<section id="navbar" class="navbar">
<div class="container">
<nav class="clearfix">
<a href="{% url 'common:home' %}">
<img src="{% static 'img/logo.svg' %}" alt="" class="logo">
</a>
<input type="search" class="search-box" name="keywords" id="searchInput" required="true" placeholder="{% trans '搜索书影音,多个关键字以空格分割' %}">
<a class="nav-link" id="logoutLink" href="{% url 'users:logout' %}">{% trans '登出' %}</a>
<a class="nav-link" href="{% url 'common:home' %}">{% trans '主页' %}</a>
<a class="nav-link" href="{% admin_url %}">{% trans '后台' %}</a>
</nav>
</div>
</section>
<section id="content" class="container">
<div class="row">
<div id="main">
<div class="clearfix">
<img src="{% static 'img/default.jpg' %}" class="display-image" alt="">
<div class="display-info">
<h5 class="display-title">
{{ book.title }}
</h5>
<div class="display-info-detail">
<div>{% if book.isbn %}{% trans 'ISBN' %}{{ book.isbn }}{% endif %}</div>
<div>{% if book.author %}{% trans '作者:' %}
{% for author in book.author %}
<span>{{ author }}</span>
{% endfor %}
{% endif %}</div>
<div>{% if book.pub_house %}{% trans '出版社:' %}{{ book.pub_house }}{% endif %}</div>
<div>{% if book.subtitle %}{% trans '副标题:' %}{{ book.subtitle }}{% endif %}</div>
<div>{% if book.translator %}{% trans '译者:' %}
{% for translator in book.translator %}
<span>{{ translator }}</span>
{% endfor %}
{% endif %}</div>
<div>{% if book.orig_title %}{% trans '原作名:' %}{{ book.orig_title }}{% endif %}</div>
<div>{% if book.language %}{% trans '语言:' %}{{ book.language }}{% endif %}</div>
<div>{%if book.pub_year %}{% trans '出版时间:' %}{{ book.pub_year }}{% trans '年' %}{% if book.pub_month %}{{ book.pub_month }}{% trans '月' %}{% endif %}{% endif %}</div>
</div>
<div class="display-info-detail">
{% if book.rating %}
<span class="rating-star" data-rating-score="{{ book.rating | floatformat:"0" }}">
</span>
<span class="rating-score"> {{ book.rating }} </span>
{% else %}
<span> {% trans '评分:暂无评分' %}</span>
{% endif %}
<div>{% if book.binding %}{% trans '装帧:' %}{{ book.binding }}{% endif %}</div>
<div>{% if book.price %}{% trans '定价:' %}{{ book.price }}{% endif %}</div>
<div>{% if book.pages %}{% trans '页数' %}{{ book.pages }}{% endif %}</div>
{% if book.other_info %}
{% for k, v in book.other_info.items %}
<div>
{{k}}{{v}}
</div>
{% endfor %}
{% endif %}
{% comment %}
{% url 'users:home' book.last_editor %}
{% endcomment %}
<div>{% trans '最近编辑者:' %}<a href="">someone</a></div>
</div>
</div>
</div>
<div class="dividing-line"></div>
<div class="set">
<h5 class="set-title">{% trans '简介' %}</h5>
{% if book.brief %}
<p class="set-content">{{ book.brief }}</p>
{% else %}
<p class="set-empty">{% trans '暂无简介' %}</p>
{% endif %}
</div>
</div>
<div id="aside">
<div class="aside-card mast-user">
<p class="info-brief mast-brief"></p>
<!-- <a href="#" class="follow">{% trans '关注TA' %}</a> -->
<!-- <a href="#" class="report">{% trans '举报用户' %}</a> -->
</div>
</div>
</section>
</div>
<footer class="container">
<a href="">whitiewhite@donotban.com</a>
<a href="" id="githubLink">Github</a>
</footer>
</div>
<script>
$("#searchInput").on('keyup', function (e) {
if (e.keyCode === 13) {
let keywords = $(this).val();
if (keywords)
location.href = "{% url 'common:search' %}" + "?keywords=" + keywords;
}
});
</script>
</body>
</html>

3
books/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
books/urls.py Normal file
View file

@ -0,0 +1,9 @@
from django.urls import path
from .views import *
app_name = 'books'
urlpatterns = [
path('create/', create, name='create'),
path('<int:id>/', retrieve, name='retrieve'),
]

46
books/views.py Normal file
View file

@ -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()

0
common/__init__.py Normal file
View file

3
common/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
common/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
name = 'common'

30
common/forms.py Normal file
View file

@ -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

View file

22
common/mastodon/api.py Normal file
View file

@ -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'

76
common/models.py Normal file
View file

@ -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

View file

@ -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;
}

View file

@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 102.13 41.38"><title>logo</title><path d="M1.73,32.77H4.26c1.63,0,2.83.49,2.83,1.95A1.77,1.77,0,0,1,6,36.43v0a1.81,1.81,0,0,1,1.52,1.88c0,1.59-1.3,2.33-3,2.33H1.73ZM4.14,36c1.1,0,1.57-.43,1.57-1.12s-.51-1.06-1.54-1.06h-1V36Zm.18,3.55c1.17,0,1.82-.41,1.82-1.3s-.63-1.2-1.82-1.2H3.14v2.5Z" style="fill:#9b4dca"/><path d="M13.87,36.7c0-2.57,1.43-4.08,3.5-4.08s3.49,1.52,3.49,4.08-1.43,4.13-3.49,4.13S13.87,39.26,13.87,36.7Zm5.55,0c0-1.78-.81-2.86-2.05-2.86s-2.05,1.08-2.05,2.86.8,2.91,2.05,2.91S19.42,38.47,19.42,36.7Z" style="fill:#9b4dca"/><path d="M27.37,36.7c0-2.57,1.43-4.08,3.49-4.08s3.5,1.52,3.5,4.08-1.43,4.13-3.5,4.13S27.37,39.26,27.37,36.7Zm5.54,0c0-1.78-.8-2.86-2-2.86s-2.05,1.08-2.05,2.86.81,2.91,2.05,2.91S32.91,38.47,32.91,36.7Z" style="fill:#9b4dca"/><path d="M41.28,32.77h4.79V34H42.69V36.2h2.88v1.18H42.69v3.31H41.28Z" style="fill:#9b4dca"/><path d="M52.78,32.77h1.41v7.92H52.78Z" style="fill:#9b4dca"/><path d="M61.53,32.77h1.41v6.74h3.29v1.18h-4.7Z" style="fill:#9b4dca"/><path d="M72.1,39.67l.82-1a3.1,3.1,0,0,0,2.06.9c.89,0,1.38-.4,1.38-1s-.51-.86-1.23-1.16L74.05,37a2.29,2.29,0,0,1-1.6-2.11,2.41,2.41,0,0,1,2.66-2.23,3.47,3.47,0,0,1,2.44,1l-.72.89a2.55,2.55,0,0,0-1.72-.68c-.75,0-1.24.35-1.24.93s.6.85,1.26,1.12l1.07.45a2.2,2.2,0,0,1,1.6,2.14c0,1.28-1.07,2.35-2.85,2.35A4.07,4.07,0,0,1,72.1,39.67Z" style="fill:#9b4dca"/><path d="M84.57,32.77H86v7.92h-1.4Z" style="fill:#9b4dca"/><path d="M92.9,36.75c0-2.59,1.62-4.13,3.63-4.13a3.18,3.18,0,0,1,2.28,1l-.75.91a2,2,0,0,0-1.5-.69c-1.29,0-2.21,1.09-2.21,2.87s.85,2.9,2.18,2.9a2.23,2.23,0,0,0,1.69-.81l.75.88a3.15,3.15,0,0,1-2.5,1.15C94.48,40.83,92.9,39.38,92.9,36.75Z" style="fill:#9b4dca"/><path d="M15.67,26.75h-.15L1.58,23.81A.7.7,0,0,1,1,23.12V2.72A.71.71,0,0,1,1.87,2L15.81,5a.72.72,0,0,1,.56.69v20.4a.74.74,0,0,1-.26.55A.71.71,0,0,1,15.67,26.75ZM2.42,22.56,15,25.19v-19L2.42,3.58Z" style="fill:#9b4dca"/><path d="M15.67,26.75a.74.74,0,0,1-.45-.15.73.73,0,0,1-.25-.55V5.65A.71.71,0,0,1,15.52,5L29.47,2a.68.68,0,0,1,.58.15.67.67,0,0,1,.26.54v20.4a.7.7,0,0,1-.56.69L15.81,26.74Zm.7-20.53v19l12.54-2.63v-19Zm13.24,16.9h0Z" style="fill:#9b4dca"/><path d="M87.93,26.88A13.17,13.17,0,1,1,101.1,13.71,13.18,13.18,0,0,1,87.93,26.88Zm0-24.94A11.77,11.77,0,1,0,99.7,13.71,11.78,11.78,0,0,0,87.93,1.94Z" style="fill:#9b4dca"/><path d="M87.93,18.78A5.07,5.07,0,1,1,93,13.71,5.08,5.08,0,0,1,87.93,18.78Zm0-8.74a3.67,3.67,0,1,0,3.68,3.67A3.67,3.67,0,0,0,87.93,10Z" style="fill:#9b4dca"/><path d="M60.82,23H36.4a.7.7,0,0,1-.7-.7V5.09a.7.7,0,0,1,.7-.7H60.82a.7.7,0,0,1,.7.7V22.33A.7.7,0,0,1,60.82,23ZM37.1,21.63h23V5.79h-23Z" style="fill:#9b4dca"/><path d="M69.36,23a.68.68,0,0,1-.31-.08L60.5,18.66a.69.69,0,0,1-.38-.62V9.38a.71.71,0,0,1,.38-.63l8.55-4.29a.72.72,0,0,1,.68,0,.7.7,0,0,1,.33.6V22.33a.7.7,0,0,1-.33.6A.79.79,0,0,1,69.36,23Zm-7.84-5.42,7.14,3.58v-15L61.52,9.81Z" style="fill:#9b4dca"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,23 @@
$(document).ready( function() {
// assume there is only one input[file] on page
$("input[type='file']").each(function() {
$(this).after('<img src="#" alt="" id="previewImage" style="margin:10px 0;"/>');
})
// 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]);
}
});
});

View file

@ -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
});
});
});

83
common/static/js/home.js Normal file
View file

@ -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);
});
}
}
);
});

View file

@ -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": "<p></p>",
// "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": "<p></p>",
// "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": "<p>Developer of Mastodon and administrator of mastodon.social. I post service announcements, development updates, and personal stuff.</p>",
// "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": "<a href=\"https://www.patreon.com/mastodon\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://www.</span><span class=\"\">patreon.com/mastodon</span><span class=\"invisible\"></span></a>",
// "verified_at": null
// },
// {
// "name": "Homepage",
// "value": "<a href=\"https://zeonfederated.com\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">https://</span><span class=\"\">zeonfederated.com</span><span class=\"invisible\"></span></a>",
// "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 "<img src=" + dict[match] + " class=emoji alt=" + match + ">";
});
return translation;
}

View file

@ -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
});
});
});

View file

@ -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,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#d1d1d1" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>') center right no-repeat;
padding-right: 3.0rem;
}
select:focus {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="14" viewBox="0 0 29 14" width="29"><path fill="#9b4dca" d="M9.37727 3.625l5.08154 6.93523L19.54036 3.625"/></svg>');
}
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 */

View file

@ -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 );
}

View file

@ -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 = '<div class="jq-star" style="width:' + s.starSize+ 'px; height:' + s.starSize + 'px;"><svg version="1.0" class="jq-star-svg" shape-rendering="geometricPrecision" xmlns="http://www.w3.org/2000/svg" ' + this.getSvgDimensions(s.starShape) + ' stroke-width:' + s.strokeWidth + 'px;" xml:space="preserve"><style type="text/css">.svg-empty-' + this._uid + '{fill:url(' + baseUrl + '#' + this._uid + '_SVGID_1_);}.svg-hovered-' + this._uid + '{fill:url(' + baseUrl + '#' + this._uid + '_SVGID_2_);}.svg-active-' + this._uid + '{fill:url(' + baseUrl + '#' + this._uid + '_SVGID_3_);}.svg-rated-' + this._uid + '{fill:' + s.ratedColor + ';}</style>' +
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
} ) +
'</svg></div>';
// 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 '<polygon data-side="center" class="svg-empty-' + id + '" points="281.1,129.8 364,55.7 255.5,46.8 214,-59 172.5,46.8 64,55.4 146.8,129.7 121.1,241 212.9,181.1 213.9,181 306.5,241 " style="fill: transparent; stroke: ' + attrs.strokeColor + ';" />' +
'<polygon data-side="left" class="svg-empty-' + id + '" points="281.1,129.8 364,55.7 255.5,46.8 214,-59 172.5,46.8 64,55.4 146.8,129.7 121.1,241 213.9,181.1 213.9,181 306.5,241 " style="stroke-opacity: 0;" />' +
'<polygon data-side="right" class="svg-empty-' + id + '" points="364,55.7 255.5,46.8 214,-59 213.9,181 306.5,241 281.1,129.8 " style="stroke-opacity: 0;" />';
},
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 '<path data-side="center" class="svg-empty-' + id + '" d="' + fullPoints + '" style="stroke: ' + attrs.strokeColor + '; fill: transparent; " /><path data-side="right" class="svg-empty-' + id + '" d="' + fullPoints + '" style="stroke-opacity: 0;" /><path data-side="left" class="svg-empty-' + id + '" d="M121,648c-7.3,0-14.1-2.2-19.8-6.4c-10.4-7.6-15.6-20.3-13.4-33l24-139.9l-101.6-99 c-9.1-8.9-12.4-22.4-8.6-34.5c3.9-12.1,14.6-21.1,27.2-23l140.4-20.4L232,164.6c5.7-11.6,17.3-18.8,30.2-16.8c0.6,0,1,0.4,1,1 v430.1c0,0.4-0.2,0.7-0.5,0.9l-126,66.3C132,646.6,126.6,648,121,648z" style="stroke: ' + attrs.strokeColor + '; stroke-opacity: 0;" />';
},
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 '<linearGradient id="' + id + '" gradientUnits="userSpaceOnUse" x1="0" y1="-50" x2="0" y2="' + height + '"><stop offset="0" style="stop-color:' + startColor + '"/><stop offset="1" style="stop-color:' + endColor + '"/> </linearGradient>';
},
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 );

View file

@ -0,0 +1,187 @@
{% load static %}
{% load i18n %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans 'Boofilsic - 主页' %}</title>
<script src="https://cdn.staticfile.org/jquery/3.5.0/jquery.min.js"></script>
<script src="{% static 'js/mastodon.js' %}"></script>
<script src="{% static 'js/home.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/boofilsic_browse.css' %}">
<link rel="stylesheet" href="{% static 'lib/css/milligram.css' %}">
</head>
<body>
<div id="page-wrapper">
<div id="content-wrapper">
<section id="navbar" class="navbar">
<div class="container">
<nav class="clearfix">
<a href="{% url 'common:home' %}">
<img src="{% static 'img/logo.svg' %}" alt="" class="logo">
</a>
<input type="search" class="search-box" name="keywords" id="searchInput" required="true" placeholder="{% trans '搜索书影音,多个关键字以空格分割' %}">
<a class="nav-link" id="logoutLink" href="{% url 'users:logout' %}">{% trans '登出' %}</a>
<a class="nav-link" href="{% url 'common:home' %}">{% trans '主页' %}</a>
<a class="nav-link" href="{% admin_url %}">{% trans '后台' %}</a>
</nav>
</div>
</section>
<section id="content" class="container">
<div class="row">
<div id="main">
<div class="set" id="bookWish">
<h5 class="set-title">
{% trans '我想看的书' %}
</h5>
{% if wish_books_more %}
<a href="" class="more-link">{% trans '更多' %}</a>
{% endif %}
<ul class="row set-item-list">
{% for wish_book in wish_books %}
<li class="column column-20 set-item">
<!-- <img src="{{ wish_book.cover.url }}" alt="{{ wish_book.title }}"> -->
<a href="{% url 'books:retrieve' wish_book.id %}" >
<img src="{% static 'img/default.jpg' %}" alt="" class="set-item-image">
<span class="set-item-title">{{ wish_book.title | truncate:9 }}</span>
</a>
</li>
{% empty %}
<p class="set-empty">暂无记录</p>
{% endfor %}
</ul>
</div>
<div class="set" id="bookDo">
<h5 class="set-title">
{% trans '我在看的书' %}
</h5>
{% if do_books_more %}
<a href="" class="more-link">{% trans '更多' %}</a>
{% endif %}
<ul class="row set-item-list">
{% for do_book in do_books %}
<li class="column column-20 set-item">
<!-- <img src="{{ do_book.cover.url }}" alt="{{ do_book.title }}"> -->
<a href="{% url 'books:retrieve' do_book.id %}" >
<img src="{% static 'img/default.jpg' %}" alt="" class="set-item-image">
<span class="set-item-title">{{ do_book.title | truncate:9 }}</span>
</a>
</li>
{% empty %}
<p class="set-empty">暂无记录</p>
{% endfor %}
</ul>
</div>
<div class="set" id="bookCollect">
<h5 class="set-title">
{% trans '我看过的书' %}
</h5>
{% if collect_books_more %}
<a href="" class="more-link">{% trans '更多' %}</a>
{% endif %}
<ul class="row set-item-list">
{% for collect_book in collect_books %}
<li class="column column-20 set-item">
<!-- <img src="{{ collect_book.cover.url }}" alt="{{ collect_book.title }}"> -->
<a href="{% url 'books:retrieve' collect_book.id %}" >
<img src="{% static 'img/default.jpg' %}" alt="" class="set-item-image">
<span class="set-item-title">{{ collect_book.title | truncate:9 }}</span>
</a>
</li>
{% empty %}
<p class="set-empty">暂无记录</p>
{% endfor %}
</ul>
</div>
</div>
<div id="aside">
<div class="aside-card mast-user" id="userInfoCard">
<div class="clearfix">
<img src="" class="info-avatar mast-avatar" alt="{{ user.username }}">
<a href="">
<h5 class="info-name mast-displayname"></h5>
</a>
</div>
<p class="info-brief mast-brief"></p>
<!-- <a href="#" class="follow">{% trans '关注TA' %}</a> -->
<!-- <a href="#" class="report">{% trans '举报用户' %}</a> -->
</div>
<div class="relation-card" id="userRelationCard">
<h5 class="relation-label">
{% trans '我关注的人' %}
</h5>
<a href="" class="more-link mast-following-more">{% trans '更多' %}</a>
<ul class="row mast-following relation-user-list">
<li class="column column-25 relation-user">
<img src="" alt="" class="relation-avatar">
<a class="relation-name"></a>
</li>
</ul>
<h5 class="relation-label">
{% trans '关注我的人' %}
</h5>
<a href="" class="more-link mast-followers-more">{% trans '更多' %}</a>
<ul class="row mast-followers relation-user-list">
<li class="column column-25 relation-user">
<img src="" alt="" class="relation-avatar">
<a class="relation-name"></a>
</li>
</ul>
</div>
{% if user.is_staff %}
<div class="report-card" id="reportMessageCard">
<h5 class="report-label">{% trans '举报信息' %}</h5>
<ul class="report-list">
{% for report in reports %}
<li class="report-message">
<a href="" class="report-user-link">{{ report.submit_user }}</a>{% trans '举报了' %}<a href="">{{ report.reported_user }}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</section>
</div>
<footer class="container">
<a href="">whitiewhite@donotban.com</a>
<a href="" id="githubLink">Github</a>
</footer>
</div>
<div id="oauth2Token" hidden="true">{% oauth_token %}</div>
<div id="mastodonURI" hidden="true">{% mastodon %}</div>
<!--current user mastodon id-->
<div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div>
<script>
$("#searchInput").on('keyup', function (e) {
if (e.keyCode === 13) {
let keywords = $(this).val();
if (keywords)
location.href = "{% url 'common:search' %}" + "?keywords=" + keywords;
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,189 @@
{% load static %}
{% load i18n %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
{% load highlight %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans 'Boofilsic - 搜索结果' %}</title>
<script src="https://cdn.staticfile.org/jquery/3.5.0/jquery.min.js"></script>
<script src="{% static 'lib/js/rating-star.js' %}"></script>
<script src="{% static 'js/search_result.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/boofilsic_browse.css' %}">
<link rel="stylesheet" href="{% static 'lib/css/rating-star.css' %}">
<link rel="stylesheet" href="{% static 'lib/css/milligram.css' %}">
</head>
<body>
<div id="page-wrapper">
<div id="content-wrapper">
<section id="navbar" class="navbar">
<div class="container">
<nav class="clearfix">
<a href="{% url 'common:home' %}">
<img src="{% static 'img/logo.svg' %}" alt="" class="logo">
</a>
<input type="search" class="search-box" name="keywords"
value="{% if request.GET.keywords %}{{ request.GET.keywords }}{% endif %}" id="searchInput" required="true" placeholder="{% trans '搜索书影音,多个关键字以空格分割' %}">
<a class="nav-link" id="logoutLink" href="{% url 'users:logout' %}">{% trans '登出' %}</a>
<a class="nav-link" href="{% url 'common:home' %}">{% trans '主页' %}</a>
<a class="nav-link" href="{% admin_url %}">{% trans '后台' %}</a>
</nav>
</div>
</section>
<section id="content" class="container">
<div class="row">
<div id="main">
<ul class="result-items">
{% for book in items %}
<li class="result-item clearfix">
<img src="{% static 'img/default.jpg' %}" alt="" class="result-book-cover">
<div class="result-info">
<a href="{% url 'books:retrieve' book.id %}" class="result-book-title">
{% if request.GET.keywords %}
{{ book.title | highlight:request.GET.keywords }}
{% else %}
{{ book.title }}
{% endif %}
</a>
{% if book.rating %}
<div class="rating-star" data-rating-score="{{ book.rating | floatformat:"0" }}"></div>
<span class="rating-score">
{{ book.rating }}
</span>
{% else %}
<span class="rating-empty"> {% trans '暂无评分' %}</span>
{% endif %}
<span class="result-book-info">
{% 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 %}
&nbsp;{% trans '原名' %}
{{ book.orig_title }}
{% endif %}
</span>
<p class="result-book-brief">
{{ book.brief | truncate:170 }}
</p>
</div>
</li>
{% empty %}
{% trans '无结果' %}
{% endfor %}
</ul>
<div class="pagination" >
<a
{% if items.has_previous %}
href="?page=1&keywords={% if request.GET.keywords %}{{ request.GET.keywords }}{% endif %}"
{%else %}
disabled
{% endif %}>
<button {% if not items.has_previous %}disabled{% endif %} class="button button-clear">{% trans "首页" %}</button>
</a>&nbsp;&nbsp;
<a
{% if items.has_previous %}
href="?page={{ items.previous_page_number }}&keywords={% if request.GET.keywords %}{{ request.GET.keywords }}{% endif %}"
{%else %}
disabled
{% endif %}>
<button {% if not items.has_previous %}disabled{% endif %} class="button button-clear">{% trans "上一页" %}</button>
</a>&nbsp;&nbsp;
<span class="page-index">
{% trans "第" %}{% if request.GET.page %}{{ request.GET.page }}{% else %}1{% endif %}{% trans "页" %}
</span>
&nbsp;&nbsp;<a
{% if items.has_next %}
href="?page={{ items.next_page_number }}&keywords={% if request.GET.keywords %}{{ request.GET.keywords }}{% endif %}"
{% else %}
disabled
{% endif %}
>
<button {% if not items.has_next %}disabled{% endif %} class="button button-clear">{% trans "下一页" %}</button>
</a>
&nbsp;&nbsp;<a
{% if items.has_next %}
href="?page={{ items.paginator.num_pages }}&keywords={% if request.GET.keywords %}{{ request.GET.keywords }}{% endif %}"
{%else %}
disabled
{% endif %}>
<button {% if not items.has_next %}disabled{% endif %} class="button button-clear">{% trans "末页" %}</button>
</a>
</div>
</div>
<div id="aside">
<div class="aside-card">
<div>
{% trans '没有想要的结果?' %}
</div>
<a href="{% url 'books:create' %}" class="button add-button">{% trans '添加一个条目' %}</a>
</div>
</div>
</section>
</div>
<footer class="container">
<a href="">whitiewhite@donotban.com</a>
<a href="" id="githubLink">Github</a>
</footer>
</div>
{% comment %}
<div id="oauth2Token" hidden="true">{% oauth_token %}</div>
<div id="mastodonURI" hidden="true">{% mastodon %}</div>
<!--current user mastodon id-->
<div id="userMastodonID" hidden="true">{{ user.mastodon_id }}</div>
{% endcomment %}
<script>
$("#searchInput").on('keyup', function (e) {
if (e.keyCode === 13) {
let keywords = $(this).val();
if (keywords)
location.href = "{% url 'common:search' %}" + "?keywords=" + keywords;
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,63 @@
<style>
.widget-value-key-input input:nth-child(odd) {
width: 49%;
margin-right: 1%;
}
.widget-value-key-input input:nth-child(even) {
width: 49%;
margin-left: 1%;
}
</style>
<div class="widget-value-key-input"></div>
<input type="text" class="widget-value-key-input-data" hidden name="{{ widget.name }}">
<script>
//init
$(".widget-value-key-input").append('<input type="text"><input type="text">');
// add new input pair
$(".widget-value-key-input").on('input', ':nth-last-child(1)', function() {
let newInputPair = $('<input type="text"><input type="text">');
if ($(this).val() && $(this).prev().val()) {
$(".widget-value-key-input").append($(newInputPair).clone());
}
});
$(".widget-value-key-input").on('input', ':nth-last-child(2)', function() {
let newInputPair = $('<input type="text"><input type="text">');
if ($(this).val() && $(this).next().val()) {
$(".widget-value-key-input").append($(newInputPair).clone());
}
});
$(".widget-value-key-input").on('input', ':nth-last-child(4)', function() {
if (!$(this).val() && !$(this).next().val() && $(".widget-value-key-input input").length > 2) {
$(this).next().remove();
$(this).remove();
}
});
$(".widget-value-key-input").on('input', ':nth-last-child(3)', function() {
if (!$(this).val() && !$(this).prev().val() && $(".widget-value-key-input input").length > 2) {
$(this).prev().remove();
$(this).remove();
}
});
$(".widget-value-key-input").on('input', function() {
let keys = $(this).children(":nth-child(odd)").map(function() {
if ($(this).val()) {
return $(this).val();
}
}).get();
let values = $(this).children(":nth-child(even)").map(function() {
if ($(this).val()) {
return $(this).val();
}
}).get();
if (keys.length == values.length) {
let json = new Object;
keys.forEach(function(key, i) {
json[key] = values[i];
});
$("input.widget-value-key-input-data").val(JSON.stringify(json));
} else {
}
});
</script>

View file

View file

@ -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)

View file

@ -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, '<span class="highlight">{}</span>'.format(search))
return mark_safe(highlighted)

View file

@ -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)

View file

@ -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()

View file

@ -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="...")

3
common/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
common/urls.py Normal file
View file

@ -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'),
]

77
common/views.py Normal file
View file

@ -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()

BIN
docs/ui_layout/ui (1).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/ui_layout/ui (10).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
docs/ui_layout/ui (11).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
docs/ui_layout/ui (12).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
docs/ui_layout/ui (2).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/ui_layout/ui (3).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
docs/ui_layout/ui (4).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
docs/ui_layout/ui (5).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
docs/ui_layout/ui (6).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
docs/ui_layout/ui (7).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
docs/ui_layout/ui (8).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
docs/ui_layout/ui (9).jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

21
manage.py Normal file
View file

@ -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()

0
users/__init__.py Normal file
View file

3
users/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

5
users/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'

88
users/auth.py Normal file
View file

@ -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

34
users/models.py Normal file
View file

@ -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)

View file

@ -0,0 +1,27 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="loginBox">
<div id="logo">
<img src="" alt="boofilsic logo">
</div>
<div id="loginButton">
{% if user.is_authenticated %}
<a href="{% url 'common:home' %}"">{% trans '前往我的主页' %}</a>
{% else %}
<a href="{{ oauth_auth_url }}">{% trans '使用长毛象授权登录' %}</a>
{% endif %}
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register</title>
</head>
<body>
Are you sure you wanna join?
<form action="{% url 'users:register' %}" method="post">
{% csrf_token %}
<input type="radio" name="confirm" id="confirmRadio">
<button type="submit">Cut the sh*t get me in!</button>
</form>
</body>
</html>

3
users/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

11
users/urls.py Normal file
View file

@ -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'),
]

110
users/views.py Normal file
View file

@ -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)