Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Ling
7a7d9eb3a1 Update README.md 2025-03-23 16:16:02 +07:00
25 changed files with 1686 additions and 1803 deletions

View File

@@ -1,11 +0,0 @@
module.exports = {
parser: '@babel/eslint-parser',
parserOptions: { requireConfigFile: false },
plugins: ['prettier'],
rules: {
eqeqeq: ['error', 'always'],
'object-shorthand': ['error', 'always'],
'prettier/prettier': 'error',
'no-var': 'error',
},
};

View File

@@ -1,6 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2
}

View File

@@ -29,7 +29,6 @@ test:
check:
crystal tool format --check
./bin/ameba
yarn lint
arm32v7:
crystal build src/mango.cr --release --progress --error-trace --cross-compile --target='arm-linux-gnueabihf' -o mango-arm32v7

View File

@@ -4,6 +4,9 @@
[![Patreon](https://img.shields.io/badge/support-patreon-brightgreen?link=https://www.patreon.com/hkalexling)](https://www.patreon.com/hkalexling) ![Build](https://github.com/hkalexling/Mango/workflows/Build/badge.svg) [![Gitter](https://badges.gitter.im/mango-cr/mango.svg)](https://gitter.im/mango-cr/mango?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Discord](https://img.shields.io/discord/855633663425118228?label=discord)](http://discord.com/invite/ezKtacCp9Q)
> [!CAUTION]
> As of March 2025, Mango is no longer maintained. We are incredibly grateful to everyone who used it, contributed, or gave feedback along the way - thank you! Unfortunately, we just don't have the time to keep it going right now. That said, it's open source, so you're more than welcome to fork it, build on it, or maintain your own version. If you're looking for alternatives, check out the wiki for similar projects. We might return to it someday, but for now, we don't recommend using it as-is - running unmaintained software can introduce security risks.
Mango is a self-hosted manga server and reader. Its features include
- Multi-user support
@@ -51,7 +54,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI
```
Mango - Manga Server and Web Reader. Version 0.27.1
Mango - Manga Server and Web Reader. Version 0.27.0
Usage:

View File

@@ -5,17 +5,13 @@ const minifyCss = require('gulp-minify-css');
const less = require('gulp-less');
gulp.task('copy-img', () => {
return gulp
.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
.pipe(gulp.dest('public/img'));
});
gulp.task('copy-font', () => {
return gulp
.src(
'node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**',
)
.pipe(gulp.dest('public/webfonts'));
return gulp.src('node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff**')
.pipe(gulp.dest('public/webfonts'));
});
// Copy files from node_modules
@@ -23,60 +19,49 @@ gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
// Compile less
gulp.task('less', () => {
return gulp
.src(['public/css/mango.less', 'public/css/tags.less'])
.pipe(less())
.pipe(gulp.dest('public/css'));
return gulp.src([
'public/css/mango.less',
'public/css/tags.less'
])
.pipe(less())
.pipe(gulp.dest('public/css'));
});
// Transpile and minify JS files and output to dist
gulp.task('babel', () => {
return gulp
.src(['public/js/*.js', '!public/js/*.min.js'])
.pipe(
babel({
presets: [
[
'@babel/preset-env',
{
targets: '>0.25%, not dead, ios>=9',
},
],
],
}),
)
.pipe(
minify({
removeConsole: true,
builtIns: false,
}),
)
.pipe(gulp.dest('dist/js'));
return gulp.src(['public/js/*.js', '!public/js/*.min.js'])
.pipe(babel({
presets: [
['@babel/preset-env', {
targets: '>0.25%, not dead, ios>=9'
}]
],
}))
.pipe(minify({
removeConsole: true,
builtIns: false
}))
.pipe(gulp.dest('dist/js'));
});
// Minify CSS and output to dist
gulp.task('minify-css', () => {
return gulp
.src('public/css/*.css')
.pipe(minifyCss())
.pipe(gulp.dest('dist/css'));
return gulp.src('public/css/*.css')
.pipe(minifyCss())
.pipe(gulp.dest('dist/css'));
});
// Copy static files (includeing images) to dist
gulp.task('copy-files', () => {
return gulp
.src(
[
'public/*.*',
'public/img/**',
'public/webfonts/*',
'public/js/*.min.js',
],
{
base: 'public',
},
)
.pipe(gulp.dest('dist'));
return gulp.src([
'public/*.*',
'public/img/**',
'public/webfonts/*',
'public/js/*.min.js'
], {
base: 'public'
})
.pipe(gulp.dest('dist'));
});
// Set up the public folder for development

View File

@@ -6,25 +6,20 @@
"author": "Alex Ling <hkalexling@gmail.com>",
"license": "MIT",
"devDependencies": {
"@babel/eslint-parser": "^7.18.9",
"@babel/preset-env": "^7.11.5",
"all-contributors-cli": "^6.19.0",
"eslint": "^8.22.0",
"eslint-plugin-prettier": "^4.2.1",
"gulp": "^4.0.2",
"gulp-babel": "^8.0.0",
"gulp-babel-minify": "^0.5.1",
"gulp-less": "^4.0.1",
"gulp-minify-css": "^1.2.4",
"less": "^3.11.3",
"prettier": "^2.7.1"
"less": "^3.11.3"
},
"scripts": {
"uglify": "gulp",
"lint": "eslint public/js *.js --ext .js"
"uglify": "gulp"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.14.0",
"uikit": "~3.14.0"
"uikit": "^3.5.4"
}
}

View File

@@ -1,56 +1,58 @@
const component = () => {
return {
progress: 1.0,
generating: false,
scanning: false,
scanTitles: 0,
scanMs: -1,
themeSetting: '',
return {
progress: 1.0,
generating: false,
scanning: false,
scanTitles: 0,
scanMs: -1,
themeSetting: '',
init() {
this.getProgress();
setInterval(() => {
this.getProgress();
}, 5000);
init() {
this.getProgress();
setInterval(() => {
this.getProgress();
}, 5000);
const setting = loadThemeSetting();
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
},
themeChanged(event) {
const newSetting = $(event.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting);
setTheme();
},
scan() {
if (this.scanning) return;
this.scanning = true;
this.scanMs = -1;
this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`)
.then((data) => {
this.scanMs = data.milliseconds;
this.scanTitles = data.titles;
})
.catch((e) => {
alert('danger', `Failed to trigger a scan. Error: ${e}`);
})
.always(() => {
this.scanning = false;
});
},
generateThumbnails() {
if (this.generating) return;
this.generating = true;
this.progress = 0.0;
$.post(`${base_url}api/admin/generate_thumbnails`).then(() => {
this.getProgress();
});
},
getProgress() {
$.get(`${base_url}api/admin/thumbnail_progress`).then((data) => {
this.progress = data.progress;
this.generating = data.progress > 0;
});
},
};
const setting = loadThemeSetting();
this.themeSetting = setting.charAt(0).toUpperCase() + setting.slice(1);
},
themeChanged(event) {
const newSetting = $(event.currentTarget).val().toLowerCase();
saveThemeSetting(newSetting);
setTheme();
},
scan() {
if (this.scanning) return;
this.scanning = true;
this.scanMs = -1;
this.scanTitles = 0;
$.post(`${base_url}api/admin/scan`)
.then(data => {
this.scanMs = data.milliseconds;
this.scanTitles = data.titles;
})
.catch(e => {
alert('danger', `Failed to trigger a scan. Error: ${e}`);
})
.always(() => {
this.scanning = false;
});
},
generateThumbnails() {
if (this.generating) return;
this.generating = true;
this.progress = 0.0;
$.post(`${base_url}api/admin/generate_thumbnails`)
.then(() => {
this.getProgress()
});
},
getProgress() {
$.get(`${base_url}api/admin/thumbnail_progress`)
.then(data => {
this.progress = data.progress;
this.generating = data.progress > 0;
});
},
};
};

View File

@@ -1,6 +1,6 @@
const alert = (level, text) => {
$('#alert').empty();
const html = `<div class="uk-alert-${level}" uk-alert><a class="uk-alert-close" uk-close></a><p>${text}</p></div>`;
$('#alert').append(html);
$('html, body').animate({ scrollTop: 0 });
$('#alert').empty();
const html = `<div class="uk-alert-${level}" uk-alert><a class="uk-alert-close" uk-close></a><p>${text}</p></div>`;
$('#alert').append(html);
$("html, body").animate({ scrollTop: 0 });
};

View File

