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

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>