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:
2022-08-25 15:42:20 +01:00
committed by GitHub
parent 3be7d9cb5c
commit c5633bf54a
86 changed files with 25205 additions and 644 deletions

23
frontend/.gitignore vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

19
frontend/jsconfig.json Normal file
View 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

File diff suppressed because it is too large Load Diff

69
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

138
frontend/public/logo.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

13
frontend/src/App.vue Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

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

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

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

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

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

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

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

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

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

View 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&nbsp;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&nbsp;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>

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

View 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&nbsp;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&nbsp;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>

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

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

View 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
View 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')

View 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
View 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: {
}
})

View File

@@ -0,0 +1,5 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

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

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

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

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

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

View 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
View 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
View File

@@ -0,0 +1,4 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true
})

63
frontend/webpack.dev.js Normal file
View 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
View 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',
},
},
};
}