@@ -11,7 +11,7 @@
* @param {string} selector - The jQuery selector to the root element
*/
const setProp = (key, prop, selector = '#root') => {
$(selector).get(0).__x.$data[key] = prop;
$(selector).get(0).__x.$data[key] = prop;
};
/**
@@ -23,7 +23,7 @@ const setProp = (key, prop, selector = '#root') => {
* @return {*} The data property
*/
const getProp = (key, selector = '#root') => {
return $(selector).get(0).__x.$data[key];
return $(selector).get(0).__x.$data[key];
};
/**
@@ -41,10 +41,7 @@ const getProp = (key, selector = '#root') => {
* @return {bool}
*/
const preferDarkMode = () => {
return (
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
);
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
};
/**
@@ -55,7 +52,7 @@ const preferDarkMode = () => {
* @return {bool}
*/
const validThemeSetting = (theme) => {
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
return ['dark', 'light', 'system'].indexOf(theme) >= 0;
};
/**
@@ -65,9 +62,9 @@ const validThemeSetting = (theme) => {
* @return {string} A theme setting ('dark', 'light', or 'system')
*/
const loadThemeSetting = () => {
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'system';
return str;
let str = localStorage.getItem('theme');
if (!str || !validThemeSetting(str)) str = 'system';
return str;
};
/**
@@ -77,11 +74,11 @@ const loadThemeSetting = () => {
* @return {string} The current theme to use ('dark' or 'light')
*/
const loadTheme = () => {
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
let setting = loadThemeSetting();
if (setting === 'system') {
setting = preferDarkMode() ? 'dark' : 'light';
}
return setting;
};
/**
@@ -90,9 +87,9 @@ const loadTheme = () => {
* @function saveThemeSetting
* @param {string} setting - A theme setting
*/
const saveThemeSetting = (setting) => {
if (!validThemeSetting(setting)) setting = 'system';
localStorage.setItem('theme', setting);
const saveThemeSetting = setting => {
if (!validThemeSetting(setting)) setting = 'system';
localStorage.setItem('theme', setting);
};
/**
@@ -102,10 +99,10 @@ const saveThemeSetting = (setting) => {
* @function toggleTheme
*/
const toggleTheme = () => {
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
const theme = loadTheme();
const newTheme = theme === 'dark' ? 'light' : 'dark';
saveThemeSetting(newTheme);
setTheme(newTheme);
};
/**
@@ -116,32 +113,31 @@ const toggleTheme = () => {
* `loadTheme` to get a theme and apply it.
*/
const setTheme = (theme) => {
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.ui-widget-content').removeClass('dark');
}
if (!theme) theme = loadTheme();
if (theme === 'dark') {
$('html').css('background', 'rgb(20, 20, 20)');
$('body').addClass('uk-light');
$('.ui-widget-content').addClass('dark');
} else {
$('html').css('background', '');
$('body').removeClass('uk-light');
$('.ui-widget-content').removeClass('dark');
}
};
// do it before document is ready to prevent the initial flash of white on
// most pages
setTheme();
$(() => {
// hack for the reader page
setTheme();
// hack for the reader page
setTheme();
// on system dark mode setting change
if (window.matchMedia) {
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (event) => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
// on system dark mode setting change
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', event => {
if (loadThemeSetting() === 'system')
setTheme(event.matches ? 'dark' : 'light');
});
}
});

View File

@@ -5,22 +5,22 @@
* @param {object} e - The title element to truncate
*/
const truncate = (e) => {
$(e).dotdotdot({
truncate: 'letter',
watch: true,
callback: (truncated) => {
if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title'));
} else {
$(e).removeAttr('uk-tooltip');
}
},
});
$(e).dotdotdot({
truncate: 'letter',
watch: true,
callback: (truncated) => {
if (truncated) {
$(e).attr('uk-tooltip', $(e).attr('data-title'));
} else {
$(e).removeAttr('uk-tooltip');
}
}
});
};
$('.uk-card-title').each((i, e) => {
// Truncate the title when it first enters the view
$(e).one('inview', () => {
truncate(e);
});
// Truncate the title when it first enters the view
$(e).one('inview', () => {
truncate(e);
});
});

View File

@@ -1,135 +1,116 @@
const component = () => {
return {
jobs: [],
paused: undefined,
loading: false,
toggling: false,
ws: undefined,
return {
jobs: [],
paused: undefined,
loading: false,
toggling: false,
ws: undefined,
wsConnect(secure = true) {
const url = `${secure ? 'wss' : 'ws'}://${
location.host
}${base_url}api/admin/mangadex/queue`;
console.log(`Connecting to ${url}`);
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.jobs = data.jobs;
this.paused = data.paused;
};
this.ws.onclose = () => {
if (this.ws.failed) return this.wsConnect(false);
alert('danger', 'Socket connection closed');
};
this.ws.onerror = () => {
if (secure) return (this.ws.failed = true);
alert('danger', 'Socket connection failed');
};
},
init() {
this.wsConnect();
this.load();
},
load() {
this.loading = true;
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json',
})
.done((data) => {
if (!data.success && data.error) {
alert(
'danger',
`Failed to fetch download queue. Error: ${data.error}`,
);
return;
}
this.jobs = data.jobs;
this.paused = data.paused;
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
})
.always(() => {
this.loading = false;
});
},
jobAction(action, event) {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (event) {
const id = event.currentTarget
.closest('tr')
.id.split('-')
.slice(1)
.join('-');
url = `${url}?${$.param({
id,
})}`;
}
console.log(url);
$.ajax({
type: 'POST',
url,
dataType: 'json',
})
.done((data) => {
if (!data.success && data.error) {
alert(
'danger',
`Failed to ${action} job from download queue. Error: ${data.error}`,
);
return;
}
this.load();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
toggle() {
this.toggling = true;
const action = this.paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url,
dataType: 'json',
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
})
.always(() => {
this.load();
this.toggling = false;
});
},
statusClass(status) {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
return cls;
},
};
wsConnect(secure = true) {
const url = `${secure ? 'wss' : 'ws'}://${location.host}${base_url}api/admin/mangadex/queue`;
console.log(`Connecting to ${url}`);
this.ws = new WebSocket(url);
this.ws.onmessage = event => {
const data = JSON.parse(event.data);
this.jobs = data.jobs;
this.paused = data.paused;
};
this.ws.onclose = () => {
if (this.ws.failed)
return this.wsConnect(false);
alert('danger', 'Socket connection closed');
};
this.ws.onerror = () => {
if (secure)
return this.ws.failed = true;
alert('danger', 'Socket connection failed');
};
},
init() {
this.wsConnect();
this.load();
},
load() {
this.loading = true;
$.ajax({
type: 'GET',
url: base_url + 'api/admin/mangadex/queue',
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to fetch download queue. Error: ${data.error}`);
return;
}
this.jobs = data.jobs;
this.paused = data.paused;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to fetch download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.loading = false;
});
},
jobAction(action, event) {
let url = `${base_url}api/admin/mangadex/queue/${action}`;
if (event) {
const id = event.currentTarget.closest('tr').id.split('-').slice(1).join('-');
url = `${url}?${$.param({
id: id
})}`;
}
console.log(url);
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.done(data => {
if (!data.success && data.error) {
alert('danger', `Failed to ${action} job from download queue. Error: ${data.error}`);
return;
}
this.load();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} job from download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
toggle() {
this.toggling = true;
const action = this.paused ? 'resume' : 'pause';
const url = `${base_url}api/admin/mangadex/queue/${action}`;
$.ajax({
type: 'POST',
url: url,
dataType: 'json'
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${action} download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
this.load();
this.toggling = false;
});
},
statusClass(status) {
let cls = 'label ';
switch (status) {
case 'Pending':
cls += 'label-pending';
break;
case 'Completed':
cls += 'label-success';
break;
case 'Error':
cls += 'label-danger';
break;
case 'MissingPages':
cls += 'label-warning';
break;
}
return cls;
}
};
};

View File

@@ -1,74 +1,60 @@
const component = () => {
return {
empty: true,
titles: [],
entries: [],
loading: true,
return {
empty: true,
titles: [],
entries: [],
loading: true,
load() {
this.loading = true;
this.request('GET', `${base_url}api/admin/titles/missing`, (data) => {
this.titles = data.titles;
this.request('GET', `${base_url}api/admin/entries/missing`, (data) => {
this.entries = data.entries;
this.loading = false;
this.empty = this.entries.length === 0 && this.titles.length === 0;
});
});
},
rm(event) {
const rawID = event.currentTarget.closest('tr').id;
const [type, id] = rawID.split('-');
const url = `${base_url}api/admin/${
type === 'title' ? 'titles' : 'entries'
}/missing/${id}`;
this.request('DELETE', url, () => {
this.load();
});
},
rmAll() {
UIkit.modal
.confirm(
'Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.',
{
labels: {
ok: 'Yes, delete them',
cancel: 'Cancel',
},
},
)
.then(() => {
this.request('DELETE', `${base_url}api/admin/titles/missing`, () => {
this.request(
'DELETE',
`${base_url}api/admin/entries/missing`,
() => {
this.load();
},
);
});
});
},
request(method, url, cb) {
console.log(url);
$.ajax({
type: method,
url,
contentType: 'application/json',
})
.done((data) => {
if (data.error) {
alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
return;
}
if (cb) cb(data);
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
};
load() {
this.loading = true;
this.request('GET', `${base_url}api/admin/titles/missing`, data => {
this.titles = data.titles;
this.request('GET', `${base_url}api/admin/entries/missing`, data => {
this.entries = data.entries;
this.loading = false;
this.empty = this.entries.length === 0 && this.titles.length === 0;
});
});
},
rm(event) {
const rawID = event.currentTarget.closest('tr').id;
const [type, id] = rawID.split('-');
const url = `${base_url}api/admin/${type === 'title' ? 'titles' : 'entries'}/missing/${id}`;
this.request('DELETE', url, () => {
this.load();
});
},
rmAll() {
UIkit.modal.confirm('Are you sure? All metadata associated with these items, including their tags and thumbnails, will be deleted from the database.', {
labels: {
ok: 'Yes, delete them',
cancel: 'Cancel'
}
}).then(() => {
this.request('DELETE', `${base_url}api/admin/titles/missing`, () => {
this.request('DELETE', `${base_url}api/admin/entries/missing`, () => {
this.load();
});
});
});
},
request(method, url, cb) {
console.log(url);
$.ajax({
type: method,
url: url,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to ${method} ${url}. Error: ${data.error}`);
return;
}
if (cb) cb(data);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to ${method} ${url}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
};

View File

