mirror of
https://github.com/ajurna/cbwebreader.git
synced 2026-11-02 10:36:55 +00:00
**Add continuous reading mode and enhance accessibility in TheComicReader**
- Introduced a continuous reading mode ("scroll"), allowing users to read without pagination.
- Implemented a toggle in the UI to switch between "paged" and "continuous" modes, with preferences saved in `localStorage`.
- Enhanced keyboard navigation by throttling repeated actions and ignoring irrelevant keypresses during form interactions.
- Integrated `IntersectionObserver` to handle lazy-loading of images and dynamically extending rendered content in continuous mode.
- Updated tests to validate both reading modes, ensuring proper state management and observer handling.
- Adjusted animations and transitions for smoother visual feedback in both modes, respecting users' motion-reduction preferences.
This commit is contained in:
1 parent
494b64621e
commit
80566e657e
6 files changed
+627
-72
No files matched your search
+3
-1
@@ -1 +1,3 @@
|
|||||||
*/15 * * * * root /opt/venv/bin/python /src/manage.py scan_comics --settings=cbreader.settings.base
|
# Cron captures job output for e-mail, which is discarded without an MTA, so write to
|
||||||
|
# the cron daemon's own stderr instead - that is what `docker logs` shows.
|
||||||
|
*/15 * * * * root /opt/venv/bin/python /src/manage.py scan_comics --settings=cbreader.settings.base >> /proc/1/fd/2 2>&1
|
||||||
@@ -52,6 +52,7 @@ export default [
|
|||||||
console: 'readonly',
|
console: 'readonly',
|
||||||
setTimeout: 'readonly',
|
setTimeout: 'readonly',
|
||||||
clearTimeout: 'readonly',
|
clearTimeout: 'readonly',
|
||||||
|
IntersectionObserver: 'readonly',
|
||||||
process: 'readonly',
|
process: 'readonly',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="reveal" id="comic_box" ref="comic_box" >
|
<div class="reveal" :class="{ continuous: mode === 'scroll' }" id="comic_box" ref="comic_box">
|
||||||
<div id="slides_div" class="slides" ref="slides">
|
<div id="slides_div" class="slides" ref="slides">
|
||||||
<section class="" v-for="page in pages" :key="page" :data-menu-title="page" hidden>
|
<section
|
||||||
<img :data-src="'/api/read/' + selector + '/image/' + page + '/'" class="w-100" :alt="page">
|
v-for="page in visible_pages"
|
||||||
|
:key="page"
|
||||||
|
:data-page="page"
|
||||||
|
:data-menu-title="page"
|
||||||
|
:hidden="mode === 'paged'"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:class="{ shown: mode === 'scroll' && inview.has(page) }"
|
||||||
|
:src="mode === 'scroll' ? image_url(page) : null"
|
||||||
|
:data-src="mode === 'paged' ? image_url(page) : null"
|
||||||
|
:loading="mode === 'scroll' ? 'lazy' : null"
|
||||||
|
class="w-100"
|
||||||
|
:alt="page"
|
||||||
|
>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sentinel" ref="sentinel" v-if="mode === 'scroll' && rendered < pages"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row navButtons pb-2">
|
<div class="row navButtons pb-2">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="Reading mode">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
:class="{ active: mode === 'paged' }"
|
||||||
|
@click="setMode('paged')"
|
||||||
|
>One page</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
:class="{ active: mode === 'scroll' }"
|
||||||
|
@click="setMode('scroll')"
|
||||||
|
>Continuous</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
<comic-paginate
|
<comic-paginate
|
||||||
v-model="paginate_page"
|
v-model="paginate_page"
|
||||||
:page_count="pages"
|
:page_count="pages"
|
||||||
@@ -15,6 +46,7 @@
|
|||||||
@nextComic="nextComic"
|
@nextComic="nextComic"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -24,6 +56,14 @@ import api from "@/api";
|
|||||||
import Hammer from 'hammerjs'
|
import Hammer from 'hammerjs'
|
||||||
import ComicPaginate from "@/components/ComicPaginate";
|
import ComicPaginate from "@/components/ComicPaginate";
|
||||||
|
|
||||||
|
// How many pages are rendered at a time in continuous mode. The window grows as the
|
||||||
|
// reader approaches the end of what has already been rendered.
|
||||||
|
const WINDOW_SIZE = 20;
|
||||||
|
|
||||||
|
// Held keys repeat, so a key press is handled immediately and then ignored for this
|
||||||
|
// long. The page write is already debounced by queue_save, so nothing is sent per key.
|
||||||
|
const KEY_THROTTLE_MS = 100;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "TheComicReader",
|
name: "TheComicReader",
|
||||||
components: {ComicPaginate},
|
components: {ComicPaginate},
|
||||||
@@ -31,23 +71,67 @@ export default {
|
|||||||
return {
|
return {
|
||||||
current_page: 0,
|
current_page: 0,
|
||||||
paginate_page: 1,
|
paginate_page: 1,
|
||||||
|
inview: new Set(),
|
||||||
deck: null,
|
deck: null,
|
||||||
title: '',
|
title: '',
|
||||||
prev_comic: {},
|
prev_comic: {},
|
||||||
next_comic: {},
|
next_comic: {},
|
||||||
pages: 1,
|
pages: 1,
|
||||||
|
rendered: 0,
|
||||||
|
visible: new Map(),
|
||||||
|
page_observer: null,
|
||||||
|
growth_observer: null,
|
||||||
|
save_timeout: null,
|
||||||
|
key_locked: false,
|
||||||
|
key_timeout: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
selector: String
|
selector: String
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
mode () {
|
||||||
|
return this.$store.state.reader_mode
|
||||||
|
},
|
||||||
|
set_read_url () {
|
||||||
|
return '/api/read/' + this.selector + '/set_page/'
|
||||||
|
},
|
||||||
|
visible_pages () {
|
||||||
|
if (this.mode === 'paged') {
|
||||||
|
return Array.from({length: this.pages}, (_item, index) => index + 1)
|
||||||
|
}
|
||||||
|
return Array.from({length: Math.min(this.rendered, this.pages)}, (_item, index) => index + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
prevPage(){
|
image_url (page) {
|
||||||
|
return '/api/read/' + this.selector + '/image/' + page + '/'
|
||||||
|
},
|
||||||
|
prevPage(behavior = 'smooth'){
|
||||||
|
if (this.mode === 'scroll') {
|
||||||
|
if (this.current_page <= 0) {
|
||||||
|
this.prevComic()
|
||||||
|
} else {
|
||||||
|
this.current_page -= 1
|
||||||
|
this.scroll_to_page(this.current_page + 1, behavior)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// reveal initializes asynchronously, so a key pressed before the promise resolves
|
||||||
|
// has no deck to move.
|
||||||
|
if (!this.deck) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.deck.isFirstSlide()){
|
if (this.deck.isFirstSlide()){
|
||||||
this.prevComic()
|
this.prevComic()
|
||||||
} else {
|
} else {
|
||||||
this.current_page -= 1
|
this.current_page -= 1
|
||||||
this.deck.slide(this.current_page)
|
this.deck.slide(this.current_page)
|
||||||
|
// Pages can be taller than the viewport, so show the top of the new page instead
|
||||||
|
// of keeping the scroll offset left over from the previous one.
|
||||||
|
this.scroll_to_page(this.current_page + 1, behavior)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
prevComic(){
|
prevComic(){
|
||||||
@@ -62,59 +146,140 @@ export default {
|
|||||||
params: {selector: this.next_comic.selector}
|
params: {selector: this.next_comic.selector}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
nextPage(){
|
nextPage(behavior = 'smooth'){
|
||||||
|
if (this.mode === 'scroll') {
|
||||||
|
if (this.current_page + 1 >= this.pages) {
|
||||||
|
this.nextComic()
|
||||||
|
} else {
|
||||||
|
this.current_page += 1
|
||||||
|
this.scroll_to_page(this.current_page + 1, behavior)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.deck) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.deck.isLastSlide()){
|
if (this.deck.isLastSlide()){
|
||||||
this.nextComic()
|
this.nextComic()
|
||||||
} else {
|
} else {
|
||||||
this.current_page += 1
|
this.current_page += 1
|
||||||
this.deck.slide(this.current_page)
|
this.deck.slide(this.current_page)
|
||||||
|
this.scroll_to_page(this.current_page + 1, behavior)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setPage(pageNum){
|
setPage(pageNum){
|
||||||
|
if (this.mode === 'scroll') {
|
||||||
|
const page = Math.min(pageNum, this.pages)
|
||||||
|
this.rendered = Math.max(this.rendered, page)
|
||||||
|
this.current_page = page - 1
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.observe_pages()
|
||||||
|
this.scroll_to_page(page)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.current_page = pageNum-1
|
this.current_page = pageNum-1
|
||||||
this.deck.slide(this.current_page)
|
this.deck.slide(this.current_page)
|
||||||
},
|
},
|
||||||
keyPressDebounce(e){
|
scroll_to_page (page, behavior = 'smooth') {
|
||||||
clearTimeout(this.key_timeout)
|
const section = this.$refs.slides.querySelector('section[data-page="' + page + '"]')
|
||||||
this.key_timeout = setTimeout(() => {this.keyPress(e)}, 50)
|
if (section) {
|
||||||
|
section.scrollIntoView({behavior: behavior, block: 'start'})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
keyPress (e) {
|
keyPress (e) {
|
||||||
if (e.key === 'ArrowRight') {
|
// Alt+Left is the browser's back button and Ctrl/Cmd+Arrow moves a caret in a
|
||||||
this.nextPage()
|
// form control, so only a bare arrow key pages.
|
||||||
} else if (e.key === 'ArrowLeft') {
|
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||||
this.prevPage()
|
return
|
||||||
} 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
|
|
||||||
|
|
||||||
|
// The reader listens on window, so a key pressed while a control has focus would
|
||||||
|
// otherwise page as well as activate that control.
|
||||||
|
if (e.target && e.target.closest && e.target.closest('input, select, textarea, button')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.key_locked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.key_locked = true
|
||||||
|
this.key_timeout = setTimeout(() => { this.key_locked = false }, KEY_THROTTLE_MS)
|
||||||
|
|
||||||
|
// Left and Right change pages. Up and Down are left to the browser's default
|
||||||
|
// scrolling: the page is taller than the viewport, and intercepting them here would
|
||||||
|
// double up with the native scroll. In continuous mode the observer records whichever
|
||||||
|
// page is topmost, so scrolling inside a page does not change the saved page.
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
this.nextPage('auto')
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
this.prevPage('auto')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Continuous mode: reveal is not used for visibility here, so the page in view is
|
||||||
|
// worked out from what is actually intersecting the viewport.
|
||||||
|
start_scroll () {
|
||||||
|
this.visible.clear()
|
||||||
|
this.inview.clear()
|
||||||
|
|
||||||
|
this.page_observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
this.visible.set(entry.target, entry.boundingClientRect.top)
|
||||||
|
this.inview.add(Number(entry.target.dataset.page))
|
||||||
|
} else {
|
||||||
|
this.visible.delete(entry.target)
|
||||||
|
this.inview.delete(Number(entry.target.dataset.page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.visible.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = [...this.visible.entries()].sort((a, b) => a[1] - b[1])[0][0]
|
||||||
|
const page = Number(top.dataset.page)
|
||||||
|
if (page && page !== this.current_page + 1) {
|
||||||
|
this.current_page = page - 1
|
||||||
|
this.queue_save(page - 1)
|
||||||
|
}
|
||||||
|
}, { threshold: 0 })
|
||||||
|
|
||||||
|
this.growth_observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
this.extend_window()
|
||||||
|
}
|
||||||
|
}, { rootMargin: '1200px 0px' })
|
||||||
|
|
||||||
|
this.observe_pages()
|
||||||
|
if (this.$refs.sentinel) {
|
||||||
|
this.growth_observer.observe(this.$refs.sentinel)
|
||||||
|
}
|
||||||
|
this.scroll_to_page(this.current_page + 1)
|
||||||
|
},
|
||||||
|
observe_pages () {
|
||||||
|
this.$refs.slides.querySelectorAll('section[data-page]').forEach((section) => {
|
||||||
|
this.page_observer.observe(section)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
extend_window () {
|
||||||
|
if (this.rendered >= this.pages) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.rendered = Math.min(this.pages, this.rendered + WINDOW_SIZE)
|
||||||
|
this.$nextTick(() => this.observe_pages())
|
||||||
|
},
|
||||||
|
// One write per burst of scrolling rather than one per intersection.
|
||||||
|
queue_save (page) {
|
||||||
|
clearTimeout(this.save_timeout)
|
||||||
|
this.save_timeout = setTimeout(() => {
|
||||||
|
api.put(this.set_read_url, {page: page})
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
start_paged () {
|
||||||
this.deck = Reveal(this.$refs.comic_box)
|
this.deck = Reveal(this.$refs.comic_box)
|
||||||
this.deck.initialize({
|
this.deck.initialize({
|
||||||
controls: false,
|
controls: false,
|
||||||
@@ -130,13 +295,83 @@ export default {
|
|||||||
plugins: [ ]
|
plugins: [ ]
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
this.deck.slide(this.current_page)
|
this.deck.slide(this.current_page)
|
||||||
api.put(set_read_url, {page: this.current_page})
|
|
||||||
this.slideChanged = (event) => {
|
this.slideChanged = (event) => {
|
||||||
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'})
|
this.$refs.comic_box.scrollIntoView({behavior: 'smooth'})
|
||||||
api.put(set_read_url, {page: event.indexh})
|
api.put(this.set_read_url, {page: event.indexh})
|
||||||
}
|
}
|
||||||
this.deck.on('slidechanged', this.slideChanged);
|
this.deck.on('slidechanged', this.slideChanged);
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
async setMode (mode) {
|
||||||
|
if (mode === this.mode) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.commit('setReaderMode', mode)
|
||||||
|
this.teardown()
|
||||||
|
await this.$nextTick()
|
||||||
|
|
||||||
|
if (mode === 'scroll') {
|
||||||
|
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
|
||||||
|
await this.$nextTick()
|
||||||
|
this.start_scroll()
|
||||||
|
} else {
|
||||||
|
this.start_paged()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
teardown () {
|
||||||
|
clearTimeout(this.save_timeout)
|
||||||
|
this.inview.clear()
|
||||||
|
|
||||||
|
if (this.page_observer) {
|
||||||
|
this.page_observer.disconnect()
|
||||||
|
this.page_observer = null
|
||||||
|
}
|
||||||
|
if (this.growth_observer) {
|
||||||
|
this.growth_observer.disconnect()
|
||||||
|
this.growth_observer = null
|
||||||
|
}
|
||||||
|
this.visible.clear()
|
||||||
|
|
||||||
|
if (this.deck) {
|
||||||
|
if (this.slideChanged) {
|
||||||
|
this.deck.off('slidechanged', this.slideChanged)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.deck.destroy()
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
this.deck = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
'current_page' (new_page) {
|
||||||
|
this.paginate_page = new_page + 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
let comic_data_url = '/api/read/' + this.selector + '/'
|
||||||
|
window.addEventListener('keydown', this.keyPress)
|
||||||
|
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
|
||||||
|
|
||||||
|
if (this.mode === 'scroll') {
|
||||||
|
this.rendered = Math.min(this.pages, Math.max(WINDOW_SIZE, this.current_page + 1))
|
||||||
|
return this.$nextTick().then(() => this.start_scroll())
|
||||||
|
}
|
||||||
|
return this.$nextTick().then(() => this.start_paged())
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
api.put(this.set_read_url, {page: this.current_page})
|
||||||
|
})
|
||||||
|
.catch((error) => {console.log(error)})
|
||||||
|
|
||||||
this.hammertime = new Hammer(this.$refs.comic_box, {})
|
this.hammertime = new Hammer(this.$refs.comic_box, {})
|
||||||
this.hammertime.on('swipeleft', (_e, self=this) => {
|
this.hammertime.on('swipeleft', (_e, self=this) => {
|
||||||
@@ -148,23 +383,11 @@ export default {
|
|||||||
this.hammertime.on('tap', (_e, self=this) => {
|
this.hammertime.on('tap', (_e, self=this) => {
|
||||||
self.nextPage()
|
self.nextPage()
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
|
||||||
.catch((error) => {console.log(error)})
|
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
window.removeEventListener('keyup', this.keyPressDebounce)
|
window.removeEventListener('keydown', this.keyPress)
|
||||||
clearTimeout(this.key_timeout)
|
clearTimeout(this.key_timeout)
|
||||||
if (this.deck) {
|
this.teardown()
|
||||||
if (this.slideChanged) {
|
|
||||||
this.deck.off('slidechanged', this.slideChanged)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.deck.destroy()
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
this.hammertime.off('swipeleft')
|
this.hammertime.off('swipeleft')
|
||||||
this.hammertime.off('swiperight')
|
this.hammertime.off('swiperight')
|
||||||
@@ -191,4 +414,34 @@ export default {
|
|||||||
section {
|
section {
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* reveal.css is deliberately not loaded, so visibility is the `hidden` attribute and
|
||||||
|
reveal's own transition classes do nothing. A transition cannot run across
|
||||||
|
display:none -> block, so paged mode uses an animation on the slide that becomes
|
||||||
|
present; continuous mode uses a transition driven by the IntersectionObserver. */
|
||||||
|
.reveal section.present img {
|
||||||
|
animation: page-fade 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continuous img {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.continuous img.shown {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes page-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.reveal section.present img,
|
||||||
|
.continuous img {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -14,6 +14,12 @@ function get_user_from_storage(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reading mode is a preference rather than part of the session, so it is kept on its own.
|
||||||
|
function get_reader_mode_from_storage(){
|
||||||
|
const mode = localStorage.getItem('reader_mode')
|
||||||
|
return mode === 'scroll' ? 'scroll' : 'paged'
|
||||||
|
}
|
||||||
|
|
||||||
function session_expired_query(next){
|
function session_expired_query(next){
|
||||||
return {
|
return {
|
||||||
name: 'login',
|
name: 'login',
|
||||||
@@ -28,6 +34,7 @@ export default createStore({
|
|||||||
state: {
|
state: {
|
||||||
filters: {},
|
filters: {},
|
||||||
user: get_user_from_storage(),
|
user: get_user_from_storage(),
|
||||||
|
reader_mode: get_reader_mode_from_storage(),
|
||||||
classifications: [
|
classifications: [
|
||||||
{label: 'G', value: '0'},
|
{label: 'G', value: '0'},
|
||||||
{label: 'PG', value: '1'},
|
{label: 'PG', value: '1'},
|
||||||
@@ -57,6 +64,13 @@ export default createStore({
|
|||||||
localStorage.setItem('u', JSON.stringify(userData));
|
localStorage.setItem('u', JSON.stringify(userData));
|
||||||
state.user = userData
|
state.user = userData
|
||||||
},
|
},
|
||||||
|
setReaderMode(state, mode){
|
||||||
|
if (mode !== 'paged' && mode !== 'scroll') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem('reader_mode', mode);
|
||||||
|
state.reader_mode = mode
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
obtainToken(context, {username, password}){
|
obtainToken(context, {username, password}){
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
|
const api = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
|
||||||
const router = vi.hoisted(() => ({ push: vi.fn() }))
|
const router = vi.hoisted(() => ({ push: vi.fn() }))
|
||||||
@@ -14,6 +15,31 @@ const deck = vi.hoisted(() => ({
|
|||||||
}))
|
}))
|
||||||
const hammertime = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }))
|
const hammertime = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn() }))
|
||||||
const reveal = vi.hoisted(() => vi.fn(() => deck))
|
const reveal = vi.hoisted(() => vi.fn(() => deck))
|
||||||
|
const store = vi.hoisted(() => ({
|
||||||
|
state: { reader_mode: 'paged' },
|
||||||
|
commit: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const observers = []
|
||||||
|
|
||||||
|
// jsdom has no IntersectionObserver, so the tests drive the callbacks by hand.
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
constructor(callback, options) {
|
||||||
|
this.callback = callback
|
||||||
|
this.options = options
|
||||||
|
this.targets = []
|
||||||
|
this.disconnected = false
|
||||||
|
observers.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(target) {
|
||||||
|
if (!this.targets.includes(target)) this.targets.push(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
unobserve(target) { this.targets = this.targets.filter((item) => item !== target) }
|
||||||
|
disconnect() { this.disconnected = true; this.targets = [] }
|
||||||
|
fire(entries) { this.callback(entries, this) }
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock('@/api', () => ({ default: api }))
|
vi.mock('@/api', () => ({ default: api }))
|
||||||
vi.mock('reveal.js', () => ({ default: reveal }))
|
vi.mock('reveal.js', () => ({ default: reveal }))
|
||||||
@@ -35,7 +61,7 @@ function mountReader() {
|
|||||||
const wrapper = mount(TheComicReader, {
|
const wrapper = mount(TheComicReader, {
|
||||||
props: { selector: 'a1' },
|
props: { selector: 'a1' },
|
||||||
global: {
|
global: {
|
||||||
mocks: { $router: router },
|
mocks: { $router: router, $store: store },
|
||||||
stubs: { 'comic-paginate': true },
|
stubs: { 'comic-paginate': true },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -51,6 +77,15 @@ beforeEach(() => {
|
|||||||
api.get.mockResolvedValue({ data: { ...COMIC } })
|
api.get.mockResolvedValue({ data: { ...COMIC } })
|
||||||
api.put.mockResolvedValue({ data: {} })
|
api.put.mockResolvedValue({ data: {} })
|
||||||
router.push.mockReset()
|
router.push.mockReset()
|
||||||
|
store.state = reactive({ reader_mode: 'paged' })
|
||||||
|
store.commit.mockReset()
|
||||||
|
store.commit.mockImplementation((name, value) => {
|
||||||
|
if (name === 'setReaderMode' && (value === 'paged' || value === 'scroll')) {
|
||||||
|
store.state.reader_mode = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observers.length = 0
|
||||||
|
globalThis.IntersectionObserver = FakeIntersectionObserver
|
||||||
reveal.mockClear()
|
reveal.mockClear()
|
||||||
for (const fn of Object.values(deck)) {
|
for (const fn of Object.values(deck)) {
|
||||||
if (typeof fn === 'function') fn.mockClear?.()
|
if (typeof fn === 'function') fn.mockClear?.()
|
||||||
@@ -87,16 +122,19 @@ describe('TheComicReader', () => {
|
|||||||
expect(wrapper.vm.paginate_page).toBe(4)
|
expect(wrapper.vm.paginate_page).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('debounces arrow keys so held keys do not spam the deck', async () => {
|
it('acts on the first arrow key and ignores held keys until the throttle expires', async () => {
|
||||||
const wrapper = mountReader()
|
const wrapper = mountReader()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||||
vi.advanceTimersByTime(50)
|
|
||||||
|
|
||||||
expect(deck.slide).toHaveBeenCalledTimes(2)
|
|
||||||
expect(wrapper.vm.current_page).toBe(3)
|
expect(wrapper.vm.current_page).toBe(3)
|
||||||
|
expect(deck.slide).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||||
|
expect(wrapper.vm.current_page).toBe(4)
|
||||||
|
expect(deck.slide).toHaveBeenCalledTimes(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('moves to the next book when the last slide is reached', async () => {
|
it('moves to the next book when the last slide is reached', async () => {
|
||||||
@@ -138,11 +176,11 @@ describe('TheComicReader', () => {
|
|||||||
const wrapper = mountReader()
|
const wrapper = mountReader()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(addSpy).toHaveBeenCalledWith('keyup', wrapper.vm.keyPressDebounce)
|
expect(addSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
|
||||||
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
|
|
||||||
expect(removeSpy).toHaveBeenCalledWith('keyup', wrapper.vm.keyPressDebounce)
|
expect(removeSpy).toHaveBeenCalledWith('keydown', wrapper.vm.keyPress)
|
||||||
expect(deck.off).toHaveBeenCalledWith('slidechanged', wrapper.vm.slideChanged)
|
expect(deck.off).toHaveBeenCalledWith('slidechanged', wrapper.vm.slideChanged)
|
||||||
expect(deck.destroy).toHaveBeenCalled()
|
expect(deck.destroy).toHaveBeenCalled()
|
||||||
expect(hammertime.off).toHaveBeenCalledWith('swipeleft')
|
expect(hammertime.off).toHaveBeenCalledWith('swipeleft')
|
||||||
@@ -153,15 +191,238 @@ describe('TheComicReader', () => {
|
|||||||
removeSpy.mockRestore()
|
removeSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not act on a key press that was debounced just before unmounting', async () => {
|
it('leaves arrow keys with a modifier to the browser', async () => {
|
||||||
const wrapper = mountReader()
|
const wrapper = mountReader()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
deck.slide.mockClear()
|
deck.slide.mockClear()
|
||||||
|
|
||||||
window.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }))
|
wrapper.vm.keyPress({ key: 'ArrowLeft', altKey: true })
|
||||||
wrapper.unmount()
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
vi.advanceTimersByTime(100)
|
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
expect(wrapper.vm.current_page).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores arrow keys pressed while a form control has focus', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
deck.slide.mockClear()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('input') })
|
||||||
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight', target: document.createElement('button') })
|
||||||
expect(deck.slide).not.toHaveBeenCalled()
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('leaves Up and Down to the browser so they scroll within the page', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
deck.slide.mockClear()
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowDown' })
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowUp' })
|
||||||
|
|
||||||
|
// Nothing is intercepted, so the native scroll is not doubled up and the recorded
|
||||||
|
// page stays where the reader actually is.
|
||||||
|
expect(wrapper.vm.current_page).toBe(2)
|
||||||
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
|
expect(api.put).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the top of the page when changing pages in paged mode', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
const scrollIntoView = HTMLElement.prototype.scrollIntoView
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not move the deck when a key arrives before reveal has initialized', async () => {
|
||||||
|
const wrapper = mountReader()
|
||||||
|
deck.slide.mockClear()
|
||||||
|
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
expect(deck.slide).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
expect(deck.initialize).toHaveBeenCalled()
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
wrapper.vm.keyPress({ key: 'ArrowRight' })
|
||||||
|
expect(wrapper.vm.current_page).toBe(3)
|
||||||
|
expect(deck.slide).toHaveBeenCalledWith(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TheComicReader in continuous mode', () => {
|
||||||
|
function mountContinuous(pageCount) {
|
||||||
|
store.state.reader_mode = 'scroll'
|
||||||
|
api.get.mockResolvedValue({ data: { ...COMIC, pages: pageCount } })
|
||||||
|
return mountReader()
|
||||||
|
}
|
||||||
|
|
||||||
|
function growthObserver() {
|
||||||
|
return observers.find((observer) => observer.options.rootMargin)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageObserver() {
|
||||||
|
return observers.find((observer) => !observer.options.rootMargin)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders only the first window of pages and does not start a deck', async () => {
|
||||||
|
const wrapper = mountContinuous(45)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(20)
|
||||||
|
expect(wrapper.findAll('section[data-page]')).toHaveLength(20)
|
||||||
|
expect(reveal).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders far enough to include the page it resumed on', async () => {
|
||||||
|
store.state.reader_mode = 'scroll'
|
||||||
|
api.get.mockResolvedValue({ data: { ...COMIC, last_read_page: 30, pages: 45 } })
|
||||||
|
const wrapper = mountReader()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(31)
|
||||||
|
expect(wrapper.findAll('section[data-page]')).toHaveLength(31)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('grows the window when the reader reaches the end of what is rendered', async () => {
|
||||||
|
const wrapper = mountContinuous(45)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(40)
|
||||||
|
expect(wrapper.findAll('section[data-page]')).toHaveLength(40)
|
||||||
|
expect(pageObserver().targets).toHaveLength(40)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not grow past the last page', async () => {
|
||||||
|
const wrapper = mountContinuous(25)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
growthObserver().fire([{ isIntersecting: true, target: wrapper.find('.sentinel').element, boundingClientRect: { top: 0 } }])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(25)
|
||||||
|
expect(wrapper.find('.sentinel').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('records the page at the top of the viewport', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
const sections = wrapper.findAll('section[data-page]')
|
||||||
|
pageObserver().fire([
|
||||||
|
{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 10 } },
|
||||||
|
{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 400 } },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(wrapper.vm.current_page).toBe(4)
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the recorded page while scrolling inside a page taller than the viewport', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
const sections = wrapper.findAll('section[data-page]')
|
||||||
|
const observer = pageObserver()
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: 0 } }])
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 4 })
|
||||||
|
|
||||||
|
// Scrolling down inside that page leaves it topmost, so nothing is written.
|
||||||
|
api.put.mockClear()
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[4].element, boundingClientRect: { top: -300 } }])
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
expect(wrapper.vm.current_page).toBe(4)
|
||||||
|
expect(api.put).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
// The recorded page only changes when the next page becomes the topmost one.
|
||||||
|
observer.fire([
|
||||||
|
{ isIntersecting: false, target: sections[4].element },
|
||||||
|
{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 0 } },
|
||||||
|
])
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
expect(wrapper.vm.current_page).toBe(5)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes once for a burst of scrolling rather than once per intersection', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
api.put.mockClear()
|
||||||
|
|
||||||
|
const sections = wrapper.findAll('section[data-page]')
|
||||||
|
const observer = pageObserver()
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[5].element, boundingClientRect: { top: 400 } }])
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[6].element, boundingClientRect: { top: 0 } }])
|
||||||
|
vi.advanceTimersByTime(500)
|
||||||
|
|
||||||
|
expect(api.put).toHaveBeenCalledTimes(1)
|
||||||
|
expect(api.put).toHaveBeenCalledWith('/api/read/a1/set_page/', { page: 6 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the page in view as shown so it fades in', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const sections = wrapper.findAll('section[data-page]')
|
||||||
|
const observer = pageObserver()
|
||||||
|
observer.fire([{ isIntersecting: true, target: sections[2].element, boundingClientRect: { top: 0 } }])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(sections[2].find('img').classes()).toContain('shown')
|
||||||
|
expect(sections[0].find('img').classes()).not.toContain('shown')
|
||||||
|
|
||||||
|
observer.fire([{ isIntersecting: false, target: sections[2].element, boundingClientRect: { top: 0 } }])
|
||||||
|
await flushPromises()
|
||||||
|
expect(sections[2].find('img').classes()).not.toContain('shown')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scrolls to a page chosen from the pager, rendering it first if it is outside the window', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const target = wrapper.findAll('section[data-page]')[19].element
|
||||||
|
const spy = vi.spyOn(target, 'scrollIntoView')
|
||||||
|
|
||||||
|
wrapper.vm.setPage(20)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.vm.rendered).toBe(20)
|
||||||
|
expect(spy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disconnects both observers when it is unmounted', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
expect(observers.every((observer) => observer.disconnected)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits the mode change and rebuilds the deck when switching back to one page at a time', async () => {
|
||||||
|
const wrapper = mountContinuous(30)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.vm.setMode('paged')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(store.commit).toHaveBeenCalledWith('setReaderMode', 'paged')
|
||||||
|
expect(observers.every((observer) => observer.disconnected)).toBe(true)
|
||||||
|
expect(reveal).toHaveBeenCalled()
|
||||||
|
expect(wrapper.findAll('section[data-page]')).toHaveLength(30)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -40,6 +40,30 @@ describe('session state', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('reader mode', () => {
|
||||||
|
it('starts in paged mode when nothing is stored', async () => {
|
||||||
|
vi.resetModules()
|
||||||
|
const fresh = (await import('@/store')).default
|
||||||
|
|
||||||
|
expect(fresh.state.reader_mode).toBe('paged')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the chosen mode in localStorage', () => {
|
||||||
|
store.commit('setReaderMode', 'scroll')
|
||||||
|
|
||||||
|
expect(store.state.reader_mode).toBe('scroll')
|
||||||
|
expect(localStorage.getItem('reader_mode')).toBe('scroll')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a mode it does not recognise', () => {
|
||||||
|
store.commit('setReaderMode', 'scroll')
|
||||||
|
store.commit('setReaderMode', 'banana')
|
||||||
|
|
||||||
|
expect(store.state.reader_mode).toBe('scroll')
|
||||||
|
expect(localStorage.getItem('reader_mode')).toBe('scroll')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('restoreSession', () => {
|
describe('restoreSession', () => {
|
||||||
it('refreshes the stored profile from the cookie session', async () => {
|
it('refreshes the stored profile from the cookie session', async () => {
|
||||||
store.commit('updateUser', { id: 1, is_superuser: false })
|
store.commit('updateUser', { id: 1, is_superuser: false })
|
||||||
|
|||||||
Reference in new issue
Block a user