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:
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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user