@@ -1,435 +1,452 @@
const component = () => {
return {
plugins: [],
subscribable: false,
info: undefined,
pid: undefined,
chapters: undefined, // undefined: not searched yet, []: empty
manga: undefined, // undefined: not searched yet, []: empty
mid: undefined, // id of the selected manga
allChapters: [],
query: '',
mangaTitle: '',
searching: false,
adding: false,
sortOptions: [],
showFilters: false,
appliedFilters: [],
chaptersLimit: 500,
listManga: false,
subscribing: false,
subscriptionName: '',
return {
plugins: [],
subscribable: false,
info: undefined,
pid: undefined,
chapters: undefined, // undefined: not searched yet, []: empty
manga: undefined, // undefined: not searched yet, []: empty
mid: undefined, // id of the selected manga
allChapters: [],
query: "",
mangaTitle: "",
searching: false,
adding: false,
sortOptions: [],
showFilters: false,
appliedFilters: [],
chaptersLimit: 500,
listManga: false,
subscribing: false,
subscriptionName: "",
init() {
const tableObserver = new MutationObserver(() => {
console.log('table mutated');
$('#selectable').selectable({
filter: 'tr',
});
});
tableObserver.observe($('table').get(0), {
childList: true,
subtree: true,
});
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
init() {
const tableObserver = new MutationObserver(() => {
console.log("table mutated");
$("#selectable").selectable({
filter: "tr",
});
});
tableObserver.observe($("table").get(0), {
childList: true,
subtree: true,
});
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
const pid = localStorage.getItem('plugin');
if (pid && this.plugins.map((p) => p.id).includes(pid))
return this.loadPlugin(pid);
const pid = localStorage.getItem("plugin");
if (pid && this.plugins.map((p) => p.id).includes(pid))
return this.loadPlugin(pid);
if (this.plugins.length > 0) this.loadPlugin(this.plugins[0].id);
})
.catch((e) => {
alert('danger', `Failed to list the available plugins. Error: ${e}`);
});
},
loadPlugin(pid) {
fetch(
`${base_url}api/admin/plugin/info?${new URLSearchParams({
plugin: pid,
})}`,
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.info = data.info;
this.subscribable = data.subscribable;
this.pid = pid;
})
.catch((e) => {
alert('danger', `Failed to get plugin metadata. Error: ${e}`);
});
},
pluginChanged() {
this.manga = undefined;
this.chapters = undefined;
this.mid = undefined;
this.loadPlugin(this.pid);
localStorage.setItem('plugin', this.pid);
},
get chapterKeys() {
if (this.allChapters.length < 1) return [];
return Object.keys(this.allChapters[0]).filter(
(k) => !['manga_title'].includes(k),
);
},
searchChapters(query) {
this.searching = true;
this.allChapters = [];
this.sortOptions = [];
this.chapters = undefined;
this.listManga = false;
fetch(
`${base_url}api/admin/plugin/list?${new URLSearchParams({
plugin: this.pid,
query,
})}`,
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
try {
this.mangaTitle = data.chapters[0].manga_title;
if (!this.mangaTitle) throw new Error();
} catch (e) {
this.mangaTitle = data.title;
}
if (this.plugins.length > 0)
this.loadPlugin(this.plugins[0].id);
})
.catch((e) => {
alert(
"danger",
`Failed to list the available plugins. Error: ${e}`
);
});
},
loadPlugin(pid) {
fetch(
`${base_url}api/admin/plugin/info?${new URLSearchParams({
plugin: pid,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.info = data.info;
this.subscribable = data.subscribable;
this.pid = pid;
})
.catch((e) => {
alert(
"danger",
`Failed to get plugin metadata. Error: ${e}`
);
});
},
pluginChanged() {
this.manga = undefined;
this.chapters = undefined;
this.mid = undefined;
this.loadPlugin(this.pid);
localStorage.setItem("plugin", this.pid);
},
get chapterKeys() {
if (this.allChapters.length < 1) return [];
return Object.keys(this.allChapters[0]).filter(
(k) => !["manga_title"].includes(k)
);
},
searchChapters(query) {
this.searching = true;
this.allChapters = [];
this.sortOptions = [];
this.chapters = undefined;
this.listManga = false;
fetch(
`${base_url}api/admin/plugin/list?${new URLSearchParams({
plugin: this.pid,
query: query,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
try {
this.mangaTitle = data.chapters[0].manga_title;
if (!this.mangaTitle) throw new Error();
} catch (e) {
this.mangaTitle = data.title;
}
this.allChapters = data.chapters;
this.chapters = data.chapters;
})
.catch((e) => {
alert('danger', `Failed to list chapters. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
searchManga(query) {
this.searching = true;
this.allChapters = [];
this.chapters = undefined;
this.manga = undefined;
fetch(
`${base_url}api/admin/plugin/search?${new URLSearchParams({
plugin: this.pid,
query,
})}`,
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.manga = data.manga;
this.listManga = true;
})
.catch((e) => {
alert('danger', `Search failed. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
search() {
const query = this.query.trim();
if (!query) return;
this.allChapters = data.chapters;
this.chapters = data.chapters;
})
.catch((e) => {
alert("danger", `Failed to list chapters. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
searchManga(query) {
this.searching = true;
this.allChapters = [];
this.chapters = undefined;
this.manga = undefined;
fetch(
`${base_url}api/admin/plugin/search?${new URLSearchParams({
plugin: this.pid,
query: query,
})}`
)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.manga = data.manga;
this.listManga = true;
})
.catch((e) => {
alert("danger", `Search failed. Error: ${e}`);
})
.finally(() => {
this.searching = false;
});
},
search() {
const query = this.query.trim();
if (!query) return;
this.manga = undefined;
this.mid = undefined;
if (this.info.version === 1) {
this.searchChapters(query);
} else {
this.searchManga(query);
}
},
selectAll() {
$('tbody#selectable > tr').each((i, e) => {
$(e).addClass('ui-selected');
});
},
clearSelection() {
$('tbody#selectable > tr').each((i, e) => {
$(e).removeClass('ui-selected');
});
},
download() {
const selected = $('tbody#selectable > tr.ui-selected').get();
if (selected.length === 0) return;
this.manga = undefined;
this.mid = undefined;
if (this.info.version === 1) {
this.searchChapters(query);
} else {
this.searchManga(query);
}
},
selectAll() {
$("tbody > tr").each((i, e) => {
$(e).addClass("ui-selected");
});
},
clearSelection() {
$("tbody > tr").each((i, e) => {
$(e).removeClass("ui-selected");
});
},
download() {
const selected = $("tbody > tr.ui-selected").get();
if (selected.length === 0) return;
UIkit.modal
.confirm(`Download ${selected.length} selected chapters?`)
.then(() => {
const ids = selected.map((e) => e.id);
const chapters = this.chapters.filter((c) => ids.includes(c.id));
console.log(chapters);
this.adding = true;
fetch(`${base_url}api/admin/plugin/download`, {
method: 'POST',
body: JSON.stringify({
chapters,
plugin: this.pid,
title: this.mangaTitle,
}),
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert(
'success',
`${successCount} of ${
successCount + failCount
} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`,
);
})
.catch((e) => {
alert(
'danger',
`Failed to add chapters to the download queue. Error: ${e}`,
);
})
.finally(() => {
this.adding = false;
});
});
},
thClicked(event) {
const idx = parseInt(event.currentTarget.id.split('-')[1]);
if (idx === undefined || isNaN(idx)) return;
const curOption = this.sortOptions[idx];
let option;
this.sortOptions = [];
switch (curOption) {
case 1:
option = -1;
break;
case -1:
option = 0;
break;
default:
option = 1;
}
this.sortOptions[idx] = option;
this.sort(this.chapterKeys[idx], option);
},
// Returns an array of filtered but unsorted chapters. Useful when
// reseting the sort options.
get filteredChapters() {
let ary = this.allChapters.slice();
UIkit.modal
.confirm(`Download ${selected.length} selected chapters?`)
.then(() => {
const ids = selected.map((e) => e.id);
const chapters = this.chapters.filter((c) =>
ids.includes(c.id)
);
console.log(chapters);
this.adding = true;
fetch(`${base_url}api/admin/plugin/download`, {
method: "POST",
body: JSON.stringify({
chapters,
plugin: this.pid,
title: this.mangaTitle,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
const successCount = parseInt(data.success);
const failCount = parseInt(data.fail);
alert(
"success",
`${successCount} of ${
successCount + failCount
} chapters added to the download queue. You can view and manage your download queue on the <a href="${base_url}admin/downloads">download manager page</a>.`
);
})
.catch((e) => {
alert(
"danger",
`Failed to add chapters to the download queue. Error: ${e}`
);
})
.finally(() => {
this.adding = false;
});
});
},
thClicked(event) {
const idx = parseInt(event.currentTarget.id.split("-")[1]);
if (idx === undefined || isNaN(idx)) return;
const curOption = this.sortOptions[idx];
let option;
this.sortOptions = [];
switch (curOption) {
case 1:
option = -1;
break;
case -1:
option = 0;
break;
default:
option = 1;
}
this.sortOptions[idx] = option;
this.sort(this.chapterKeys[idx], option);
},
// Returns an array of filtered but unsorted chapters. Useful when
// reseting the sort options.
get filteredChapters() {
let ary = this.allChapters.slice();
console.log('initial size:', ary.length);
for (let filter of this.appliedFilters) {
if (!filter.value) continue;
if (filter.type === 'array' && filter.value === 'all') continue;
if (filter.type.startsWith('number') && isNaN(filter.value)) continue;
console.log("initial size:", ary.length);
for (let filter of this.appliedFilters) {
if (!filter.value) continue;
if (filter.type === "array" && filter.value === "all") continue;
if (filter.type.startsWith("number") && isNaN(filter.value))
continue;
if (filter.type === 'string') {
ary = ary.filter((ch) =>
ch[filter.key].toLowerCase().includes(filter.value.toLowerCase()),
);
}
if (filter.type === 'number-min') {
ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value),
);
}
if (filter.type === 'number-max') {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value),
);
}
if (filter.type === 'date-min') {
ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value),
);
}
if (filter.type === 'date-max') {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value),
);
}
if (filter.type === 'array') {
ary = ary.filter((ch) =>
ch[filter.key]
.map((s) => (typeof s === 'string' ? s.toLowerCase() : s))
.includes(filter.value.toLowerCase()),
);
}
if (filter.type === "string") {
ary = ary.filter((ch) =>
ch[filter.key]
.toLowerCase()
.includes(filter.value.toLowerCase())
);
}
if (filter.type === "number-min") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value)
);
}
if (filter.type === "number-max") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value)
);
}
if (filter.type === "date-min") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) >= Number(filter.value)
);
}
if (filter.type === "date-max") {
ary = ary.filter(
(ch) => Number(ch[filter.key]) <= Number(filter.value)
);
}
if (filter.type === "array") {
ary = ary.filter((ch) =>
ch[filter.key]
.map((s) =>
typeof s === "string" ? s.toLowerCase() : s
)
.includes(filter.value.toLowerCase())
);
}
console.log('filtered size:', ary.length);
}
console.log("filtered size:", ary.length);
}
return ary;
},
// option:
// - 1: asending
// - -1: desending
// - 0: unsorted
sort(key, option) {
if (option === 0) {
this.chapters = this.filteredChapters;
return;
}
return ary;
},
// option:
// - 1: asending
// - -1: desending
// - 0: unsorted
sort(key, option) {
if (option === 0) {
this.chapters = this.filteredChapters;
return;
}
this.chapters = this.filteredChapters.sort((a, b) => {
const comp = this.compare(a[key], b[key]);
return option < 0 ? comp * -1 : comp;
});
},
compare(a, b) {
if (a === b) return 0;
this.chapters = this.filteredChapters.sort((a, b) => {
const comp = this.compare(a[key], b[key]);
return option < 0 ? comp * -1 : comp;
});
},
compare(a, b) {
if (a === b) return 0;
// try numbers (also covers dates)
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
// try numbers (also covers dates)
if (!isNaN(a) && !isNaN(b)) return Number(a) - Number(b);
const preprocessString = (val) => {
if (typeof val !== 'string') return val;
return val.toLowerCase().replace(/\s\s/g, ' ').trim();
};
const preprocessString = (val) => {
if (typeof val !== "string") return val;
return val.toLowerCase().replace(/\s\s/g, " ").trim();
};
return preprocessString(a) > preprocessString(b) ? 1 : -1;
},
fieldType(values) {
if (values.every((v) => this.numIsDate(v))) return 'date';
if (values.every((v) => !isNaN(v))) return 'number';
if (values.every((v) => Array.isArray(v))) return 'array';
return 'string';
},
get filters() {
if (this.allChapters.length < 1) return [];
const keys = Object.keys(this.allChapters[0]).filter(
(k) => !['manga_title', 'id'].includes(k),
);
return keys.map((k) => {
let values = this.allChapters.map((c) => c[k]);
const type = this.fieldType(values);
return preprocessString(a) > preprocessString(b) ? 1 : -1;
},
fieldType(values) {
if (values.every((v) => this.numIsDate(v))) return "date";
if (values.every((v) => !isNaN(v))) return "number";
if (values.every((v) => Array.isArray(v))) return "array";
return "string";
},
get filters() {
if (this.allChapters.length < 1) return [];
const keys = Object.keys(this.allChapters[0]).filter(
(k) => !["manga_title", "id"].includes(k)
);
return keys.map((k) => {
let values = this.allChapters.map((c) => c[k]);
const type = this.fieldType(values);
if (type === 'array') {
// if the type is an array, return the list of available elements
// example: an array of groups or authors
values = Array.from(
new Set(
values.flat().map((v) => {
if (typeof v === 'string') return v.toLowerCase();
}),
),
);
}
if (type === "array") {
// if the type is an array, return the list of available elements
// example: an array of groups or authors
values = Array.from(
new Set(
values.flat().map((v) => {
if (typeof v === "string")
return v.toLowerCase();
})
)
);
}
return {
key: k,
type,
values,
};
});
},
get filterSettings() {
return $('#filter-form input:visible, #filter-form select:visible')
.get()
.map((i) => {
const type = i.getAttribute('data-filter-type');
let value = i.value.trim();
if (type.startsWith('date'))
value = value ? Date.parse(value).toString() : '';
return {
key: i.getAttribute('data-filter-key'),
value,
type,
};
});
},
applyFilters() {
this.appliedFilters = this.filterSettings;
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
clearFilters() {
$('#filter-form input')
.get()
.forEach((i) => (i.value = ''));
$('#filter-form select').val('all');
this.appliedFilters = [];
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
mangaSelected(event) {
const mid = event.currentTarget.getAttribute('data-id');
this.mid = mid;
this.searchChapters(mid);
},
subscribe(modal) {
this.subscribing = true;
fetch(`${base_url}api/admin/plugin/subscriptions`, {
method: 'POST',
body: JSON.stringify({
filters: this.filterSettings,
plugin: this.pid,
name: this.subscriptionName.trim(),
manga: this.mangaTitle,
manga_id: this.mid,
}),
headers: {
'Content-Type': 'application/json',
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
alert('success', 'Subscription created');
})
.catch((e) => {
alert('danger', `Failed to subscribe. Error: ${e}`);
})
.finally(() => {
this.subscribing = false;
UIkit.modal(modal).hide();
});
},
numIsDate(num) {
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
},
renderCell(value) {
if (this.numIsDate(value))
return `<span>${moment(Number(value)).format('MMM D, YYYY')}</span>`;
const maxLength = 40;
if (value && value.length > maxLength)
return `<span>${value.substr(
0,
maxLength,
)}...</span><div uk-dropdown>${value}</div>`;
return `<span>${value}</span>`;
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case 'number-min':
type = 'number (minimum value)';
break;
case 'number-max':
type = 'number (maximum value)';
break;
case 'date-min':
type = 'minimum date';
break;
case 'date-max':
type = 'maximum date';
break;
}
let value = ft.value;
return {
key: k,
type: type,
values: values,
};
});
},
get filterSettings() {
return $("#filter-form input:visible, #filter-form select:visible")
.get()
.map((i) => {
const type = i.getAttribute("data-filter-type");
let value = i.value.trim();
if (type.startsWith("date"))
value = value ? Date.parse(value).toString() : "";
return {
key: i.getAttribute("data-filter-key"),
value: value,
type: type,
};
});
},
applyFilters() {
this.appliedFilters = this.filterSettings;
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
clearFilters() {
$("#filter-form input")
.get()
.forEach((i) => (i.value = ""));
$("#filter-form select").val("all");
this.appliedFilters = [];
this.chapters = this.filteredChapters;
this.sortOptions = [];
},
mangaSelected(event) {
const mid = event.currentTarget.getAttribute("data-id");
this.mid = mid;
this.searchChapters(mid);
},
subscribe(modal) {
this.subscribing = true;
fetch(`${base_url}api/admin/plugin/subscriptions`, {
method: "POST",
body: JSON.stringify({
filters: this.filterSettings,
plugin: this.pid,
name: this.subscriptionName.trim(),
manga: this.mangaTitle,
manga_id: this.mid,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
alert("success", "Subscription created");
})
.catch((e) => {
alert("danger", `Failed to subscribe. Error: ${e}`);
})
.finally(() => {
this.subscribing = false;
UIkit.modal(modal).hide();
});
},
numIsDate(num) {
return !isNaN(num) && Number(num) > 328896000000; // 328896000000 => 1 Jan, 1980
},
renderCell(value) {
if (this.numIsDate(value))
return `<span>${moment(Number(value)).format(
"MMM D, YYYY"
)}</span>`;
const maxLength = 40;
if (value && value.length > maxLength)
return `<span>${value.substr(
0,
maxLength
)}...</span><div uk-dropdown>${value}</div>`;
return `<span>${value}</span>`;
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case "number-min":
type = "number (minimum value)";
break;
case "number-max":
type = "number (maximum value)";
break;
case "date-min":
type = "minimum date";
break;
case "date-max":
type = "maximum date";
break;
}
let value = ft.value;
if (ft.type.startsWith('number') && isNaN(value)) value = '';
else if (ft.type.startsWith('date') && value)
value = moment(Number(value)).format('MMM D, YYYY');
if (ft.type.startsWith("number") && isNaN(value)) value = "";
else if (ft.type.startsWith("date") && value)
value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
};
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
};
};

View File

@@ -1,370 +1,361 @@
const readerComponent = () => {
return {
loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
enableFlipAnimation: true,
flipAnimation: null,
longPages: false,
lastSavedPage: page,
selectedIndex: 0, // 0: not selected; 1: the first page
margin: 30,
preloadLookahead: 3,
enableRightToLeft: false,
fitType: 'vert',
return {
loading: true,
mode: 'continuous', // Can be 'continuous', 'height' or 'width'
msg: 'Loading the web reader. Please wait...',
alertClass: 'uk-alert-primary',
items: [],
curItem: {},
enableFlipAnimation: true,
flipAnimation: null,
longPages: false,
lastSavedPage: page,
selectedIndex: 0, // 0: not selected; 1: the first page
margin: 30,
preloadLookahead: 3,
enableRightToLeft: false,
fitType: 'vert',
/**
* Initialize the component by fetching the page dimensions
*/
init(nextTick) {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then((data) => {
if (!data.success && data.error) throw new Error(resp.error);
const dimensions = data.dimensions;
/**
* Initialize the component by fetching the page dimensions
*/
init(nextTick) {
$.get(`${base_url}api/dimensions/${tid}/${eid}`)
.then(data => {
if (!data.success && data.error)
throw new Error(resp.error);
const dimensions = data.dimensions;
this.items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i + 1}`,
width: d.width === 0 ? '100%' : d.width,
height: d.height === 0 ? '100%' : d.height,
};
});
this.items = dimensions.map((d, i) => {
return {
id: i + 1,
url: `${base_url}api/page/${tid}/${eid}/${i+1}`,
width: d.width == 0 ? "100%" : d.width,
height: d.height == 0 ? "100%" : d.height,
};
});
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`.
// TODO: support more image types in image_size.cr
const avgRatio =
dimensions.reduce((acc, cur) => {
return acc + cur.height / cur.width;
}, 0) / dimensions.length;
// Note: for image types not supported by image_size.cr, the width and height will be 0, and so `avgRatio` will be `Infinity`.
// TODO: support more image types in image_size.cr
const avgRatio = dimensions.reduce((acc, cur) => {
return acc + cur.height / cur.width
}, 0) / dimensions.length;
console.log(avgRatio);
this.longPages = avgRatio > 2;
this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
console.log(avgRatio);
this.longPages = avgRatio > 2;
this.loading = false;
this.mode = localStorage.getItem('mode') || 'continuous';
// Here we save a copy of this.mode, and use the copy as
// the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`,
// which are not available in mode-select
const mode = this.mode;
this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode);
// Here we save a copy of this.mode, and use the copy as
// the model-select value. This is because `updateMode`
// might change this.mode and make it `height` or `width`,
// which are not available in mode-select
const mode = this.mode;
this.updateMode(this.mode, page, nextTick);
$('#mode-select').val(mode);
const savedMargin = localStorage.getItem('margin');
if (savedMargin) {
this.margin = savedMargin;
}
const savedMargin = localStorage.getItem('margin');
if (savedMargin) {
this.margin = savedMargin;
}
// Preload Images
this.preloadLookahead = +(
localStorage.getItem('preloadLookahead') ?? 3
);
const limit = Math.min(
page + this.preloadLookahead,
this.items.length,
);
for (let idx = page + 1; idx <= limit; idx++) {
this.preloadImage(this.items[idx - 1].url);
}
// Preload Images
this.preloadLookahead = +(localStorage.getItem('preloadLookahead') ?? 3);
const limit = Math.min(page + this.preloadLookahead, this.items.length);
for (let idx = page + 1; idx <= limit; idx++) {
this.preloadImage(this.items[idx - 1].url);
}
const savedFitType = localStorage.getItem('fitType');
if (savedFitType) {
this.fitType = savedFitType;
$('#fit-select').val(savedFitType);
}
const savedFlipAnimation = localStorage.getItem(
'enableFlipAnimation',
);
this.enableFlipAnimation =
savedFlipAnimation === null || savedFlipAnimation === 'true';
const savedFitType = localStorage.getItem('fitType');
if (savedFitType) {
this.fitType = savedFitType;
$('#fit-select').val(savedFitType);
}
const savedFlipAnimation = localStorage.getItem('enableFlipAnimation');
this.enableFlipAnimation = savedFlipAnimation === null || savedFlipAnimation === 'true';
const savedRightToLeft = localStorage.getItem('enableRightToLeft');
if (savedRightToLeft === null) {
this.enableRightToLeft = false;
} else {
this.enableRightToLeft = savedRightToLeft === 'true';
}
})
.catch((e) => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
this.alertClass = 'uk-alert-danger';
this.msg = errMsg;
});
},
/**
* Preload an image, which is expected to be cached
*/
preloadImage(url) {
new Image().src = url;
},
/**
* Handles the `change` event for the page selector
*/
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
const savedRightToLeft = localStorage.getItem('enableRightToLeft');
if (savedRightToLeft === null) {
this.enableRightToLeft = false;
} else {
this.enableRightToLeft = (savedRightToLeft === 'true');
}
})
.catch(e => {
const errMsg = `Failed to get the page dimensions. ${e}`;
console.error(e);
this.alertClass = 'uk-alert-danger';
this.msg = errMsg;
})
},
/**
* Preload an image, which is expected to be cached
*/
preloadImage(url) {
(new Image()).src = url;
},
/**
* Handles the `change` event for the page selector
*/
pageChanged() {
const p = parseInt($('#page-select').val());
this.toPage(p);
},
/**
* Handles the `change` event for the mode selector
*
* @param {function} nextTick - Alpine $nextTick magic property
*/
modeChanged(nextTick) {
const mode = $('#mode-select').val();
const curIdx = parseInt($('#page-select').val());
this.updateMode(mode, curIdx, nextTick);
},
/**
* Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
this.updateMode(mode, curIdx, nextTick);
},
/**
* Handles the window `resize` event
*/
resized() {
if (this.mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height();
this.mode = wideScreen ? 'height' : 'width';
},
/**
* Handles the window `keydown` event
*
* @param {Event} event - The triggering event
*/
keyHandler(event) {
if (this.mode === 'continuous') return;
const wideScreen = $(window).width() > $(window).height();
this.mode = wideScreen ? 'height' : 'width';
},
/**
* Handles the window `keydown` event
*
* @param {Event} event - The triggering event
*/
keyHandler(event) {
if (this.mode === 'continuous') return;
if (event.key === 'ArrowLeft' || event.key === 'k')
this.flipPage(false ^ this.enableRightToLeft);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true ^ this.enableRightToLeft);
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (event.key === 'ArrowLeft' || event.key === 'k')
this.flipPage(false ^ this.enableRightToLeft);
if (event.key === 'ArrowRight' || event.key === 'j')
this.flipPage(true ^ this.enableRightToLeft);
},
/**
* Flips to the next or the previous page
*
* @param {bool} isNext - Whether we are going to the next page
*/
flipPage(isNext) {
const idx = parseInt(this.curItem.id);
const newIdx = idx + (isNext ? 1 : -1);
if (newIdx <= 0) return;
if (newIdx > this.items.length) {
this.showControl(idx);
return;
}
if (newIdx <= 0) return;
if (newIdx > this.items.length) {
this.showControl(idx);
return;
}
if (newIdx + this.preloadLookahead < this.items.length + 1) {
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
}
if (newIdx + this.preloadLookahead < this.items.length + 1) {
this.preloadImage(this.items[newIdx + this.preloadLookahead - 1].url);
}
this.toPage(newIdx);
this.toPage(newIdx);
if (this.enableFlipAnimation) {
if (isNext ^ this.enableRightToLeft) this.flipAnimation = 'right';
else this.flipAnimation = 'left';
}
if (this.enableFlipAnimation) {
if (isNext ^ this.enableRightToLeft)
this.flipAnimation = 'right';
else
this.flipAnimation = 'left';
}
setTimeout(() => {
this.flipAnimation = null;
}, 500);
setTimeout(() => {
this.flipAnimation = null;
}, 500);
this.replaceHistory(newIdx);
},
/**
* Jumps to a specific page
*
* @param {number} idx - One-based index of the page
*/
toPage(idx) {
if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= this.items.length) {
this.curItem = this.items[idx - 1];
}
}
this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
},
/**
* Replace the address bar history and save the reading progress if necessary
*
* @param {number} idx - One-based index of the page
*/
replaceHistory(idx) {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
this.saveProgress(idx);
history.replaceState(null, '', url);
},
/**
* Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (
Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 ||
idx === this.items.length
) {
this.lastSavedPage = idx;
console.log('saving progress', idx);
this.replaceHistory(newIdx);
},
/**
* Jumps to a specific page
*
* @param {number} idx - One-based index of the page
*/
toPage(idx) {
if (this.mode === 'continuous') {
$(`#${idx}`).get(0).scrollIntoView(true);
} else {
if (idx >= 1 && idx <= this.items.length) {
this.curItem = this.items[idx - 1];
}
}
this.replaceHistory(idx);
UIkit.modal($('#modal-sections')).hide();
},
/**
* Replace the address bar history and save the reading progress if necessary
*
* @param {number} idx - One-based index of the page
*/
replaceHistory(idx) {
const ary = window.location.pathname.split('/');
ary[ary.length - 1] = idx;
ary.shift(); // remove leading `/`
ary.unshift(window.location.origin);
const url = ary.join('/');
this.saveProgress(idx);
history.replaceState(null, "", url);
},
/**
* Updates the backend reading progress if:
* 1) the current page is more than five pages away from the last
* saved page, or
* 2) the average height/width ratio of the pages is over 2, or
* 3) the current page is the first page, or
* 4) the current page is the last page
*
* @param {number} idx - One-based index of the page
* @param {function} cb - Callback
*/
saveProgress(idx, cb) {
idx = parseInt(idx);
if (Math.abs(idx - this.lastSavedPage) >= 5 ||
this.longPages ||
idx === 1 || idx === this.items.length
) {
this.lastSavedPage = idx;
console.log('saving progress', idx);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({
eid,
})}`;
$.ajax({
method: 'PUT',
url,
dataType: 'json',
})
.done((data) => {
if (data.error) alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
},
/**
* Updates the reader mode
*
* @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
const url = `${base_url}api/progress/${tid}/${idx}?${$.param({eid: eid})}`;
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.error)
alert('danger', data.error);
if (cb) cb();
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
},
/**
* Updates the reader mode
*
* @param {string} mode - Either `continuous` or `paged`
* @param {number} targetPage - The one-based index of the target page
* @param {function} nextTick - Alpine $nextTick magic property
*/
updateMode(mode, targetPage, nextTick) {
localStorage.setItem('mode', mode);
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
// The mode to be put into the `mode` prop. It can't be `screen`
let propMode = mode;
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
if (mode === 'paged') {
const wideScreen = $(window).width() > $(window).height();
propMode = wideScreen ? 'height' : 'width';
}
this.mode = propMode;
this.mode = propMode;
if (mode === 'continuous') {
nextTick(() => {
this.setupScroller();
});
}
if (mode === 'continuous') {
nextTick(() => {
this.setupScroller();
});
}
nextTick(() => {
this.toPage(targetPage);
});
},
/**
* Handles clicked image
*
* @param {Event} event - The triggering event
*/
clickImage(event) {
const idx = event.currentTarget.id;
this.showControl(idx);
},
/**
* Shows the control modal
*
* @param {number} idx - selected page index
*/
showControl(idx) {
this.selectedIndex = idx;
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
nextTick(() => {
this.toPage(targetPage);
});
},
/**
* Handles clicked image
*
* @param {Event} event - The triggering event
*/
clickImage(event) {
const idx = event.currentTarget.id;
this.showControl(idx);
},
/**
* Shows the control modal
*
* @param {number} idx - selected page index
*/
showControl(idx) {
this.selectedIndex = idx;
UIkit.modal($('#modal-sections')).show();
},
/**
* Redirects to a URL
*
* @param {string} url - The target URL
*/
redirect(url) {
window.location.replace(url);
},
/**
* Set up the scroll handler that calls `replaceHistory` when an image
* enters the view port
*/
setupScroller() {
if (this.mode !== 'continuous') return;
$('img').each((idx, el) => {
$(el).on('inview', (event, inView) => {
if (inView) {
const current = $(event.currentTarget).attr('id');
this.curItem = this.items[current - 1];
this.replaceHistory(current);
}
});
});
},
/**
* Marks progress as 100% and jumps to the next entry
*
* @param {string} nextUrl - URL of the next entry
*/
nextEntry(nextUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(nextUrl);
});
},
/**
* Exits the reader, and sets the reading progress tp 100%
*
* @param {string} exitUrl - The Exit URL
*/
exitReader(exitUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
});
},
this.curItem = this.items[current - 1];
this.replaceHistory(current);
}
});
});
},
/**
* Marks progress as 100% and jumps to the next entry
*
* @param {string} nextUrl - URL of the next entry
*/
nextEntry(nextUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(nextUrl);
});
},
/**
* Exits the reader, and sets the reading progress tp 100%
*
* @param {string} exitUrl - The Exit URL
*/
exitReader(exitUrl) {
this.saveProgress(this.items.length, () => {
this.redirect(exitUrl);
});
},
/**
* Handles the `change` event for the entry selector
*/
entryChanged() {
const id = $('#entry-select').val();
this.redirect(`${base_url}reader/${tid}/${id}`);
},
/**
* Handles the `change` event for the entry selector
*/
entryChanged() {
const id = $('#entry-select').val();
this.redirect(`${base_url}reader/${tid}/${id}`);
},
marginChanged() {
localStorage.setItem('margin', this.margin);
this.toPage(this.selectedIndex);
},
marginChanged() {
localStorage.setItem('margin', this.margin);
this.toPage(this.selectedIndex);
},
fitChanged() {
this.fitType = $('#fit-select').val();
localStorage.setItem('fitType', this.fitType);
},
fitChanged(){
this.fitType = $('#fit-select').val();
localStorage.setItem('fitType', this.fitType);
},
preloadLookaheadChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead);
},
preloadLookaheadChanged() {
localStorage.setItem('preloadLookahead', this.preloadLookahead);
},
enableFlipAnimationChanged() {
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
},
enableFlipAnimationChanged() {
localStorage.setItem('enableFlipAnimation', this.enableFlipAnimation);
},
enableRightToLeftChanged() {
localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
},
};
};
enableRightToLeftChanged() {
localStorage.setItem('enableRightToLeft', this.enableRightToLeft);
},
};
}

View File

@@ -1,28 +1,30 @@
$(function () {
let filter = [];
let result = [];
$('.uk-card-title').each(function () {
filter.push($(this).text());
});
$('.uk-search-input').keyup(function () {
let input = $('.uk-search-input').val();
let regex = new RegExp(input, 'i');
$(function(){
var filter = [];
var result = [];
$('.uk-card-title').each(function(){
filter.push($(this).text());
});
$('.uk-search-input').keyup(function(){
var input = $('.uk-search-input').val();
var regex = new RegExp(input, 'i');
if (input === '') {
$('.item').each(function () {
$(this).removeAttr('hidden');
});
} else {
filter.forEach(function (text, i) {
result[i] = text.match(regex);
});
$('.item').each(function (i) {
if (result[i]) {
$(this).removeAttr('hidden');
} else {
$(this).attr('hidden', '');
}
});
}
});
if (input === '') {
$('.item').each(function(){
$(this).removeAttr('hidden');
});
}
else {
filter.forEach(function(text, i){
result[i] = text.match(regex);
});
$('.item').each(function(i){
if (result[i]) {
$(this).removeAttr('hidden');
}
else {
$(this).attr('hidden', '');
}
});
}
});
});

View File

@@ -1,15 +1,15 @@
$(() => {
$('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id');
const ary = sort.split('-');
const by = ary[0];
const dir = ary[1];
$('#sort-select').change(() => {
const sort = $('#sort-select').find(':selected').attr('id');
const ary = sort.split('-');
const by = ary[0];
const dir = ary[1];
const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({
sort: by,
ascend: dir === 'up' ? 1 : 0,
})}`;
window.location.href = newURL;
});
const url = `${location.protocol}//${location.host}${location.pathname}`;
const newURL = `${url}?${$.param({
sort: by,
ascend: dir === 'up' ? 1 : 0
})}`;
window.location.href = newURL;
});
});

