mirror of
https://github.com/ajurna/cbwebreader.git
synced 2025-12-06 06:17:17 +00:00
New Frontend in Vue with drf interface (#72)
* frontend rewrite with vie initial commit * got ComicCard.vue working nice. * got TheComicList.vue working. * added router and basic config * getting jwt stuff working. * login with jwt now working. * implemented browse api call * implemented browse api recievers * jwt token is now updating automatically. * removed code for jwt testing. * enabled browsing * breadcrumbs working * adding django webpack loader * linking up navigation * fixes for ComicCard.vue stying * added thumbnail view * added thumbnail generation and handling. * detached breadcrumbs * fix breadcrumbs * added first stages of reader * reader view is working. * reader is now working with keyboard shortcuts * implemented setting read page. * implemented pagination on comic reader. * hide elements that shouldn't be shown. * fixed the ComicCard.vue to use as little space as possible. * fix navbar browse link * added RecentView.vue and added manual option for breadcrumbs * updated rest api to handle recent comics. * most functionality of recent comics done * modified comicstatus relation to use uuid relation and implemented mark read and unread for batches. * added functions to TheRecentTable.vue * added feed link to TheRecentTable.vue * fixes for comicstatus updates. * added constraints to comicstatus * update to python packages. * some changes for django 4, also removed django-recaptcha2 as it doesnt support django 4. * some fixes and updates to ComicCard.vue * cleaned up generate_directory. fixed bug where pages not visible on first call. * cleaned up generate_directory. fixed bug where pages not visible on first call. * cleaned up generate_directory. fixed bug where pages not visible on first call. * cleaned up generate_directory. * added silk stubs * fix for re-requesting thumbnail after getting it already. * fix for removing stale comics. adding leeway to access token. * mark read and unread * added filtering to comic list. * stored filtering state. * stored filtering state. * added next functionality to login. * cleanup LoginView.vue * bump font-awesome. * working on AccountView.vue * fixed form submission on LoginView.vue * account page should now be working. * hide users option if not superuser. * added pdf support * make pdf resize. * added touch controls to pdf reader * added touch controls to comic reader * beginnings of routing between issues. * fixes for navigating pages. * fixes for navigating pages. * fixes for navigating pages. * renamed HomeView.vue to BrowseView.vue * stubs for users page added. api ready * users page further functinality * fix for notification * fix for notification * moved messages to parent. * form to add users * added error handling * removed console logging * classification in base directory should be lowest * renamed usermisc to classification to be more consistent with what it does. * renamed usermisc to classification to be more consistent with what it does. * added functionality to change classification of directories. * merged rss_id api into account api. * merged breadcrumbs api into browse api. * clears some warnings from console. * fixed read/unread rendering. * added build script and starting lint * fixing lint errors * fixing lint errors * fixing lint errors * fixing lint errors * fixing lint errors * fixing lint errors * fixing lint errors * fixing lint errors * fixing navigation bugs * cleanup and fixes * fixed generated tooltips over calling. * fixed classifications. * initial setup now working * fix navbar branding * fix favicon * added beta build script. * fixes to get ready for production * optimisations for loading new comics. * added loading indicators to TheComicList.vue * lint fixes * made two methods static. may use them elsewhere. * fix for scanning files. * version updates. * fixes for production * fixes for production Co-authored-by: Peter Dwyer <peter.dwyer@clanwilliamhealth.com>
This commit is contained in:
@@ -14,4 +14,5 @@
|
||||
!entrypoint-cron.sh
|
||||
!requirements.txt
|
||||
!package-lock.json
|
||||
!package.json
|
||||
!package.json
|
||||
!frontend
|
||||
@@ -28,10 +28,5 @@ MEDIA_ROOT='/media'
|
||||
# Will work without setting if it is in the path
|
||||
# UNRAR_TOOL = 'unrar.exe'
|
||||
|
||||
# for google recaptcha 2
|
||||
# DJANGO_CBREADER_USE_RECAPTCHA = True
|
||||
# DJANGO_RECAPTCHA_PRIVATE_KEY = ''
|
||||
# DJANGO_RECAPTCHA_PUBLIC_KEY = ''
|
||||
|
||||
# Comment the following if not using a reverse proxy.
|
||||
USE_X_FORWARDED_HOST=True
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -92,4 +92,7 @@ media
|
||||
db.sqlite3
|
||||
identifier.sqlite
|
||||
.dmypy.json
|
||||
node_modules
|
||||
node_modules
|
||||
localhost-key.pem
|
||||
localhost.pem
|
||||
webpack-stats.json
|
||||
30
Dockerfile
30
Dockerfile
@@ -20,28 +20,20 @@ RUN apt-add-repository non-free
|
||||
RUN apt update
|
||||
|
||||
COPY requirements.txt /src
|
||||
COPY package.json /src
|
||||
COPY package-lock.json /src
|
||||
|
||||
#RUN apt install -y build-essential postgresql libmariadb-dev libmupdf-dev python3-dev libfreetype-dev libffi-dev libjbig2dec0-dev libjpeg-dev libharfbuzz-dev npm\
|
||||
# && apt install tini bash unrar python3 mariadb-connector-c jpeg postgresql-libs jbig2dec jpeg openjpeg harfbuzz mupdf postgresql-client\
|
||||
# && npm install \
|
||||
# && pip install --upgrade pip \
|
||||
# && pip install -r requirements.txt \
|
||||
# && apt remove build-essential postgresql-dev mariadb-dev mariadb-connector-c-dev mupdf-dev python3-dev freetype-dev libffi-dev jbig2dec-dev jpeg-dev openjpeg-dev harfbuzz-dev npm
|
||||
|
||||
RUN apt install -y software-properties-common \
|
||||
&& apt install -y npm cron unrar \
|
||||
&& npm install \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt \
|
||||
&& apt remove -y npm \
|
||||
&& apt -y auto-remove
|
||||
|
||||
COPY entrypoint.sh /src
|
||||
|
||||
COPY . /src/
|
||||
|
||||
RUN apt install -y npm cron unrar \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt \
|
||||
&& cd frontend \
|
||||
&& npm install \
|
||||
&& npm run build \
|
||||
&& rm -r node_modules \
|
||||
&& apt -y auto-remove
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
RUN cat /src/cbreader/crontab >> /etc/cron.daily/cbreader
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
4
build-test.ps1
Normal file
4
build-test.ps1
Normal file
@@ -0,0 +1,4 @@
|
||||
poetry export --without-hashes -f requirements.txt --output requirements.txt
|
||||
$version=poetry version -s
|
||||
docker build . -t ajurna/cbwebreader:beta --no-cache
|
||||
# docker push ajurna/cbwebreader:beta
|
||||
@@ -1,4 +1,4 @@
|
||||
poetry export -f requirements.txt --output requirements.txt
|
||||
poetry export --without-hashes -f requirements.txt --output requirements.txt
|
||||
$version=poetry version -s
|
||||
docker build . --no-cache -t ajurna/cbwebreader -t ajurna/cbwebreader:$version
|
||||
docker push ajurna/cbwebreader --all-tags
|
||||
@@ -4,6 +4,7 @@ Django settings for cbreader project.
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import dj_database_url
|
||||
@@ -21,10 +22,10 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", None)
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', False) == 'True'
|
||||
# DEBUG = False
|
||||
|
||||
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost").split(",")
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = (
|
||||
@@ -34,7 +35,8 @@ INSTALLED_APPS = (
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"snowpenguin.django.recaptcha2",
|
||||
'drf_yasg',
|
||||
'webpack_loader',
|
||||
'bootstrap4',
|
||||
"comic",
|
||||
"comic_auth",
|
||||
@@ -42,37 +44,28 @@ INSTALLED_APPS = (
|
||||
'imagekit',
|
||||
'django_boost',
|
||||
'sri',
|
||||
"corsheaders",
|
||||
'django_filters',
|
||||
'rest_framework',
|
||||
# 'silk'
|
||||
)
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django_permissions_policy.PermissionsPolicyMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
# 'silk.middleware.SilkyMiddleware',
|
||||
'csp.middleware.CSPMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "cbreader.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": ["templates"],
|
||||
"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 = "cbreader.wsgi.application"
|
||||
|
||||
@@ -109,7 +102,9 @@ STATIC_URL = "/static/"
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
Path(BASE_DIR, "static"),
|
||||
Path(BASE_DIR, "node_modules")
|
||||
# Path(BASE_DIR, "node_modules"),
|
||||
Path(BASE_DIR, "frontend", "node_modules"),
|
||||
Path(BASE_DIR, "frontend", "dist")
|
||||
]
|
||||
|
||||
STATIC_ROOT = os.getenv('STATIC_ROOT', None)
|
||||
@@ -125,9 +120,6 @@ LOGIN_URL = "/login/"
|
||||
|
||||
UNRAR_TOOL = os.getenv("DJANGO_UNRAR_TOOL", None)
|
||||
|
||||
CBREADER_USE_RECAPTCHA = os.getenv("DJANGO_CBREADER_USE_RECAPTCHA", False)
|
||||
RECAPTCHA_PRIVATE_KEY = os.getenv("DJANGO_RECAPTCHA_PRIVATE_KEY", '')
|
||||
RECAPTCHA_PUBLIC_KEY = os.getenv("DJANGO_RECAPTCHA_PUBLIC_KEY", '')
|
||||
|
||||
COMIC_BOOK_VOLUME = Path(os.getenv("COMIC_BOOK_VOLUME"))
|
||||
|
||||
@@ -151,10 +143,10 @@ BOOTSTRAP4 = {
|
||||
},
|
||||
}
|
||||
CSP_DEFAULT_SRC = ("'none'",)
|
||||
CSP_STYLE_SRC = ("'self'",)
|
||||
CSP_STYLE_SRC = ("'self'", "'sha256-MBVp6JYxbC/wICelYC6eULCRpgi9kGezXXSaq/TS2+I='")
|
||||
CSP_IMG_SRC = ("'self'", "data:")
|
||||
CSP_FONT_SRC = ("'self'",)
|
||||
CSP_SCRIPT_SRC = ("'self'", "'sha256-khnq7MWUoC3fJlH98ZjaCbVOvyd5+vnfVyue/ca55JA='")
|
||||
CSP_SCRIPT_SRC = ("'self'",)
|
||||
CSP_CONNECT_SRC = ("'self'",)
|
||||
CSP_INCLUDE_NONCE_IN = ['script-src']
|
||||
CSP_SCRIPT_SRC_ATTR = ("'self'",)# "'unsafe-inline'")
|
||||
@@ -177,10 +169,78 @@ PERMISSIONS_POLICY = {
|
||||
"usb": [],
|
||||
}
|
||||
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SAMESITE = 'Strict'
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SAMESITE = 'Strict'
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
# SESSION_COOKIE_HTTPONLY = True
|
||||
# SESSION_COOKIE_SECURE = True
|
||||
# SESSION_COOKIE_SAMESITE = 'Strict'
|
||||
# CSRF_COOKIE_HTTPONLY = True
|
||||
# CSRF_COOKIE_SECURE = True
|
||||
# CSRF_COOKIE_SAMESITE = 'Strict'
|
||||
# SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
# Use Django's standard `django.contrib.auth` permissions,
|
||||
# or allow read-only access for unauthenticated users.
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
)
|
||||
}
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
SIMPLE_JWT = {
|
||||
"ROTATE_REFRESH_TOKENS": True,
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=10),
|
||||
'LEEWAY': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
TEMPLATES_DIR = os.path.join(BASE_DIR, 'templates')
|
||||
FRONTEND_DIR = os.path.join(BASE_DIR, 'frontend')
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [TEMPLATES_DIR, ],
|
||||
"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",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
WEBPACK_LOADER = {
|
||||
'DEFAULT': {
|
||||
'CACHE': not DEBUG,
|
||||
'BUNDLE_DIR_NAME': '/bundles/', # must end with slash
|
||||
'STATS_FILE': os.path.join(FRONTEND_DIR, 'webpack-stats.json'),
|
||||
'INTEGRITY': True,
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
'OPTIONS': {
|
||||
'min_length': 9,
|
||||
}
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
SUPPORTED_FILES = [".rar", ".zip", ".cbr", ".cbz", ".pdf"]
|
||||
|
||||
@@ -14,21 +14,71 @@ Including another URLconf
|
||||
2. Add a URL to urlpatterns: url(r'^blog/', include(blog_urls))
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.conf.urls import include
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import path, re_path
|
||||
from rest_framework import routers, permissions
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||
from rest_framework_extensions.routers import ExtendedDefaultRouter
|
||||
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from drf_yasg.views import get_schema_view
|
||||
from drf_yasg import openapi
|
||||
|
||||
schema_view = get_schema_view(
|
||||
openapi.Info(
|
||||
title="CBWebReader API",
|
||||
default_version='v1',
|
||||
description="API to access your comic collection",
|
||||
contact=openapi.Contact(name="Ajurna", url="https://github.com/ajurna/cbwebreader"),
|
||||
license=openapi.License(name="MIT License"),
|
||||
),
|
||||
public=True,
|
||||
permission_classes=[permissions.AllowAny]
|
||||
)
|
||||
|
||||
|
||||
import comic.views
|
||||
import comic_auth.views
|
||||
from comic import rest, feeds
|
||||
|
||||
router = ExtendedDefaultRouter()
|
||||
router.register(r'users', rest.UserViewSet)
|
||||
# router.register(r'usermisc', rest.UserMiscViewSet)
|
||||
# router.register(r'groups', rest.GroupViewSet)
|
||||
router.register(r'browse', rest.BrowseViewSet, basename='browse')
|
||||
router.register(r'generate_thumbnail', rest.GenerateThumbnailViewSet, basename='generate_thumbnail')
|
||||
router.register(r'read', rest.ReadViewSet, basename='read')\
|
||||
.register(r'image', rest.ImageViewSet, basename='image', parents_query_lookups=['selector'])
|
||||
router.register(r'recent', rest.RecentComicsView, basename="recent")
|
||||
router.register(r'action', rest.ActionViewSet, basename='action')
|
||||
router.register(r'account', rest.AccountViewSet, basename='account')
|
||||
router.register(r'directory', rest.DirectoryViewSet, basename='directory')
|
||||
router.register(r'initial_setup', rest.InitialSetup, basename='initial_setup')
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^$", comic.views.comic_redirect),
|
||||
url(r"^login/", comic_auth.views.comic_login),
|
||||
url(r"^logout/", comic_auth.views.comic_logout),
|
||||
url(r"^setup/", comic.views.initial_setup),
|
||||
url(r"^comic/", include("comic.urls")),
|
||||
url(r"^admin/", admin.site.urls),
|
||||
|
||||
# url(r"^$", comic.views.comic_redirect),
|
||||
# url(r"^login/", comic_auth.views.comic_login),
|
||||
# url(r"^logout/", comic_auth.views.comic_logout),
|
||||
# url(r"^setup/", comic.views.initial_setup),
|
||||
# url(r"^comic/", include("comic.urls")),
|
||||
path('admin/', admin.site.urls),
|
||||
path("feed/<user_selector>/", feeds.RecentComicsAPI()),
|
||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
||||
re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||
path('api/', include(router.urls)),
|
||||
path("",
|
||||
TemplateView.as_view(template_name="application.html"),
|
||||
name="app",
|
||||
),
|
||||
]
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
# urlpatterns += [path('silk/', include('silk.urls', namespace='silk'))]
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class ComicBookAdmin(admin.ModelAdmin):
|
||||
)
|
||||
list_filter = ('date_added',)
|
||||
raw_id_fields = ('directory',)
|
||||
search_fields = ['file_name']
|
||||
|
||||
|
||||
@admin.register(ComicPage)
|
||||
|
||||
@@ -44,3 +44,37 @@ class RecentComics(Feed):
|
||||
# item_link is only needed if NewsItem has no get_absolute_url method.
|
||||
def item_link(self, item: ComicBook) -> str:
|
||||
return reverse('read_comic', args=(urlsafe_base64_encode(item.selector.bytes),))
|
||||
|
||||
|
||||
class RecentComicsAPI(Feed):
|
||||
title = "CBWebReader Recent Comics"
|
||||
link = "/read/"
|
||||
description = "Recently added Comics"
|
||||
user: User
|
||||
|
||||
def get_object(self, request: HttpRequest, user_selector: str, *args, **kwargs) -> UserMisc:
|
||||
user_misc = get_object_or_404(UserMisc, feed_id=user_selector)
|
||||
self.user = user_misc.user
|
||||
return user_misc.user
|
||||
|
||||
def items(self) -> ComicBook:
|
||||
comics = ComicBook.objects.order_by("-date_added")
|
||||
comics = comics.annotate(
|
||||
classification=Case(
|
||||
When(directory__isnull=True, then=Directory.Classification.C_18),
|
||||
default=F('directory__classification'),
|
||||
output_field=PositiveSmallIntegerField(choices=Directory.Classification.choices)
|
||||
)
|
||||
)
|
||||
comics = comics.filter(classification__lte=self.user.usermisc.allowed_to_read)
|
||||
return comics[:10]
|
||||
|
||||
def item_title(self, item: ComicBook) -> str:
|
||||
return item.file_name
|
||||
|
||||
def item_description(self, item: ComicBook) -> str:
|
||||
return item.date_added.strftime("%a, %e %b %Y %H:%M")
|
||||
|
||||
# item_link is only needed if NewsItem has no get_absolute_url method.
|
||||
def item_link(self, item: ComicBook) -> str:
|
||||
return f'#/read/{item.selector}/'
|
||||
|
||||
@@ -74,6 +74,8 @@ class Command(BaseCommand):
|
||||
next_directory.save()
|
||||
self.scan_directory(next_directory)
|
||||
else:
|
||||
if file.suffix.lower() not in settings.SUPPORTED_FILES:
|
||||
continue
|
||||
if self.OUTPUT:
|
||||
logger.info(f"Scanning File {file}")
|
||||
try:
|
||||
|
||||
19
comic/migrations/0029_comicbook_directory2.py
Normal file
19
comic/migrations/0029_comicbook_directory2.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-07 16:03
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0028_alter_comicpage_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='comicbook',
|
||||
name='directory2',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='directory2', to='comic.directory', to_field='selector'),
|
||||
),
|
||||
]
|
||||
22
comic/migrations/0030_auto_20220707_1720.py
Normal file
22
comic/migrations/0030_auto_20220707_1720.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-07 16:20
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
ComicBook = apps.get_model("comic", "ComicBook")
|
||||
for comic in ComicBook.objects.all():
|
||||
if comic.directory:
|
||||
comic.directory2 = comic.directory
|
||||
comic.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0029_comicbook_directory2'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards_func),
|
||||
]
|
||||
17
comic/migrations/0031_remove_comicbook_directory.py
Normal file
17
comic/migrations/0031_remove_comicbook_directory.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-07 16:25
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0030_auto_20220707_1720'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='comicbook',
|
||||
name='directory',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-07 16:25
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0031_remove_comicbook_directory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='comicbook',
|
||||
old_name='directory2',
|
||||
new_name='directory',
|
||||
),
|
||||
]
|
||||
19
comic/migrations/0033_alter_comicbook_directory.py
Normal file
19
comic/migrations/0033_alter_comicbook_directory.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-07 16:32
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0032_rename_directory2_comicbook_directory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='comicbook',
|
||||
name='directory',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comic.directory', to_field='selector'),
|
||||
),
|
||||
]
|
||||
19
comic/migrations/0034_directory_parent2.py
Normal file
19
comic/migrations/0034_directory_parent2.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-08 08:09
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0033_alter_comicbook_directory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='directory',
|
||||
name='parent2',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parent_new', to='comic.directory', to_field='selector'),
|
||||
),
|
||||
]
|
||||
28
comic/migrations/0035_auto_20220708_0910.py
Normal file
28
comic/migrations/0035_auto_20220708_0910.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-08 08:10
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
Directory = apps.get_model("comic", "Directory")
|
||||
for directory in Directory.objects.all():
|
||||
if directory.parent:
|
||||
directory.parent2 = directory.parent
|
||||
directory.save()
|
||||
|
||||
def backwards_func(apps, schema_editor):
|
||||
return
|
||||
Directory = apps.get_model("comic", "Directory")
|
||||
for directory in Directory.objects.all():
|
||||
if directory.parent:
|
||||
directory.parent2 = directory.parent
|
||||
directory.save()
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0034_directory_parent2'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards_func, backwards_func),
|
||||
]
|
||||
17
comic/migrations/0036_remove_directory_parent.py
Normal file
17
comic/migrations/0036_remove_directory_parent.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-08 08:12
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0035_auto_20220708_0910'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='directory',
|
||||
name='parent',
|
||||
),
|
||||
]
|
||||
18
comic/migrations/0037_rename_parent2_directory_parent.py
Normal file
18
comic/migrations/0037_rename_parent2_directory_parent.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-08 08:14
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0036_remove_directory_parent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='directory',
|
||||
old_name='parent2',
|
||||
new_name='parent',
|
||||
),
|
||||
]
|
||||
19
comic/migrations/0038_alter_directory_parent.py
Normal file
19
comic/migrations/0038_alter_directory_parent.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-08 08:14
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0037_rename_parent2_directory_parent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='directory',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comic.directory', to_field='selector'),
|
||||
),
|
||||
]
|
||||
19
comic/migrations/0039_comicstatus_comic2.py
Normal file
19
comic/migrations/0039_comicstatus_comic2.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-21 10:26
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0038_alter_directory_parent'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='comicstatus',
|
||||
name='comic2',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='comic2', to='comic.comicbook', to_field='selector'),
|
||||
),
|
||||
]
|
||||
28
comic/migrations/0040_auto_20220721_1126.py
Normal file
28
comic/migrations/0040_auto_20220721_1126.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-21 10:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
ComicStatus = apps.get_model("comic", "ComicStatus")
|
||||
for status in ComicStatus.objects.all():
|
||||
status.comic2 = status.comic
|
||||
status.save()
|
||||
|
||||
|
||||
def backwards_func(apps, schema_editor):
|
||||
ComicStatus = apps.get_model("comic", "ComicStatus")
|
||||
for status in ComicStatus.objects.all():
|
||||
status.comic = status.comic2
|
||||
status.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0039_comicstatus_comic2'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forwards_func, backwards_func),
|
||||
]
|
||||
19
comic/migrations/0041_alter_comicstatus_comic2.py
Normal file
19
comic/migrations/0041_alter_comicstatus_comic2.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-21 10:27
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0040_auto_20220721_1126'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='comicstatus',
|
||||
name='comic2',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comic2', to='comic.comicbook', to_field='selector'),
|
||||
),
|
||||
]
|
||||
17
comic/migrations/0042_remove_comicstatus_comic.py
Normal file
17
comic/migrations/0042_remove_comicstatus_comic.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-21 10:29
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0041_alter_comicstatus_comic2'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='comicstatus',
|
||||
name='comic',
|
||||
),
|
||||
]
|
||||
18
comic/migrations/0043_rename_comic2_comicstatus_comic.py
Normal file
18
comic/migrations/0043_rename_comic2_comicstatus_comic.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-21 10:29
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0042_remove_comicstatus_comic'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='comicstatus',
|
||||
old_name='comic2',
|
||||
new_name='comic',
|
||||
),
|
||||
]
|
||||
19
comic/migrations/0044_alter_comicstatus_comic.py
Normal file
19
comic/migrations/0044_alter_comicstatus_comic.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-21 10:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0043_rename_comic2_comicstatus_comic'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='comicstatus',
|
||||
name='comic',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='comic.comicbook', to_field='selector'),
|
||||
),
|
||||
]
|
||||
17
comic/migrations/0045_comicstatus_one_per_user_per_comic.py
Normal file
17
comic/migrations/0045_comicstatus_one_per_user_per_comic.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.14 on 2022-07-22 08:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0044_alter_comicstatus_comic'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='comicstatus',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'comic'), name='one_per_user_per_comic'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.0.6 on 2022-07-22 11:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('comic', '0045_comicstatus_one_per_user_per_comic'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='comicbook',
|
||||
constraint=models.UniqueConstraint(fields=('directory', 'file_name'), name='one_comic_name_per_directory'),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,7 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import User, AbstractUser
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
from django.db import models
|
||||
from django.db.models import UniqueConstraint
|
||||
from django.db.transaction import atomic
|
||||
from django.templatetags.static import static
|
||||
from django.utils.http import urlsafe_base64_encode
|
||||
@@ -36,7 +37,7 @@ class Directory(models.Model):
|
||||
C_18 = 4, '18'
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
parent = models.ForeignKey("Directory", null=True, blank=True, on_delete=models.CASCADE)
|
||||
parent = models.ForeignKey("Directory", null=True, blank=True, on_delete=models.CASCADE, to_field="selector")
|
||||
selector = models.UUIDField(unique=True, default=uuid.uuid4, db_index=True)
|
||||
thumbnail = ProcessedImageField(upload_to='thumbs',
|
||||
processors=[ResizeToFill(200, 300)],
|
||||
@@ -55,6 +56,14 @@ class Directory(models.Model):
|
||||
def __str__(self):
|
||||
return "Directory: {0}; {1}".format(self.name, self.parent)
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return 'Directory'
|
||||
|
||||
def mark_read(self, user):
|
||||
books = ComicBook.objects.filter(directory=self)
|
||||
for book in books:
|
||||
@@ -124,7 +133,7 @@ class Directory(models.Model):
|
||||
class ComicBook(models.Model):
|
||||
file_name = models.TextField()
|
||||
date_added = models.DateTimeField(auto_now_add=True)
|
||||
directory = models.ForeignKey(Directory, blank=True, null=True, on_delete=models.CASCADE)
|
||||
directory = models.ForeignKey(Directory, blank=True, null=True, on_delete=models.CASCADE, to_field="selector")
|
||||
selector = models.UUIDField(unique=True, default=uuid.uuid4, db_index=True)
|
||||
version = models.IntegerField(default=1)
|
||||
thumbnail = ProcessedImageField(upload_to='thumbs',
|
||||
@@ -134,9 +143,22 @@ class ComicBook(models.Model):
|
||||
null=True)
|
||||
thumbnail_index = models.PositiveIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['directory', 'file_name'], name='one_comic_name_per_directory')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.file_name
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.file_name
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return 'ComicBook'
|
||||
|
||||
def mark_read(self, user: User):
|
||||
status, _ = ComicStatus.objects.get_or_create(comic=self, user=user)
|
||||
status.mark_read()
|
||||
@@ -158,7 +180,10 @@ class ComicBook(models.Model):
|
||||
|
||||
def get_pdf(self) -> Path:
|
||||
base_dir = settings.COMIC_BOOK_VOLUME
|
||||
return Path(base_dir, self.directory.get_path(), self.file_name)
|
||||
if self.directory:
|
||||
return Path(base_dir, self.directory.get_path(), self.file_name)
|
||||
else:
|
||||
return Path(base_dir, self.file_name)
|
||||
|
||||
def get_image(self, page: int):
|
||||
base_dir = settings.COMIC_BOOK_VOLUME
|
||||
@@ -468,11 +493,16 @@ class ComicPage(models.Model):
|
||||
|
||||
class ComicStatus(models.Model):
|
||||
user = models.ForeignKey(User, unique=False, null=False, on_delete=models.CASCADE)
|
||||
comic = models.ForeignKey(ComicBook, unique=False, null=False, on_delete=models.CASCADE)
|
||||
comic = models.ForeignKey(ComicBook, unique=False, blank=False, null=False, on_delete=models.CASCADE, to_field="selector")
|
||||
last_read_page = models.IntegerField(default=0)
|
||||
unread = models.BooleanField(default=True)
|
||||
finished = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['user', 'comic'], name='one_per_user_per_comic')
|
||||
]
|
||||
|
||||
def mark_read(self):
|
||||
page_count = ComicPage.objects.filter(Comic=self.comic).count()
|
||||
self.unread = False
|
||||
|
||||
758
comic/rest.py
Normal file
758
comic/rest.py
Normal file
@@ -0,0 +1,758 @@
|
||||
import mimetypes
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import Union, NamedTuple, List
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, Case, When, F, PositiveSmallIntegerField, Q
|
||||
from django.http import FileResponse
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import viewsets, serializers, mixins, permissions, status, renderers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from comic import models
|
||||
from comic.errors import NotCompatibleArchive
|
||||
from comic.util import generate_breadcrumbs_from_path
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
classification = serializers.SlugRelatedField(many=False, read_only=True, slug_field='allowed_to_read',
|
||||
source='usermisc')
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'username', 'email', 'is_superuser', 'classification']
|
||||
|
||||
|
||||
class AdminPasswordResetSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
password = serializers.CharField(required=False)
|
||||
|
||||
|
||||
class ClassificationSerializer(serializers.Serializer):
|
||||
classification = serializers.IntegerField()
|
||||
|
||||
def validate_classification(self, data):
|
||||
if data in models.Directory.Classification:
|
||||
return data
|
||||
raise serializers.ValidationError('Invalid Classification sent.')
|
||||
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint that allows users to be viewed or edited.
|
||||
"""
|
||||
queryset = User.objects.all().order_by('username')
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
@action(methods=['patch'], detail=True, serializer_class=AdminPasswordResetSerializer)
|
||||
def reset_password(self, request: Request, pk: int) -> Response:
|
||||
"""
|
||||
This will return a new password set on the user.
|
||||
"""
|
||||
target_user = get_object_or_404(User, id=pk)
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if target_user.username == serializer.data['username']:
|
||||
password = User.objects.make_random_password()
|
||||
target_user.set_password(password)
|
||||
resp_serializer = self.get_serializer({
|
||||
'username': target_user.username,
|
||||
'password': password
|
||||
})
|
||||
return Response(resp_serializer.data)
|
||||
|
||||
return Response({'errors': ['Invalid request']}, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(methods=['patch'], detail=True, serializer_class=ClassificationSerializer)
|
||||
def set_classification(self, request: Request, pk: int) -> Response:
|
||||
"""
|
||||
API Endpoint that will set the classification on the specified user.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
misc, _ = models.UserMisc.objects.get_or_create(user_id=pk)
|
||||
misc.allowed_to_read = serializer.data['classification']
|
||||
misc.save()
|
||||
return Response(data={'classification': misc.allowed_to_read})
|
||||
else:
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class UserMiscSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.UserMisc
|
||||
fields = ['user', 'feed_id', 'allowed_to_read']
|
||||
|
||||
|
||||
class UserMiscViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.UserMisc.objects.all()
|
||||
serializer_class = UserMiscSerializer
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
|
||||
class GroupSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = ['url', 'name']
|
||||
|
||||
|
||||
class GroupViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint that allows groups to be viewed or edited.
|
||||
"""
|
||||
queryset = Group.objects.all()
|
||||
serializer_class = GroupSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BrowseFileField(serializers.FileField):
|
||||
def to_representation(self, value):
|
||||
if not value:
|
||||
return None
|
||||
return Path(settings.MEDIA_URL, value.name).as_posix()
|
||||
|
||||
|
||||
class BrowseSerializer(serializers.Serializer):
|
||||
selector = serializers.UUIDField()
|
||||
title = serializers.CharField()
|
||||
progress = serializers.IntegerField()
|
||||
total = serializers.IntegerField()
|
||||
type = serializers.CharField()
|
||||
thumbnail = BrowseFileField()
|
||||
classification = serializers.IntegerField()
|
||||
finished = serializers.BooleanField()
|
||||
unread = serializers.BooleanField()
|
||||
|
||||
|
||||
class BreadcrumbSerializer(serializers.Serializer):
|
||||
id = serializers.IntegerField()
|
||||
selector = serializers.UUIDField()
|
||||
name = serializers.CharField()
|
||||
|
||||
|
||||
class ArchiveFile(NamedTuple):
|
||||
file_name: str
|
||||
mime_type: str
|
||||
|
||||
|
||||
class BrowseViewSet(viewsets.GenericViewSet):
|
||||
serializer_class = BrowseSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_field = 'selector'
|
||||
|
||||
def list(self, request):
|
||||
serializer = self.get_serializer(self.generate_directory(request.user), many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def retrieve(self, request, selector: UUID):
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
serializer = self.get_serializer(self.generate_directory(request.user, directory), many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: BreadcrumbSerializer(many=True)})
|
||||
@action(methods=['get'], detail=True, serializer_class=BreadcrumbSerializer)
|
||||
def breadcrumbs(self, _request: Request, selector: UUID) -> Response:
|
||||
queryset = []
|
||||
comic = False
|
||||
try:
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
except models.Directory.DoesNotExist:
|
||||
comic = models.ComicBook.objects.get(selector=selector)
|
||||
directory = comic.directory
|
||||
|
||||
for index, item in enumerate(generate_breadcrumbs_from_path(directory, comic)):
|
||||
queryset.append({
|
||||
"id": index,
|
||||
"selector": item.selector,
|
||||
"name": item.name,
|
||||
})
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@staticmethod
|
||||
def clean_directories(directories, dir_path, directory=None):
|
||||
|
||||
dir_db_set = set([Path(settings.COMIC_BOOK_VOLUME, x.path) for x in directories])
|
||||
dir_list = set([x for x in sorted(dir_path.glob('*')) if x.is_dir()])
|
||||
# Create new directories db instances
|
||||
for new_directory in dir_list - dir_db_set:
|
||||
models.Directory(name=new_directory.name, parent=directory).save()
|
||||
|
||||
# Remove stale db instances
|
||||
for stale_directory in dir_db_set - dir_list:
|
||||
models.Directory.objects.get(name=stale_directory.name, parent=directory).delete()
|
||||
|
||||
@staticmethod
|
||||
def get_archive_files(archive) -> List[ArchiveFile]:
|
||||
return [
|
||||
ArchiveFile(x, mimetypes.guess_type(x)[0]) for x in sorted(archive.namelist())
|
||||
if not x.endswith('/') and mimetypes.guess_type(x)[0]
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def clean_files(files, user, dir_path, directory=None):
|
||||
file_list = set([x for x in sorted(dir_path.glob('*')) if x.is_file()])
|
||||
files_db_set = set([Path(dir_path, x.file_name) for x in files])
|
||||
|
||||
# Parse new comics
|
||||
books_to_add = []
|
||||
for new_comic in file_list - files_db_set:
|
||||
if new_comic.suffix.lower() in settings.SUPPORTED_FILES:
|
||||
books_to_add.append(
|
||||
models.ComicBook(file_name=new_comic.name, directory=directory)
|
||||
)
|
||||
models.ComicBook.objects.bulk_create(books_to_add)
|
||||
|
||||
pages_to_add = []
|
||||
status_to_add = []
|
||||
for book in books_to_add:
|
||||
status_to_add.append(models.ComicStatus(user=user, comic=book))
|
||||
try:
|
||||
archive, archive_type = book.get_archive()
|
||||
if archive_type == 'archive':
|
||||
pages_to_add.extend([
|
||||
models.ComicPage(
|
||||
Comic=book, index=idx, page_file_name=page.file_name, content_type=page.mime_type
|
||||
) for idx, page in enumerate(BrowseViewSet.get_archive_files(archive))
|
||||
])
|
||||
elif archive_type == 'pdf':
|
||||
pages_to_add.extend([
|
||||
models.ComicPage(
|
||||
Comic=book, index=idx, page_file_name=idx + 1, content_type='application/pdf'
|
||||
) for idx in range(archive.page_count)
|
||||
])
|
||||
except NotCompatibleArchive:
|
||||
pass
|
||||
|
||||
models.ComicStatus.objects.bulk_create(status_to_add)
|
||||
models.ComicPage.objects.bulk_create(pages_to_add)
|
||||
|
||||
# Remove stale comic instances
|
||||
for stale_comic in files_db_set - file_list:
|
||||
models.ComicBook.objects.get(file_name=stale_comic.name, directory=directory).delete()
|
||||
|
||||
def generate_directory(self, user: User, directory=None):
|
||||
"""
|
||||
:type user: User
|
||||
:type directory: Directory
|
||||
"""
|
||||
dir_path = Path(settings.COMIC_BOOK_VOLUME, directory.path) if directory else settings.COMIC_BOOK_VOLUME
|
||||
files = []
|
||||
|
||||
dir_db_query = models.Directory.objects.filter(parent=directory)
|
||||
self.clean_directories(dir_db_query, dir_path, directory)
|
||||
|
||||
file_db_query = models.ComicBook.objects.filter(directory=directory)
|
||||
self.clean_files(file_db_query, user, dir_path, directory)
|
||||
|
||||
dir_db_query = dir_db_query.annotate(
|
||||
total=Count('comicbook', distinct=True),
|
||||
progress=Count('comicbook__comicstatus', Q(comicbook__comicstatus__finished=True,
|
||||
comicbook__comicstatus__user=user), distinct=True),
|
||||
finished=Q(total=F('progress')),
|
||||
unread=Q(total__gt=F('progress'))
|
||||
)
|
||||
files.extend(dir_db_query)
|
||||
|
||||
# Create Missing Status
|
||||
new_status = [models.ComicStatus(comic=file, user=user) for file in
|
||||
file_db_query.exclude(comicstatus__in=models.ComicStatus.objects.filter(
|
||||
comic__in=file_db_query, user=user))]
|
||||
models.ComicStatus.objects.bulk_create(new_status)
|
||||
|
||||
file_db_query = file_db_query.annotate(
|
||||
total=Count('comicpage', distinct=True),
|
||||
progress=F('comicstatus__last_read_page') + 1,
|
||||
finished=F('comicstatus__finished'),
|
||||
unread=F('comicstatus__unread'),
|
||||
user=F('comicstatus__user'),
|
||||
classification=Case(
|
||||
When(directory__isnull=True, then=models.Directory.Classification.C_G),
|
||||
default=F('directory__classification'),
|
||||
output_field=PositiveSmallIntegerField(choices=models.Directory.Classification.choices)
|
||||
)
|
||||
).filter(Q(user__isnull=True) | Q(user=user.id))
|
||||
|
||||
files.extend(file_db_query)
|
||||
|
||||
for file in chain(file_db_query, dir_db_query):
|
||||
if file.thumbnail and not Path(file.thumbnail.path).exists():
|
||||
file.thumbnail.delete()
|
||||
file.save()
|
||||
files.sort(key=lambda x: x.title)
|
||||
files.sort(key=lambda x: x.type, reverse=True)
|
||||
return files
|
||||
|
||||
|
||||
class GenerateThumbnailSerializer(serializers.Serializer):
|
||||
selector = serializers.UUIDField()
|
||||
thumbnail = BrowseFileField()
|
||||
|
||||
|
||||
class GenerateThumbnailViewSet(viewsets.ViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = GenerateThumbnailSerializer
|
||||
lookup_field = 'selector'
|
||||
|
||||
def retrieve(self, _request, selector: UUID):
|
||||
try:
|
||||
directory = models.Directory.objects.get(selector=selector)
|
||||
if not directory.thumbnail:
|
||||
directory.generate_thumbnail()
|
||||
return Response(
|
||||
self.serializer_class({
|
||||
"selector": directory.selector,
|
||||
"thumbnail": directory.thumbnail
|
||||
}).data
|
||||
)
|
||||
except models.Directory.DoesNotExist:
|
||||
comic = models.ComicBook.objects.get(selector=selector)
|
||||
if not comic.thumbnail:
|
||||
comic.generate_thumbnail()
|
||||
return Response(
|
||||
self.serializer_class({
|
||||
"selector": comic.selector,
|
||||
"thumbnail": comic.thumbnail
|
||||
}).data
|
||||
)
|
||||
|
||||
|
||||
class PageSerializer(serializers.Serializer):
|
||||
index = serializers.IntegerField()
|
||||
page_file_name = serializers.CharField()
|
||||
content_type = serializers.CharField()
|
||||
|
||||
|
||||
class DirectionSerializer(serializers.Serializer):
|
||||
route = serializers.ChoiceField(choices=['read', 'browse'])
|
||||
selector = serializers.UUIDField(required=False)
|
||||
|
||||
|
||||
class ReadSerializer(serializers.Serializer):
|
||||
selector = serializers.UUIDField()
|
||||
title = serializers.CharField()
|
||||
last_read_page = serializers.IntegerField()
|
||||
prev_comic = DirectionSerializer()
|
||||
next_comic = DirectionSerializer()
|
||||
pages = PageSerializer(many=True)
|
||||
|
||||
|
||||
class TypeSerializer(serializers.Serializer):
|
||||
type = serializers.CharField()
|
||||
|
||||
|
||||
class ReadPageSerializer(serializers.Serializer):
|
||||
page = serializers.IntegerField(source='last_read_page')
|
||||
|
||||
|
||||
class ReadViewSet(viewsets.GenericViewSet):
|
||||
serializer_class = ReadSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_field = 'selector'
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadSerializer()})
|
||||
def retrieve(self, request: Request, selector: UUID) -> Response:
|
||||
comic = get_object_or_404(models.ComicBook, selector=selector)
|
||||
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
|
||||
pages = models.ComicPage.objects.filter(Comic=comic)
|
||||
comic_status, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
comic_list = list(models.ComicBook.objects.filter(directory=comic.directory).order_by('file_name'))
|
||||
comic_index = comic_list.index(comic)
|
||||
try:
|
||||
prev_comic = {'route': 'browse', 'selector': comic.directory.selector} if comic_index == 0 else \
|
||||
{'route': 'read', 'selector': comic_list[comic_index-1].selector}
|
||||
except AttributeError:
|
||||
prev_comic = {'route': 'browse'}
|
||||
try:
|
||||
next_comic = {'route': 'browse', 'selector': comic.directory.selector} if comic_index+1 == len(comic_list) \
|
||||
else {'route': 'read', 'selector': comic_list[comic_index+1].selector}
|
||||
except AttributeError:
|
||||
next_comic = {'route': 'browse'}
|
||||
data = {
|
||||
"selector": comic.selector,
|
||||
"title": comic.file_name,
|
||||
"last_read_page": comic_status.last_read_page,
|
||||
"prev_comic": prev_comic,
|
||||
"next_comic": next_comic,
|
||||
"pages": pages,
|
||||
}
|
||||
serializer = self.serializer_class(data)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: 'PDF Binary Data',
|
||||
status.HTTP_400_BAD_REQUEST: 'User below classification allowed'})
|
||||
@action(methods=['get'], detail=True)
|
||||
def pdf(self, request: Request, selector: UUID) -> Union[FileResponse, Response]:
|
||||
book = models.ComicBook.objects.get(selector=selector)
|
||||
misc, _ = models.UserMisc.objects.get_or_create(user=request.user)
|
||||
try:
|
||||
if book.directory.classification > misc.allowed_to_read:
|
||||
return Response(status=400, data={'errors': 'Not allowed to read.'})
|
||||
except AttributeError:
|
||||
pass
|
||||
return FileResponse(open(book.get_pdf(), 'rb'), content_type='application/pdf')
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: TypeSerializer()})
|
||||
@action(methods=['get'], detail=True)
|
||||
def type(self, _request: Request, selector: UUID) -> Response:
|
||||
book = models.ComicBook.objects.get(selector=selector)
|
||||
return Response({'type': book.file_name.split('.')[-1].lower()})
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: ReadPageSerializer()}, request_body=ReadPageSerializer)
|
||||
@action(methods=['put'], detail=True, serializer_class=ReadPageSerializer)
|
||||
def set_page(self, request: Request, selector: UUID) -> Response:
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
comic_status, _ = models.ComicStatus.objects.annotate(page_count=Count('comic__comicpage'))\
|
||||
.get_or_create(comic_id=selector, user=request.user)
|
||||
comic_status.last_read_page = serializer.data['page']
|
||||
comic_status.unread = False
|
||||
if comic_status.page_count-1 == comic_status.last_read_page:
|
||||
comic_status.finished = True
|
||||
else:
|
||||
comic_status.finished = False
|
||||
|
||||
comic_status.save()
|
||||
return Response(self.get_serializer(comic_status).data)
|
||||
else:
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class PassthroughRenderer(renderers.BaseRenderer):
|
||||
"""
|
||||
Return data as-is. View should supply a Response.
|
||||
"""
|
||||
media_type = '*/*'
|
||||
format = ''
|
||||
|
||||
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||
return data
|
||||
|
||||
|
||||
class ImageViewSet(viewsets.ViewSet):
|
||||
queryset = models.ComicPage.objects.all()
|
||||
lookup_field = 'page'
|
||||
renderer_classes = [PassthroughRenderer]
|
||||
|
||||
def retrieve(self, _request, parent_lookup_selector, page):
|
||||
book = models.ComicBook.objects.get(selector=parent_lookup_selector)
|
||||
img, content = book.get_image(int(page))
|
||||
self.renderer_classes[0].media_type = content
|
||||
response = FileResponse(img, content_type=content)
|
||||
return response
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
class RecentComicsSerializer(serializers.ModelSerializer):
|
||||
total_pages = serializers.IntegerField()
|
||||
unread = serializers.BooleanField()
|
||||
finished = serializers.BooleanField()
|
||||
last_read_page = serializers.IntegerField()
|
||||
|
||||
class Meta:
|
||||
model = models.ComicBook
|
||||
fields = ['file_name', 'date_added', 'selector', 'total_pages', 'unread', 'finished', 'last_read_page']
|
||||
|
||||
|
||||
class RecentComicsView(mixins.ListModelMixin, viewsets.GenericViewSet):
|
||||
queryset = models.ComicBook.objects.all()
|
||||
serializer_class = RecentComicsSerializer
|
||||
pagination_class = StandardResultsSetPagination
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if "search_text" in self.request.query_params:
|
||||
query = models.ComicBook.objects.filter(file_name__icontains=self.request.query_params["search_text"])
|
||||
else:
|
||||
query = models.ComicBook.objects.all()
|
||||
|
||||
query = query.annotate(
|
||||
total_pages=Count('comicpage'),
|
||||
unread=Case(When(comicstatus__user=user, then='comicstatus__unread')),
|
||||
finished=Case(When(comicstatus__user=user, then='comicstatus__finished')),
|
||||
last_read_page=Case(When(comicstatus__user=user, then='comicstatus__last_read_page')) + 1,
|
||||
classification=Case(
|
||||
When(directory__isnull=True, then=models.Directory.Classification.C_18),
|
||||
default=F('directory__classification'),
|
||||
output_field=PositiveSmallIntegerField(choices=models.Directory.Classification.choices)
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by('-date_added')
|
||||
return query
|
||||
|
||||
|
||||
class ActionSerializer(serializers.Serializer):
|
||||
selectors = serializers.ListSerializer(child=serializers.UUIDField())
|
||||
|
||||
|
||||
class ActionViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_field = 'action'
|
||||
serializer_class = ActionSerializer
|
||||
|
||||
@action(detail=False, methods=['PUT'])
|
||||
def mark_read(self, request):
|
||||
serializer = ActionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
comics = self.get_comics(serializer.data['selectors'])
|
||||
comic_status = models.ComicStatus.objects.filter(comic__selector__in=comics, user=request.user)
|
||||
comic_status = comic_status.annotate(total_pages=Count('comic__comicpage'))
|
||||
status_to_update = []
|
||||
for c_status in comic_status:
|
||||
c_status.last_read_page = c_status.total_pages-1
|
||||
c_status.unread = False
|
||||
c_status.finished = True
|
||||
status_to_update.append(c_status)
|
||||
comics.remove(str(c_status.comic_id))
|
||||
for new_status in comics:
|
||||
comic = models.ComicBook.objects.annotate(
|
||||
total_pages=Count('comicpage')).get(selector=new_status)
|
||||
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
obj.unread = False
|
||||
obj.finished = True
|
||||
obj.last_read_page = comic.total_pages
|
||||
status_to_update.append(obj)
|
||||
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
|
||||
return Response({'status': 'marked_read'})
|
||||
else:
|
||||
return Response(serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['PUT'])
|
||||
def mark_unread(self, request):
|
||||
serializer = ActionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer = ActionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
comics = self.get_comics(serializer.data['selectors'])
|
||||
comic_status = models.ComicStatus.objects.filter(comic__selector__in=comics, user=request.user)
|
||||
status_to_update = []
|
||||
for c_status in comic_status:
|
||||
c_status.last_read_page = 0
|
||||
c_status.unread = True
|
||||
c_status.finished = False
|
||||
status_to_update.append(c_status)
|
||||
comics.remove(str(c_status.comic_id))
|
||||
for new_status in comics:
|
||||
comic = models.ComicBook.objects.get(selector=new_status)
|
||||
obj, _ = models.ComicStatus.objects.get_or_create(comic=comic, user=request.user)
|
||||
obj.unread = True
|
||||
obj.finished = False
|
||||
obj.last_read_page = 0
|
||||
status_to_update.append(obj)
|
||||
models.ComicStatus.objects.bulk_update(status_to_update, ['unread', 'finished', 'last_read_page'])
|
||||
return Response({'status': 'marked_unread'})
|
||||
else:
|
||||
return Response(serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def get_comics(self, selectors):
|
||||
data = set()
|
||||
data = data.union(
|
||||
set(models.ComicBook.objects.filter(selector__in=selectors).values_list('selector', flat=True)))
|
||||
directories = models.Directory.objects.filter(selector__in=selectors)
|
||||
if directories:
|
||||
for directory in directories:
|
||||
data = data.union(
|
||||
set(models.ComicBook.objects.filter(directory=directory).values_list('selector', flat=True)))
|
||||
data = data.union(self.get_comics(models.Directory.objects.filter(
|
||||
parent__in=directories).values_list('selector', flat=True)))
|
||||
return [str(x) for x in data]
|
||||
|
||||
|
||||
class RSSSerializer(serializers.Serializer):
|
||||
feed_id = serializers.UUIDField()
|
||||
|
||||
|
||||
class AccountSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'email', 'is_superuser']
|
||||
|
||||
|
||||
class UpdateEmailSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField()
|
||||
|
||||
|
||||
class PasswordResetSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
old_password = serializers.CharField()
|
||||
new_password = serializers.CharField(required=False)
|
||||
new_password_confirm = serializers.CharField(required=False)
|
||||
|
||||
def validate_new_password(self, data):
|
||||
if data == '':
|
||||
return data
|
||||
try:
|
||||
validate_password(data)
|
||||
except ValidationError as e:
|
||||
raise serializers.ValidationError(e)
|
||||
return data
|
||||
|
||||
def validate(self, attrs):
|
||||
super().validate(attrs)
|
||||
if attrs['new_password'] != attrs['new_password_confirm']:
|
||||
raise serializers.ValidationError('New passwords do not match')
|
||||
return attrs
|
||||
|
||||
|
||||
class AccountViewSet(viewsets.GenericViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
lookup_field = 'username'
|
||||
serializer_class = AccountSerializer
|
||||
|
||||
@swagger_auto_schema(responses={200: AccountSerializer()})
|
||||
@action(detail=False, methods=['PATCH'], serializer_class=PasswordResetSerializer)
|
||||
def reset_password(self, request: Request) -> Response:
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if request.user.username != serializer.data['username']:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={'errors': 'Username does not match account'})
|
||||
if not request.user.check_password(serializer.data['old_password']):
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={'errors': 'Password Incorrect'})
|
||||
request.user.set_password(serializer.data['new_password'])
|
||||
request.user.save()
|
||||
return Response(AccountSerializer(request.user).data)
|
||||
else:
|
||||
return Response({"errors": serializer.errors}, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def list(self, request):
|
||||
serializer = self.get_serializer(request.user)
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(responses={200: AccountSerializer()})
|
||||
@action(detail=False, methods=['PATCH'], serializer_class=UpdateEmailSerializer)
|
||||
def update_email(self, request: Request) -> Response:
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if request.user.username != serializer.data['username']:
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={'errors': 'Username does not match account'})
|
||||
if not request.user.check_password(serializer.data['password']):
|
||||
return Response(status=status.HTTP_400_BAD_REQUEST, data={'errors': 'Password Incorrect'})
|
||||
request.user.email = serializer.data['email']
|
||||
request.user.save()
|
||||
account = AccountSerializer(request.user)
|
||||
return Response(account.data)
|
||||
else:
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: RSSSerializer()})
|
||||
@action(methods=['get'], detail=False, serializer_class=RSSSerializer)
|
||||
def feed_id(self, request: Request):
|
||||
"""
|
||||
Return the RSS feed id needed to get users RSS Feed.
|
||||
"""
|
||||
user_misc = get_object_or_404(models.UserMisc, user=request.user)
|
||||
serializer = self.get_serializer(user_misc)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class DirectorySerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Directory
|
||||
fields = ['selector', 'classification']
|
||||
extra_kwargs = {
|
||||
'selector': {'validators': []},
|
||||
}
|
||||
|
||||
|
||||
class DirectoryViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
||||
serializer_class = DirectorySerializer
|
||||
queryset = models.Directory.objects.all()
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
lookup_field = 'selector'
|
||||
|
||||
@swagger_auto_schema(responses={200: DirectorySerializer(many=True)})
|
||||
def update(self, request: Request, selector: UUID) -> Response:
|
||||
"""
|
||||
This will set the classification of a directory and all it's children.
|
||||
"""
|
||||
main_parent = get_object_or_404(models.Directory, selector=selector)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
main_parent.classification = serializer.data['classification']
|
||||
to_update = {main_parent}
|
||||
to_visit = {main_parent}
|
||||
|
||||
while to_visit:
|
||||
parent = to_visit.pop()
|
||||
for child in models.Directory.objects.filter(parent=parent):
|
||||
child.classification = serializer.data['classification']
|
||||
to_visit.add(child)
|
||||
to_update.add(child)
|
||||
models.Directory.objects.bulk_update(to_update, fields=['classification'])
|
||||
data = models.Directory.objects.filter(directory__in=to_update)
|
||||
response = self.get_serializer(data, many=True)
|
||||
return Response(response.data)
|
||||
else:
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
"""
|
||||
This will set the classification of a directory and none of its children.
|
||||
"""
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class InitialSetupSerializer(serializers.Serializer):
|
||||
username = serializers.CharField()
|
||||
email = serializers.EmailField(required=False, allow_blank=True)
|
||||
password = serializers.CharField()
|
||||
|
||||
|
||||
class InitialSetupRequired(serializers.Serializer):
|
||||
required = serializers.BooleanField()
|
||||
|
||||
|
||||
class InitialSetup(viewsets.GenericViewSet):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@swagger_auto_schema(responses={status.HTTP_200_OK: InitialSetupRequired(many=False)})
|
||||
@action(detail=False, methods=['get'], serializer_class=InitialSetupRequired)
|
||||
def required(self, _request: Request) -> Response:
|
||||
serializer = self.get_serializer({'required': User.objects.count() == 0})
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(methods=['post'], detail=False, serializer_class=InitialSetupSerializer)
|
||||
def create_user(self, request: Request) -> Response:
|
||||
if User.objects.count() == 0:
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
admin = User(
|
||||
username=serializer.data['username'],
|
||||
email=serializer.data['email'],
|
||||
is_superuser=True,
|
||||
is_active=True,
|
||||
is_staff=True
|
||||
)
|
||||
admin.set_password(serializer.data['password'])
|
||||
admin.save()
|
||||
return Response(serializer.data, status.HTTP_201_CREATED)
|
||||
else:
|
||||
return Response(serializer.errors, status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response({}, status.HTTP_400_BAD_REQUEST)
|
||||
23
comic/templates/application.html
Normal file
23
comic/templates/application.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{% load static %}
|
||||
{% load render_bundle from webpack_loader %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="{% static "img/logo.svg" %}">
|
||||
<title>CBWebReader</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app">
|
||||
<app id="inspire"></app>
|
||||
</div>
|
||||
{% render_bundle 'main' %}
|
||||
<!-- built files will be auto injected -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,3 @@
|
||||
from django.conf.urls import url
|
||||
from django.urls import path
|
||||
|
||||
from . import feeds, views
|
||||
@@ -17,7 +16,7 @@ urlpatterns = [
|
||||
path("recent/", views.recent_comics, name="recent_comics"),
|
||||
path("recent/json/", views.recent_comics_json, name="recent_comics_json"),
|
||||
path("edit/", views.comic_edit, name="comic_edit"),
|
||||
path("feed/<user_selector>/", feeds.RecentComics()),
|
||||
# path("feed/<user_selector>/", feeds.RecentComics()),
|
||||
path("<directory_selector>/", views.comic_list, name="comic_list"),
|
||||
path("<directory_selector>/thumb", views.directory_thumbnail, name="directory_thumbnail"),
|
||||
path("action/<operation>/<item_type>/<selector>/", views.perform_action, name="perform_action")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import Union, Iterable
|
||||
|
||||
@@ -37,6 +38,7 @@ class Breadcrumb:
|
||||
def __init__(self):
|
||||
self.name = "Home"
|
||||
self.url = "/comic/"
|
||||
self.selector = ''
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -60,11 +62,13 @@ def generate_breadcrumbs_from_path(directory=False, book=False):
|
||||
bc = Breadcrumb()
|
||||
bc.name = item.name
|
||||
bc.url = "/comic/" + urlsafe_base64_encode(item.selector.bytes)
|
||||
bc.selector = item.selector
|
||||
output.append(bc)
|
||||
if book:
|
||||
bc = Breadcrumb()
|
||||
bc.name = book.file_name
|
||||
bc.url = "/read/" + urlsafe_base64_encode(book.selector.bytes)
|
||||
bc.selector = book.selector
|
||||
output.append(bc)
|
||||
|
||||
return output
|
||||
@@ -87,9 +91,9 @@ class DirFile:
|
||||
item_type: str = ''
|
||||
percent: int = 0
|
||||
selector: str = ''
|
||||
total: int = None
|
||||
total_read: int = None
|
||||
total_unread: int = None
|
||||
total: int = 0
|
||||
total_read: int = 0
|
||||
total_unread: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
self.item_type = type(self.obj).__name__
|
||||
@@ -111,36 +115,40 @@ class DirFile:
|
||||
self.name = self.obj.name
|
||||
elif isinstance(self.obj, ComicBook):
|
||||
self.name = self.obj.file_name
|
||||
@property
|
||||
def type(self):
|
||||
return 'ComicBook'
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.name
|
||||
|
||||
def generate_directory(user: User, directory=False):
|
||||
@property
|
||||
def progress(self):
|
||||
return self.total_read
|
||||
|
||||
@property
|
||||
def thumbnail(self):
|
||||
return '/error.jpg'
|
||||
|
||||
def generate_directory(user: User, directory=None):
|
||||
"""
|
||||
:type user: User
|
||||
:type directory: Directory
|
||||
"""
|
||||
base_dir = settings.COMIC_BOOK_VOLUME
|
||||
files = []
|
||||
if directory:
|
||||
dir_path = Path(base_dir, directory.path)
|
||||
dir_list = [x for x in sorted(dir_path.glob('*')) if Path(base_dir, directory.path, x).is_dir()]
|
||||
else:
|
||||
dir_path = base_dir
|
||||
dir_list = [x for x in sorted(dir_path.glob('*')) if Path(base_dir, x).is_dir()]
|
||||
dir_path = Path(base_dir, directory.path) if directory else base_dir
|
||||
dir_list = [x for x in sorted(dir_path.glob('*')) if x.is_dir()]
|
||||
|
||||
file_list = [x for x in sorted(dir_path.glob('*')) if x.is_file()]
|
||||
if directory:
|
||||
dir_list_obj = Directory.objects.filter(name__in=[x.name for x in dir_list], parent=directory)
|
||||
file_list_obj = ComicBook.objects.filter(file_name__in=[x.name for x in file_list], directory=directory)
|
||||
else:
|
||||
dir_list_obj: Iterable[Directory] = Directory.objects.filter(name__in=[x.name for x in dir_list], parent__isnull=True)
|
||||
file_list_obj: Iterable[ComicBook] = ComicBook.objects.filter(file_name__in=[x.name for x in file_list], directory__isnull=True)
|
||||
for file in file_list_obj:
|
||||
dir_list_obj = Directory.objects.filter(name__in=[x.name for x in dir_list], parent=directory)
|
||||
file_list_obj = ComicBook.objects.filter(file_name__in=[x.name for x in file_list], directory=directory)
|
||||
|
||||
for file in chain(file_list_obj, dir_list_obj):
|
||||
if file.thumbnail and not Path(file.thumbnail.path).exists():
|
||||
file.thumbnail.delete()
|
||||
file.save()
|
||||
for folder in dir_list_obj:
|
||||
if folder.thumbnail and not Path(folder.thumbnail.path).exists():
|
||||
folder.thumbnail.delete()
|
||||
folder.save()
|
||||
|
||||
dir_list_obj = dir_list_obj.annotate(
|
||||
total=Count('comicbook', distinct=True),
|
||||
@@ -186,10 +194,27 @@ def generate_directory(user: User, directory=False):
|
||||
files.append(DirFile(directory_obj))
|
||||
files = [file for file in files if file.obj.classification <= user.usermisc.allowed_to_read]
|
||||
|
||||
comics_to_annotate = []
|
||||
for file_name in file_list:
|
||||
if file_name.suffix.lower() in [".rar", ".zip", ".cbr", ".cbz", ".pdf"]:
|
||||
book = ComicBook.process_comic_book(file_name, directory)
|
||||
files.append(DirFile(book))
|
||||
ComicStatus(user=user, comic=book).save()
|
||||
comics_to_annotate.append(book.selector)
|
||||
if comics_to_annotate:
|
||||
new_comics = ComicBook.objects.filter(selector__in=comics_to_annotate).annotate(
|
||||
total=Count('comicpage', distinct=True),
|
||||
total_read=F('comicstatus__last_read_page'),
|
||||
finished=F('comicstatus__finished'),
|
||||
unread=F('comicstatus__unread'),
|
||||
user=F('comicstatus__user'),
|
||||
classification=Case(
|
||||
When(directory__isnull=True, then=Directory.Classification.C_18),
|
||||
default=F('directory__classification'),
|
||||
output_field=PositiveSmallIntegerField(choices=Directory.Classification.choices)
|
||||
)
|
||||
).filter(Q(user__isnull=True) | Q(user=user.id))
|
||||
|
||||
files.extend([DirFile(b) for b in new_comics])
|
||||
files.sort(key=lambda x: x.name)
|
||||
files.sort(key=lambda x: x.item_type, reverse=True)
|
||||
return files
|
||||
|
||||
@@ -339,6 +339,16 @@ def get_image(request, comic_selector, page):
|
||||
return FileResponse(img, content_type=content)
|
||||
|
||||
|
||||
@login_required
|
||||
def get_image_api(request, selector, page):
|
||||
book = ComicBook.objects.get(selector=selector)
|
||||
misc, _ = UserMisc.objects.get_or_create(user=request.user)
|
||||
if book.directory and book.directory.classification > misc.allowed_to_read:
|
||||
return HttpResponse(status=401)
|
||||
img, content = book.get_image(int(page))
|
||||
return FileResponse(img, content_type=content)
|
||||
|
||||
|
||||
@xframe_options_sameorigin
|
||||
@login_required
|
||||
def comic_thumbnail(request, comic_selector):
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from snowpenguin.django.recaptcha2.fields import ReCaptchaField
|
||||
from snowpenguin.django.recaptcha2.widgets import ReCaptchaWidget
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
@@ -17,8 +14,3 @@ class LoginForm(forms.Form):
|
||||
label="Password",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control", "placeholder": "Password", "required": True}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LoginForm, self).__init__(*args, **kwargs)
|
||||
if settings.CBREADER_USE_RECAPTCHA if hasattr(settings, "CBREADER_USE_RECAPTCHA") else False:
|
||||
self.fields["captcha"] = ReCaptchaField(widget=ReCaptchaWidget())
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% load recaptcha2 %}
|
||||
{% load bootstrap4 %}
|
||||
{% block title %}CBWebReader - Login{% endblock %}
|
||||
{% block content %}
|
||||
{% recaptcha_init %}
|
||||
<div class="container">
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert"><p>Your username and password didn't match. Please try again.</p></div>
|
||||
|
||||
@@ -14,24 +14,25 @@ services:
|
||||
- 8000
|
||||
volumes:
|
||||
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
|
||||
# - c:/comics:/comics
|
||||
- static_files:/static
|
||||
- media_files:/media
|
||||
- .env:/src/.env
|
||||
command: /bin/bash entrypoint.sh
|
||||
command: /bin/bash /src/entrypoint.sh
|
||||
|
||||
cbwebreader-cron:
|
||||
build: .
|
||||
env_file: .env
|
||||
links:
|
||||
- database
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
|
||||
- media_files:/media
|
||||
- .env:/src/.env
|
||||
command: /bin/bash entrypoint-cron.sh
|
||||
# cbwebreader-cron:
|
||||
# build: .
|
||||
# env_file: .env
|
||||
# links:
|
||||
# - database
|
||||
# depends_on:
|
||||
# database:
|
||||
# condition: service_healthy
|
||||
# volumes:
|
||||
# - ${COMIC_BOOK_VOLUME}:${COMIC_BOOK_VOLUME}
|
||||
# - media_files:/media
|
||||
# - .env:/src/.env
|
||||
# command: /bin/bash entrypoint-cron.sh
|
||||
|
||||
database:
|
||||
image: postgres:11.4-alpine
|
||||
|
||||
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
24
frontend/README.md
Normal file
24
frontend/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# frontend
|
||||
|
||||
## Project setup
|
||||
```
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
5
frontend/babel.config.js
Normal file
5
frontend/babel.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
||||
19
frontend/jsconfig.json
Normal file
19
frontend/jsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
}
|
||||
}
|
||||
20566
frontend/package-lock.json
generated
Normal file
20566
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
69
frontend/package.json
Normal file
69
frontend/package.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "webpack-dev-server --config webpack.dev.js",
|
||||
"build": "npx webpack --config webpack.prod.js",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coreui/coreui": "^4.2.0",
|
||||
"@coreui/vue": "^4.3.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.2",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.1",
|
||||
"axios": "^0.27.2",
|
||||
"axios-jwt": "^1.8.0",
|
||||
"bootstrap": "^4.6.2",
|
||||
"core-js": "^3.8.3",
|
||||
"hammerjs": "^2.0.8",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"pdfvuer": "^2.0.1",
|
||||
"reveal.js": "^4.3.1",
|
||||
"reveal.js-menu": "^2.1.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"timeago.js": "^4.0.2",
|
||||
"vue": "^3.2.13",
|
||||
"vue-loader": "^17.0.0",
|
||||
"vue-router": "^4.0.3",
|
||||
"vue-toast-notification": "3.0",
|
||||
"vuejs-paginate-next": "^1.0.2",
|
||||
"vuex": "^4.0.0",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-bundle-tracker": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.16",
|
||||
"@babel/eslint-parser": "^7.12.16",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-router": "~5.0.0",
|
||||
"@vue/cli-plugin-vuex": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
"mini-css-extract-plugin": "^2.6.1",
|
||||
"webpack-cli": "^4.10.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "@babel/eslint-parser"
|
||||
},
|
||||
"rules": {}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
"last 2 versions",
|
||||
"not dead",
|
||||
"not ie 11"
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
17
frontend/public/index.html
Normal file
17
frontend/public/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
||||
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
138
frontend/public/logo.svg
Normal file
138
frontend/public/logo.svg
Normal file
@@ -0,0 +1,138 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xml:space="preserve"
|
||||
width="5.2083335in"
|
||||
height="5.2083335in"
|
||||
version="1.1"
|
||||
style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"
|
||||
viewBox="0 0 32.77887 32.77723"
|
||||
id="svg48"
|
||||
sodipodi:docname="logo.svg"
|
||||
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)"><metadata
|
||||
id="metadata52"><rdf:RDF><cc:Work
|
||||
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1777"
|
||||
inkscape:window-height="1057"
|
||||
id="namedview50"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.63130142"
|
||||
inkscape:cx="-31.625386"
|
||||
inkscape:cy="264.63314"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="_2302217657600"
|
||||
inkscape:document-rotation="0" />
|
||||
<defs
|
||||
id="defs31">
|
||||
<font
|
||||
id="FontID0"
|
||||
horiz-adv-x="722"
|
||||
font-variant="normal"
|
||||
style="fill-rule:nonzero"
|
||||
font-style="normal"
|
||||
font-weight="700"
|
||||
horiz-origin-x="0"
|
||||
horiz-origin-y="0"
|
||||
vert-origin-x="512"
|
||||
vert-origin-y="768"
|
||||
vert-adv-y="1024">
|
||||
<font-face
|
||||
font-family="Futura Md BT"
|
||||
id="font-face10">
|
||||
<font-face-src>
|
||||
<font-face-name
|
||||
name="Futura Md BT Bold" />
|
||||
</font-face-src>
|
||||
</font-face>
|
||||
<missing-glyph
|
||||
id="missing-glyph14"><path
|
||||
d="M0 0z"
|
||||
id="path12" /></missing-glyph>
|
||||
<glyph
|
||||
unicode="A"
|
||||
horiz-adv-x="722"
|
||||
d="M265.169 266.998l193.83 0 -72.8403 235.995c-2.15538,7.17818 -5.33072,18.6671 -9.16036,34.6785 -4.00285,15.9921 -9.00641,36.4875 -15.1646,61.4861 -4.17605,-17.4932 -8.33285,-34.3321 -12.5089,-50.3243 -3.9836,-15.8382 -8.15965,-31.1568 -12.3165,-45.8403l-71.8396 -235.995zm-273.175 -266.998l246.829 715.009 244.174 0 247.003 -715.009 -193.83 0 -36.1603 127.995 -276.851 0 -37.1611 -127.995 -194.003 0z"
|
||||
id="glyph16" />
|
||||
<glyph
|
||||
unicode="B"
|
||||
horiz-adv-x="678"
|
||||
d="M256.991 428.998l39.0085 0c48.1689,0 81.1731,5.33072 99.1667,15.8382 17.8396,10.6614 26.8268,29.0014 26.8268,55.0007 0,27 -8.33285,45.8211 -25.1525,56.8289 -16.6657,10.8346 -49.3428,16.3385 -97.8388,16.3385l-42.0107 0 0 -144.006zm-180.994 -428.998l0 715.009 196.178 0c80.6535,0 137.001,-2.67498 169.159,-7.8325 32.0035,-5.17676 59.5039,-14.1832 82.6742,-26.846 26.3264,-14.6643 46.4946,-34.3321 60.3314,-58.8303 13.8368,-24.6714 20.6686,-52.9992 20.6686,-85.3299 0,-40.6635 -10.3343,-72.9942 -31.0028,-97.0113 -20.6686,-23.8246 -53.6728,-41.9914 -99.0128,-54.5003 50.6707,-3.82965 90.5067,-21.65 119.008,-53.3264 28.6742,-31.8303 42.9921,-74.1682 42.9921,-127.341 0,-37.9886 -7.98645,-71.4932 -24.1518,-100.495 -16.0114,-29.0014 -39.1817,-51.4982 -69.0106,-67.3364 -24.325,-12.99 -54.5003,-22.3236 -90.1603,-27.8275 -35.8332,-5.50392 -94.3364,-8.33285 -175.663,-8.33285l-202.009 0zm180.994 146.008l68.3371 0c46.1675,0 78.8446,5.83107 98.012,17.32 19.1675,11.6814 28.655,31.0028 28.655,58.0028 0,30.0021 -8.83321,50.8439 -26.3264,62.8332 -17.4932,11.8353 -49.9971,17.8396 -97.3385,17.8396l-71.3392 0 0 -155.996z"
|
||||
id="glyph18" />
|
||||
<glyph
|
||||
unicode="D"
|
||||
horiz-adv-x="766"
|
||||
d="M75.9964 0l0 715.009 149.01 0c111.002,0 189.5,-5.17676 235.494,-15.6842 46.1675,-10.315 86.5039,-27.8275 121.336,-52.1525 45.3207,-31.6764 79.6721,-72.1667 102.996,-121.336 23.4975,-49.3428 35.1596,-105.671 35.1596,-168.832 0,-63.1796 -11.6621,-119.335 -35.1596,-168.678 -23.3243,-49.3236 -57.6757,-89.8332 -102.996,-121.49 -34.5053,-23.9978 -73.841,-41.1639 -118.161,-51.4982 -44.1853,-10.1803 -112.85,-15.3378 -205.839,-15.3378l-32.8311 0 -149.01 0zm193.003 159.998l32.6771 0c76.4967,0 132.498,15.665 167.658,47.1682 35.1596,31.33 52.6721,81.1731 52.6721,149.664 0,68.3371 -17.5125,118.334 -52.6721,150.338 -35.1596,31.8303 -91.161,47.8225 -167.658,47.8225l-32.6771 0 0 -394.993z"
|
||||
id="glyph20" />
|
||||
<glyph
|
||||
unicode="E"
|
||||
horiz-adv-x="566"
|
||||
d="M75.9964 0l0 715.009 438.004 0 0 -157.016 -248.003 0 0 -123.992 233.839 0 0 -152.993 -233.839 0 0 -121.009 248.003 0 0 -159.998 -438.004 0z"
|
||||
id="glyph22" />
|
||||
<glyph
|
||||
unicode="R"
|
||||
horiz-adv-x="641"
|
||||
d="M75.9964 0l0 715.009 204.011 0c79.6528,0 135.327,-3.67569 166.657,-11.0078 31.33,-7.33214 58.5032,-19.6678 81.3271,-36.8339 25.6721,-19.4946 45.5132,-44.4932 59.3499,-74.6685 13.8175,-30.3293 20.6686,-63.6607 20.6686,-100.167 0,-55.3278 -13.6828,-100.341 -40.8367,-135 -27.3464,-34.6593 -67.0092,-57.6564 -119.181,-68.9914l195.004 -288.34 -220.003 0 -164.001 280.007 0 -280.007 -182.996 0zm182.996 376.999l36.0064 0c42.0107,0 72.6671,7.15894 92.0078,21.4961 19.3214,14.3371 29.0014,36.8339 29.0014,67.3364 0,35.8332 -9.00641,61.3321 -27,76.4967 -18.1668,15.1646 -48.3421,22.67 -91.0071,22.67l-39.0085 0 0 -187.999z"
|
||||
id="glyph24" />
|
||||
<glyph
|
||||
unicode="W"
|
||||
horiz-adv-x="970"
|
||||
d="M562.997 715.009l89.8332 -340.011c4.17605,-15.1646 8.1789,-31.1568 11.6621,-47.8225 3.67569,-16.5117 7.33214,-35.6792 11.335,-57.1753 4.83036,25.4989 9.00641,46.3407 12.3357,62.8332 3.50249,16.4925 6.83178,30.5025 10.0071,42.1646l84.0021 340.011 196.832 0 -202.163 -715.009 -180.667 0 -88.3321 305.333c-3.34854,10.6614 -8.67925,31.6764 -16.1846,63.0064 -3.15609,13.9907 -5.83107,24.8254 -7.8325,32.6579 -1.50107,-6.83178 -3.82965,-16.4925 -6.83178,-28.8282 -7.4861,-31.6764 -13.1632,-53.8268 -16.9929,-66.836l-87.0042 -305.333 -181.167 0 -201.836 715.009 197.006 0 82.0007 -341.839c4.00285,-17.6664 7.8325,-35.1789 11.5082,-52.8453 3.82965,-17.4932 7.33214,-35.66 10.4882,-54.1539 3.00214,13.6636 6.17747,28.0007 9.50676,42.9921 3.50249,15.0107 8.66001,36.3335 15.4918,64.0071l89.8332 341.839 157.17 0z"
|
||||
id="glyph26" />
|
||||
</font>
|
||||
<style
|
||||
type="text/css"
|
||||
id="style29">
|
||||
<![CDATA[
|
||||
@font-face { font-family:"Futura Md BT";font-variant:normal;font-style:normal;font-weight:bold;src:url("#FontID0") format(svg)}
|
||||
.fil2 {fill:#336666}
|
||||
.fil0 {fill:#E6E6E6}
|
||||
.fil1 {fill:#003333;fill-rule:nonzero}
|
||||
.fnt0 {font-weight:bold;font-size:1.287px;font-family:'Futura Md BT'}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g
|
||||
id="Layer_x0020_1"
|
||||
transform="translate(11.673934,11.673698)">
|
||||
<metadata
|
||||
id="CorelCorpID_0Corel-Layer" />
|
||||
<g
|
||||
id="_2302217657600"
|
||||
transform="matrix(3.4756515,0,0,3.4756515,-11.673934,-11.673934)">
|
||||
|
||||
<g
|
||||
id="g42">
|
||||
<path
|
||||
class="fil1"
|
||||
d="M 6.32,3.205 C 6.038,2.964 5.738,2.783 5.424,2.663 5.108,2.543 4.777,2.482 4.43,2.482 3.759,2.482 3.212,2.697 2.791,3.129 2.369,3.561 2.159,4.119 2.159,4.805 c 0,0.662 0.205,1.21 0.615,1.642 0.41,0.432 0.928,0.647 1.552,0.647 0.364,0 0.71,-0.065 1.04,-0.194 C 5.694,6.771 6.01,6.577 6.312,6.316 v 1.13 C 6.044,7.64 5.751,7.785 5.434,7.879 5.117,7.975 4.774,8.022 4.406,8.022 3.936,8.022 3.501,7.945 3.102,7.791 2.702,7.636 2.352,7.41 2.049,7.11 1.749,6.816 1.52,6.469 1.358,6.07 1.197,5.671 1.117,5.245 1.117,4.795 1.117,4.343 1.197,3.92 1.358,3.525 1.52,3.128 1.752,2.781 2.058,2.481 2.363,2.178 2.713,1.949 3.108,1.794 3.502,1.638 3.93,1.56 4.389,1.56 4.75,1.56 5.095,1.613 5.422,1.718 5.75,1.824 6.067,1.983 6.376,2.197 L 6.321,3.204 Z"
|
||||
id="path36" />
|
||||
<path
|
||||
class="fil2"
|
||||
d="M 5.438,4.315 H 5.819 C 6.295,4.315 6.631,4.25 6.829,4.12 7.025,3.99 7.124,3.77 7.124,3.463 7.124,3.126 7.034,2.891 6.852,2.755 6.771,2.694 6.658,2.647 6.512,2.614 L 6.539,2.118 6.466,2.067 C 6.272,1.932 6.068,1.814 5.854,1.717 h 0.155 c 0.443,0 0.77,0.024 0.981,0.072 C 7.202,1.836 7.381,1.915 7.531,2.024 7.722,2.165 7.87,2.348 7.974,2.575 8.079,2.803 8.132,3.053 8.132,3.328 8.132,3.656 8.056,3.93 7.905,4.15 7.753,4.371 7.534,4.524 7.245,4.61 7.603,4.666 7.885,4.829 8.09,5.1 8.295,5.37 8.398,5.711 8.398,6.123 8.398,6.371 8.355,6.608 8.268,6.833 8.182,7.057 8.059,7.247 7.899,7.404 7.73,7.575 7.522,7.695 7.272,7.765 7.022,7.835 6.605,7.87 6.017,7.87 H 5.909 C 6.082,7.789 6.248,7.691 6.404,7.578 L 6.47,7.53 V 7.006 C 6.505,7.002 6.537,6.998 6.567,6.993 6.74,6.964 6.877,6.916 6.979,6.845 7.102,6.765 7.197,6.654 7.264,6.518 7.331,6.38 7.364,6.23 7.364,6.063 7.364,5.868 7.324,5.696 7.242,5.551 7.16,5.405 7.044,5.291 6.893,5.211 6.798,5.162 6.689,5.128 6.567,5.105 6.445,5.083 6.283,5.072 6.081,5.072 H 5.78 5.436 V 6.696 C 5.393,6.716 5.349,6.734 5.304,6.752 5.03,6.859 4.747,6.918 4.455,6.932 V 2.643 c 0.312,0.003 0.617,0.058 0.909,0.17 0.024,0.009 0.048,0.019 0.072,0.029 v 1.474 z"
|
||||
id="path38" />
|
||||
<path
|
||||
class="fil1"
|
||||
d="M 6.32,3.205 C 6.038,2.964 5.738,2.783 5.424,2.663 5.108,2.543 4.777,2.482 4.43,2.482 3.759,2.482 3.212,2.697 2.791,3.129 2.369,3.561 2.159,4.119 2.159,4.805 c 0,0.662 0.205,1.21 0.615,1.642 0.41,0.432 0.928,0.647 1.552,0.647 0.364,0 0.71,-0.065 1.04,-0.194 C 5.694,6.771 6.01,6.577 6.312,6.316 v 1.13 C 6.044,7.64 5.751,7.785 5.434,7.879 5.117,7.975 4.774,8.022 4.406,8.022 3.936,8.022 3.501,7.945 3.102,7.791 2.702,7.636 2.352,7.41 2.049,7.11 1.749,6.816 1.52,6.469 1.358,6.07 1.197,5.671 1.117,5.245 1.117,4.795 1.117,4.343 1.197,3.92 1.358,3.525 1.52,3.128 1.752,2.781 2.058,2.481 2.363,2.178 2.713,1.949 3.108,1.794 3.502,1.638 3.93,1.56 4.389,1.56 4.75,1.56 5.095,1.613 5.422,1.718 5.75,1.824 6.067,1.983 6.376,2.197 L 6.321,3.204 Z"
|
||||
id="path40" />
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
BIN
frontend/public/placeholder.png
Normal file
BIN
frontend/public/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
13
frontend/src/App.vue
Normal file
13
frontend/src/App.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<the-navbar />
|
||||
<router-view/>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
<script>
|
||||
import TheNavbar from "@/components/TheNavbar.vue";
|
||||
export default {
|
||||
components: {TheNavbar}
|
||||
}
|
||||
</script>
|
||||
40
frontend/src/api/index.js
Normal file
40
frontend/src/api/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from "axios";
|
||||
import router from "@/router";
|
||||
import store from "@/store";
|
||||
import jwtDecode from "jwt-decode";
|
||||
|
||||
async function get_access_token() {
|
||||
let access = jwtDecode(store.state.jwt.access)
|
||||
let refresh = jwtDecode(store.state.jwt.refresh)
|
||||
if (access.exp - Date.now()/1000 < 5) {
|
||||
if (refresh.exp - Date.now()/1000 < 5) {
|
||||
await router.push({name: 'login', params: { username: 'eduardo' }})
|
||||
return null
|
||||
} else {
|
||||
return store.dispatch('refreshToken').then(() => {return store.state.jwt.access})
|
||||
}
|
||||
}
|
||||
return store.state.jwt.access
|
||||
}
|
||||
|
||||
const axios_jwt = axios.create();
|
||||
|
||||
axios_jwt.interceptors.request.use(async function (config) {
|
||||
let access_token = await get_access_token().catch(() => {
|
||||
if (router.currentRoute.value.fullPath.includes('login')){
|
||||
router.push({name: 'login'})
|
||||
}else {
|
||||
router.push({name: 'login', query: { next: router.currentRoute.value.fullPath }})
|
||||
}
|
||||
|
||||
})
|
||||
config.headers = {
|
||||
Authorization: "Bearer " + access_token
|
||||
}
|
||||
return config
|
||||
}, function (error) {
|
||||
// Do something with request error
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
export default axios_jwt
|
||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
79
frontend/src/components/AddUser.vue
Normal file
79
frontend/src/components/AddUser.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<CButton color="secondary" @click="visible = true">Add User</CButton>
|
||||
<CModal :visible="visible" @close="visible = false">
|
||||
<CModalHeader>
|
||||
<CModalTitle>Add user</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CForm @submit="addUser">
|
||||
<CModalBody>
|
||||
<CFormInput
|
||||
type="text"
|
||||
label="Username"
|
||||
v-model="username"
|
||||
/>
|
||||
<CFormInput
|
||||
type="email"
|
||||
label="Email address"
|
||||
text="Must be 8-20 characters long."
|
||||
v-model="email"
|
||||
feedback-invalid="Email address invalid."
|
||||
/>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="visible = false">
|
||||
Close
|
||||
</CButton>
|
||||
<CButton color="primary" type="submit">Submit</CButton>
|
||||
</CModalFooter>
|
||||
</CForm>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "@/api";
|
||||
export default {
|
||||
name: "AddUser",
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
username: '',
|
||||
email: ''
|
||||
}
|
||||
},
|
||||
props: {
|
||||
messages: Array,
|
||||
},
|
||||
methods: {
|
||||
addUser() {
|
||||
let payload = {
|
||||
username: this.username,
|
||||
email: this.email
|
||||
}
|
||||
api.post('/api/users/', payload).then(response => {
|
||||
payload = {
|
||||
username: response.data.username
|
||||
}
|
||||
api.patch('/api/users/' + response.data.id + '/reset_password/', payload).then(response2 => {
|
||||
this.$emit('add-message', {
|
||||
color: 'success',
|
||||
text: 'New user "' + response.data.username + '" created with password "' + response2.data.password + '".'
|
||||
})
|
||||
this.visible=false
|
||||
this.$emit('user-added')
|
||||
})
|
||||
}).catch(err => {
|
||||
this.$emit('add-message', {
|
||||
color: 'danger',
|
||||
text: 'Cannot create user "' + this.username + '" with error "' + (err.response.data.username? err.response.data.username: err.response.data.email) + '".'
|
||||
})
|
||||
this.visible = false
|
||||
})
|
||||
}
|
||||
},
|
||||
emits: ['user-added', 'add-message']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
23
frontend/src/components/AlertMessages.vue
Normal file
23
frontend/src/components/AlertMessages.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<CAlert :color="message.color" dismissible v-for="message in messages" :key="message.text">
|
||||
{{message.text}}
|
||||
</CAlert>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "AlertMessages",
|
||||
data() {
|
||||
return {
|
||||
|
||||
}
|
||||
},
|
||||
props: {
|
||||
messages: Array
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
178
frontend/src/components/ComicCard.vue
Normal file
178
frontend/src/components/ComicCard.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<CCard class="col-xl-2 col-lg-2 col-md-3 col-sm-4 p-0 m-1 ">
|
||||
<CCardImage orientation="top" :src="thumbnail"/>
|
||||
<CCardBody class="pb-0 pt-0 pl-1 pr-1 card-img-overlay d-flex">
|
||||
<span class="badge rounded-pill bg-primary unread-badge" v-if="this.unread > 0 && data.type === 'Directory'">{{ this.unread }}</span>
|
||||
<span class="badge rounded-pill bg-warning classification-badge" v-if="card_type === 'Directory'" >{{ this.$store.state.classifications.find(i => i.value === classification).label }}</span>
|
||||
<CCardTitle class="align-self-end pb-5 mb-4 text-break" style="">
|
||||
<router-link :to="(data.type === 'Directory' ? {'name': 'browse', params: { selector: data.selector }} : {'name': 'read', params: { selector: data.selector }})">{{ data.title }}</router-link>
|
||||
</CCardTitle>
|
||||
</CCardBody>
|
||||
<CCardFooter class="pl-0 pr-0 pt-0">
|
||||
<CProgress class="mb-1 position-relative" >
|
||||
<CProgressBar :value="progressPercentCalc" />
|
||||
<small class="justify-content-center d-flex position-absolute w-100 h-100" style="line-height: normal">{{ progressCalc }} / {{data.total}}</small>
|
||||
</CProgress>
|
||||
<CButtonGroup class="w-100">
|
||||
<CButton color="primary" @click="updateComic('mark_unread')" ><font-awesome-icon icon='book' /></CButton>
|
||||
<CButton color="primary" @click="updateComic('mark_read')" ><font-awesome-icon icon='book-open' /></CButton>
|
||||
<CDropdown variant="btn-group">
|
||||
<CDropdownToggle color="primary"><font-awesome-icon icon='edit' /></CDropdownToggle>
|
||||
<CDropdownMenu>
|
||||
<CDropdownItem @click="updateComic('mark_unread')"><font-awesome-icon icon='book' />Mark Un-read</CDropdownItem>
|
||||
<CDropdownItem @click="updateComic('mark_read')"><font-awesome-icon icon='book-open' />Mark read</CDropdownItem>
|
||||
<CDropdownItem v-if="data.type === 'ComicBook'" @click="$emit('markPreviousRead', data.selector)"><font-awesome-icon icon='book' /><font-awesome-icon icon='turn-up' />Mark previous comics read</CDropdownItem>
|
||||
<CDropdownItem v-if="data.type === 'Directory'" @click="editDirectoryVisible = true"><font-awesome-icon icon='edit' />Edit comic</CDropdownItem>
|
||||
</CDropdownMenu>
|
||||
</CDropdown>
|
||||
</CButtonGroup>
|
||||
</CCardFooter>
|
||||
</CCard>
|
||||
<CModal :visible="editDirectoryVisible" @close="editDirectoryVisible = false">
|
||||
<CModalHeader>
|
||||
<CModalTitle>{{ data.title }}</CModalTitle>
|
||||
</CModalHeader>
|
||||
<CForm @submit="updateDirectory">
|
||||
<CModalBody>
|
||||
<CFormSelect
|
||||
label="Classification"
|
||||
aria-label="Set Classification"
|
||||
v-model="new_classification"
|
||||
:options="[...this.$store.state.classifications]">
|
||||
</CFormSelect>
|
||||
<CFormCheck
|
||||
label="Recursive"
|
||||
class="mt-2"
|
||||
v-model="recursive"
|
||||
/>
|
||||
</CModalBody>
|
||||
<CModalFooter>
|
||||
<CButton color="secondary" @click="editDirectoryVisible = false ">
|
||||
Close
|
||||
</CButton>
|
||||
<CButton color="primary" type="submit">Save changes</CButton>
|
||||
</CModalFooter>
|
||||
</CForm>
|
||||
</CModal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {useToast} from "vue-toast-notification";
|
||||
import api from "@/api";
|
||||
|
||||
export default {
|
||||
name: "ComicCard",
|
||||
components: {
|
||||
},
|
||||
props: {
|
||||
data: Object
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
thumbnail: '/static/img/placeholder.png',
|
||||
unread: 0,
|
||||
progress: 0,
|
||||
classification: '0',
|
||||
new_classification: '0',
|
||||
card_type: '',
|
||||
editDirectoryVisible: false,
|
||||
recursive: true
|
||||
}},
|
||||
methods: {
|
||||
updateThumbnail () {
|
||||
api.get('/api/generate_thumbnail/' + this.data.selector + '/')
|
||||
.then((response) => {
|
||||
if (response.data.thumbnail) {
|
||||
this.$emit('updateThumbnail', response.data)
|
||||
this.thumbnail = response.data.thumbnail
|
||||
}
|
||||
}).catch(() => {
|
||||
useToast().error('Error Generating Thumbnail: ' + this.data.title, {position:'top'});
|
||||
})
|
||||
},
|
||||
updateComic(action){
|
||||
let payload = { selectors: [this.data.selector] }
|
||||
api.put('/api/action/' + action +'/', payload).then(() => {
|
||||
this.$emit('updateComicList')
|
||||
}).catch(() => {
|
||||
useToast().error('action: ' + action + ' Failed', {position:'top'});
|
||||
})
|
||||
},
|
||||
updateDirectory() {
|
||||
let payload = {
|
||||
selector: this.data.selector,
|
||||
classification: ~~this.new_classification
|
||||
}
|
||||
if (this.recursive){
|
||||
api.put('/api/directory/' + this.data.selector + '/', payload).then(response => {
|
||||
this.classification = response.data[0].classification.toString()
|
||||
useToast().success('Change classification of ' + this.data.title + ' to "' + this.$store.state.classifications.find(i => i.value === this.classification).label + '"', {position:'top'});
|
||||
this.editDirectoryVisible = false
|
||||
})
|
||||
} else {
|
||||
api.patch('/api/directory/' + this.data.selector + '/', payload).then(response => {
|
||||
this.classification = response.data.classification.toString()
|
||||
useToast().success('Change classification of ' + this.data.title + ' to "' + this.$store.state.classifications.find(i => i.value === this.classification).label + '"', {position:'top'});
|
||||
this.editDirectoryVisible = false
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.data.thumbnail) {
|
||||
this.thumbnail = this.data.thumbnail
|
||||
} else {
|
||||
this.updateThumbnail()
|
||||
}
|
||||
this.unread = this.data.total - this.data.progress
|
||||
this.classification = this.data.classification.toString()
|
||||
this.new_classification = this.classification
|
||||
this.card_type = this.data.type
|
||||
},
|
||||
beforeUpdate() {
|
||||
this.unread = this.data.total - this.data.progress
|
||||
},
|
||||
emits: ['updateComicList', 'markPreviousRead', 'updateThumbnail'],
|
||||
computed: {
|
||||
progressCalc () {
|
||||
if (this.data.type === 'ComicBook'){
|
||||
return (this.data.unread ? 0 : this.data.progress)
|
||||
} else {
|
||||
return this.data.progress
|
||||
}
|
||||
},
|
||||
progressPercentCalc () {
|
||||
if (this.data.type === 'ComicBook') {
|
||||
return (this.data.unread ? 0 : this.data.progress / this.data.total * 100)
|
||||
} else {
|
||||
return this.data.progress / this.data.total * 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-title a {
|
||||
color: white;
|
||||
text-shadow: .2rem .2rem .3rem black ;
|
||||
}
|
||||
.card .unread-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
padding: 5px;
|
||||
color: #fff;
|
||||
}
|
||||
.dropdown-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card .classification-badge {
|
||||
position:absolute;
|
||||
top:10px;
|
||||
right: 10px;
|
||||
padding:5px;
|
||||
color:black;
|
||||
}
|
||||
</style>
|
||||
38
frontend/src/components/ConfirmButton.vue
Normal file
38
frontend/src/components/ConfirmButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<CButtonGroup>
|
||||
<CButton :color="color" v-if="!confirm" @click="confirm = !confirm">{{ label }}</CButton>
|
||||
<CButton color="success" class="text-nowrap" v-if="confirm" variant="outline" @click="performAction">
|
||||
<font-awesome-icon icon='check' class=""/>
|
||||
Yes
|
||||
</CButton>
|
||||
<CButton color="danger" class="text-nowrap" v-if="confirm" variant="outline"
|
||||
@click="confirm = !confirm">
|
||||
<font-awesome-icon icon='times' class=""/>
|
||||
No
|
||||
</CButton>
|
||||
</CButtonGroup>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'confirm-button',
|
||||
data () {
|
||||
return {
|
||||
confirm: false,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
label: String,
|
||||
color: {
|
||||
type: String,
|
||||
default: 'danger'
|
||||
},
|
||||
action: {}
|
||||
},
|
||||
methods: {
|
||||
performAction() {
|
||||
this.action()
|
||||
this.confirm = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
58
frontend/src/components/HelloWorld.vue
Normal file
58
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="hello">
|
||||
<h1>{{ msg }}</h1>
|
||||
<p>
|
||||
For a guide and recipes on how to configure / customize this project,<br>
|
||||
check out the
|
||||
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
|
||||
</p>
|
||||
<h3>Installed CLI Plugins</h3>
|
||||
<ul>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
|
||||
</ul>
|
||||
<h3>Essential Links</h3>
|
||||
<ul>
|
||||
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
|
||||
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
|
||||
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
|
||||
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
|
||||
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
|
||||
</ul>
|
||||
<h3>Ecosystem</h3>
|
||||
<ul>
|
||||
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
|
||||
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
|
||||
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
|
||||
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
|
||||
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HelloWorld',
|
||||
props: {
|
||||
msg: String
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
h3 {
|
||||
margin: 40px 0 0;
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
}
|
||||
a {
|
||||
color: #42b983;
|
||||
}
|
||||
</style>
|
||||
63
frontend/src/components/InitialSetup.vue
Normal file
63
frontend/src/components/InitialSetup.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<h1>Create your admin account.</h1>
|
||||
<CForm @submit="saveForm">
|
||||
<CFormInput
|
||||
type="text"
|
||||
label="Username"
|
||||
v-model="username"
|
||||
/>
|
||||
<CFormInput
|
||||
type="email"
|
||||
label="Email address"
|
||||
text="Must be 8-20 characters long."
|
||||
v-model="email"
|
||||
feedback-invalid="Email address invalid."
|
||||
/>
|
||||
<CFormInput
|
||||
type="password"
|
||||
label="Password"
|
||||
v-model="password"
|
||||
/>
|
||||
<CFormInput
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
v-model="confirm_password"
|
||||
/>
|
||||
<CButton color="primary" type="submit" class="mr-5 mt-2">Save</CButton>
|
||||
</CForm>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import router from "@/router";
|
||||
|
||||
export default {
|
||||
name: "InitialSetup",
|
||||
data () {
|
||||
return {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirm_password: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
saveForm() {
|
||||
if (this.password === this.confirm_password) {
|
||||
let payload = {
|
||||
username: this.username,
|
||||
email: this.email,
|
||||
password: this.password
|
||||
}
|
||||
axios.post('/api/initial_setup/create_user/', payload).then(() => {
|
||||
router.push({'name': 'home'})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
134
frontend/src/components/TheAccountForm.vue
Normal file
134
frontend/src/components/TheAccountForm.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<CContainer>
|
||||
<CForm @submit="updateAccount">
|
||||
<CFormInput
|
||||
type="text"
|
||||
label="Username"
|
||||
readonly
|
||||
v-model="username"
|
||||
/>
|
||||
<CFormInput
|
||||
type="email"
|
||||
label="Email address"
|
||||
:placeholder="email"
|
||||
text="Must be 8-20 characters long."
|
||||
v-model="email"
|
||||
feedback-invalid="Email address invalid."
|
||||
:valid="validateEmail(email)"
|
||||
/>
|
||||
<CFormInput
|
||||
type="password"
|
||||
label="Current Password"
|
||||
placeholder="Enter Current Password"
|
||||
text="Must enter current password to change settings."
|
||||
v-model="current_password"
|
||||
feedback-invalid="Wrong Password."
|
||||
:valid="current_password.length > 0"
|
||||
/>
|
||||
<CFormInput
|
||||
type="password"
|
||||
label="New Password"
|
||||
placeholder="Enter New Password"
|
||||
text="Must be at least 9 characters long."
|
||||
v-model="new_password"
|
||||
feedback-invalid="Password is not complex enough."
|
||||
:valid="checkNewPassword(new_password)"
|
||||
/>
|
||||
<CFormInput
|
||||
type="password"
|
||||
label="New Password Confirm"
|
||||
placeholder="Enter New Password"
|
||||
text="Must be at least 9 characters long."
|
||||
v-model="new_password_confirm"
|
||||
feedback-invalid="New passwords should match."
|
||||
:valid="new_password === new_password_confirm && new_password.length > 8"
|
||||
/>
|
||||
<CButton color="primary" type="submit">Save</CButton>
|
||||
</CForm>
|
||||
</CContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {CForm, CFormInput, CContainer, CButton} from "@coreui/vue";
|
||||
import api from "@/api";
|
||||
import {useToast} from "vue-toast-notification";
|
||||
const toast = useToast();
|
||||
export default {
|
||||
name: "TheAccountForm",
|
||||
components: {
|
||||
CForm,
|
||||
CFormInput,
|
||||
CContainer,
|
||||
CButton
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
username: '',
|
||||
email: '',
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
new_password_confirm: '',
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateFromServer()
|
||||
},
|
||||
methods: {
|
||||
updateFromServer() {
|
||||
api.get('/api/account/').then(response => {
|
||||
this.$store.commit('updateUser', response.data)
|
||||
this.username = this.$store.state.user.username
|
||||
this.email = this.$store.state.user.email
|
||||
this.current_password = ''
|
||||
this.new_password = ''
|
||||
this.new_password_confirm = ''
|
||||
})
|
||||
},
|
||||
updateAccount () {
|
||||
if (!this.current_password) {
|
||||
toast.error('Please enter your current password.', {position:'top'});
|
||||
} else {
|
||||
if (this.email !== this.$store.state.user.email) {
|
||||
let payload = {
|
||||
username: this.username,
|
||||
email: this.email,
|
||||
password: this.current_password
|
||||
}
|
||||
api.patch('/api/account/update_email/', payload).then(() => {
|
||||
toast.success('Email Address updated')
|
||||
this.updateFromServer()
|
||||
}).catch(error => {
|
||||
toast.error(error.response.data.errors)
|
||||
})
|
||||
}
|
||||
if (this.new_password === this.new_password_confirm) {
|
||||
let payload = {
|
||||
username: this.username,
|
||||
old_password: this.current_password,
|
||||
new_password: this.new_password,
|
||||
new_password_confirm: this.new_password_confirm
|
||||
}
|
||||
api.patch('/api/account/reset_password/', payload).then(() => {
|
||||
toast.success('Password reset successfully')
|
||||
this.updateFromServer()
|
||||
}).catch(error => {
|
||||
console.log(error.response.data)
|
||||
toast.error(error.response.data.errors)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
validateEmail(mail){
|
||||
return (/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(mail))
|
||||
},
|
||||
checkNewPassword(pass){
|
||||
return (pass.length >= 9)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
72
frontend/src/components/TheBreadcrumbs.vue
Normal file
72
frontend/src/components/TheBreadcrumbs.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<CBreadcrumb>
|
||||
<template v-for="(item, index) in crumbs" :key="item.id">
|
||||
<template v-if="index !== crumbs.length - 1">
|
||||
<CBreadcrumbItem v-if="item.selector">
|
||||
<router-link :to="{'name': 'browse', params: { selector: item.selector }}">{{ item.name }}</router-link>
|
||||
</CBreadcrumbItem>
|
||||
<CBreadcrumbItem v-else-if="item.route">
|
||||
<router-link :to="item.route">{{ item.name }}</router-link>
|
||||
</CBreadcrumbItem>
|
||||
<CBreadcrumbItem v-else>
|
||||
<router-link :to="{'name': 'browse'}">{{ item.name }}</router-link>
|
||||
</CBreadcrumbItem>
|
||||
</template>
|
||||
<CBreadcrumbItem v-else active>{{ item.name }}</CBreadcrumbItem>
|
||||
</template>
|
||||
</CBreadcrumb>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CBreadcrumbItem, CBreadcrumb } from '@coreui/vue'
|
||||
import api from "@/api";
|
||||
export default {
|
||||
name: "TheBreadcrumbs",
|
||||
components: {
|
||||
CBreadcrumb,
|
||||
CBreadcrumbItem,
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
crumbs: []
|
||||
}},
|
||||
props: {
|
||||
selector: String,
|
||||
manual_crumbs: Object
|
||||
},
|
||||
methods: {
|
||||
updateBreadcrumbs () {
|
||||
if (this.selector) {
|
||||
let breadcrumb_url = '/api/browse/' + this.selector + '/breadcrumbs/'
|
||||
api.get(breadcrumb_url)
|
||||
.then(response => {
|
||||
this.crumbs = response.data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error)
|
||||
})
|
||||
}else if (this.manual_crumbs){
|
||||
this.crumbs = this.manual_crumbs
|
||||
} else {
|
||||
this.crumbs = [{id: 0, selector: '', name: 'Home'}]
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selector() {
|
||||
this.updateBreadcrumbs()
|
||||
},
|
||||
manual_crumbs () {
|
||||
this.updateBreadcrumbs()
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateBreadcrumbs()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
148
frontend/src/components/TheComicList.vue
Normal file
148
frontend/src/components/TheComicList.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<CContainer>
|
||||
<CRow>
|
||||
<CInputGroup>
|
||||
<CFormInput placeholder="Search" aria-label="Filter comics by name" v-model="this.filters.search_string"/>
|
||||
<CButton type="button" :color="(!filters.filter_read && !filters.filter_unread? 'primary' : 'secondary')" variant="outline" @click="filters.filter_read=false; filters.filter_unread=false">All</CButton>
|
||||
<CButton type="button" :color="(filters.filter_read && !filters.filter_unread? 'primary' : 'secondary')" variant="outline" @click="filters.filter_read=true; filters.filter_unread=false">Read</CButton>
|
||||
<CButton type="button" :color="(!filters.filter_read && filters.filter_unread? 'primary' : 'secondary')" variant="outline" @click="filters.filter_read=false; filters.filter_unread=true">Un-read</CButton>
|
||||
<CDropdown variant="input-group">
|
||||
<CDropdownToggle color="secondary" variant="outline">Action</CDropdownToggle>
|
||||
<CDropdownMenu>
|
||||
<CDropdownItem @click="markAll('mark_unread')"><font-awesome-icon icon='book' />Mark Un-read</CDropdownItem>
|
||||
<CDropdownItem @click="markAll('mark_read')"><font-awesome-icon icon='book-open' />Mark read</CDropdownItem>
|
||||
</CDropdownMenu>
|
||||
</CDropdown>
|
||||
</CInputGroup>
|
||||
</CRow>
|
||||
<CRow>
|
||||
<template v-if="loading">
|
||||
<CCol>
|
||||
<CProgress class="mt-3" >
|
||||
<CProgressBar color="success" variant="striped" animated :value="100"/>
|
||||
</CProgress>
|
||||
</CCol>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="comic in filteredComics" :key="comic.selector" >
|
||||
<comic-card :data="comic" @updateComicList="updateComicList" @markPreviousRead="markPreviousRead" @updateThumbnail="updateThumbnail" />
|
||||
</template>
|
||||
</template>
|
||||
</CRow>
|
||||
</CContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ComicCard from "@/components/ComicCard";
|
||||
import api from '@/api'
|
||||
import store from "@/store";
|
||||
|
||||
export default {
|
||||
name: "TheComicList",
|
||||
components: {ComicCard},
|
||||
data () {
|
||||
return {
|
||||
comics: [],
|
||||
breadcrumbs: [
|
||||
{id: 0, selector: '', name: 'Home'}
|
||||
],
|
||||
filters: {
|
||||
search_string: '',
|
||||
filter_read: false,
|
||||
filter_unread: false
|
||||
},
|
||||
loading: true
|
||||
}},
|
||||
props: {
|
||||
selector: String
|
||||
},
|
||||
methods: {
|
||||
updateComicList () {
|
||||
this.loading = true
|
||||
let comic_list_url = '/api/browse/'
|
||||
if (this.selector) {
|
||||
comic_list_url += this.selector + '/'
|
||||
}
|
||||
api.get(comic_list_url)
|
||||
.then(response => {
|
||||
this.comics = response.data
|
||||
this.loading = false
|
||||
})
|
||||
.catch((error) => {console.log(error)})
|
||||
},
|
||||
markPreviousRead (selector) {
|
||||
let selectors = []
|
||||
this.comics.every((item) => {
|
||||
if (item.selector === selector) {
|
||||
selectors.push(item.selector)
|
||||
return false
|
||||
} else {
|
||||
if (item.type === 'ComicBook') {
|
||||
selectors.push(item.selector)
|
||||
}
|
||||
return true
|
||||
}
|
||||
})
|
||||
let payload = { selectors: selectors }
|
||||
api.put('/api/action/mark_read/', payload).then(() => {
|
||||
this.updateComicList()
|
||||
})
|
||||
},
|
||||
markAll (action) {
|
||||
let selectors = []
|
||||
this.comics.filter(item => item.type === 'ComicBook').forEach((item) => {selectors.push(item.selector)})
|
||||
let payload = { selectors: selectors }
|
||||
api.put('/api/action/' + action + '/', payload).then(() => {
|
||||
this.updateComicList()
|
||||
})
|
||||
},
|
||||
updateThumbnail(resp){
|
||||
this.comics.find(i => i.selector === resp.selector).thumbnail = resp.thumbnail
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredComics() {
|
||||
let filtered_comics = [...this.comics]
|
||||
if (this.filters.search_string) {
|
||||
filtered_comics = filtered_comics.filter(comic => {
|
||||
return comic.title.toLowerCase().includes(this.filters.search_string.toLowerCase()) })
|
||||
}
|
||||
if (this.filters.filter_read) {
|
||||
filtered_comics = filtered_comics.filter(comic => comic.finished )
|
||||
}
|
||||
if (this.filters.filter_unread) {
|
||||
filtered_comics = filtered_comics.filter(comic => comic.unread )
|
||||
}
|
||||
return filtered_comics
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateComicList()
|
||||
},
|
||||
beforeUpdate() {
|
||||
let filter_id = ( this.selector ? this.selector : 'home')
|
||||
if (filter_id in store.state.filters) {
|
||||
this.filters = store.state.filters[filter_id]
|
||||
} else {
|
||||
this.filters = {
|
||||
search_string: '',
|
||||
filter_read: false,
|
||||
filter_unread: false
|
||||
}
|
||||
store.state.filters[filter_id] = this.filters
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
filters() {
|
||||
let filter_id = ( this.selector ? this.selector : 'home')
|
||||
store.state.filters[filter_id] = this.filters
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
191
frontend/src/components/TheComicReader.vue
Normal file
191
frontend/src/components/TheComicReader.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="reveal" id="comic_box" ref="comic_box" >
|
||||
<div id="slides_div" class="slides" ref="slides">
|
||||
<section class="" v-for="page in pages" :key="page.index" :data-menu-title="page.page_file_name" hidden>
|
||||
<img :data-src="'/api/read/' + selector + '/image/' + page.index + '/'" class="w-100" :alt="page.page_file_name">
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<CRow class="navButtons pb-2">
|
||||
<CListGroup :layout="'horizontal'">
|
||||
<CListGroupItem class="p-1 pt-2 page-link pl-2 pr-2" @click="prevComic">Prev Comic</CListGroupItem>
|
||||
<paginate
|
||||
v-model="paginate_page"
|
||||
:page-count="pages.length"
|
||||
:click-handler="this.setPage"
|
||||
:prev-text="'Prev'"
|
||||
:next-text="'Next'"
|
||||
:container-class="'pagination'"
|
||||
>
|
||||
</paginate>
|
||||
<CListGroupItem class="p-1 pt-2 page-link pl-2 pr-2" @click="nextComic">Next Comic</CListGroupItem>
|
||||
</CListGroup>
|
||||
</CRow>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Reveal from "reveal.js";
|
||||
import api from "@/api";
|
||||
import 'reveal.js-menu/menu.css'
|
||||
import Paginate from "vuejs-paginate-next";
|
||||
import * as Hammer from 'hammerjs'
|
||||
|
||||
export default {
|
||||
name: "TheComicReader",
|
||||
components: {Paginate},
|
||||
data () {
|
||||
return {
|
||||
current_page: 0,
|
||||
paginate_page: 1,
|
||||
deck: null,
|
||||
title: '',
|
||||
prev_comic: {},
|
||||
next_comic: {},
|
||||
pages: [],
|
||||
}
|
||||
},
|
||||
props: {
|
||||
selector: String
|
||||
},
|
||||
methods: {
|
||||
prevPage(){
|
||||
if (this.deck.isFirstSlide()){
|
||||
this.prevComic()
|
||||
} else {
|
||||
this.current_page -= 1
|
||||
this.deck.slide(this.current_page)
|
||||
}
|
||||
},
|
||||
prevComic(){
|
||||
this.$router.push({
|
||||
name: this.prev_comic.route,
|
||||
params: {selector: this.prev_comic.selector}
|
||||
})
|
||||
},
|
||||
nextComic(){
|
||||
this.$router.push({
|
||||
name: this.next_comic.route,
|
||||
params: {selector: this.next_comic.selector}
|
||||
})
|
||||
},
|
||||
nextPage(){
|
||||
if (this.deck.isLastSlide()){
|
||||
this.nextComic()
|
||||
} else {
|
||||
this.current_page += 1
|
||||
this.deck.slide(this.current_page)
|
||||
}
|
||||
},
|
||||
setPage(pageNum){
|
||||
this.current_page = pageNum-1
|
||||
this.deck.slide(this.current_page)
|
||||
},
|
||||
keyPressDebounce(e){
|
||||
clearTimeout(this.key_timeout)
|
||||
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
|
||||
},
|
||||
keyPress(e) {
|
||||
if (e.key === 'ArrowRight') {
|
||||
this.nextPage()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
this.prevPage()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
window.scrollTo({
|
||||
top: window.scrollY-window.innerHeight*.7,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
window.scrollTo({
|
||||
top: window.scrollY+window.innerHeight*.7,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'current_page' (new_page) {
|
||||
this.paginate_page = new_page + 1
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
const set_read_url = '/api/read/' + this.selector + '/set_page/'
|
||||
let comic_data_url = '/api/read/' + this.selector + '/'
|
||||
window.addEventListener('keyup', this.keyPressDebounce)
|
||||
api.get(comic_data_url)
|
||||
.then(response => {
|
||||
this.title = response.data.title
|
||||
this.current_page = response.data.last_read_page
|
||||
this.prev_comic = response.data.prev_comic
|
||||
this.next_comic = response.data.next_comic
|
||||
this.pages = response.data.pages
|
||||
|
||||
this.deck = Reveal(this.$refs.comic_box)
|
||||
this.deck.initialize({
|
||||
controls: false,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
margin: 0,
|
||||
minScale: 1,
|
||||
maxScale: 1,
|
||||
keyboard: null,
|
||||
touch: false,
|
||||
transition: 'slide',
|
||||
embedded: true,
|
||||
plugins: [ ]
|
||||
}).then(() => {
|
||||
this.deck.slide(this.current_page)
|
||||
this.deck.on( 'slidechanged', () => {
|
||||
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'})
|
||||
api.put(set_read_url, {page: event.indexh})
|
||||
});
|
||||
})
|
||||
|
||||
this.hammertime = new Hammer(this.$refs.comic_box, {})
|
||||
this.hammertime.on('swipeleft', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
this.hammertime.on('swiperight', (_e, self=this) => {
|
||||
self.prevPage()
|
||||
})
|
||||
this.hammertime.on('tap', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
|
||||
})
|
||||
.catch((error) => {console.log(error)})
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('keyup', this.keyPressDebounce)
|
||||
try {
|
||||
this.hammertime.off('swipeleft')
|
||||
this.hammertime.off('swiperight')
|
||||
this.hammertime.off('tap')
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.navButtons {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 0;
|
||||
z-index: 1030;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
section {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
.list-group-item {
|
||||
/*padding: 0;*/
|
||||
}
|
||||
</style>
|
||||
62
frontend/src/components/TheNavbar.vue
Normal file
62
frontend/src/components/TheNavbar.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<CNavbar expand="lg" color-scheme="light" class="bg-light">
|
||||
<CContainer fluid>
|
||||
<CNavbarBrand href="#"><img src="/static/img/logo.svg" width="35" class="d-inline-block align-top" alt="CB"> Web Reader</CNavbarBrand>
|
||||
<CNavbarToggler @click="visible = !visible"/>
|
||||
<CCollapse class="navbar-collapse" :visible="visible">
|
||||
<CNavbarNav>
|
||||
<CNavItem>
|
||||
<router-link :to="{name: 'browse'}" class="nav-link" >Browse</router-link>
|
||||
</CNavItem>
|
||||
<CNavItem>
|
||||
<router-link :to="{name: 'recent'}" class="nav-link" >Recent</router-link>
|
||||
</CNavItem>
|
||||
<CNavItem>
|
||||
<router-link :to="{name: 'account'}" class="nav-link" >Account</router-link>
|
||||
</CNavItem>
|
||||
<CNavItem>
|
||||
<router-link :to="{name: 'user'}" class="nav-link" v-if="this.$store.getters.is_superuser">Users</router-link>
|
||||
</CNavItem>
|
||||
<CNavItem>
|
||||
<CNavLink @click="logout">Log Out</CNavLink>
|
||||
</CNavItem>
|
||||
</CNavbarNav>
|
||||
</CCollapse>
|
||||
</CContainer>
|
||||
</CNavbar>
|
||||
</template>
|
||||
<script>
|
||||
import { CNavbar, CNavbarNav, CContainer, CNavbarBrand, CNavbarToggler, CCollapse, CNavItem, CNavLink } from '@coreui/vue'
|
||||
import store from "@/store";
|
||||
import router from "@/router";
|
||||
export default {
|
||||
name: "TheNavbar",
|
||||
components: {
|
||||
CNavbar,
|
||||
CNavbarNav,
|
||||
CContainer,
|
||||
CNavbarBrand,
|
||||
CNavbarToggler,
|
||||
CCollapse,
|
||||
CNavItem,
|
||||
CNavLink
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logout () {
|
||||
store.commit('logOut')
|
||||
router.push({name: 'login'})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
176
frontend/src/components/ThePdfReader.vue
Normal file
176
frontend/src/components/ThePdfReader.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<CContainer ref="pdfContainer">
|
||||
<CRow class="w-100 pb-5 mb-5" v-if="loaded" >
|
||||
<pdf :src="pdfdata" :page="page" ref="pdfWindow" :resize="true">
|
||||
<template v-slot:loading>
|
||||
loading content here...
|
||||
</template>
|
||||
</pdf>
|
||||
</CRow>
|
||||
</CContainer>
|
||||
<CRow class="navButtons pb-2">
|
||||
<CListGroup :layout="'horizontal'">
|
||||
<CListGroupItem class="p-1 pt-2 page-link pl-2 pr-2" @click="prevComic">Prev Comic</CListGroupItem>
|
||||
<paginate
|
||||
v-model="page"
|
||||
:page-count="numPages"
|
||||
:click-handler="this.setPage"
|
||||
:prev-text="'Prev'"
|
||||
:next-text="'Next'"
|
||||
:container-class="'pagination'"
|
||||
>
|
||||
</paginate>
|
||||
<CListGroupItem class="p-1 pt-2 page-link pl-2 pr-2" @click="nextComic">Next Comic</CListGroupItem>
|
||||
</CListGroup>
|
||||
</CRow>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pdfvuer from 'pdfvuer'
|
||||
import api from "@/api";
|
||||
import Paginate from "vuejs-paginate-next";
|
||||
import * as Hammer from 'hammerjs'
|
||||
|
||||
|
||||
export default {
|
||||
name: "ThePdfReader",
|
||||
components: {
|
||||
pdf: pdfvuer, Paginate
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
page: 1,
|
||||
numPages: 0,
|
||||
pdfdata: undefined,
|
||||
errors: [],
|
||||
scale: 'page-width',
|
||||
loaded: false,
|
||||
key_timeout: null,
|
||||
hammertime: null,
|
||||
next_comic: {},
|
||||
prev_comic: {}
|
||||
}
|
||||
},
|
||||
props: {
|
||||
selector: String
|
||||
},
|
||||
computed: {
|
||||
},
|
||||
mounted () {
|
||||
this.getPdf()
|
||||
window.addEventListener('keyup', this.keyPressDebounce)
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('keyup', this.keyPressDebounce)
|
||||
},
|
||||
watch: {
|
||||
},
|
||||
methods: {
|
||||
getPdf () {
|
||||
let comic_data_url = '/api/read/' + this.selector + '/'
|
||||
api.get(comic_data_url)
|
||||
.then(response => {
|
||||
let parameter = {
|
||||
url: '/api/read/' + this.selector + '/pdf/',
|
||||
httpHeaders: { Authorization: 'Bearer ' + this.$store.state.jwt.access },
|
||||
withCredentials: true,
|
||||
}
|
||||
this.pdfdata = pdfvuer.createLoadingTask(parameter);
|
||||
this.pdfdata.then(pdf => {
|
||||
this.numPages = pdf.numPages;
|
||||
this.loaded = true
|
||||
this.page = response.data.last_read_page+1
|
||||
this.setReadPage(this.page)
|
||||
this.next_comic = response.data.next_comic
|
||||
this.prev_comic = response.data.prev_comic
|
||||
this.hammertime = new Hammer(this.$refs.pdfContainer.$el, {})
|
||||
this.hammertime.on('swipeleft', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
this.hammertime.on('swiperight', (_e, self=this) => {
|
||||
self.prevPage()
|
||||
})
|
||||
this.hammertime.on('tap', (_e, self=this) => {
|
||||
self.nextPage()
|
||||
})
|
||||
}).catch(e => {console.log(e)});
|
||||
})
|
||||
|
||||
},
|
||||
prevComic(){
|
||||
this.$router.push({
|
||||
name: this.prev_comic.route,
|
||||
params: {selector: this.prev_comic.selector}
|
||||
})
|
||||
},
|
||||
nextComic(){
|
||||
this.$router.push({
|
||||
name: this.next_comic.route,
|
||||
params: {selector: this.next_comic.selector}
|
||||
})
|
||||
},
|
||||
nextPage () {
|
||||
if (this.page < this.numPages){
|
||||
this.page += 1
|
||||
this.setReadPage(this.page)
|
||||
} else {
|
||||
this.nextComic()
|
||||
}
|
||||
},
|
||||
prevPage() {
|
||||
if (this.page > 1){
|
||||
this.page -= 1
|
||||
this.setReadPage(this.page)
|
||||
} else {
|
||||
this.prevComic()
|
||||
}
|
||||
},
|
||||
setPage(num) {
|
||||
this.page = num
|
||||
this.setReadPage(this.page)
|
||||
},
|
||||
setReadPage(num){
|
||||
this.$refs.pdfContainer.$el.scrollIntoView()
|
||||
let payload = {
|
||||
page: num-1
|
||||
}
|
||||
api.put('/api/read/'+ this.selector +'/set_page/', payload)
|
||||
},
|
||||
keyPressDebounce(e){
|
||||
clearTimeout(this.key_timeout)
|
||||
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
|
||||
},
|
||||
keyPress(e) {
|
||||
if (e.key === 'ArrowRight') {
|
||||
this.nextPage()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
this.prevPage()
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
window.scrollTo({
|
||||
top: window.scrollY-window.innerHeight*.7,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
window.scrollTo({
|
||||
top: window.scrollY+window.innerHeight*.7,
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navButtons {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 0;
|
||||
z-index: 1030;
|
||||
width: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
184
frontend/src/components/TheRecentTable.vue
Normal file
184
frontend/src/components/TheRecentTable.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<CContainer>
|
||||
<CRow>
|
||||
<CCol>
|
||||
<form class="form-inline ">
|
||||
<label class="my-1 mr-2" for="selectChoices">Show</label>
|
||||
<select class="custom-select my-1 mr-sm-2 " id="selectChoices" v-model="this.page_size" @change="this.setPage(this.page)">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<label class="my-1 mr-2" for="selectChoices">entries</label>
|
||||
</form>
|
||||
</CCol>
|
||||
<CCol class="d-flex justify-content-end">
|
||||
<form class="form-inline">
|
||||
<label for="searchText" class="my-1 mr-2">Search</label>
|
||||
<input type="text" id="searchText" class="form-control my-1 mr-sm-2" v-model="search_text" @keyup="this.debounceInput()">
|
||||
</form>
|
||||
</CCol>
|
||||
</CRow>
|
||||
<CRow>
|
||||
<caption>
|
||||
<h2>Recent Comics - <a :href="'/feed/' + this.feed_id + '/'">Feed</a></h2>
|
||||
Mark selected issues as:
|
||||
<select name="func" id="func_selector" @change="this.performFunction()" v-model="func_selected">
|
||||
<option value="choose">Choose...</option>
|
||||
<option value="mark_read">Read</option>
|
||||
<option value="mark_unread">Un-Read</option>
|
||||
</select>
|
||||
</caption>
|
||||
</CRow>
|
||||
<CRow>
|
||||
<CTable striped bordered>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col"><input class="form-check-input m-0 position-relative mt-1" type="checkbox" value="" ref="select-all"></CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col"></CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Comic</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Date Added</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">status</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<template v-for="item in comics" :key="item.id">
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="row"><input ref="comic_selector" class="form-check-input m-0 position-relative mt-1" type="checkbox" :value="item.selector"></CTableHeaderCell>
|
||||
<CTableDataCell class=""><font-awesome-icon icon='book' class="" /></CTableDataCell>
|
||||
<CTableDataCell><router-link :to="{name: 'read', params: { selector: item.selector }}" class="" >{{ item.file_name }}</router-link></CTableDataCell>
|
||||
<CTableDataCell>{{ timeago(item.date_added) }}</CTableDataCell>
|
||||
<CTableDataCell>{{ get_status(item) }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
</template>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</CRow>
|
||||
<CRow>
|
||||
<CCol>
|
||||
Showing page {{ this.page }} of {{ this.page_count }} pages.
|
||||
</CCol>
|
||||
<CCol class="d-flex justify-content-end">
|
||||
<paginate
|
||||
v-model="this.page"
|
||||
:page-count="this.page_count"
|
||||
:click-handler="this.setPage"
|
||||
:prev-text="'Prev'"
|
||||
:next-text="'Next'"
|
||||
:container-class="'pagination '"
|
||||
>
|
||||
</paginate>
|
||||
</CCol>
|
||||
</CRow>
|
||||
</CContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from "@/api";
|
||||
import * as timeago from 'timeago.js';
|
||||
import Paginate from "vuejs-paginate-next";
|
||||
|
||||
export default {
|
||||
name: "TheRecentTable",
|
||||
components: {
|
||||
Paginate
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
page_count: 1,
|
||||
search_text: '',
|
||||
comics: [],
|
||||
timeout: null,
|
||||
func_selected: 'choose',
|
||||
feed_id: ''
|
||||
}},
|
||||
computed: {
|
||||
},
|
||||
methods: {
|
||||
updateComicList () {
|
||||
let comic_list_url = '/api/recent/'
|
||||
let params = { params: { page: this.page, page_size: this.page_size } }
|
||||
|
||||
if (this.search_text) {
|
||||
params.params.search_text = this.search_text
|
||||
}
|
||||
|
||||
api.get(comic_list_url, params)
|
||||
.then(response => {
|
||||
this.comics = response.data.results
|
||||
this.page_count = Math.ceil(response.data.count / this.page_size)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response.data.detail === 'Invalid page.') {
|
||||
this.setPage(1)
|
||||
} else {
|
||||
console.log(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
timeago(input) {
|
||||
return timeago.format(input)
|
||||
},
|
||||
get_status(item) {
|
||||
if (item.unread || item.unread === null) {
|
||||
return "Unread"
|
||||
} else if (item.finished) {
|
||||
return "Finished"
|
||||
} else {
|
||||
return item.last_read_page + 1 + ' / ' + item.total_pages
|
||||
}
|
||||
},
|
||||
setPage(page) {
|
||||
this.page = page
|
||||
this.updateComicList()
|
||||
},
|
||||
debounceInput() {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = setTimeout(() => {
|
||||
this.setPage(this.page)
|
||||
}, 500)
|
||||
},
|
||||
performFunction() {
|
||||
let selected_ids = []
|
||||
this.$refs.comic_selector.forEach((selector) => {
|
||||
if (selector.checked){
|
||||
selected_ids.push(selector.value)
|
||||
}
|
||||
})
|
||||
if (this.func_selected === 'mark_read') {
|
||||
let comic_mark_read = '/api/action/mark_read/'
|
||||
const payload = { selectors: selected_ids }
|
||||
api.put(comic_mark_read, payload).then(() => {
|
||||
this.updateComicList()
|
||||
this.func_selected = "choose"
|
||||
})
|
||||
} else if (this.func_selected === 'mark_unread') {
|
||||
let comic_mark_unread = '/api/action/mark_unread/'
|
||||
const payload = { selectors: selected_ids }
|
||||
api.put(comic_mark_unread, payload).then(() => {
|
||||
this.updateComicList()
|
||||
this.func_selected = "choose"
|
||||
})
|
||||
} else {
|
||||
this.func_selected = 'choose'
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateComicList()
|
||||
let comic_mark_unread = '/api/account/feed_id/'
|
||||
api.get(comic_mark_unread).then((response) => {
|
||||
this.feed_id = response.data.feed_id
|
||||
})
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
114
frontend/src/components/UserEdit.vue
Normal file
114
frontend/src/components/UserEdit.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<CContainer>
|
||||
<CForm @submit="saveForm">
|
||||
<CFormInput
|
||||
type="text"
|
||||
label="Username"
|
||||
readonly
|
||||
v-model="username"
|
||||
/>
|
||||
<CFormInput
|
||||
type="email"
|
||||
label="Email address"
|
||||
:placeholder="user.email"
|
||||
text="Must be 8-20 characters long."
|
||||
v-model="email"
|
||||
feedback-invalid="Email address invalid."
|
||||
/>
|
||||
<CFormSelect
|
||||
aria-label="Default select example"
|
||||
v-model="classification"
|
||||
:options="[...this.$store.state.classifications]">
|
||||
</CFormSelect>
|
||||
<CRow class="mt-2">
|
||||
<CCol>
|
||||
<CButton color="primary" type="submit" class="mr-5">Save</CButton>
|
||||
<confirm-button class="mr-5" label="Reset Password" :action="resetPassword" />
|
||||
<confirm-button label="Delete User" :action="deleteUser" />
|
||||
</CCol>
|
||||
</CRow>
|
||||
|
||||
</CForm>
|
||||
</CContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import api from "@/api";
|
||||
import ConfirmButton from "@/components/ConfirmButton";
|
||||
import router from "@/router";
|
||||
|
||||
export default {
|
||||
name: "UserEdit",
|
||||
components: {ConfirmButton},
|
||||
data () {
|
||||
return {
|
||||
username: '',
|
||||
email: '',
|
||||
classification: '0',
|
||||
new_password: null,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
user: Object,
|
||||
},
|
||||
methods: {
|
||||
saveForm () {
|
||||
if (this.email !== this.user.email){
|
||||
let payload = {
|
||||
username: this.username,
|
||||
email: this.email
|
||||
}
|
||||
api.patch('/api/users/'+ this.user.id + '/', payload).then(response => {
|
||||
this.$emit('add-message',{
|
||||
color: 'success',
|
||||
text: 'Email address now set to "' + response.data.email + '"'
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.classification !== this.user.classification.toString()){
|
||||
let payload = {
|
||||
username: this.username,
|
||||
classification: this.classification
|
||||
}
|
||||
api.patch('/api/users/' + this.user.id + '/set_classification/', payload).then(response => {
|
||||
this.$emit('add-message', {
|
||||
color: 'success',
|
||||
text: 'Classification Limit now set to "' + this.$store.state.classifications.find(i => i.value === response.data.classification.toString()).label + '"'
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
resetPassword() {
|
||||
let payload = {
|
||||
username: this.username
|
||||
}
|
||||
api.patch('/api/users/' + this.user.id + '/reset_password/', payload).then(response => {
|
||||
this.$emit('add-message', {
|
||||
color: 'success',
|
||||
text: 'Password reset with new password "' + response.data.password + '"'
|
||||
})
|
||||
this.new_password = response.data.password
|
||||
})
|
||||
},
|
||||
deleteUser() {
|
||||
api.delete('/api/users/' + this.user.id + '/').then(() => {
|
||||
this.$emit('add-message', {
|
||||
color: 'danger',
|
||||
text: 'User "' + this.username + '" has been deleted.'
|
||||
})
|
||||
router.push({name: 'user'})
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.new_password = null
|
||||
},
|
||||
mounted() {
|
||||
this.username = this.user.username
|
||||
this.email = this.user.email
|
||||
this.classification = this.user.classification.toString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
39
frontend/src/components/UserList.vue
Normal file
39
frontend/src/components/UserList.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<CTable striped bordered>
|
||||
<CTableHead>
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="col">#</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Username</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Email</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Superuser</CTableHeaderCell>
|
||||
<CTableHeaderCell scope="col">Classification</CTableHeaderCell>
|
||||
</CTableRow>
|
||||
</CTableHead>
|
||||
<CTableBody>
|
||||
<template v-for="item in users" :key="item.id">
|
||||
<CTableRow>
|
||||
<CTableHeaderCell scope="row">{{ item.id }}</CTableHeaderCell>
|
||||
<CTableDataCell class="">
|
||||
<router-link :to="{'name': 'user', params: { userid: item.id }}">{{ item.username }}</router-link>
|
||||
</CTableDataCell>
|
||||
<CTableDataCell>{{ item.email }}</CTableDataCell>
|
||||
<CTableDataCell>{{ item.is_superuser }}</CTableDataCell>
|
||||
<CTableDataCell>{{ this.$store.state.classifications.find(i => i.value === item.classification.toString()).label }}</CTableDataCell>
|
||||
</CTableRow>
|
||||
</template>
|
||||
</CTableBody>
|
||||
</CTable>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "UserList",
|
||||
props: {
|
||||
users: Object
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
29
frontend/src/main.js
Normal file
29
frontend/src/main.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import ToastPlugin from 'vue-toast-notification';
|
||||
|
||||
import CoreuiVue from '@coreui/vue';
|
||||
import '@coreui/coreui/dist/css/coreui.min.css'
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import 'vue-toast-notification/dist/theme-default.css';
|
||||
|
||||
/* import the fontawesome core */
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
/* import specific icons */
|
||||
import {faBook, faBookOpen, faEdit, faTurnUp} from '@fortawesome/free-solid-svg-icons'
|
||||
library.add(faBook, faBookOpen, faEdit, faTurnUp)
|
||||
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
|
||||
Vue.createApp(App)
|
||||
.use(CoreuiVue)
|
||||
.use(ToastPlugin)
|
||||
.use(store)
|
||||
.use(router)
|
||||
.component('font-awesome-icon', FontAwesomeIcon)
|
||||
.mount('#app')
|
||||
66
frontend/src/router/index.js
Normal file
66
frontend/src/router/index.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const ReadView = () => import('@/views/ReadView')
|
||||
const RecentView = () => import('@/views/RecentView')
|
||||
const AccountView = () => import('@/views/AccountView')
|
||||
const BrowseView = () => import('@/views/BrowseView')
|
||||
const UserView = () => import('@/views/UserView')
|
||||
const LoginView = () => import('@/views/LoginView')
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
redirect: () => {
|
||||
return { name: 'browse' }
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/browse/:selector?',
|
||||
name: 'browse',
|
||||
component: BrowseView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/read/:selector',
|
||||
name: 'read',
|
||||
component: ReadView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView
|
||||
},
|
||||
{
|
||||
path: '/recent',
|
||||
name: 'recent',
|
||||
component: RecentView
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
component: AccountView
|
||||
},
|
||||
{
|
||||
path: '/user/:userid?',
|
||||
name: 'user',
|
||||
component: UserView,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (about.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import(/* webpackChunkName: "about" */ '../views/AboutView.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(process.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
117
frontend/src/store/index.js
Normal file
117
frontend/src/store/index.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createStore } from 'vuex'
|
||||
import axios from 'axios'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import {useToast} from "vue-toast-notification";
|
||||
import router from "@/router";
|
||||
import api from "@/api";
|
||||
|
||||
function get_jwt_from_storage(){
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('t'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
function get_user_from_storage(){
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('u'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default createStore({
|
||||
state: {
|
||||
jwt: get_jwt_from_storage(),
|
||||
filters: {},
|
||||
user: get_user_from_storage(),
|
||||
classifications: [
|
||||
{label: 'G', value: '0'},
|
||||
{label: 'PG', value: '1'},
|
||||
{label: '12', value: '2'},
|
||||
{label: '15', value: '3'},
|
||||
{label: '18', value: '4'},
|
||||
],
|
||||
},
|
||||
getters: {
|
||||
is_superuser (state) {
|
||||
if (state.user === null){
|
||||
return false
|
||||
} else {
|
||||
return state.user.is_superuser
|
||||
}
|
||||
}
|
||||
},
|
||||
mutations: {
|
||||
updateToken(state, newToken){
|
||||
localStorage.setItem('t', JSON.stringify(newToken));
|
||||
state.jwt = newToken;
|
||||
},
|
||||
logOut(state){
|
||||
localStorage.removeItem('t');
|
||||
localStorage.removeItem('u')
|
||||
state.jwt = null;
|
||||
state.user = null
|
||||
},
|
||||
updateUser(state, userData){
|
||||
localStorage.setItem('u', JSON.stringify(userData));
|
||||
state.user = userData
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
obtainToken(context, {username, password}){
|
||||
const payload = {
|
||||
username: username,
|
||||
password: password
|
||||
}
|
||||
axios.post('/api/token/', payload)
|
||||
.then((response)=>{
|
||||
context.commit('updateToken', response.data);
|
||||
api.get('/api/account').then(response => {
|
||||
context.commit('updateUser', response.data)
|
||||
})
|
||||
if ('next' in router.currentRoute.value.query) {
|
||||
router.push(router.currentRoute.value.query.next)
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
})
|
||||
.catch((error)=>{
|
||||
// console.log(error);
|
||||
const $toast = useToast();
|
||||
$toast.error(error.response.data.detail, {position:'top'});
|
||||
})
|
||||
},
|
||||
refreshToken(){
|
||||
const payload = {
|
||||
refresh: this.state.jwt.refresh
|
||||
}
|
||||
return axios.post('/api/token/refresh/', payload)
|
||||
.then((response)=>{
|
||||
this.commit('updateToken', response.data)
|
||||
})
|
||||
.catch((error)=>{
|
||||
console.log(error)
|
||||
// router.push({name: 'login', query: {area: 'store'}})
|
||||
})
|
||||
},
|
||||
inspectToken(){
|
||||
const token = this.state.jwt;
|
||||
if(token){
|
||||
const decoded = jwtDecode(token);
|
||||
const exp = decoded.exp
|
||||
const orig_iat = decoded.iat
|
||||
if(exp - (Date.now()/1000) < 1800 && (Date.now()/1000) - orig_iat < 628200){
|
||||
this.dispatch('refreshToken')
|
||||
} else if (exp -(Date.now()/1000) < 1800){
|
||||
// DO NOTHING, DO NOT REFRESH
|
||||
} else {
|
||||
// PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modules: {
|
||||
}
|
||||
})
|
||||
5
frontend/src/views/AboutView.vue
Normal file
5
frontend/src/views/AboutView.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
24
frontend/src/views/AccountView.vue
Normal file
24
frontend/src/views/AccountView.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<the-breadcrumbs :manual_crumbs="this.crumbs" />
|
||||
<the-account-form />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
|
||||
import TheAccountForm from "@/components/TheAccountForm";
|
||||
export default {
|
||||
name: "AccountView",
|
||||
components: {TheAccountForm, TheBreadcrumbs},
|
||||
data () {
|
||||
return {
|
||||
crumbs: [
|
||||
{id: 0, selector: '', name: 'Home'},
|
||||
{id: 1, selector: '', name: 'Account'}
|
||||
]
|
||||
}},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
20
frontend/src/views/BrowseView.vue
Normal file
20
frontend/src/views/BrowseView.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<the-breadcrumbs :selector="selector"/>
|
||||
<the-comic-list :selector="selector" :key="selector" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TheComicList from "@/components/TheComicList";
|
||||
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
|
||||
|
||||
export default {
|
||||
name: 'BrowseView',
|
||||
components: {
|
||||
TheBreadcrumbs,
|
||||
TheComicList,
|
||||
},
|
||||
props: {
|
||||
selector: String
|
||||
}
|
||||
}
|
||||
</script>
|
||||
68
frontend/src/views/LoginView.vue
Normal file
68
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<CContainer>
|
||||
<CRow v-if="!initialSetupRequired">
|
||||
<CCol lg="4"/>
|
||||
<CCol lg="4" id="login-col">
|
||||
<CForm @submit="login">
|
||||
<CFormInput
|
||||
type="username"
|
||||
id="username"
|
||||
label="Username"
|
||||
placeholder="username"
|
||||
text="Please enter your username"
|
||||
aria-describedby="loginFormControlInputHelpInline"
|
||||
v-model="username"
|
||||
/>
|
||||
<CFormInput
|
||||
type="password"
|
||||
id="password"
|
||||
label="password"
|
||||
placeholder="password"
|
||||
text="Please enter your password"
|
||||
aria-describedby="loginFormControlInputHelpInline"
|
||||
v-model="password"
|
||||
@keyup.enter="login"
|
||||
/>
|
||||
<CButton color="primary" class="mb-3">Login</CButton>
|
||||
</CForm>
|
||||
</CCol>
|
||||
</CRow>
|
||||
<CRow>
|
||||
<initial-setup v-if="initialSetupRequired" />
|
||||
</CRow>
|
||||
</CContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InitialSetup from "@/components/InitialSetup";
|
||||
import axios from "axios";
|
||||
|
||||
export default {
|
||||
name: "LoginView",
|
||||
components: {InitialSetup},
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
password_alert: false,
|
||||
initialSetupRequired: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
login () {
|
||||
this.$store.dispatch("obtainToken", {username: this.username, password: this.password})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
axios.get('/api/initial_setup/required/').then(response => {
|
||||
if (response.data.required){
|
||||
this.initialSetupRequired = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
52
frontend/src/views/ReadView.vue
Normal file
52
frontend/src/views/ReadView.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<the-breadcrumbs :selector="selector" />
|
||||
<the-comic-reader :selector="selector" v-if="comic_loaded" :key="selector" />
|
||||
<the-pdf-reader :selector="selector" v-if="pdf_loaded" :key="selector" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
|
||||
import TheComicReader from "@/components/TheComicReader";
|
||||
import api from "@/api";
|
||||
import ThePdfReader from "@/components/ThePdfReader";
|
||||
export default {
|
||||
name: "ReadView",
|
||||
components: {ThePdfReader, TheComicReader, TheBreadcrumbs},
|
||||
props: {
|
||||
selector: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
comic_data: {},
|
||||
comic_loaded: false,
|
||||
pdf_loaded: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateType() {
|
||||
let comic_data_url = '/api/read/' + this.selector + '/type/'
|
||||
api.get(comic_data_url)
|
||||
.then(response => {
|
||||
if (response.data.type === 'pdf'){
|
||||
this.pdf_loaded = true
|
||||
this.comic_loaded = false
|
||||
} else {
|
||||
this.comic_loaded = true
|
||||
this.pdf_loaded = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {console.log(error)})
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.updateType()
|
||||
},
|
||||
beforeUpdate() {
|
||||
this.updateType()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
24
frontend/src/views/RecentView.vue
Normal file
24
frontend/src/views/RecentView.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<the-breadcrumbs :manual_crumbs="this.crumbs" />
|
||||
<the-recent-table />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
|
||||
import TheRecentTable from "@/components/TheRecentTable";
|
||||
export default {
|
||||
name: "RecentView",
|
||||
components: {TheRecentTable, TheBreadcrumbs},
|
||||
data () {
|
||||
return {
|
||||
crumbs: [
|
||||
{id: 0, selector: '', name: 'Home'},
|
||||
{id: 1, selector: '', name: 'Recent'}
|
||||
]
|
||||
}},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
81
frontend/src/views/UserView.vue
Normal file
81
frontend/src/views/UserView.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<the-breadcrumbs :manual_crumbs="this.crumbs" />
|
||||
<CContainer>
|
||||
<alert-messages :messages="messages" />
|
||||
<user-list :users="users" v-if="!userid"/>
|
||||
<user-edit v-if="user_data" :user="user_data" @add-message="addMessage"/>
|
||||
<add-user v-if="!userid" @user-added="updateUsers" @add-message="addMessage"/>
|
||||
</CContainer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TheBreadcrumbs from "@/components/TheBreadcrumbs";
|
||||
import UserList from "@/components/UserList";
|
||||
import api from "@/api";
|
||||
import UserEdit from "@/components/UserEdit";
|
||||
import alertMessages from "@/components/AlertMessages";
|
||||
import AddUser from "@/components/AddUser";
|
||||
import router from "@/router";
|
||||
|
||||
const default_crumbs = [
|
||||
{id: 0, selector: '', name: 'Home'},
|
||||
{id: 1, route: {'name': 'user'}, name: 'Users'}
|
||||
]
|
||||
export default {
|
||||
name: "UserView",
|
||||
components: {AddUser, alertMessages, UserEdit, UserList, TheBreadcrumbs},
|
||||
props: {
|
||||
userid: String
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
crumbs: [...default_crumbs],
|
||||
users: [],
|
||||
viewUserList: true,
|
||||
user_data: null,
|
||||
messages: []
|
||||
}},
|
||||
methods: {
|
||||
updateUsers() {
|
||||
api.get('/api/users/').then(response => {
|
||||
this.users = response.data
|
||||
})
|
||||
},
|
||||
getUser() {
|
||||
api.get('/api/users/' + this.userid + '/').then(response => {
|
||||
this.user_data = response.data
|
||||
this.crumbs.push({id: 1, selector: '', name: response.data.username})
|
||||
}).catch(() => {
|
||||
this.messages.push({
|
||||
color: 'danger',
|
||||
text: 'User with id "' + this.userid + '" does not exist.'
|
||||
})
|
||||
router.push({name: 'user'})
|
||||
})
|
||||
},
|
||||
addMessage(message){
|
||||
this.messages.push(message)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateUsers()
|
||||
if (this.userid){
|
||||
this.getUser()
|
||||
}
|
||||
},
|
||||
beforeUpdate() {
|
||||
this.updateUsers()
|
||||
this.crumbs = [...default_crumbs]
|
||||
if (this.userid){
|
||||
this.getUser()
|
||||
} else {
|
||||
this.user_data = null
|
||||
this.crumbs = default_crumbs
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"declaration": false,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"module": "es2015",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitAny": false,
|
||||
"noLib": false,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"target": "es2015",
|
||||
"baseUrl": "./src"
|
||||
},
|
||||
"exclude": [
|
||||
"./node_modules"
|
||||
],
|
||||
"include": [
|
||||
"./src/**/*.ts",
|
||||
"./src/**/*.vue"
|
||||
]
|
||||
}
|
||||
4
frontend/vue.config.js
Normal file
4
frontend/vue.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
const { defineConfig } = require('@vue/cli-service')
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true
|
||||
})
|
||||
63
frontend/webpack.dev.js
Normal file
63
frontend/webpack.dev.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const path = require('path')
|
||||
const { VueLoaderPlugin } = require('vue-loader')
|
||||
const BundleTracker = require('webpack-bundle-tracker');
|
||||
const webpack = require('webpack')
|
||||
|
||||
module.exports = () => {
|
||||
return {
|
||||
|
||||
mode: 'development',
|
||||
devtool: 'eval-cheap-source-map',
|
||||
entry: path.resolve(__dirname, './src/main.js'),
|
||||
output: {
|
||||
path: path.resolve(__dirname, './dist/bundles/'),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
use: 'vue-loader'
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
'style-loader',
|
||||
'css-loader'
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js', '.vue', '.json'],
|
||||
alias: {
|
||||
'vue': '@vue/runtime-dom',
|
||||
'@': path.resolve('src'),
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
new BundleTracker({
|
||||
filename: './webpack-stats.json',
|
||||
publicPath: 'http://localhost:8080/'
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
|
||||
}),
|
||||
new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: true }),
|
||||
],
|
||||
devServer: {
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin":"*"
|
||||
},
|
||||
hot: true,
|
||||
}
|
||||
};
|
||||
}
|
||||
66
frontend/webpack.prod.js
Normal file
66
frontend/webpack.prod.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const path = require('path')
|
||||
const { VueLoaderPlugin } = require('vue-loader')
|
||||
const BundleTracker = require('webpack-bundle-tracker');
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
|
||||
|
||||
const webpack = require('webpack')
|
||||
|
||||
|
||||
module.exports = (env = {}) => {
|
||||
env.prod = true
|
||||
return {
|
||||
|
||||
mode: 'production',
|
||||
devtool: false,
|
||||
entry: path.resolve(__dirname, './src/main.js'),
|
||||
output: {
|
||||
path: path.resolve(__dirname, './dist/bundles/'),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.vue$/,
|
||||
use: 'vue-loader'
|
||||
},
|
||||
{
|
||||
test: /\.ts$/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
appendTsSuffixTo: [/\.vue$/],
|
||||
transpileOnly: true
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader"],
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.ts', '.js', '.vue', '.json'],
|
||||
alias: {
|
||||
'vue': '@vue/runtime-dom',
|
||||
'@': path.resolve('src'),
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
new BundleTracker({
|
||||
filename: './webpack-stats.json',
|
||||
publicPath: '/static/bundles/',
|
||||
integrity: true
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.BASE_URL': JSON.stringify(process.env.BASE_URL),
|
||||
}),
|
||||
new webpack.DefinePlugin({ __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: false }),
|
||||
new MiniCssExtractPlugin(),
|
||||
],
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
728
poetry.lock
generated
728
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,39 +3,47 @@ line_length = 119
|
||||
|
||||
[tool.poetry]
|
||||
name = "cbwebreader"
|
||||
version = "0.4.6"
|
||||
version = "1.0.0-alpha.1"
|
||||
description = "CBR/Z Web Reader"
|
||||
authors = ["ajurna <ajurna@gmail.com>"]
|
||||
license = "Creative Commons Attribution-ShareAlike 4.0 International License"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
Django = "^3.2.14"
|
||||
python = "^3.10"
|
||||
Django = "4.0.7"
|
||||
gunicorn = "^20.0.4"
|
||||
django-recaptcha2 = "^1.4.1"
|
||||
dj-database-url = "^0.5.0"
|
||||
dj-database-url = "^1.0.0"
|
||||
python-dotenv = "^0.20.0"
|
||||
loguru = "^0.6.0"
|
||||
django-silk = "^4.1.0"
|
||||
django-silk = "^5.0.0"
|
||||
mysqlclient = "^2.0.1"
|
||||
psycopg2 = "^2.8.6"
|
||||
rarfile = "^4.0"
|
||||
coverage = "^6.2"
|
||||
django-extensions = "^3.1.3"
|
||||
django-extensions = "^3.2.0"
|
||||
Pillow = "^9.1.1"
|
||||
django-imagekit = "^4.0.2"
|
||||
PyMuPDF = "~1.18"
|
||||
django-bootstrap4 = "^22.1"
|
||||
django-csp = "^3.7"
|
||||
django-boost = "^2.0"
|
||||
django-sri = "^0.3.0"
|
||||
django-sri = "^0.4.0"
|
||||
django-node-assets = "^0.9.9"
|
||||
django-permissions-policy = "^4.9.0"
|
||||
djangorestframework = "^3.13.1"
|
||||
Markdown = "^3.3.7"
|
||||
django-filter = "^22.1"
|
||||
django-cors-headers = "^3.13.0"
|
||||
djangorestframework-simplejwt = "^5.2.0"
|
||||
django-webpack-loader = "^1.6.0"
|
||||
drf-yasg = "^1.20.0"
|
||||
drf-extensions = "^0.7.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
mypy = "^0.942"
|
||||
mypy = "^0.971"
|
||||
Werkzeug = "<2.1"
|
||||
pyOpenSSL = "^22.0.0"
|
||||
ipython = "^8.4.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
|
||||
365
requirements.txt
365
requirements.txt
@@ -1,302 +1,63 @@
|
||||
asgiref==3.5.2; python_version >= "3.7" \
|
||||
--hash=sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4 \
|
||||
--hash=sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424
|
||||
autopep8==1.6.0; python_version >= "3.7" \
|
||||
--hash=sha256:ed77137193bbac52d029a52c59bec1b0629b5a186c495f1eb21b126ac466083f \
|
||||
--hash=sha256:44f0932855039d2c15c4510d6df665e4730f2b8582704fa48f9c55bd3e17d979
|
||||
beautifulsoup4==4.11.1; python_full_version >= "3.6.0" and python_version >= "3.7" \
|
||||
--hash=sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30 \
|
||||
--hash=sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693
|
||||
certifi==2022.6.15; python_version >= "3.7" and python_version < "4" \
|
||||
--hash=sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412 \
|
||||
--hash=sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d
|
||||
charset-normalizer==2.1.0; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.0" \
|
||||
--hash=sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413 \
|
||||
--hash=sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5
|
||||
colorama==0.4.5; python_version >= "3.5" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.5" and python_full_version >= "3.5.0" \
|
||||
--hash=sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da \
|
||||
--hash=sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4
|
||||
coverage==6.4.1; python_version >= "3.7" \
|
||||
--hash=sha256:f1d5aa2703e1dab4ae6cf416eb0095304f49d004c39e9db1d86f57924f43006b \
|
||||
--hash=sha256:4ce1b258493cbf8aec43e9b50d89982346b98e9ffdfaae8ae5793bc112fb0068 \
|
||||
--hash=sha256:83c4e737f60c6936460c5be330d296dd5b48b3963f48634c53b3f7deb0f34ec4 \
|
||||
--hash=sha256:84e65ef149028516c6d64461b95a8dbcfce95cfd5b9eb634320596173332ea84 \
|
||||
--hash=sha256:f69718750eaae75efe506406c490d6fc5a6161d047206cc63ce25527e8a3adad \
|
||||
--hash=sha256:e57816f8ffe46b1df8f12e1b348f06d164fd5219beba7d9433ba79608ef011cc \
|
||||
--hash=sha256:01c5615d13f3dd3aa8543afc069e5319cfa0c7d712f6e04b920431e5c564a749 \
|
||||
--hash=sha256:75ab269400706fab15981fd4bd5080c56bd5cc07c3bccb86aab5e1d5a88dc8f4 \
|
||||
--hash=sha256:a7f3049243783df2e6cc6deafc49ea123522b59f464831476d3d1448e30d72df \
|
||||
--hash=sha256:ee2ddcac99b2d2aec413e36d7a429ae9ebcadf912946b13ffa88e7d4c9b712d6 \
|
||||
--hash=sha256:fb73e0011b8793c053bfa85e53129ba5f0250fdc0392c1591fd35d915ec75c46 \
|
||||
--hash=sha256:106c16dfe494de3193ec55cac9640dd039b66e196e4641fa8ac396181578b982 \
|
||||
--hash=sha256:87f4f3df85aa39da00fd3ec4b5abeb7407e82b68c7c5ad181308b0e2526da5d4 \
|
||||
--hash=sha256:961e2fb0680b4f5ad63234e0bf55dfb90d302740ae9c7ed0120677a94a1590cb \
|
||||
--hash=sha256:cec3a0f75c8f1031825e19cd86ee787e87cf03e4fd2865c79c057092e69e3a3b \
|
||||
--hash=sha256:129cd05ba6f0d08a766d942a9ed4b29283aff7b2cccf5b7ce279d50796860bb3 \
|
||||
--hash=sha256:bf5601c33213d3cb19d17a796f8a14a9eaa5e87629a53979a5981e3e3ae166f6 \
|
||||
--hash=sha256:269eaa2c20a13a5bf17558d4dc91a8d078c4fa1872f25303dddcbba3a813085e \
|
||||
--hash=sha256:f02cbbf8119db68455b9d763f2f8737bb7db7e43720afa07d8eb1604e5c5ae28 \
|
||||
--hash=sha256:ffa9297c3a453fba4717d06df579af42ab9a28022444cae7fa605af4df612d54 \
|
||||
--hash=sha256:145f296d00441ca703a659e8f3eb48ae39fb083baba2d7ce4482fb2723e050d9 \
|
||||
--hash=sha256:d67d44996140af8b84284e5e7d398e589574b376fb4de8ccd28d82ad8e3bea13 \
|
||||
--hash=sha256:2bd9a6fc18aab8d2e18f89b7ff91c0f34ff4d5e0ba0b33e989b3cd4194c81fd9 \
|
||||
--hash=sha256:3384f2a3652cef289e38100f2d037956194a837221edd520a7ee5b42d00cc605 \
|
||||
--hash=sha256:9b3e07152b4563722be523e8cd0b209e0d1a373022cfbde395ebb6575bf6790d \
|
||||
--hash=sha256:1480ff858b4113db2718848d7b2d1b75bc79895a9c22e76a221b9d8d62496428 \
|
||||
--hash=sha256:865d69ae811a392f4d06bde506d531f6a28a00af36f5c8649684a9e5e4a85c83 \
|
||||
--hash=sha256:664a47ce62fe4bef9e2d2c430306e1428ecea207ffd68649e3b942fa8ea83b0b \
|
||||
--hash=sha256:26dff09fb0d82693ba9e6231248641d60ba606150d02ed45110f9ec26404ed1c \
|
||||
--hash=sha256:d9c80df769f5ec05ad21ea34be7458d1dc51ff1fb4b2219e77fe24edf462d6df \
|
||||
--hash=sha256:39ee53946bf009788108b4dd2894bf1349b4e0ca18c2016ffa7d26ce46b8f10d \
|
||||
--hash=sha256:f5b66caa62922531059bc5ac04f836860412f7f88d38a476eda0a6f11d4724f4 \
|
||||
--hash=sha256:fd180ed867e289964404051a958f7cccabdeed423f91a899829264bb7974d3d3 \
|
||||
--hash=sha256:84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3 \
|
||||
--hash=sha256:8c08da0bd238f2970230c2a0d28ff0e99961598cb2e810245d7fc5afcf1254e8 \
|
||||
--hash=sha256:d42c549a8f41dc103a8004b9f0c433e2086add8a719da00e246e17cbe4056f72 \
|
||||
--hash=sha256:309ce4a522ed5fca432af4ebe0f32b21d6d7ccbb0f5fcc99290e71feba67c264 \
|
||||
--hash=sha256:fdb6f7bd51c2d1714cea40718f6149ad9be6a2ee7d93b19e9f00934c0f2a74d9 \
|
||||
--hash=sha256:342d4aefd1c3e7f620a13f4fe563154d808b69cccef415415aece4c786665397 \
|
||||
--hash=sha256:4803e7ccf93230accb928f3a68f00ffa80a88213af98ed338a57ad021ef06815 \
|
||||
--hash=sha256:4321f075095a096e70aff1d002030ee612b65a205a0a0f5b815280d5dc58100c
|
||||
dj-database-url==0.5.0 \
|
||||
--hash=sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163 \
|
||||
--hash=sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9
|
||||
django-appconf==1.0.5; python_version >= "3.6" \
|
||||
--hash=sha256:be3db0be6c81fa84742000b89a81c016d70ae66a7ccb620cdef592b1f1a6aaa4 \
|
||||
--hash=sha256:ae9f864ee1958c815a965ed63b3fba4874eec13de10236ba063a788f9a17389d
|
||||
django-boost==2.0 \
|
||||
--hash=sha256:266c22a5a7bdae480cfdc337b11073e1e906521a4f9c5b3dbb7e15bfeb916065 \
|
||||
--hash=sha256:6d6c2d7c34d54cfdbb6232d755967eabaaf8db5395bc003637e87a7f0e329016
|
||||
django-bootstrap4==22.1; python_version >= "3.7" \
|
||||
--hash=sha256:fc9984f7238fbcd330ec5111bf0435083caa7192b022eedd53bfa4128bee318f \
|
||||
--hash=sha256:b6da4cb54682012ff8baa1a1e672ba30cbfae82fb3d74f4b341109074e8e239f
|
||||
django-csp==3.7 \
|
||||
--hash=sha256:01443a07723f9a479d498bd7bb63571aaa771e690f64bde515db6cdb76e8041a \
|
||||
--hash=sha256:01eda02ad3f10261c74131cdc0b5a6a62b7c7ad4fd017fbefb7a14776e0a9727
|
||||
django-extensions==3.1.5; python_version >= "3.6" \
|
||||
--hash=sha256:28e1e1bf49f0e00307ba574d645b0af3564c981a6dfc87209d48cb98f77d0b1a \
|
||||
--hash=sha256:9238b9e016bb0009d621e05cf56ea8ce5cce9b32e91ad2026996a7377ca28069
|
||||
django-imagekit==4.1.0 \
|
||||
--hash=sha256:e559aeaae43a33b34f87631a9fa5696455e4451ffa738a42635fde442fedac5c \
|
||||
--hash=sha256:87e36f8dc1d8745647881f4366ef4965225f048042dacebbee0dcb87425defef
|
||||
django-node-assets==0.9.11 \
|
||||
--hash=sha256:df6ca9aeb868aa9692cbf8f6265132b6159798866b15ac95d7d0d4dd5f3cb6da \
|
||||
--hash=sha256:4d37659c07976dc4ebccb6704051c25204e3381aa5e4f98a4a76b57e33cb1776
|
||||
django-permissions-policy==4.12.0; python_version >= "3.7" \
|
||||
--hash=sha256:1f10fc354110fef9270fbabbbf8d5358a6d6f5380c79f0dfba56dd41e45b1936 \
|
||||
--hash=sha256:88ee9cb345898414a6ad3f2eb0d8284c67decdfc43c6fc5331531b7c39809d8c
|
||||
django-recaptcha2==1.4.1 \
|
||||
--hash=sha256:c0b43851b05c6bf6ebb5ecc890c13ccedacd9bb33d64b4291c74dd6fcbc89366 \
|
||||
--hash=sha256:9ea90db0cec502741be1066c09ec1b8e02a73162a319a042e78e67c4605087af
|
||||
django-silk==4.4.1; python_version >= "3.7" \
|
||||
--hash=sha256:5d4d3f1d4e3454fb073be8928293bc8af33c61cb32987783c8f93243ebf9705e \
|
||||
--hash=sha256:72f20020177e929ca5733dfebf226b4ce8559c5a7bdb1517daf9aaf7916b188a
|
||||
django-sri==0.3.0; python_version >= "3.6" \
|
||||
--hash=sha256:961e316c0663d2b277a60f677bae3bed451a26f045129eddf09827f98fe00b86 \
|
||||
--hash=sha256:9fa50b4b41b4cc3e8072d1bc4a60a81e38fd95698aed115d2f56f3d7e83a6877
|
||||
django==3.2.15; python_version >= "3.6" \
|
||||
--hash=sha256:a8681e098fa60f7c33a4b628d6fcd3fe983a0939ff1301ecacac21d0b38bad56 \
|
||||
--hash=sha256:677182ba8b5b285a4e072f3ac17ceee6aff1b5ce77fd173cc5b6a2d3dc022fcf
|
||||
gprof2dot==2021.2.21; python_version >= "3.7" \
|
||||
--hash=sha256:1223189383b53dcc8ecfd45787ac48c0ed7b4dbc16ee8b88695d053eea1acabf
|
||||
gunicorn==20.1.0; python_version >= "3.5" \
|
||||
--hash=sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e \
|
||||
--hash=sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8
|
||||
idna==3.3; python_version >= "3.7" and python_version < "4" \
|
||||
--hash=sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff \
|
||||
--hash=sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d
|
||||
jinja2==3.1.2; python_version >= "3.7" \
|
||||
--hash=sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61 \
|
||||
--hash=sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852
|
||||
loguru==0.6.0; python_version >= "3.5" \
|
||||
--hash=sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3 \
|
||||
--hash=sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c
|
||||
markupsafe==2.1.1; python_version >= "3.7" \
|
||||
--hash=sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812 \
|
||||
--hash=sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a \
|
||||
--hash=sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e \
|
||||
--hash=sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5 \
|
||||
--hash=sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4 \
|
||||
--hash=sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f \
|
||||
--hash=sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e \
|
||||
--hash=sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933 \
|
||||
--hash=sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6 \
|
||||
--hash=sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417 \
|
||||
--hash=sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02 \
|
||||
--hash=sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a \
|
||||
--hash=sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37 \
|
||||
--hash=sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980 \
|
||||
--hash=sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a \
|
||||
--hash=sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3 \
|
||||
--hash=sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a \
|
||||
--hash=sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff \
|
||||
--hash=sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a \
|
||||
--hash=sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452 \
|
||||
--hash=sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003 \
|
||||
--hash=sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1 \
|
||||
--hash=sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601 \
|
||||
--hash=sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925 \
|
||||
--hash=sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f \
|
||||
--hash=sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88 \
|
||||
--hash=sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63 \
|
||||
--hash=sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1 \
|
||||
--hash=sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7 \
|
||||
--hash=sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a \
|
||||
--hash=sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f \
|
||||
--hash=sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6 \
|
||||
--hash=sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77 \
|
||||
--hash=sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603 \
|
||||
--hash=sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7 \
|
||||
--hash=sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135 \
|
||||
--hash=sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96 \
|
||||
--hash=sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c \
|
||||
--hash=sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247 \
|
||||
--hash=sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b
|
||||
mysqlclient==2.1.1; python_version >= "3.5" \
|
||||
--hash=sha256:c1ed71bd6244993b526113cca3df66428609f90e4652f37eb51c33496d478b37 \
|
||||
--hash=sha256:c812b67e90082a840efb82a8978369e6e69fc62ce1bda4ca8f3084a9d862308b \
|
||||
--hash=sha256:0d1cd3a5a4d28c222fa199002810e8146cffd821410b67851af4cc80aeccd97c \
|
||||
--hash=sha256:b355c8b5a7d58f2e909acdbb050858390ee1b0e13672ae759e5e784110022994 \
|
||||
--hash=sha256:996924f3483fd36a34a5812210c69e71dea5a3d5978d01199b78b7f6d485c855 \
|
||||
--hash=sha256:dea88c8d3f5a5d9293dfe7f087c16dd350ceb175f2f6631c9cf4caf3e19b7a96 \
|
||||
--hash=sha256:828757e419fb11dd6c5ed2576ec92c3efaa93a0f7c39e263586d1ee779c3d782
|
||||
pilkit==2.0 \
|
||||
--hash=sha256:ddb30c2f0198a147e56b151476c3bb9fe045fbfd5b0a0fa2a3148dba62d1559f
|
||||
pillow==9.2.0; python_version >= "3.7" \
|
||||
--hash=sha256:a9c9bc489f8ab30906d7a85afac4b4944a572a7432e00698a7239f44a44e6efb \
|
||||
--hash=sha256:510cef4a3f401c246cfd8227b300828715dd055463cdca6176c2e4036df8bd4f \
|
||||
--hash=sha256:7888310f6214f19ab2b6df90f3f06afa3df7ef7355fc025e78a3044737fab1f5 \
|
||||
--hash=sha256:831e648102c82f152e14c1a0938689dbb22480c548c8d4b8b248b3e50967b88c \
|
||||
--hash=sha256:1cc1d2451e8a3b4bfdb9caf745b58e6c7a77d2e469159b0d527a4554d73694d1 \
|
||||
--hash=sha256:136659638f61a251e8ed3b331fc6ccd124590eeff539de57c5f80ef3a9594e58 \
|
||||
--hash=sha256:6e8c66f70fb539301e064f6478d7453e820d8a2c631da948a23384865cd95544 \
|
||||
--hash=sha256:37ff6b522a26d0538b753f0b4e8e164fdada12db6c6f00f62145d732d8a3152e \
|
||||
--hash=sha256:c79698d4cd9318d9481d89a77e2d3fcaeff5486be641e60a4b49f3d2ecca4e28 \
|
||||
--hash=sha256:254164c57bab4b459f14c64e93df11eff5ded575192c294a0c49270f22c5d93d \
|
||||
--hash=sha256:408673ed75594933714482501fe97e055a42996087eeca7e5d06e33218d05aa8 \
|
||||
--hash=sha256:727dd1389bc5cb9827cbd1f9d40d2c2a1a0c9b32dd2261db522d22a604a6eec9 \
|
||||
--hash=sha256:50dff9cc21826d2977ef2d2a205504034e3a4563ca6f5db739b0d1026658e004 \
|
||||
--hash=sha256:cb6259196a589123d755380b65127ddc60f4c64b21fc3bb46ce3a6ea663659b0 \
|
||||
--hash=sha256:7b0554af24df2bf96618dac71ddada02420f946be943b181108cac55a7a2dcd4 \
|
||||
--hash=sha256:15928f824870535c85dbf949c09d6ae7d3d6ac2d6efec80f3227f73eefba741c \
|
||||
--hash=sha256:bdd0de2d64688ecae88dd8935012c4a72681e5df632af903a1dca8c5e7aa871a \
|
||||
--hash=sha256:d5b87da55a08acb586bad5c3aa3b86505f559b84f39035b233d5bf844b0834b1 \
|
||||
--hash=sha256:b6d5e92df2b77665e07ddb2e4dbd6d644b78e4c0d2e9272a852627cdba0d75cf \
|
||||
--hash=sha256:6bf088c1ce160f50ea40764f825ec9b72ed9da25346216b91361eef8ad1b8f8c \
|
||||
--hash=sha256:2c58b24e3a63efd22554c676d81b0e57f80e0a7d3a5874a7e14ce90ec40d3069 \
|
||||
--hash=sha256:eef7592281f7c174d3d6cbfbb7ee5984a671fcd77e3fc78e973d492e9bf0eb3f \
|
||||
--hash=sha256:dcd7b9c7139dc8258d164b55696ecd16c04607f1cc33ba7af86613881ffe4ac8 \
|
||||
--hash=sha256:a138441e95562b3c078746a22f8fca8ff1c22c014f856278bdbdd89ca36cff1b \
|
||||
--hash=sha256:93689632949aff41199090eff5474f3990b6823404e45d66a5d44304e9cdc467 \
|
||||
--hash=sha256:f3fac744f9b540148fa7715a435d2283b71f68bfb6d4aae24482a890aed18b59 \
|
||||
--hash=sha256:fa768eff5f9f958270b081bb33581b4b569faabf8774726b283edb06617101dc \
|
||||
--hash=sha256:69bd1a15d7ba3694631e00df8de65a8cb031911ca11f44929c97fe05eb9b6c1d \
|
||||
--hash=sha256:030e3460861488e249731c3e7ab59b07c7853838ff3b8e16aac9561bb345da14 \
|
||||
--hash=sha256:74a04183e6e64930b667d321524e3c5361094bb4af9083db5c301db64cd341f3 \
|
||||
--hash=sha256:2d33a11f601213dcd5718109c09a52c2a1c893e7461f0be2d6febc2879ec2402 \
|
||||
--hash=sha256:1fd6f5e3c0e4697fa7eb45b6e93996299f3feee73a3175fa451f49a74d092b9f \
|
||||
--hash=sha256:a647c0d4478b995c5e54615a2e5360ccedd2f85e70ab57fbe817ca613d5e63b8 \
|
||||
--hash=sha256:4134d3f1ba5f15027ff5c04296f13328fecd46921424084516bdb1b2548e66ff \
|
||||
--hash=sha256:bc431b065722a5ad1dfb4df354fb9333b7a582a5ee39a90e6ffff688d72f27a1 \
|
||||
--hash=sha256:1536ad017a9f789430fb6b8be8bf99d2f214c76502becc196c6f2d9a75b01b76 \
|
||||
--hash=sha256:2ad0d4df0f5ef2247e27fc790d5c9b5a0af8ade9ba340db4a73bb1a4a3e5fb4f \
|
||||
--hash=sha256:ec52c351b35ca269cb1f8069d610fc45c5bd38c3e91f9ab4cbbf0aebc136d9c8 \
|
||||
--hash=sha256:0ed2c4ef2451de908c90436d6e8092e13a43992f1860275b4d8082667fbb2ffc \
|
||||
--hash=sha256:4ad2f835e0ad81d1689f1b7e3fbac7b01bb8777d5a985c8962bedee0cc6d43da \
|
||||
--hash=sha256:ea98f633d45f7e815db648fd7ff0f19e328302ac36427343e4432c84432e7ff4 \
|
||||
--hash=sha256:7761afe0126d046974a01e030ae7529ed0ca6a196de3ec6937c11df0df1bc91c \
|
||||
--hash=sha256:9a54614049a18a2d6fe156e68e188da02a046a4a93cf24f373bffd977e943421 \
|
||||
--hash=sha256:5aed7dde98403cd91d86a1115c78d8145c83078e864c1de1064f52e6feb61b20 \
|
||||
--hash=sha256:13b725463f32df1bfeacbf3dd197fb358ae8ebcd8c5548faa75126ea425ccb60 \
|
||||
--hash=sha256:808add66ea764ed97d44dda1ac4f2cfec4c1867d9efb16a33d158be79f32b8a4 \
|
||||
--hash=sha256:337a74fd2f291c607d220c793a8135273c4c2ab001b03e601c36766005f36885 \
|
||||
--hash=sha256:fac2d65901fb0fdf20363fbd345c01958a742f2dc62a8dd4495af66e3ff502a4 \
|
||||
--hash=sha256:ad2277b185ebce47a63f4dc6302e30f05762b688f8dc3de55dbae4651872cdf3 \
|
||||
--hash=sha256:7c7b502bc34f6e32ba022b4a209638f9e097d7a9098104ae420eb8186217ebbb \
|
||||
--hash=sha256:3d1f14f5f691f55e1b47f824ca4fdcb4b19b4323fe43cc7bb105988cad7496be \
|
||||
--hash=sha256:dfe4c1fedfde4e2fbc009d5ad420647f7730d719786388b7de0999bf32c0d9fd \
|
||||
--hash=sha256:f07f1f00e22b231dd3d9b9208692042e29792d6bd4f6639415d2f23158a80013 \
|
||||
--hash=sha256:1802f34298f5ba11d55e5bb09c31997dc0c6aed919658dfdf0198a2fe75d5490 \
|
||||
--hash=sha256:17d4cafe22f050b46d983b71c707162d63d796a1235cdf8b9d7a112e97b15bac \
|
||||
--hash=sha256:96b5e6874431df16aee0c1ba237574cb6dff1dcb173798faa6a9d8b399a05d0e \
|
||||
--hash=sha256:0030fdbd926fb85844b8b92e2f9449ba89607231d3dd597a21ae72dc7fe26927 \
|
||||
--hash=sha256:75e636fd3e0fb872693f23ccb8a5ff2cd578801251f3a4f6854c6a5d437d3c04
|
||||
psycopg2==2.9.3; python_version >= "3.6" \
|
||||
--hash=sha256:083707a696e5e1c330af2508d8fab36f9700b26621ccbcb538abe22e15485362 \
|
||||
--hash=sha256:d3ca6421b942f60c008f81a3541e8faf6865a28d5a9b48544b0ee4f40cac7fca \
|
||||
--hash=sha256:9572e08b50aed176ef6d66f15a21d823bb6f6d23152d35e8451d7d2d18fdac56 \
|
||||
--hash=sha256:a81e3866f99382dfe8c15a151f1ca5fde5815fde879348fe5a9884a7c092a305 \
|
||||
--hash=sha256:cb10d44e6694d763fa1078a26f7f6137d69f555a78ec85dc2ef716c37447e4b2 \
|
||||
--hash=sha256:4295093a6ae3434d33ec6baab4ca5512a5082cc43c0505293087b8a46d108461 \
|
||||
--hash=sha256:34b33e0162cfcaad151f249c2649fd1030010c16f4bbc40a604c1cb77173dcf7 \
|
||||
--hash=sha256:0762c27d018edbcb2d34d51596e4346c983bd27c330218c56c4dc25ef7e819bf \
|
||||
--hash=sha256:8cf3878353cc04b053822896bc4922b194792df9df2f1ad8da01fb3043602126 \
|
||||
--hash=sha256:06f32425949bd5fe8f625c49f17ebb9784e1e4fe928b7cce72edc36fb68e4c0c \
|
||||
--hash=sha256:8e841d1bf3434da985cc5ef13e6f75c8981ced601fd70cc6bf33351b91562981
|
||||
pycodestyle==2.8.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.7" \
|
||||
--hash=sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20 \
|
||||
--hash=sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f
|
||||
pymupdf==1.18.19 \
|
||||
--hash=sha256:ee8cc8aadaa818c9a5e2fb2a944c99a98822a7a3bc618d9c5d32f126874c0635 \
|
||||
--hash=sha256:03ebf6fce6889df4708061a499b912909ead5e7bf1066f05b94458dcf164e3c3 \
|
||||
--hash=sha256:f4bc63b0696c2f276703fadf3232d7ccaa01ab7548d1541fbc3762c573d3e1b5 \
|
||||
--hash=sha256:c2fc348061c14c79e546a207088ba8bf676dc8f1d302cb94cefe53d53c2a7808 \
|
||||
--hash=sha256:a795f40654a98459007148fa0660de5a9e1e1e0d12dc5d56bbdea481a1ec8fbc \
|
||||
--hash=sha256:c802bdc3fdf690470a490cbcbf026895bb497f8049d33df47e1c64da939d46b3 \
|
||||
--hash=sha256:5044f9447686874f442a676776615b48b6a04d63a68dd99da21ad7a0efd5d13a \
|
||||
--hash=sha256:7a7fc4b4069934d7663dbd2bcf698f123b9bb0c4b4e25383a549692bee56f466 \
|
||||
--hash=sha256:dc2df761b59ff06d16db7a5ce6d19bcab365efd4391eac96dab30ddda3a09f87 \
|
||||
--hash=sha256:af2c6934eb8dc803c958d9742c7b64aa7d6287f764844f296dfdfe9e576e4df4 \
|
||||
--hash=sha256:d772141c9e007b887da54da5677e13975305bd85ae833eec35b923bed35ea9c8 \
|
||||
--hash=sha256:ea96c0129d6be8a289b80e7f0a5f6842e09c3dcb568a170297afb256cc387146 \
|
||||
--hash=sha256:e7f1897600f7a56073b1370a1709f68b00c8700c4ef945bcffde1a09f58c2746 \
|
||||
--hash=sha256:0c43b849c1daef36dc83667726518af3178c831c053e3e946e1989b5fb477bca \
|
||||
--hash=sha256:47a1588ce2296517019e8ba64279fe4cc03592d96fb1fc726ed9d0f5f01b5ef8 \
|
||||
--hash=sha256:fc8e1f0c5fb54cb91ad3b630ef623834cb2e4b110c33eabccad9b8609053b20d \
|
||||
--hash=sha256:230d130f22a91da0a897c6b7e9b3523d24dc512ee32568a40d0df0e2cf2562c0 \
|
||||
--hash=sha256:7c0f145445b3ef8eb45794bf0f86fcd278696da5a6b879666b690e4856f5e481 \
|
||||
--hash=sha256:c12e1870841a8746f023a64ef6d8a44fa02324d17252c3e182f639a9d5530261 \
|
||||
--hash=sha256:69308316b031aace9e490e9010d6c5334129eed47b47804da72d5d07eb5b2f9a \
|
||||
--hash=sha256:616533cff3605c22a327488b4470f9929da675b006d7a1498f8d7ed8bf91f386 \
|
||||
--hash=sha256:f407dbeecdefdfd46b9df7b083da71558143859216dc9891c40ea9cd2cf49a04 \
|
||||
--hash=sha256:439b972026fbe8636aed0fe9d2cabb321542fa92bc48cd4c96dbdd2508fc41ee \
|
||||
--hash=sha256:ecc684e9c45bd4072f538cc42998cfda4d00f066ba009226e8a212b112d9992c
|
||||
python-dateutil==2.8.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \
|
||||
--hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
|
||||
--hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9
|
||||
python-dotenv==0.20.0; python_version >= "3.5" \
|
||||
--hash=sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f \
|
||||
--hash=sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938
|
||||
pytz==2022.1; python_version >= "3.7" \
|
||||
--hash=sha256:e68985985296d9a66a881eb3193b0906246245294a881e7c8afe623866ac6a5c \
|
||||
--hash=sha256:1e760e2fe6a8163bc0b3d9a19c4f84342afa0a2affebfaa84b01b978a02ecaa7
|
||||
rarfile==4.0 \
|
||||
--hash=sha256:1094869119012f95c31a6f22cc3a9edbdca61861b805241116adbe2d737b68f8 \
|
||||
--hash=sha256:67548769229c5bda0827c1663dce3f54644f9dbfba4ae86d4da2b2afd3e602a1
|
||||
requests==2.28.1; python_version >= "3.7" and python_version < "4" \
|
||||
--hash=sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349 \
|
||||
--hash=sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983
|
||||
six==1.16.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \
|
||||
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 \
|
||||
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926
|
||||
soupsieve==2.3.2.post1; python_full_version >= "3.6.0" and python_version >= "3.7" \
|
||||
--hash=sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759 \
|
||||
--hash=sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d
|
||||
sqlparse==0.4.2; python_version >= "3.7" \
|
||||
--hash=sha256:48719e356bb8b42991bdbb1e8b83223757b93789c00910a616a071910ca4a64d \
|
||||
--hash=sha256:0c00730c74263a94e5a9919ade150dfc3b19c574389985446148402998287dae
|
||||
toml==0.10.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7" \
|
||||
--hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
|
||||
--hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
|
||||
ua-parser==0.15.0 \
|
||||
--hash=sha256:e441c982ffe81aa7e31af40ac6bf1d39f8ad24f1d34a2d91baae415470b26e9b \
|
||||
--hash=sha256:a93592ee96922b5f969bde9ae79662bdd41d041760280b099a6700264a1b7291
|
||||
urllib3==1.26.9; python_version >= "3.7" and python_full_version < "3.0.0" and python_version < "4" or python_full_version >= "3.5.0" and python_version < "4" and python_version >= "3.7" \
|
||||
--hash=sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14 \
|
||||
--hash=sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e
|
||||
user-agents==2.2.0 \
|
||||
--hash=sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26 \
|
||||
--hash=sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7
|
||||
win32-setctime==1.1.0; sys_platform == "win32" and python_version >= "3.5" \
|
||||
--hash=sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad \
|
||||
--hash=sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2
|
||||
asgiref==3.5.2; python_version >= "3.8"
|
||||
autopep8==1.7.0; python_version >= "3.7"
|
||||
beautifulsoup4==4.11.1; python_full_version >= "3.6.0" and python_version >= "3.7"
|
||||
certifi==2022.6.15; python_version >= "3.7" and python_version < "4"
|
||||
charset-normalizer==2.1.1; python_version >= "3.7" and python_version < "4" and python_full_version >= "3.6.0"
|
||||
colorama==0.4.5; python_version >= "3.5" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.5" and python_full_version >= "3.5.0"
|
||||
coreapi==2.3.3; python_version >= "3.6"
|
||||
coreschema==0.0.4; python_version >= "3.6"
|
||||
coverage==6.4.4; python_version >= "3.7"
|
||||
dj-database-url==1.0.0
|
||||
django-appconf==1.0.5; python_version >= "3.6"
|
||||
django-boost==2.0
|
||||
django-bootstrap4==22.2; python_version >= "3.7"
|
||||
django-cors-headers==3.13.0; python_version >= "3.7"
|
||||
django-csp==3.7
|
||||
django-extensions==3.2.0; python_version >= "3.6"
|
||||
django-filter==22.1; python_version >= "3.7"
|
||||
django-imagekit==4.1.0
|
||||
django-node-assets==0.9.11
|
||||
django-permissions-policy==4.13.0; python_version >= "3.7"
|
||||
django-silk==5.0.1; python_version >= "3.7"
|
||||
django-sri==0.4.0; python_version >= "3.8"
|
||||
django-webpack-loader==1.6.0
|
||||
django==4.0.7; python_version >= "3.8"
|
||||
djangorestframework-simplejwt==5.2.0; python_version >= "3.7"
|
||||
djangorestframework==3.13.1; python_version >= "3.6"
|
||||
drf-extensions==0.7.1
|
||||
drf-yasg==1.21.3; python_version >= "3.6"
|
||||
gprof2dot==2022.7.29; python_version >= "3.7"
|
||||
gunicorn==20.1.0; python_version >= "3.5"
|
||||
idna==3.3; python_version >= "3.7" and python_version < "4"
|
||||
inflection==0.5.1; python_version >= "3.6"
|
||||
itypes==1.2.0; python_version >= "3.6"
|
||||
jinja2==3.1.2; python_version >= "3.7"
|
||||
loguru==0.6.0; python_version >= "3.5"
|
||||
markdown==3.4.1; python_version >= "3.7"
|
||||
markupsafe==2.1.1; python_version >= "3.7"
|
||||
mysqlclient==2.1.1; python_version >= "3.5"
|
||||
packaging==21.3; python_version >= "3.6"
|
||||
pilkit==2.0
|
||||
pillow==9.2.0; python_version >= "3.7"
|
||||
psycopg2==2.9.3; python_version >= "3.6"
|
||||
pycodestyle==2.9.1; python_version >= "3.7"
|
||||
pyjwt==2.4.0; python_version >= "3.7"
|
||||
pymupdf==1.18.19
|
||||
pyparsing==3.0.9; python_full_version >= "3.6.8" and python_version >= "3.6"
|
||||
python-dateutil==2.8.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7"
|
||||
python-dotenv==0.20.0; python_version >= "3.5"
|
||||
pytz==2022.2.1; python_version >= "3.7"
|
||||
rarfile==4.0
|
||||
requests==2.28.1; python_version >= "3.7" and python_version < "4"
|
||||
ruamel.yaml.clib==0.2.6; platform_python_implementation == "CPython" and python_version < "3.11" and python_version >= "3.6"
|
||||
ruamel.yaml==0.17.21; python_version >= "3.6"
|
||||
six==1.16.0; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7"
|
||||
soupsieve==2.3.2.post1; python_full_version >= "3.6.0" and python_version >= "3.7"
|
||||
sqlparse==0.4.2; python_version >= "3.8"
|
||||
toml==0.10.2; python_version >= "3.7" and python_full_version < "3.0.0" or python_full_version >= "3.3.0" and python_version >= "3.7"
|
||||
tzdata==2022.2; sys_platform == "win32" and python_version >= "3.8"
|
||||
ua-parser==0.16.0
|
||||
uritemplate==4.1.1; python_version >= "3.6"
|
||||
urllib3==1.26.12; python_version >= "3.7" and python_full_version < "3.0.0" and python_version < "4" or python_full_version >= "3.6.0" and python_version < "4" and python_version >= "3.7"
|
||||
user-agents==2.2.0
|
||||
win32-setctime==1.1.0; sys_platform == "win32" and python_version >= "3.5"
|
||||
22
server.crt
22
server.crt
@@ -1,22 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDkzCCAnugAwIBAgIUXW6T5x/otOKb4BILa8OiFkLES0EwDQYJKoZIhvcNAQEL
|
||||
BQAwWTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MB4X
|
||||
DTIyMDQwMTA3MjM0OFoXDTIzMDQwMTA3MjM0OFowWTELMAkGA1UEBhMCQVUxEzAR
|
||||
BgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5
|
||||
IEx0ZDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
|
||||
MIIBCgKCAQEAyjUgZvaWUFGBZg0nKTGdwR7wSWheDB9CU07oBI+3Oitb4UztJog8
|
||||
+F0NT+iSXaz/axzKU/hRZgxULjBCLD9CA7HmosnSbrDQZ+rINA5KEpXgSI5DlH3D
|
||||
UuF8Ow0eikdeEt0GuUPY3MbbB14j94H21AW+6+9pVmoc4w5FN46bsz/k1mZp5nj0
|
||||
erp3xkSvUD/PyvfLanPcDmc88GlvGu5hqb5JIG5sI8KNjXq9MRM4dY3Q6kCFSNGk
|
||||
tWKIrPSCs8h1N6odJoB0DJpm6l8+CyAwuuCW94pD2p5tEfGIRRFqRZpY45X+7jy8
|
||||
uDAP8c5h4AUwwYp3qNMTNTZratcPlU3GSQIDAQABo1MwUTAdBgNVHQ4EFgQU5fhz
|
||||
kgGGaZczk8MsC7fqvpP0ykcwHwYDVR0jBBgwFoAU5fhzkgGGaZczk8MsC7fqvpP0
|
||||
ykcwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEALvOz0Xm+K8pv
|
||||
7YIgKLAHQNXuGI7BrTYEuB/yNuFJ9vj6z0/ScM3OJkqYNYjJg8Z8YC/bWqr5QR/H
|
||||
NdrCplhDdHrR0p0xEZwz94huu1qFLFjeDM8Ad2gaAxPd9glxdANOP0Gqxwy4oVya
|
||||
8zKW26zdn0LAWPBdeynQlfmRxoszGhzCZgPB/RmP2bvfPSWxiRZjgzlIDlObbeDi
|
||||
jF836kuAFGK6sIMYo87Unij4Y3Vq0DfE/0oH4d+48noUviL5EggIL25nGPrb2Pe9
|
||||
zgHr2EOVHDTg+9oClWKv58k1RDsc5Llcm5PJzeYJ9xv925s5pByVzaarXx/uhW4O
|
||||
XZEBrzOSfg==
|
||||
-----END CERTIFICATE-----
|
||||
28
server.key
28
server.key
@@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDKNSBm9pZQUYFm
|
||||
DScpMZ3BHvBJaF4MH0JTTugEj7c6K1vhTO0miDz4XQ1P6JJdrP9rHMpT+FFmDFQu
|
||||
MEIsP0IDseaiydJusNBn6sg0DkoSleBIjkOUfcNS4Xw7DR6KR14S3Qa5Q9jcxtsH
|
||||
XiP3gfbUBb7r72lWahzjDkU3jpuzP+TWZmnmePR6unfGRK9QP8/K98tqc9wOZzzw
|
||||
aW8a7mGpvkkgbmwjwo2Ner0xEzh1jdDqQIVI0aS1Yois9IKzyHU3qh0mgHQMmmbq
|
||||
Xz4LIDC64Jb3ikPanm0R8YhFEWpFmljjlf7uPLy4MA/xzmHgBTDBineo0xM1Nmtq
|
||||
1w+VTcZJAgMBAAECggEAIuh9CYl0dbU8c5O9uPzZ0Sc4MFQWOF2FI8CxiWgOJ7qo
|
||||
iAKxxj8q122pCKyL6CQfjCxSOaDa3JUwSzEFm4HoMZ8aw+u3qmhX8ji0C1iULRU2
|
||||
vQ/iVtZHTB2lRsSDCzg642LI8F7oJ2UlFuaHqwkCxaOgTDbupn53MjOae3bhJlaI
|
||||
cn2KDHdR99BfCBLuT4r8Y+oM56yQ0TOEt65ZQqphHJE5niuUQo8HfVl7gK9DJhEo
|
||||
aQjMCMBdCFC8DDJ2dAz/JKkZZcai+c+0cZyGAZF6Om8Mz/+Pvpj0v3nDM8Ff40hy
|
||||
OcqzgswFbVmvLuORnwWEMHNqyrDVcNfItAuw5Vr5IQKBgQD9ws82Yr2zvytOOisG
|
||||
1O/JuHELNoMqtNM/nh109ZulZaU4PQ7nAtG7bfmst/afolSy4Qpzswezu/uOX0Zl
|
||||
VyDLuAGUn73/BaOvoEunfAWVktyBGvyD629EwmVHQYEG2bVeehrPyDQVDbxD3w6j
|
||||
QaF42zciZ4TuCHvqfCP60YtRvQKBgQDL/d6CQX6O0JgxoznHj2vPnKFukk5bU/WD
|
||||
VfAub6NKv5ZqGMpmQza8kuEHMTtIYRYgHQFHW0/lpTxXMCnHO4qsOL2Hmf3bsphy
|
||||
YizCAB0wrSsoB6ByAP+QvxYr+1wsd/g421GFdWtZHqN7gjytrjPbmwhGOnr9Flhy
|
||||
/4Zk+cuhfQKBgHfACecBW8JKMZZ97rYPoITSDE6dT/LEWHhKFl6OVQANYpWSgsjT
|
||||
VMQdVtiCC9kzUsMDXdpRnw3bZQ+/uEm0fx6D3AMWCyQgtij3/RuxdDUsk/A9GvLq
|
||||
FJ0fG4ovyELCVEucVbC+Ko3Q6Ioi5hZ2r0uIL5GFxn5J9KgoIxaG8jcFAoGAOXSw
|
||||
5mlCF0GjjF+YF6BK0nggc/9beJfGUA61jq69BIHAAPQolfMaiLSqExeHxhQqYjMp
|
||||
OAr9DwaiX0BelBIuNeHpaDc0bFv6WkVSq/XSQvKTdDvpshKb6Q4ZVZv/0zqbPJBx
|
||||
frCa4sghbdk59AVb79/TzcwM9hoEIafdF7XP6BUCgYEA9mb1I1hTt1i0n0xpRzbi
|
||||
5ORYi3dBI7o8VmvdlnDLOxljpCllrO7CgRwW+apW0fkxLRrB0W6xB3lYFzqicc+z
|
||||
aMIVszUrYypn1gTcHdyuYpoItRUJofN1opHrfeZEF6MxIidyQX7ut6VlgT/ExuEr
|
||||
N6IkjeDeu6NETzYHSUbCBCA=
|
||||
-----END PRIVATE KEY-----
|
||||
Reference in New Issue
Block a user