mirror of
https://github.com/ajurna/cbwebreader.git
synced 2025-12-06 14:17:19 +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:
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>
|
||||
Reference in New Issue
Block a user