View File

@@ -1,144 +1,147 @@
const component = () => {
return {
subscriptions: [],
plugins: [],
pid: undefined,
subscription: undefined, // selected subscription
loading: false,
return {
subscriptions: [],
plugins: [],
pid: undefined,
subscription: undefined, // selected subscription
loading: false,
init() {
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
init() {
fetch(`${base_url}api/admin/plugin`)
.then((res) => res.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.plugins = data.plugins;
let pid = localStorage.getItem('plugin');
if (!pid || !this.plugins.find((p) => p.id === pid)) {
pid = this.plugins[0].id;
}
const pid = localStorage.getItem("plugin");
if (pid && this.plugins.map((p) => p.id).includes(pid))
this.pid = pid;
else if (this.plugins.length > 0)
this.pid = this.plugins[0].id;
this.pid = pid;
this.list(pid);
})
.catch((e) => {
alert('danger', `Failed to list the available plugins. Error: ${e}`);
});
},
pluginChanged() {
localStorage.setItem('plugin', this.pid);
this.list(this.pid);
},
list(pid) {
if (!pid) return;
fetch(
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams({
plugin: pid,
})}`,
{
method: 'GET',
},
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.subscriptions = data.subscriptions;
})
.catch((e) => {
alert('danger', `Failed to list subscriptions. Error: ${e}`);
});
},
renderStrCell(str) {
const maxLength = 40;
if (str.length > maxLength)
return `<td><span>${str.substring(
0,
maxLength,
)}...</span><div uk-dropdown>${str}</div></td>`;
return `<td>${str}</td>`;
},
renderDateCell(timestamp) {
return `<td>${moment
.duration(moment.unix(timestamp).diff(moment()))
.humanize(true)}</td>`;
},
selected(event, modal) {
const id = event.currentTarget.getAttribute('sid');
this.subscription = this.subscriptions.find((s) => s.id === id);
UIkit.modal(modal).show();
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case 'number-min':
type = 'number (minimum value)';
break;
case 'number-max':
type = 'number (maximum value)';
break;
case 'date-min':
type = 'minimum date';
break;
case 'date-max':
type = 'maximum date';
break;
}
let value = ft.value;
this.list(pid);
})
.catch((e) => {
alert(
"danger",
`Failed to list the available plugins. Error: ${e}`
);
});
},
pluginChanged() {
localStorage.setItem("plugin", this.pid);
this.list(this.pid);
},
list(pid) {
if (!pid) return;
fetch(
`${base_url}api/admin/plugin/subscriptions?${new URLSearchParams(
{
plugin: pid,
}
)}`,
{
method: "GET",
}
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
this.subscriptions = data.subscriptions;
})
.catch((e) => {
alert(
"danger",
`Failed to list subscriptions. Error: ${e}`
);
});
},
renderStrCell(str) {
const maxLength = 40;
if (str.length > maxLength)
return `<td><span>${str.substring(
0,
maxLength
)}...</span><div uk-dropdown>${str}</div></td>`;
return `<td>${str}</td>`;
},
renderDateCell(timestamp) {
return `<td>${moment
.duration(moment.unix(timestamp).diff(moment()))
.humanize(true)}</td>`;
},
selected(event, modal) {
const id = event.currentTarget.getAttribute("sid");
this.subscription = this.subscriptions.find((s) => s.id === id);
UIkit.modal(modal).show();
},
renderFilterRow(ft) {
const key = ft.key;
let type = ft.type;
switch (type) {
case "number-min":
type = "number (minimum value)";
break;
case "number-max":
type = "number (maximum value)";
break;
case "date-min":
type = "minimum date";
break;
case "date-max":
type = "maximum date";
break;
}
let value = ft.value;
if (ft.type.startsWith('number') && isNaN(value)) value = '';
else if (ft.type.startsWith('date') && value)
value = moment(Number(value)).format('MMM D, YYYY');
if (ft.type.startsWith("number") && isNaN(value)) value = "";
else if (ft.type.startsWith("date") && value)
value = moment(Number(value)).format("MMM D, YYYY");
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
actionHandler(event, type) {
const id = $(event.currentTarget).closest('tr').attr('sid');
if (type !== 'delete') return this.action(id, type);
UIkit.modal
.confirm(
'Are you sure you want to delete the subscription? This cannot be undone.',
{
labels: {
ok: 'Yes, delete it',
cancel: 'Cancel',
},
},
)
.then(() => {
this.action(id, type);
});
},
action(id, type) {
if (this.loading) return;
this.loading = true;
fetch(
`${base_url}api/admin/plugin/subscriptions${
type === 'update' ? '/update' : ''
}?${new URLSearchParams({
plugin: this.pid,
subscription: id,
})}`,
{
method: type === 'delete' ? 'DELETE' : 'POST',
},
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
if (type === 'update')
alert(
'success',
`Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`,
);
})
.catch((e) => {
alert('danger', `Failed to ${type} subscription. Error: ${e}`);
})
.finally(() => {
this.loading = false;
this.list(this.pid);
});
},
};
return `<td>${key}</td><td>${type}</td><td>${value}</td>`;
},
actionHandler(event, type) {
const id = $(event.currentTarget).closest("tr").attr("sid");
if (type !== 'delete') return this.action(id, type);
UIkit.modal.confirm('Are you sure you want to delete the subscription? This cannot be undone.', {
labels: {
ok: 'Yes, delete it',
cancel: 'Cancel'
}
}).then(() => {
this.action(id, type);
});
},
action(id, type) {
if (this.loading) return;
this.loading = true;
fetch(
`${base_url}api/admin/plugin/subscriptions${type === 'update' ? '/update' : ''}?${new URLSearchParams(
{
plugin: this.pid,
subscription: id,
}
)}`,
{
method: type === 'delete' ? "DELETE" : 'POST'
}
)
.then((response) => response.json())
.then((data) => {
if (!data.success) throw new Error(data.error);
if (type === 'update')
alert("success", `Checking updates for subscription ${id}. Check the log for the progress or come back to this page later.`);
})
.catch((e) => {
alert(
"danger",
`Failed to ${type} subscription. Error: ${e}`
);
})
.finally(() => {
this.loading = false;
this.list(this.pid);
});
},
};
};

View File

@@ -1,112 +1,82 @@
const component = () => {
return {
available: undefined,
subscriptions: [],
return {
available: undefined,
subscriptions: [],
init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert(
'danger',
'Failed to check MangaDex integration status. Error: ' +
data.error,
);
return;
}
this.available = Boolean(
data.expires && data.expires > Math.floor(Date.now() / 1000),
);
init() {
$.getJSON(`${base_url}api/admin/mangadex/expires`)
.done((data) => {
if (data.error) {
alert('danger', 'Failed to check MangaDex integration status. Error: ' + data.error);
return;
}
this.available = Boolean(data.expires && data.expires > Math.floor(Date.now() / 1000));
if (this.available) this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
if (this.available) this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check MangaDex integration status. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done((data) => {
if (data.error) {
alert(
'danger',
'Failed to get subscriptions. Error: ' + data.error,
);
return;
}
this.subscriptions = data.subscriptions;
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
getSubscriptions() {
$.getJSON(`${base_url}api/admin/mangadex/subscriptions`)
.done(data => {
if (data.error) {
alert('danger', 'Failed to get subscriptions. Error: ' + data.error);
return;
}
this.subscriptions = data.subscriptions;
})
.fail((jqXHR, status) => {
alert('danger', `Failed to get subscriptions. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
},
rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json',
})
.done((data) => {
if (data.error) {
alert(
'danger',
`Failed to delete subscription. Error: ${data.error}`,
);
}
this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
rm(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'DELETE',
url: `${base_url}api/admin/mangadex/subscriptions/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to delete subscription. Error: ${data.error}`);
}
this.getSubscriptions();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json',
})
.done((data) => {
if (data.error) {
alert(
'danger',
`Failed to check subscription. Error: ${data.error}`,
);
return;
}
alert(
'success',
'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.',
);
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
},
check(event) {
const id = event.currentTarget.parentNode.getAttribute('data-id');
$.ajax({
type: 'POST',
url: `${base_url}api/admin/mangadex/subscriptions/check/${id}`,
contentType: 'application/json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to check subscription. Error: ${data.error}`);
return;
}
alert('success', 'Mango is now checking the subscription for updates. This might take a while, but you can safely leave the page.');
})
.fail((jqXHR, status) => {
alert('danger', `Failed to check subscription. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
formatRange(min, max) {
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
if (isNaN(min) && isNaN(max)) return 'All';
formatRange(min, max) {
if (!isNaN(min) && isNaN(max)) return `≥ ${min}`;
if (isNaN(min) && !isNaN(max)) return `≤ ${max}`;
if (isNaN(min) && isNaN(max)) return 'All';
if (min === max) return `= ${min}`;
return `${min} - ${max}`;
},
};
if (min === max) return `= ${min}`;
return `${min} - ${max}`;
}
};
};

View File

@@ -1,421 +1,392 @@
$(() => {
setupAcard();
setupAcard();
});
const setupAcard = () => {
$('.acard.is_entry').click((e) => {
if ($(e.target).hasClass('no-modal')) return;
const card = $(e.target).closest('.acard');
$('.acard.is_entry').click((e) => {
if ($(e.target).hasClass('no-modal')) return;
const card = $(e.target).closest('.acard');
showModal(
$(card).attr('data-encoded-path'),
parseInt($(card).attr('data-pages')),
parseFloat($(card).attr('data-progress')),
$(card).attr('data-encoded-book-title'),
$(card).attr('data-encoded-title'),
$(card).attr('data-book-id'),
$(card).attr('data-id'),
);
});
showModal(
$(card).attr('data-encoded-path'),
parseInt($(card).attr('data-pages')),
parseFloat($(card).attr('data-progress')),
$(card).attr('data-encoded-book-title'),
$(card).attr('data-encoded-title'),
$(card).attr('data-book-id'),
$(card).attr('data-id')
);
});
};
function showModal(
encodedPath,
pages,
percentage,
encodedeTitle,
encodedEntryTitle,
titleID,
entryID,
) {
const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function () {
$(this).removeAttr('hidden');
});
if (percentage === 0) {
$('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', '');
} else if (percentage === 100) {
$('#read-btn').attr('hidden', '');
$('#continue-btn').attr('hidden', '');
} else {
$('#continue-btn').text('Continue from ' + percentage + '%');
}
function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTitle, titleID, entryID) {
const zipPath = decodeURIComponent(encodedPath);
const title = decodeURIComponent(encodedeTitle);
const entry = decodeURIComponent(encodedEntryTitle);
$('#modal button, #modal a').each(function() {
$(this).removeAttr('hidden');
});
if (percentage === 0) {
$('#continue-btn').attr('hidden', '');
$('#unread-btn').attr('hidden', '');
} else if (percentage === 100) {
$('#read-btn').attr('hidden', '');
$('#continue-btn').attr('hidden', '');
} else {
$('#continue-btn').text('Continue from ' + percentage + '%');
}
$('#modal-entry-title').find('span').text(entry);
$('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-entry-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages');
$('#modal-entry-title').find('span').text(entry);
$('#modal-entry-title').next().attr('data-id', titleID);
$('#modal-entry-title').next().attr('data-entry-id', entryID);
$('#modal-entry-title').next().find('.title-rename-field').val(entry);
$('#path-text').text(zipPath);
$('#pages-text').text(pages + ' pages');
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
$('#read-btn').click(function () {
updateProgress(titleID, entryID, pages);
});
$('#unread-btn').click(function () {
updateProgress(titleID, entryID, 0);
});
$('#read-btn').click(function() {
updateProgress(titleID, entryID, pages);
});
$('#unread-btn').click(function() {
updateProgress(titleID, entryID, 0);
});
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-edit-btn').attr('onclick', `edit("${entryID}")`);
$('#modal-download-btn').attr(
'href',
`${base_url}api/download/${titleID}/${entryID}`,
);
$('#modal-download-btn').attr('href', `${base_url}api/download/${titleID}/${entryID}`);
UIkit.modal($('#modal')).show();
UIkit.modal($('#modal')).show();
}
UIkit.util.on(document, 'hidden', '#modal', () => {
$('#read-btn').off('click');
$('#unread-btn').off('click');
$('#read-btn').off('click');
$('#unread-btn').off('click');
});
const updateProgress = (tid, eid, page) => {
let url = `${base_url}api/progress/${tid}/${page}`;
const query = $.param({
eid,
});
if (eid) url += `?${query}`;
let url = `${base_url}api/progress/${tid}/${page}`
const query = $.param({
eid: eid
});
if (eid)
url += `?${query}`;
$.ajax({
method: 'PUT',
url,
dataType: 'json',
})
.done((data) => {
if (data.success) {
location.reload();
} else {
error = data.error;
alert('danger', error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
$.ajax({
method: 'PUT',
url: url,
dataType: 'json'
})
.done(data => {
if (data.success) {
location.reload();
} else {
error = data.error;
alert('danger', error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const renameSubmit = (name, eid) => {
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
if (name.length === 0) {
alert('danger', 'The display name should not be empty');
return;
}
if (name.length === 0) {
alert('danger', 'The display name should not be empty');
return;
}
const query = $.param({
eid,
});
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid) url += `?${query}`;
const query = $.param({
eid: eid
});
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
if (eid)
url += `?${query}`;
$.ajax({
type: 'PUT',
url,
contentType: 'application/json',
dataType: 'json',
})
.done((data) => {
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
$.ajax({
type: 'PUT',
url: url,
contentType: "application/json",
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const renameSortNameSubmit = (name, eid) => {
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
const params = {};
if (eid) params.eid = eid;
if (name) params.name = name;
const query = $.param(params);
let url = `${base_url}api/admin/sort_title/${titleId}?${query}`;
const params = {};
if (eid) params.eid = eid;
if (name) params.name = name;
const query = $.param(params);
let url = `${base_url}api/admin/sort_title/${titleId}?${query}`;
$.ajax({
type: 'PUT',
url,
contentType: 'application/json',
dataType: 'json',
})
.done((data) => {
if (data.error) {
alert('danger', `Failed to update sort title. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
$.ajax({
type: 'PUT',
url,
contentType: 'application/json',
dataType: 'json'
})
.done(data => {
if (data.error) {
alert('danger', `Failed to update sort title. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to update sort title. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};
const edit = (eid) => {
const cover = $('#edit-modal #cover');
let url = cover.attr('data-title-cover');
let displayName = $('h2.uk-title > span').text();
let fileTitle = $('h2.uk-title').attr('data-file-title');
let sortTitle = $('h2.uk-title').attr('data-sort-title');
const cover = $('#edit-modal #cover');
let url = cover.attr('data-title-cover');
let displayName = $('h2.uk-title > span').text();
let fileTitle = $('h2.uk-title').attr('data-file-title');
let sortTitle = $('h2.uk-title').attr('data-sort-title');
if (eid) {
const item = $(`#${eid}`);
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
fileTitle = item.find('.uk-card-title').attr('data-file-title');
sortTitle = item.find('.uk-card-title').attr('data-sort-title');
$('#title-progress-control').attr('hidden', '');
} else {
$('#title-progress-control').removeAttr('hidden');
}
if (eid) {
const item = $(`#${eid}`);
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
fileTitle = item.find('.uk-card-title').attr('data-file-title');
sortTitle = item.find('.uk-card-title').attr('data-sort-title');
$('#title-progress-control').attr('hidden', '');
} else {
$('#title-progress-control').removeAttr('hidden');
}
cover.attr('data-src', url);
cover.attr('data-src', url);
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
displayNameField.attr('placeholder', fileTitle);
displayNameField.keyup((event) => {
if (event.keyCode === 13) {
renameSubmit(displayNameField.val() || fileTitle, eid);
}
});
displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val() || fileTitle, eid);
});
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
displayNameField.attr('placeholder', fileTitle);
displayNameField.keyup(event => {
if (event.keyCode === 13) {
renameSubmit(displayNameField.val() || fileTitle, eid);
}
});
displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val() || fileTitle, eid);
});
const sortTitleField = $('#sort-title-field');
sortTitleField.val(sortTitle);
sortTitleField.attr('placeholder', fileTitle);
sortTitleField.keyup((event) => {
if (event.keyCode === 13) {
renameSortNameSubmit(sortTitleField.val(), eid);
}
});
sortTitleField.siblings('a.uk-form-icon').click(() => {
renameSortNameSubmit(sortTitleField.val(), eid);
});
const sortTitleField = $('#sort-title-field');
sortTitleField.val(sortTitle);
sortTitleField.attr('placeholder', fileTitle);
sortTitleField.keyup(event => {
if (event.keyCode === 13) {
renameSortNameSubmit(sortTitleField.val(), eid);
}
});
sortTitleField.siblings('a.uk-form-icon').click(() => {
renameSortNameSubmit(sortTitleField.val(), eid);
});
setupUpload(eid);
setupUpload(eid);
UIkit.modal($('#edit-modal')).show();
UIkit.modal($('#edit-modal')).show();
};
UIkit.util.on(document, 'hidden', '#edit-modal', () => {
const displayNameField = $('#display-name-field');
displayNameField.off('keyup');
displayNameField.off('click');
const displayNameField = $('#display-name-field');
displayNameField.off('keyup');
displayNameField.off('click');
const sortTitleField = $('#sort-title-field');
sortTitleField.off('keyup');
sortTitleField.off('click');
const sortTitleField = $('#sort-title-field');
sortTitleField.off('keyup');
sortTitleField.off('click');
});
const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {
tid: titleId,
};
if (eid) queryObj['eid'] = eid;
const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`;
UIkit.upload('.upload-field', {
url,
name: 'file',
error: (e) => {
alert('danger', `Failed to upload cover image: ${e.toString()}`);
},
loadStart: (e) => {
$(bar).removeAttr('hidden');
bar.max = e.total;
bar.value = e.loaded;
},
progress: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
loadEnd: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
completeAll: () => {
$(bar).attr('hidden', '');
location.reload();
},
});
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {
tid: titleId
};
if (eid)
queryObj['eid'] = eid;
const query = $.param(queryObj);
const url = `${base_url}api/admin/upload/cover?${query}`;
UIkit.upload('.upload-field', {
url: url,
name: 'file',
error: (e) => {
alert('danger', `Failed to upload cover image: ${e.toString()}`);
},
loadStart: (e) => {
$(bar).removeAttr('hidden');
bar.max = e.total;
bar.value = e.loaded;
},
progress: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
loadEnd: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
completeAll: () => {
$(bar).attr('hidden', '');
location.reload();
}
});
};
const deselectAll = () => {
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
data['selected'] = false;
});
$('#select-bar')[0].__x.$data['count'] = 0;
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
data['selected'] = false;
});
$('#select-bar')[0].__x.$data['count'] = 0;
};
const selectAll = () => {
let count = 0;
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled']) {
data['selected'] = true;
count++;
}
});
$('#select-bar')[0].__x.$data['count'] = count;
let count = 0;
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled']) {
data['selected'] = true;
count++;
}
});
$('#select-bar')[0].__x.$data['count'] = count;
};
const selectedIDs = () => {
const ary = [];
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled'] && data['selected']) {
const item = $(e).closest('.item');
ary.push($(item).attr('id'));
}
});
return ary;
const ary = [];
$('.item .uk-card').each((i, e) => {
const data = e.__x.$data;
if (!data['disabled'] && data['selected']) {
const item = $(e).closest('.item');
ary.push($(item).attr('id'));
}
});
return ary;
};
const bulkProgress = (action, el) => {
const tid = $(el).attr('data-id');
const ids = selectedIDs();
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({
type: 'PUT',
url,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({
ids,
}),
})
.done((data) => {
if (data.error) {
alert(
'danger',
`Failed to mark entries as ${action}. Error: ${data.error}`,
);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
})
.always(() => {
deselectAll();
});
const tid = $(el).attr('data-id');
const ids = selectedIDs();
const url = `${base_url}api/bulk_progress/${action}/${tid}`;
$.ajax({
type: 'PUT',
url: url,
contentType: "application/json",
dataType: 'json',
data: JSON.stringify({
ids: ids
})
})
.done(data => {
if (data.error) {
alert('danger', `Failed to mark entries as ${action}. Error: ${data.error}`);
return;
}
location.reload();
})
.fail((jqXHR, status) => {
alert('danger', `Failed to mark entries as ${action}. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
})
.always(() => {
deselectAll();
});
};
const tagsComponent = () => {
return {
isAdmin: false,
tags: [],
tid: $('.upload-field').attr('data-title-id'),
loading: true,
return {
isAdmin: false,
tags: [],
tid: $('.upload-field').attr('data-title-id'),
loading: true,
load(admin) {
this.isAdmin = admin;
load(admin) {
this.isAdmin = admin;
$('.tag-select').select2({
tags: true,
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
disabled: !this.isAdmin,
templateSelection(state) {
const a = document.createElement('a');
a.setAttribute(
'href',
`${base_url}tags/${encodeURIComponent(state.text)}`,
);
a.setAttribute('class', 'uk-link-reset');
a.onclick = (event) => {
event.stopPropagation();
};
a.innerText = state.text;
return a;
},
});
$('.tag-select').select2({
tags: true,
placeholder: this.isAdmin ? 'Tag the title' : 'No tags found',
disabled: !this.isAdmin,
templateSelection(state) {
const a = document.createElement('a');
a.setAttribute('href', `${base_url}tags/${encodeURIComponent(state.text)}`);
a.setAttribute('class', 'uk-link-reset');
a.onclick = event => {
event.stopPropagation();
};
a.innerText = state.text;
return a;
}
});
this.request(`${base_url}api/tags`, 'GET', (data) => {
const allTags = data.tags;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', (data) => {
this.tags = data.tags;
allTags.forEach((t) => {
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
$('.tag-select').append(op);
});
$('.tag-select').on('select2:select', (e) => {
this.onAdd(e);
});
$('.tag-select').on('select2:unselect', (e) => {
this.onDelete(e);
});
$('.tag-select').on('change', () => {
this.onChange();
});
$('.tag-select').trigger('change');
this.loading = false;
});
});
},
onChange() {
this.tags = $('.tag-select')
.select2('data')
.map((o) => o.text);
},
onAdd(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(
tag,
)}`;
this.request(url, 'PUT');
},
onDelete(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(
tag,
)}`;
this.request(url, 'DELETE');
},
request(url, method, cb) {
$.ajax({
url,
method,
dataType: 'json',
})
.done((data) => {
if (data.success) {
if (cb) cb(data);
} else {
alert('danger', data.error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
},
};
this.request(`${base_url}api/tags`, 'GET', (data) => {
const allTags = data.tags;
const url = `${base_url}api/tags/${this.tid}`;
this.request(url, 'GET', data => {
this.tags = data.tags;
allTags.forEach(t => {
const op = new Option(t, t, false, this.tags.indexOf(t) >= 0);
$('.tag-select').append(op);
});
$('.tag-select').on('select2:select', e => {
this.onAdd(e);
});
$('.tag-select').on('select2:unselect', e => {
this.onDelete(e);
});
$('.tag-select').on('change', () => {
this.onChange();
});
$('.tag-select').trigger('change');
this.loading = false;
});
});
},
onChange() {
this.tags = $('.tag-select').select2('data').map(o => o.text);
},
onAdd(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'PUT');
},
onDelete(event) {
const tag = event.params.data.text;
const url = `${base_url}api/admin/tags/${this.tid}/${encodeURIComponent(tag)}`;
this.request(url, 'DELETE');
},
request(url, method, cb) {
$.ajax({
url: url,
method: method,
dataType: 'json'
})
.done(data => {
if (data.success) {
if (cb) cb(data);
} else {
alert('danger', data.error);
}
})
.fail((jqXHR, status) => {
alert('danger', `Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
}
};
};

View File

@@ -1,6 +1,6 @@
$(() => {
let target = base_url + 'admin/user/edit';
if (username) target += username;
$('form').attr('action', target);
if (error) alert('danger', error);
var target = base_url + 'admin/user/edit';
if (username) target += username;
$('form').attr('action', target);
if (error) alert('danger', error);
});

View File

@@ -1,17 +1,16 @@
const remove = (username) => {
$.ajax({
url: `${base_url}api/admin/user/delete/${username}`,
type: 'DELETE',
dataType: 'json',
})
.done((data) => {
if (data.success) location.reload();
else alert('danger', data.error);
})
.fail((jqXHR, status) => {
alert(
'danger',
`Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`,
);
});
$.ajax({
url: `${base_url}api/admin/user/delete/${username}`,
type: 'DELETE',
dataType: 'json'
})
.done(data => {
if (data.success)
location.reload();
else
alert('danger', data.error);
})
.fail((jqXHR, status) => {
alert('danger', `Failed to delete the user. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
});
};

View File

@@ -1,5 +1,5 @@
name: mango
version: 0.27.1
version: 0.27.0
authors:
- Alex Ling <hkalexling@gmail.com>

View File

@@ -8,7 +8,7 @@ class Config
"session_secret" => "mango-session-secret",
"library_path" => "~/mango/library",
"library_cache_path" => "~/mango/library.yml.gz",
"db_path" => "~/mango/mango.db",
"db_path" => "~/mango.db",
"queue_db_path" => "~/mango/queue.db",
"scan_interval_minutes" => 5,
"thumbnail_generation_interval_hours" => 24,

View File

@@ -7,7 +7,7 @@ require "option_parser"
require "clim"
require "tallboy"
MANGO_VERSION = "0.27.1"
MANGO_VERSION = "0.27.0"
# From http://www.network-science.de/ascii/
BANNER = %{

View File

@@ -184,7 +184,7 @@ def delete_cache_and_exit(path : String)
File.delete path
Logger.fatal "Invalid library cache deleted. Mango needs to " \
"perform a full reset to recover from this. " \
"Please restart Mango. This is NOT a bug."
"Pleae restart Mango. This is NOT a bug."
Logger.fatal "Exiting"
exit 1
end