This commit is contained in:
commit
cedd7990d5
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
.git
|
||||
**/node_modules
|
13
.drone.yml
Normal file
13
.drone.yml
Normal file
|
@ -0,0 +1,13 @@
|
|||
kind: pipeline
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: docker
|
||||
image: plugins/docker
|
||||
settings:
|
||||
registry: r.sbruder.de
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
repo: r.sbruder.de/mangareader
|
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
/frontend/dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
42
Dockerfile
Normal file
42
Dockerfile
Normal file
|
@ -0,0 +1,42 @@
|
|||
FROM node as frontend
|
||||
|
||||
WORKDIR /usr/src/app/frontend/
|
||||
|
||||
COPY frontend .
|
||||
|
||||
RUN yarn install \
|
||||
&& yarn build
|
||||
|
||||
FROM python:3-alpine as requirements
|
||||
|
||||
WORKDIR /usr/src/app/
|
||||
|
||||
RUN pip install --no-cache-dir pipenv
|
||||
|
||||
COPY Pipfile .
|
||||
COPY Pipfile.lock .
|
||||
|
||||
RUN pipenv lock -r > requirements.txt
|
||||
|
||||
FROM python:3-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
COPY --from=requirements /usr/src/app/requirements.txt .
|
||||
|
||||
RUN apk add --no-cache --virtual .deps \
|
||||
build-base \
|
||||
libjpeg-turbo-dev \
|
||||
zlib-dev \
|
||||
&& pip install -r requirements.txt \
|
||||
&& apk del .deps \
|
||||
&& apk add --no-cache \
|
||||
libjpeg-turbo
|
||||
|
||||
COPY --from=frontend /usr/src/app/frontend/dist/ frontend/dist
|
||||
|
||||
COPY [^frontend]* ./
|
||||
|
||||
ENTRYPOINT ["gunicorn", "mangareader:app", "--bind", "0.0.0.0:8000", "--chdir", "/library"]
|
||||
|
||||
EXPOSE 8000
|
15
Pipfile
Normal file
15
Pipfile
Normal file
|
@ -0,0 +1,15 @@
|
|||
[[source]]
|
||||
name = "pypi"
|
||||
url = "https://pypi.org/simple"
|
||||
verify_ssl = true
|
||||
|
||||
[dev-packages]
|
||||
|
||||
[packages]
|
||||
Flask = "*"
|
||||
pillow = "*"
|
||||
flask-cors = "*"
|
||||
gunicorn = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3.7"
|
145
Pipfile.lock
generated
Normal file
145
Pipfile.lock
generated
Normal file
|
@ -0,0 +1,145 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "6bf198e4fcc5777632eb0d839a8259ef2fc737c6df23cbe2923d0d4593ab06e4"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
"python_version": "3.7"
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"name": "pypi",
|
||||
"url": "https://pypi.org/simple",
|
||||
"verify_ssl": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"click": {
|
||||
"hashes": [
|
||||
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
|
||||
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
|
||||
],
|
||||
"version": "==7.0"
|
||||
},
|
||||
"flask": {
|
||||
"hashes": [
|
||||
"sha256:ad7c6d841e64296b962296c2c2dabc6543752985727af86a975072dea984b6f3",
|
||||
"sha256:e7d32475d1de5facaa55e3958bc4ec66d3762076b074296aa50ef8fdc5b9df61"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.0.3"
|
||||
},
|
||||
"flask-cors": {
|
||||
"hashes": [
|
||||
"sha256:72170423eb4612f0847318afff8c247b38bd516b7737adfc10d1c2cdbb382d16",
|
||||
"sha256:f4d97201660e6bbcff2d89d082b5b6d31abee04b1b3003ee073a6fd25ad1d69a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.8"
|
||||
},
|
||||
"gunicorn": {
|
||||
"hashes": [
|
||||
"sha256:aa8e0b40b4157b36a5df5e599f45c9c76d6af43845ba3b3b0efe2c70473c2471",
|
||||
"sha256:fa2662097c66f920f53f70621c6c58ca4a3c4d3434205e608e121b5b3b71f4f3"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==19.9.0"
|
||||
},
|
||||
"itsdangerous": {
|
||||
"hashes": [
|
||||
"sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19",
|
||||
"sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"jinja2": {
|
||||
"hashes": [
|
||||
"sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013",
|
||||
"sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"
|
||||
],
|
||||
"version": "==2.10.1"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
||||
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
||||
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
||||
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
||||
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
||||
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
||||
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
||||
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
||||
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
||||
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
||||
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
||||
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
||||
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
||||
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
||||
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
||||
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
||||
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
||||
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
||||
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
||||
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
||||
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
||||
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
||||
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
||||
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
||||
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
||||
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
||||
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
||||
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"
|
||||
],
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"pillow": {
|
||||
"hashes": [
|
||||
"sha256:0804f77cb1e9b6dbd37601cee11283bba39a8d44b9ddb053400c58e0c0d7d9de",
|
||||
"sha256:0ab7c5b5d04691bcbd570658667dd1e21ca311c62dcfd315ad2255b1cd37f64f",
|
||||
"sha256:0b3e6cf3ea1f8cecd625f1420b931c83ce74f00c29a0ff1ce4385f99900ac7c4",
|
||||
"sha256:365c06a45712cd723ec16fa4ceb32ce46ad201eb7bbf6d3c16b063c72b61a3ed",
|
||||
"sha256:38301fbc0af865baa4752ddae1bb3cbb24b3d8f221bf2850aad96b243306fa03",
|
||||
"sha256:3aef1af1a91798536bbab35d70d35750bd2884f0832c88aeb2499aa2d1ed4992",
|
||||
"sha256:3fe0ab49537d9330c9bba7f16a5f8b02da615b5c809cdf7124f356a0f182eccd",
|
||||
"sha256:45a619d5c1915957449264c81c008934452e3fd3604e36809212300b2a4dab68",
|
||||
"sha256:49f90f147883a0c3778fd29d3eb169d56416f25758d0f66775db9184debc8010",
|
||||
"sha256:571b5a758baf1cb6a04233fb23d6cf1ca60b31f9f641b1700bfaab1194020555",
|
||||
"sha256:5ac381e8b1259925287ccc5a87d9cf6322a2dc88ae28a97fe3e196385288413f",
|
||||
"sha256:6153db744a743c0c8c91b8e3b9d40e0b13a5d31dbf8a12748c6d9bfd3ddc01ad",
|
||||
"sha256:6fd63afd14a16f5d6b408f623cc2142917a1f92855f0df997e09a49f0341be8a",
|
||||
"sha256:70acbcaba2a638923c2d337e0edea210505708d7859b87c2bd81e8f9902ae826",
|
||||
"sha256:70b1594d56ed32d56ed21a7fbb2a5c6fd7446cdb7b21e749c9791eac3a64d9e4",
|
||||
"sha256:76638865c83b1bb33bcac2a61ce4d13c17dba2204969dedb9ab60ef62bede686",
|
||||
"sha256:7b2ec162c87fc496aa568258ac88631a2ce0acfe681a9af40842fc55deaedc99",
|
||||
"sha256:7cee2cef07c8d76894ebefc54e4bb707dfc7f258ad155bd61d87f6cd487a70ff",
|
||||
"sha256:7d16d4498f8b374fc625c4037742fbdd7f9ac383fd50b06f4df00c81ef60e829",
|
||||
"sha256:b50bc1780681b127e28f0075dfb81d6135c3a293e0c1d0211133c75e2179b6c0",
|
||||
"sha256:bd0582f831ad5bcad6ca001deba4568573a4675437db17c4031939156ff339fa",
|
||||
"sha256:cfd40d8a4b59f7567620410f966bb1f32dc555b2b19f82a91b147fac296f645c",
|
||||
"sha256:e3ae410089de680e8f84c68b755b42bc42c0ceb8c03dbea88a5099747091d38e",
|
||||
"sha256:e9046e559c299b395b39ac7dbf16005308821c2f24a63cae2ab173bd6aa11616",
|
||||
"sha256:ef6be704ae2bc8ad0ebc5cb850ee9139493b0fc4e81abcc240fb392a63ebc808",
|
||||
"sha256:f8dc19d92896558f9c4317ee365729ead9d7bbcf2052a9a19a3ef17abbb8ac5b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==6.1.0"
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
|
||||
"sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
|
||||
],
|
||||
"version": "==1.12.0"
|
||||
},
|
||||
"werkzeug": {
|
||||
"hashes": [
|
||||
"sha256:865856ebb55c4dcd0630cdd8f3331a1847a819dda7e8c750d3db6f2aa6c0209c",
|
||||
"sha256:a0b915f0815982fb2a09161cb8f31708052d0951c3ba433ccc5e1aa276507ca6"
|
||||
],
|
||||
"version": "==0.15.4"
|
||||
}
|
||||
},
|
||||
"develop": {}
|
||||
}
|
196
backend.py
Normal file
196
backend.py
Normal file
|
@ -0,0 +1,196 @@
|
|||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
import os.path
|
||||
import sqlite3
|
||||
import werkzeug.exceptions as exceptions
|
||||
|
||||
|
||||
# https://docs.python.org/3.7/library/sqlite3.html#sqlite3.Connection.row_factory
|
||||
def dict_factory(cursor, row):
|
||||
d = {}
|
||||
for idx, col in enumerate(cursor.description):
|
||||
d[col[0]] = row[idx]
|
||||
return d
|
||||
|
||||
|
||||
def generate_thumbnail(filepath):
|
||||
image = Image.open(filepath)
|
||||
image.thumbnail((512, 512))
|
||||
thumbnail = BytesIO()
|
||||
image.save(thumbnail, format='jpeg')
|
||||
thumbnail.seek(0)
|
||||
return thumbnail
|
||||
|
||||
|
||||
class CalibreDB:
|
||||
def __init__(self, path='metadata.db'):
|
||||
self.database_path = f'file:{path}?mode=ro'
|
||||
|
||||
def create_cursor(self):
|
||||
conn = sqlite3.connect(self.database_path, uri=True)
|
||||
conn.row_factory = dict_factory
|
||||
return conn.cursor()
|
||||
|
||||
def get_series_list(self):
|
||||
cursor = self.create_cursor()
|
||||
series = cursor.execute('''
|
||||
select
|
||||
series.id as id,
|
||||
series.name as name,
|
||||
count(*) as volumes
|
||||
from
|
||||
books,
|
||||
books_series_link,
|
||||
data,
|
||||
series
|
||||
where
|
||||
books_series_link.series = series.id and
|
||||
books_series_link.book = books.id and
|
||||
books.id = data.book and
|
||||
data.format = \'CBZ\'
|
||||
group by series.name
|
||||
having min(books.series_index)
|
||||
''')
|
||||
|
||||
return series
|
||||
|
||||
def get_series_cover(self, series_id):
|
||||
cursor = self.create_cursor()
|
||||
first_volume = cursor.execute('''
|
||||
select
|
||||
books.id
|
||||
from
|
||||
books,
|
||||
books_series_link,
|
||||
series
|
||||
where
|
||||
books_series_link.book = books.id and
|
||||
books_series_link.series = series.id and
|
||||
series.id = ?
|
||||
group by series.name
|
||||
having min(books.series_index)
|
||||
''', (str(series_id),)).fetchone()
|
||||
|
||||
if first_volume is None:
|
||||
raise exceptions.NotFound()
|
||||
|
||||
return self.get_volume_cover(first_volume['id'])
|
||||
|
||||
def get_series_cover_thumbnail(self, series_id):
|
||||
return generate_thumbnail(self.get_series_cover(series_id))
|
||||
|
||||
def get_series_volumes(self, series_id):
|
||||
cursor = self.create_cursor()
|
||||
title = cursor.execute('''
|
||||
select
|
||||
series.name
|
||||
from
|
||||
series
|
||||
where
|
||||
series.id = ?
|
||||
''', (str(series_id),)).fetchone()['name']
|
||||
volumes = cursor.execute('''
|
||||
select
|
||||
books.id,
|
||||
books.title,
|
||||
books.series_index as "index"
|
||||
from
|
||||
books,
|
||||
books_series_link,
|
||||
series
|
||||
where
|
||||
books_series_link.book = books.id and
|
||||
books_series_link.series = series.id and
|
||||
series.id = ?
|
||||
order by books.series_index
|
||||
''', (str(series_id),)).fetchall()
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'volumes': volumes
|
||||
}
|
||||
|
||||
def get_volume_cover(self, volume_id):
|
||||
cursor = self.create_cursor()
|
||||
volume = cursor.execute('''
|
||||
select
|
||||
books.has_cover as has_cover,
|
||||
books.path as path
|
||||
from
|
||||
books
|
||||
where
|
||||
books.id = ?
|
||||
order by books.series_index
|
||||
''', (str(volume_id),)).fetchone()
|
||||
|
||||
if volume['has_cover']:
|
||||
return volume['path'] + '/cover.jpg'
|
||||
else:
|
||||
raise exceptions.NotFound()
|
||||
|
||||
def get_volume_cover_thumbnail(self, volume_id):
|
||||
return generate_thumbnail(self.get_volume_cover(volume_id))
|
||||
|
||||
def get_volume_filepath(self, volume_id):
|
||||
cursor = self.create_cursor()
|
||||
location = cursor.execute('''
|
||||
select
|
||||
books.path as path,
|
||||
data.name as filename,
|
||||
lower(data.format) as extension
|
||||
from
|
||||
books,
|
||||
data
|
||||
where
|
||||
data.book = books.id and
|
||||
books.id = ?
|
||||
''', (str(volume_id),)).fetchone()
|
||||
|
||||
if location is None:
|
||||
raise exceptions.NotFound()
|
||||
|
||||
return location['path'] + '/' + location['filename'] + '.' + location['extension']
|
||||
|
||||
def get_volume_info(self, volume_id):
|
||||
cursor = self.create_cursor()
|
||||
volume_info = cursor.execute('''
|
||||
select
|
||||
books.title,
|
||||
books_series_link.series
|
||||
from
|
||||
books,
|
||||
books_series_link
|
||||
where
|
||||
books_series_link.book = books.id and
|
||||
books.id = ?
|
||||
''', (str(volume_id),)).fetchone()
|
||||
|
||||
volume_info['pages'] = self.get_volume_page_number(volume_id)
|
||||
|
||||
return volume_info
|
||||
|
||||
def get_volume_page_number(self, volume_id):
|
||||
path = self.get_volume_filepath(volume_id)
|
||||
with ZipFile(path, 'r') as volume:
|
||||
return len(volume.filelist)
|
||||
|
||||
def get_volume_page(self, volume_id, page_number):
|
||||
if page_number < 1:
|
||||
raise exceptions.NotFound()
|
||||
path = self.get_volume_filepath(volume_id)
|
||||
with ZipFile(path, 'r') as volume:
|
||||
try:
|
||||
page_filename = volume.filelist[page_number - 1].filename
|
||||
except IndexError:
|
||||
raise exceptions.NotFound()
|
||||
return None
|
||||
|
||||
page_data = BytesIO()
|
||||
page_data.write(volume.read(page_filename))
|
||||
page_data.seek(0)
|
||||
|
||||
return {
|
||||
'data': page_data,
|
||||
'extension': os.path.splitext(page_filename)[1]
|
||||
}
|
2
frontend/.browserslistrc
Normal file
2
frontend/.browserslistrc
Normal file
|
@ -0,0 +1,2 @@
|
|||
> 1%
|
||||
last 2 versions
|
5
frontend/.editorconfig
Normal file
5
frontend/.editorconfig
Normal file
|
@ -0,0 +1,5 @@
|
|||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
17
frontend/.eslintrc.js
Normal file
17
frontend/.eslintrc.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true
|
||||
},
|
||||
'extends': [
|
||||
'plugin:vue/essential',
|
||||
'@vue/standard'
|
||||
],
|
||||
rules: {
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
|
||||
},
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint'
|
||||
}
|
||||
}
|
5
frontend/babel.config.js
Normal file
5
frontend/babel.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app'
|
||||
]
|
||||
}
|
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build --modern",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^2.6.5",
|
||||
"vue": "^2.6.10",
|
||||
"vue-router": "^3.0.3",
|
||||
"vue-shortkey": "^3.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^3.8.0",
|
||||
"@vue/cli-plugin-eslint": "^3.8.0",
|
||||
"@vue/cli-service": "^3.8.0",
|
||||
"@vue/eslint-config-standard": "^4.0.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-plugin-vue": "^5.0.0",
|
||||
"vue-template-compiler": "^2.6.10"
|
||||
}
|
||||
}
|
5
frontend/postcss.config.js
Normal file
5
frontend/postcss.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {}
|
||||
}
|
||||
}
|
17
frontend/public/index.html
Normal file
17
frontend/public/index.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<!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="<%= BASE_URL %>favicon.ico">
|
||||
<title>Manga Reader</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but the manga reader 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>
|
21
frontend/src/App.vue
Normal file
21
frontend/src/App.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<div id="app">
|
||||
<router-view/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: 'PT Sans', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline dashed;
|
||||
}
|
||||
</style>
|
37
frontend/src/api-client.js
Normal file
37
frontend/src/api-client.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
const apiBase = process.env.NODE_ENV === 'production' ? '/api' : 'http://localhost:5000/api'
|
||||
|
||||
const apiRequest = (path, callback) => {
|
||||
fetch(`${apiBase}/${path}`)
|
||||
.then(text => text.json())
|
||||
.then(callback)
|
||||
}
|
||||
|
||||
export default {
|
||||
listSeries (callback) {
|
||||
apiRequest('series', series => {
|
||||
series = series.map(item => {
|
||||
item.thumbnail = `${apiBase}/series/${item.id}/cover/thumbnail`
|
||||
return item
|
||||
})
|
||||
callback(series)
|
||||
})
|
||||
},
|
||||
|
||||
listVolumes (series, callback) {
|
||||
apiRequest(`series/${series}`, info => {
|
||||
info.volumes = info.volumes.map(volume => {
|
||||
volume.thumbnail = `${apiBase}/volume/${volume.id}/cover/thumbnail`
|
||||
return volume
|
||||
})
|
||||
callback(info)
|
||||
})
|
||||
},
|
||||
|
||||
getVolumeInfo (volume, callback) {
|
||||
apiRequest(`volume/${volume}`, info => {
|
||||
info.pages = [...Array(info.pages).keys()].map(page => `${apiBase}/volume/${volume}/page/${page + 1}`)
|
||||
info.id = volume
|
||||
callback(info)
|
||||
})
|
||||
}
|
||||
}
|
12
frontend/src/components/List.vue
Normal file
12
frontend/src/components/List.vue
Normal file
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div class="list">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
29
frontend/src/components/ListItem.vue
Normal file
29
frontend/src/components/ListItem.vue
Normal file
|
@ -0,0 +1,29 @@
|
|||
<template>
|
||||
<RouterLink :to="action">
|
||||
<div class="list-item">
|
||||
<h2>{{ title }}</h2>
|
||||
<img v-bind:src="thumbnail"/>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ListItem',
|
||||
props: [
|
||||
'title',
|
||||
'thumbnail',
|
||||
'action'
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-item {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
12
frontend/src/main.js
Normal file
12
frontend/src/main.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
Vue.use(require('vue-shortkey'))
|
||||
|
||||
new Vue({
|
||||
router,
|
||||
render: h => h(App)
|
||||
}).$mount('#app')
|
29
frontend/src/router.js
Normal file
29
frontend/src/router.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
import Vue from 'vue'
|
||||
import Router from 'vue-router'
|
||||
import SeriesList from './views/SeriesList.vue'
|
||||
import VolumeList from './views/VolumeList.vue'
|
||||
import Reader from './views/Reader.vue'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
export default new Router({
|
||||
mode: 'history',
|
||||
base: process.env.BASE_URL,
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'series-list',
|
||||
component: SeriesList
|
||||
},
|
||||
{
|
||||
path: '/series/:id',
|
||||
name: 'volume-list',
|
||||
component: VolumeList
|
||||
},
|
||||
{
|
||||
path: '/volume/:id',
|
||||
name: 'reader',
|
||||
component: Reader
|
||||
}
|
||||
]
|
||||
})
|
117
frontend/src/views/Reader.vue
Normal file
117
frontend/src/views/Reader.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div class="reader" v-shortkey="{next: ['arrowleft'], prev: ['arrowright']}" @shortkey="navigation" :style="{ 'grid-template-columns': showSidebar ? 'auto 250px' : 'auto' }">
|
||||
<div>
|
||||
<img :style="{ width: pageWidth + '%' }" :src="info.pages[page]">
|
||||
<!-- prefetching -->
|
||||
<img style="display: none;" :src="info.pages[page-1]">
|
||||
<img style="display: none;" :src="info.pages[page+1]">
|
||||
</div>
|
||||
<div class="sidebar" :style="{ display: showSidebar ? '' : 'none' }">
|
||||
<h3>{{ info.title }}</h3>
|
||||
<router-link :to="'/series/' + info.series">All Volumes</router-link>
|
||||
<p>
|
||||
<button v-on:click="changePageWidth(5)">+</button>
|
||||
<button v-on:click="changePageWidth(-5)">-</button>
|
||||
</p>
|
||||
<button class="hide" v-on:click="showSidebar = false">Hide</button>
|
||||
</div>
|
||||
<div class="sidebar-enabler" :style="{ display: showSidebar ? 'none' : '' }" v-on:click="showSidebar = true"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import API from '@/api-client.js'
|
||||
|
||||
export default {
|
||||
name: 'Reader',
|
||||
data () {
|
||||
return {
|
||||
info: {
|
||||
pages: []
|
||||
},
|
||||
scrollPositions: [],
|
||||
pageWidth: 60,
|
||||
showSidebar: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
page () {
|
||||
if (this.$route.query.page) {
|
||||
return parseInt(this.$route.query.page - 1)
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
API.getVolumeInfo(this.$route.params.id, info => (this.info = info))
|
||||
},
|
||||
methods: {
|
||||
setPage (page) {
|
||||
// save scroll position
|
||||
this.scrollPositions[this.page] = window.pageYOffset
|
||||
|
||||
// set page
|
||||
if (page >= 0 && page < this.info.pages.length) {
|
||||
this.$router.push({ query: { page: page + 1 } })
|
||||
}
|
||||
|
||||
// load saved scroll posotion, if available, reset to 0 otherwise
|
||||
if (this.scrollPositions[this.page]) {
|
||||
window.scrollTo(0, this.scrollPositions[this.page])
|
||||
} else {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
},
|
||||
|
||||
navigation (event) {
|
||||
switch (event.srcKey) {
|
||||
case 'next':
|
||||
this.setPage(this.page + 1)
|
||||
break
|
||||
case 'prev':
|
||||
this.setPage(this.page - 1)
|
||||
break
|
||||
}
|
||||
},
|
||||
|
||||
changePageWidth (change) {
|
||||
if (this.pageWidth + change > 0 && this.pageWidth + change <= 100) {
|
||||
this.pageWidth += change
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reader {
|
||||
display: grid;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
.sidebar-enabler {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
height: 100%;
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #eee;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
button.hide {
|
||||
bottom: 1em;
|
||||
right: 1em;
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
27
frontend/src/views/SeriesList.vue
Normal file
27
frontend/src/views/SeriesList.vue
Normal file
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<List>
|
||||
<ListItem v-for="item in series" :key="item.id" :title="item.name" :thumbnail="item.thumbnail" :action="'/series/' + item.id"/>
|
||||
</List>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import API from '@/api-client.js'
|
||||
import List from '@/components/List'
|
||||
import ListItem from '@/components/ListItem'
|
||||
|
||||
export default {
|
||||
name: 'SeriesList',
|
||||
components: {
|
||||
List,
|
||||
ListItem
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
series: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
API.listSeries(series => (this.series = series))
|
||||
}
|
||||
}
|
||||
</script>
|
31
frontend/src/views/VolumeList.vue
Normal file
31
frontend/src/views/VolumeList.vue
Normal file
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>{{ info.title }}</h1>
|
||||
<RouterLink to="/">All Series</RouterLink>
|
||||
<List>
|
||||
<ListItem v-for="volume in info.volumes" :key="volume.id" :title="'Volume ' + volume.index" :thumbnail="volume.thumbnail" :action="'/volume/' + volume.id"/>
|
||||
</List>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import API from '@/api-client.js'
|
||||
import List from '@/components/List'
|
||||
import ListItem from '@/components/ListItem'
|
||||
|
||||
export default {
|
||||
name: 'VolumeList',
|
||||
components: {
|
||||
List,
|
||||
ListItem
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
info: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
API.listVolumes(this.$route.params.id, info => (this.info = info))
|
||||
}
|
||||
}
|
||||
</script>
|
3
frontend/vue.config.js
Normal file
3
frontend/vue.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
assetsDir: 'static'
|
||||
}
|
8345
frontend/yarn.lock
Normal file
8345
frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
72
mangareader.py
Executable file
72
mangareader.py
Executable file
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env python3
|
||||
from backend import CalibreDB
|
||||
from flask import Flask, jsonify, send_from_directory, send_file, render_template, request
|
||||
from flask_cors import CORS
|
||||
from werkzeug.routing import BaseConverter
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
|
||||
def send_from_cwd(filename):
|
||||
return send_from_directory(os.getcwd(), filename)
|
||||
|
||||
|
||||
app = Flask(__name__, static_folder='frontend/dist/static', template_folder='frontend/dist')
|
||||
CORS(app)
|
||||
|
||||
db = CalibreDB()
|
||||
|
||||
# kind of redundant, but avoids returning 200 if page does not exist
|
||||
@app.route('/')
|
||||
@app.route('/series/<id>')
|
||||
@app.route('/volume/<id>')
|
||||
def serve_app(**kwargs):
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
@app.route('/api/series')
|
||||
def get_series_list():
|
||||
return jsonify(list(db.get_series_list()))
|
||||
|
||||
|
||||
@app.route('/api/series/<int:series_id>/cover')
|
||||
def get_series_cover(series_id):
|
||||
return send_from_cwd(db.get_series_cover(series_id))
|
||||
|
||||
|
||||
@app.route('/api/series/<int:series_id>/cover/thumbnail')
|
||||
def get_series_cover_thumbnail(series_id):
|
||||
thumbnail = db.get_series_cover_thumbnail(series_id)
|
||||
return send_file(thumbnail, mimetype='image/jpeg')
|
||||
|
||||
|
||||
@app.route('/api/series/<int:series_id>')
|
||||
def get_series_info(series_id):
|
||||
return jsonify(db.get_series_volumes(series_id))
|
||||
|
||||
|
||||
@app.route('/api/volume/<int:volume_id>/cover')
|
||||
def get_volume_cover(volume_id):
|
||||
return send_from_cwd(db.get_volume_cover(volume_id))
|
||||
|
||||
|
||||
@app.route('/api/volume/<int:volume_id>/cover/thumbnail')
|
||||
def get_volume_cover_thumbnail(volume_id):
|
||||
thumbnail = db.get_volume_cover_thumbnail(volume_id)
|
||||
return send_file(thumbnail, mimetype='image/jpeg')
|
||||
|
||||
|
||||
@app.route('/api/volume/<int:volume_id>')
|
||||
def get_volume_info(volume_id):
|
||||
return jsonify(db.get_volume_info(volume_id))
|
||||
|
||||
|
||||
@app.route('/api/volume/<int:volume_id>/page/<int:page_number>')
|
||||
def get_volume_page(volume_id, page_number):
|
||||
page = db.get_volume_page(volume_id, page_number)
|
||||
mimetype = mimetypes.types_map[page['extension']]
|
||||
return send_file(page['data'], mimetype=mimetype)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0')
|
Reference in a new issue