mirror of
https://github.com/hkalexling/Mango.git
synced 2025-08-03 03:15:31 -04:00
Merge pull request #166 from hkalexling/dev
This commit is contained in:
commit
604c5d49a6
@ -95,6 +95,15 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "davidkna",
|
||||||
|
"name": "David Knaack",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/835177?v=4",
|
||||||
|
"profile": "https://github.com/davidkna",
|
||||||
|
"contributions": [
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -13,3 +13,4 @@ public/css/uikit.css
|
|||||||
public/img/*.svg
|
public/img/*.svg
|
||||||
public/js/*.min.js
|
public/js/*.min.js
|
||||||
public/css/*.css
|
public/css/*.css
|
||||||
|
public/webfonts
|
||||||
|
@ -10,6 +10,6 @@ FROM library/alpine
|
|||||||
|
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
|
|
||||||
COPY --from=builder /Mango/mango .
|
COPY --from=builder /Mango/mango /usr/local/bin/mango
|
||||||
|
|
||||||
CMD ["./mango"]
|
CMD ["/usr/local/bin/mango"]
|
||||||
|
@ -9,6 +9,7 @@ RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr &&
|
|||||||
|
|
||||||
COPY mango-arm32v7.o .
|
COPY mango-arm32v7.o .
|
||||||
|
|
||||||
RUN cc 'mango-arm32v7.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
RUN cc 'mango-arm32v7.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/arm-linux-gnueabihf/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/mango"]
|
||||||
|
|
||||||
CMD ["./mango"]
|
|
||||||
|
@ -9,6 +9,6 @@ RUN git clone https://github.com/hkalexling/image_size.cr && cd image_size.cr &&
|
|||||||
|
|
||||||
COPY mango-arm64v8.o .
|
COPY mango-arm64v8.o .
|
||||||
|
|
||||||
RUN cc 'mango-arm64v8.o' -o 'mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
RUN cc 'mango-arm64v8.o' -o '/usr/local/bin/mango' -rdynamic -lxml2 -L/image_size.cr/ext/libwebp -lwebp -L/image_size.cr/ext/stbi -lstbi /myhtml/src/ext/modest-c/lib/libmodest_static.a -L/duktape.cr/src/.build/lib -L/duktape.cr/src/.build/include -lduktape -lm `pkg-config libarchive --libs` -lz `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libssl || printf %s '-lssl -lcrypto'` `command -v pkg-config > /dev/null && pkg-config --libs --silence-errors libcrypto || printf %s '-lcrypto'` -lgmp -lsqlite3 -lyaml -lpcre -lm /usr/lib/aarch64-linux-gnu/libgc.so -lpthread /crystal/src/ext/libcrystal.a -levent -lrt -ldl -L/usr/bin/../lib/crystal/lib -L/usr/bin/../lib/crystal/lib
|
||||||
|
|
||||||
CMD ["./mango"]
|
CMD ["/usr/local/bin/mango"]
|
||||||
|
@ -52,7 +52,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango - Manga Server and Web Reader. Version 0.20.0
|
Mango - Manga Server and Web Reader. Version 0.20.1
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|
||||||
@ -172,6 +172,7 @@ Please check the [development guideline](https://github.com/hkalexling/Mango/wik
|
|||||||
<tr>
|
<tr>
|
||||||
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
<td align="center"><a href="https://github.com/Leeingnyo"><img src="https://avatars0.githubusercontent.com/u/6760150?v=4?s=100" width="100px;" alt=""/><br /><sub><b>이인용</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=Leeingnyo" title="Code">💻</a></td>
|
||||||
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
|
<td align="center"><a href="http://h45h74x.eu.org"><img src="https://avatars1.githubusercontent.com/u/27204033?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Simon</b></sub></a><br /><a href="https://github.com/hkalexling/Mango/commits?author=h45h74x" title="Code">💻</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/davidkna"><img src="https://avatars.githubusercontent.com/u/835177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Knaack</b></sub></a><br /><a href="#infra-davidkna" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
36
gulpfile.js
36
gulpfile.js
@ -4,26 +4,25 @@ const minify = require('gulp-babel-minify');
|
|||||||
const minifyCss = require('gulp-minify-css');
|
const minifyCss = require('gulp-minify-css');
|
||||||
const less = require('gulp-less');
|
const less = require('gulp-less');
|
||||||
|
|
||||||
// Copy libraries from node_moduels to public/js
|
gulp.task('copy-img', () => {
|
||||||
gulp.task('copy-js', () => {
|
|
||||||
return gulp.src([
|
|
||||||
'node_modules/@fortawesome/fontawesome-free/js/fontawesome.min.js',
|
|
||||||
'node_modules/@fortawesome/fontawesome-free/js/solid.min.js',
|
|
||||||
'node_modules/uikit/dist/js/uikit.min.js',
|
|
||||||
'node_modules/uikit/dist/js/uikit-icons.min.js'
|
|
||||||
])
|
|
||||||
.pipe(gulp.dest('public/js'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Copy UIKit SVG icons to public/img
|
|
||||||
gulp.task('copy-uikit-icons', () => {
|
|
||||||
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
return gulp.src('node_modules/uikit/src/images/backgrounds/*.svg')
|
||||||
.pipe(gulp.dest('public/img'));
|
.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'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy files from node_modules
|
||||||
|
gulp.task('node-modules-copy', gulp.parallel('copy-img', 'copy-font'));
|
||||||
|
|
||||||
// Compile less
|
// Compile less
|
||||||
gulp.task('less', () => {
|
gulp.task('less', () => {
|
||||||
return gulp.src('public/css/*.less')
|
return gulp.src([
|
||||||
|
'public/css/mango.less',
|
||||||
|
'public/css/tags.less'
|
||||||
|
])
|
||||||
.pipe(less())
|
.pipe(less())
|
||||||
.pipe(gulp.dest('public/css'));
|
.pipe(gulp.dest('public/css'));
|
||||||
});
|
});
|
||||||
@ -54,14 +53,19 @@ gulp.task('minify-css', () => {
|
|||||||
|
|
||||||
// Copy static files (includeing images) to dist
|
// Copy static files (includeing images) to dist
|
||||||
gulp.task('copy-files', () => {
|
gulp.task('copy-files', () => {
|
||||||
return gulp.src(['public/img/*', 'public/*.*', 'public/js/*.min.js'], {
|
return gulp.src([
|
||||||
|
'public/*.*',
|
||||||
|
'public/img/*',
|
||||||
|
'public/webfonts/*',
|
||||||
|
'public/js/*.min.js'
|
||||||
|
], {
|
||||||
base: 'public'
|
base: 'public'
|
||||||
})
|
})
|
||||||
.pipe(gulp.dest('dist'));
|
.pipe(gulp.dest('dist'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up the public folder for development
|
// Set up the public folder for development
|
||||||
gulp.task('dev', gulp.parallel('copy-js', 'copy-uikit-icons', 'less'));
|
gulp.task('dev', gulp.parallel('node-modules-copy', 'less'));
|
||||||
|
|
||||||
// Set up the dist folder for deployment
|
// Set up the dist folder for deployment
|
||||||
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
gulp.task('deploy', gulp.parallel('babel', 'minify-css', 'copy-files'));
|
||||||
|
@ -1,3 +1,16 @@
|
|||||||
|
// UIKit
|
||||||
|
@import "./uikit.less";
|
||||||
|
|
||||||
|
// FontAwesome
|
||||||
|
@import "../../node_modules/@fortawesome/fontawesome-free/less/fontawesome.less";
|
||||||
|
@import "../../node_modules/@fortawesome/fontawesome-free/less/solid.less";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
src: url('@{fa-font-path}/fa-solid-900.woff2');
|
||||||
|
src: url('@{fa-font-path}/fa-solid-900.woff2') format('woff2'),
|
||||||
|
url('@{fa-font-path}/fa-solid-900.woff') format('woff');
|
||||||
|
}
|
||||||
|
|
||||||
// Item cards
|
// Item cards
|
||||||
.item .uk-card {
|
.item .uk-card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
@ -1,39 +1,208 @@
|
|||||||
$(() => {
|
const downloadComponent = () => {
|
||||||
$('#search-input').keypress(event => {
|
return {
|
||||||
if (event.which === 13) {
|
chaptersLimit: 1000,
|
||||||
search();
|
loading: false,
|
||||||
|
addingToDownload: false,
|
||||||
|
searchInput: '',
|
||||||
|
data: {},
|
||||||
|
chapters: [],
|
||||||
|
langChoice: 'All',
|
||||||
|
groupChoice: 'All',
|
||||||
|
chapterRange: '',
|
||||||
|
volumeRange: '',
|
||||||
|
|
||||||
|
get languages() {
|
||||||
|
const set = new Set();
|
||||||
|
if (this.data.chapters) {
|
||||||
|
this.data.chapters.forEach(chp => {
|
||||||
|
set.add(chp.language);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
const ary = [...set].sort();
|
||||||
$('.filter-field').each((i, ele) => {
|
ary.unshift('All');
|
||||||
$(ele).change(() => {
|
return ary;
|
||||||
buildTable();
|
},
|
||||||
|
|
||||||
|
get groups() {
|
||||||
|
const set = new Set();
|
||||||
|
if (this.data.chapters) {
|
||||||
|
this.data.chapters.forEach(chp => {
|
||||||
|
Object.keys(chp.groups).forEach(g => {
|
||||||
|
set.add(g);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
const ary = [...set].sort();
|
||||||
|
ary.unshift('All');
|
||||||
|
return ary;
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const tableObserver = new MutationObserver(() => {
|
||||||
|
console.log('table mutated');
|
||||||
|
$("#selectable").selectable({
|
||||||
|
filter: 'tr'
|
||||||
});
|
});
|
||||||
const selectAll = () => {
|
});
|
||||||
|
tableObserver.observe($('table').get(0), {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
filtersUpdated() {
|
||||||
|
if (!this.data.chapters)
|
||||||
|
this.chapters = [];
|
||||||
|
const filters = {
|
||||||
|
chapter: this.parseRange(this.chapterRange),
|
||||||
|
volume: this.parseRange(this.volumeRange),
|
||||||
|
lang: this.langChoice,
|
||||||
|
group: this.groupChoice
|
||||||
|
};
|
||||||
|
console.log('filters:', filters);
|
||||||
|
let _chapters = this.data.chapters.slice();
|
||||||
|
Object.entries(filters).forEach(([k, v]) => {
|
||||||
|
if (v === 'All') return;
|
||||||
|
if (k === 'group') {
|
||||||
|
_chapters = _chapters.filter(c => {
|
||||||
|
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => this.unescapeHTML(g));
|
||||||
|
return unescaped_groups.indexOf(v) >= 0;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (k === 'lang') {
|
||||||
|
_chapters = _chapters.filter(c => c.language === v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lb = parseFloat(v[0]);
|
||||||
|
const ub = parseFloat(v[1]);
|
||||||
|
if (isNaN(lb) && isNaN(ub)) return;
|
||||||
|
_chapters = _chapters.filter(c => {
|
||||||
|
const val = parseFloat(c[k]);
|
||||||
|
if (isNaN(val)) return false;
|
||||||
|
if (isNaN(lb))
|
||||||
|
return val <= ub;
|
||||||
|
else if (isNaN(ub))
|
||||||
|
return val >= lb;
|
||||||
|
else
|
||||||
|
return val >= lb && val <= ub;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log('filtered chapters:', _chapters);
|
||||||
|
this.chapters = _chapters;
|
||||||
|
},
|
||||||
|
search() {
|
||||||
|
if (this.loading || this.searchInput === '') return;
|
||||||
|
this.loading = true;
|
||||||
|
this.data = {};
|
||||||
|
|
||||||
|
var int_id = -1;
|
||||||
|
try {
|
||||||
|
const path = new URL(this.searchInput).pathname;
|
||||||
|
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
||||||
|
int_id = parseInt(match[1]);
|
||||||
|
} catch (e) {
|
||||||
|
int_id = parseInt(this.searchInput);
|
||||||
|
}
|
||||||
|
if (int_id <= 0 || isNaN(int_id)) {
|
||||||
|
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
||||||
|
.done((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data = data;
|
||||||
|
this.chapters = data.chapters;
|
||||||
|
})
|
||||||
|
.fail((jqXHR, status) => {
|
||||||
|
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
|
})
|
||||||
|
.always(() => {
|
||||||
|
this.loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
parseRange(str) {
|
||||||
|
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
||||||
|
const matches = str.match(regex);
|
||||||
|
var num;
|
||||||
|
|
||||||
|
if (!matches) {
|
||||||
|
return [null, null];
|
||||||
|
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
||||||
|
// e.g., <= 30
|
||||||
|
num = parseInt(matches[2]);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
switch (matches[1]) {
|
||||||
|
case '<':
|
||||||
|
return [null, num - 1];
|
||||||
|
case '<=':
|
||||||
|
return [null, num];
|
||||||
|
case '>':
|
||||||
|
return [num + 1, null];
|
||||||
|
case '>=':
|
||||||
|
return [num, null];
|
||||||
|
}
|
||||||
|
} else if (typeof matches[3] !== 'undefined') {
|
||||||
|
// a single number
|
||||||
|
num = parseInt(matches[3]);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [num, num];
|
||||||
|
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
||||||
|
// e.g., 10 - 23
|
||||||
|
num = parseInt(matches[4]);
|
||||||
|
const n2 = parseInt(matches[5]);
|
||||||
|
if (isNaN(num) || isNaN(n2) || num > n2) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
return [num, n2];
|
||||||
|
} else {
|
||||||
|
// empty or space only
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unescapeHTML(str) {
|
||||||
|
var elt = document.createElement("span");
|
||||||
|
elt.innerHTML = str;
|
||||||
|
return elt.innerText;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll() {
|
||||||
$('tbody > tr').each((i, e) => {
|
$('tbody > tr').each((i, e) => {
|
||||||
$(e).addClass('ui-selected');
|
$(e).addClass('ui-selected');
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
const unselect = () => {
|
|
||||||
|
clearSelection() {
|
||||||
$('tbody > tr').each((i, e) => {
|
$('tbody > tr').each((i, e) => {
|
||||||
$(e).removeClass('ui-selected');
|
$(e).removeClass('ui-selected');
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
const download = () => {
|
|
||||||
|
download() {
|
||||||
const selected = $('tbody > tr.ui-selected');
|
const selected = $('tbody > tr.ui-selected');
|
||||||
if (selected.length === 0) return;
|
if (selected.length === 0) return;
|
||||||
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
UIkit.modal.confirm(`Download ${selected.length} selected chapters?`).then(() => {
|
||||||
$('#download-btn').attr('hidden', '');
|
|
||||||
$('#download-spinner').removeAttr('hidden');
|
|
||||||
const ids = selected.map((i, e) => {
|
const ids = selected.map((i, e) => {
|
||||||
return $(e).find('td').first().text();
|
return parseInt($(e).find('td').first().text());
|
||||||
}).get();
|
}).get();
|
||||||
const chapters = globalChapters.filter(c => ids.indexOf(c.id) >= 0);
|
const chapters = this.chapters.filter(c => ids.indexOf(c.id) >= 0);
|
||||||
console.log(ids);
|
console.log(ids);
|
||||||
|
this.addingToDownload = true;
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
url: base_url + 'api/admin/mangadex/download',
|
url: `${base_url}api/admin/mangadex/download`,
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
chapters: chapters
|
chapters: chapters
|
||||||
}),
|
}),
|
||||||
@ -56,250 +225,9 @@ const download = () => {
|
|||||||
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to add chapters to the download queue. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
})
|
})
|
||||||
.always(() => {
|
.always(() => {
|
||||||
$('#download-spinner').attr('hidden', '');
|
this.addingToDownload = false;
|
||||||
$('#download-btn').removeAttr('hidden');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
|
||||||
const toggleSpinner = () => {
|
|
||||||
var attr = $('#spinner').attr('hidden');
|
|
||||||
if (attr) {
|
|
||||||
$('#spinner').removeAttr('hidden');
|
|
||||||
$('#search-btn').attr('hidden', '');
|
|
||||||
} else {
|
|
||||||
$('#search-btn').removeAttr('hidden');
|
|
||||||
$('#spinner').attr('hidden', '');
|
|
||||||
}
|
|
||||||
searching = !searching;
|
|
||||||
};
|
|
||||||
var searching = false;
|
|
||||||
var globalChapters;
|
|
||||||
const search = () => {
|
|
||||||
if (searching) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$('#manga-details').attr('hidden', '');
|
|
||||||
$('#filter-form').attr('hidden', '');
|
|
||||||
$('table').attr('hidden', '');
|
|
||||||
$('#selection-controls').attr('hidden', '');
|
|
||||||
$('#filter-notification').attr('hidden', '');
|
|
||||||
toggleSpinner();
|
|
||||||
const input = $('input').val();
|
|
||||||
|
|
||||||
if (input === "") {
|
|
||||||
toggleSpinner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var int_id = -1;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const path = new URL(input).pathname;
|
|
||||||
const match = /\/(?:title|manga)\/([0-9]+)/.exec(path);
|
|
||||||
int_id = parseInt(match[1]);
|
|
||||||
} catch (e) {
|
|
||||||
int_id = parseInt(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (int_id <= 0 || isNaN(int_id)) {
|
|
||||||
alert('danger', 'Please make sure you are using a valid manga ID or manga URL from Mangadex.');
|
|
||||||
toggleSpinner();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$.getJSON(`${base_url}api/admin/mangadex/manga/${int_id}`)
|
|
||||||
.done((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
alert('danger', 'Failed to get manga info. Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cover = baseURL + data.cover_url;
|
|
||||||
$('#cover').attr("src", cover);
|
|
||||||
$('#title').text("Title: " + data.title);
|
|
||||||
$('#artist').text("Artist: " + data.artist);
|
|
||||||
$('#author').text("Author: " + data.author);
|
|
||||||
|
|
||||||
$('#manga-details').removeAttr('hidden');
|
|
||||||
|
|
||||||
console.log(data.chapters);
|
|
||||||
globalChapters = data.chapters;
|
|
||||||
|
|
||||||
let langs = new Set();
|
|
||||||
let group_names = new Set();
|
|
||||||
data.chapters.forEach(chp => {
|
|
||||||
Object.entries(chp.groups).forEach(([k, v]) => {
|
|
||||||
group_names.add(k);
|
|
||||||
});
|
|
||||||
langs.add(chp.language);
|
|
||||||
});
|
|
||||||
|
|
||||||
const comp = (a, b) => {
|
|
||||||
var ai;
|
|
||||||
var bi;
|
|
||||||
try {
|
|
||||||
ai = parseFloat(a);
|
|
||||||
} catch (e) {}
|
|
||||||
try {
|
|
||||||
bi = parseFloat(b);
|
|
||||||
} catch (e) {}
|
|
||||||
if (typeof ai === 'undefined') return -1;
|
|
||||||
if (typeof bi === 'undefined') return 1;
|
|
||||||
if (ai < bi) return 1;
|
|
||||||
if (ai > bi) return -1;
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
langs = [...langs].sort();
|
|
||||||
group_names = [...group_names].sort();
|
|
||||||
|
|
||||||
langs.unshift('All');
|
|
||||||
group_names.unshift('All');
|
|
||||||
|
|
||||||
$('select#lang-select').html(langs.map(e => `<option>${e}</option>`).join(''));
|
|
||||||
$('select#group-select').html(group_names.map(e => `<option>${e}</option>`).join(''));
|
|
||||||
|
|
||||||
$('#filter-form').removeAttr('hidden');
|
|
||||||
|
|
||||||
buildTable();
|
|
||||||
})
|
|
||||||
.fail((jqXHR, status) => {
|
|
||||||
alert('danger', `Failed to get manga info. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
|
||||||
})
|
|
||||||
.always(() => {
|
|
||||||
toggleSpinner();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const parseRange = str => {
|
|
||||||
const regex = /^[\t ]*(?:(?:(<|<=|>|>=)[\t ]*([0-9]+))|(?:([0-9]+))|(?:([0-9]+)[\t ]*-[\t ]*([0-9]+))|(?:[\t ]*))[\t ]*$/m;
|
|
||||||
const matches = str.match(regex);
|
|
||||||
var num;
|
|
||||||
|
|
||||||
if (!matches) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
} else if (typeof matches[1] !== 'undefined' && typeof matches[2] !== 'undefined') {
|
|
||||||
// e.g., <= 30
|
|
||||||
num = parseInt(matches[2]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
switch (matches[1]) {
|
|
||||||
case '<':
|
|
||||||
return [null, num - 1];
|
|
||||||
case '<=':
|
|
||||||
return [null, num];
|
|
||||||
case '>':
|
|
||||||
return [num + 1, null];
|
|
||||||
case '>=':
|
|
||||||
return [num, null];
|
|
||||||
}
|
|
||||||
} else if (typeof matches[3] !== 'undefined') {
|
|
||||||
// a single number
|
|
||||||
num = parseInt(matches[3]);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, num];
|
|
||||||
} else if (typeof matches[4] !== 'undefined' && typeof matches[5] !== 'undefined') {
|
|
||||||
// e.g., 10 - 23
|
|
||||||
num = parseInt(matches[4]);
|
|
||||||
const n2 = parseInt(matches[5]);
|
|
||||||
if (isNaN(num) || isNaN(n2) || num > n2) {
|
|
||||||
alert('danger', `Failed to parse filter input ${str}`);
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
return [num, n2];
|
|
||||||
} else {
|
|
||||||
// empty or space only
|
|
||||||
return [null, null];
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const getFilters = () => {
|
|
||||||
const filters = {};
|
|
||||||
$('.uk-select').each((i, ele) => {
|
|
||||||
const id = $(ele).attr('id');
|
|
||||||
const by = id.split('-')[0];
|
|
||||||
const choice = $(ele).val();
|
|
||||||
filters[by] = choice;
|
|
||||||
});
|
|
||||||
filters.volume = parseRange($('#volume-range').val());
|
|
||||||
filters.chapter = parseRange($('#chapter-range').val());
|
|
||||||
return filters;
|
|
||||||
};
|
|
||||||
const buildTable = () => {
|
|
||||||
$('table').attr('hidden', '');
|
|
||||||
$('#selection-controls').attr('hidden', '');
|
|
||||||
$('#filter-notification').attr('hidden', '');
|
|
||||||
console.log('rebuilding table');
|
|
||||||
const filters = getFilters();
|
|
||||||
console.log('filters:', filters);
|
|
||||||
var chapters = globalChapters.slice();
|
|
||||||
Object.entries(filters).forEach(([k, v]) => {
|
|
||||||
if (v === 'All') return;
|
|
||||||
if (k === 'group') {
|
|
||||||
chapters = chapters.filter(c => {
|
|
||||||
const unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
|
|
||||||
return unescaped_groups.indexOf(v) >= 0;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (k === 'lang') {
|
|
||||||
chapters = chapters.filter(c => c.language === v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lb = parseFloat(v[0]);
|
|
||||||
const ub = parseFloat(v[1]);
|
|
||||||
if (isNaN(lb) && isNaN(ub)) return;
|
|
||||||
chapters = chapters.filter(c => {
|
|
||||||
const val = parseFloat(c[k]);
|
|
||||||
if (isNaN(val)) return false;
|
|
||||||
if (isNaN(lb))
|
|
||||||
return val <= ub;
|
|
||||||
else if (isNaN(ub))
|
|
||||||
return val >= lb;
|
|
||||||
else
|
|
||||||
return val >= lb && val <= ub;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
console.log('filtered chapters:', chapters);
|
|
||||||
$('#count-text').text(`${chapters.length} chapters found`);
|
|
||||||
|
|
||||||
const chaptersLimit = 1000;
|
|
||||||
if (chapters.length > chaptersLimit) {
|
|
||||||
$('#filter-notification').text(`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters in this manga. Please use the filter options above to narrow down your search.`);
|
|
||||||
$('#filter-notification').removeAttr('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inner = chapters.map(chp => {
|
|
||||||
const group_str = Object.entries(chp.groups).map(([k, v]) => {
|
|
||||||
return `<a href="${baseURL }/group/${v}">${k}</a>`;
|
|
||||||
}).join(' | ');
|
|
||||||
return `<tr class="ui-widget-content">
|
|
||||||
<td><a href="${baseURL}/chapter/${chp.id}">${chp.id}</a></td>
|
|
||||||
<td>${chp.title}</td>
|
|
||||||
<td>${chp.language}</td>
|
|
||||||
<td>${group_str}</td>
|
|
||||||
<td>${chp.volume}</td>
|
|
||||||
<td>${chp.chapter}</td>
|
|
||||||
<td>${moment.unix(chp.time).fromNow()}</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
const tbody = `<tbody id="selectable">${inner}</tbody>`;
|
|
||||||
$('tbody').remove();
|
|
||||||
$('table').append(tbody);
|
|
||||||
$('table').removeAttr('hidden');
|
|
||||||
$("#selectable").selectable({
|
|
||||||
filter: 'tr'
|
|
||||||
});
|
|
||||||
$('#selection-controls').removeAttr('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
const unescapeHTML = (str) => {
|
|
||||||
var elt = document.createElement("span");
|
|
||||||
elt.innerHTML = str;
|
|
||||||
return elt.innerText;
|
|
||||||
};
|
};
|
||||||
|
@ -52,9 +52,13 @@ shards:
|
|||||||
git: https://github.com/hkalexling/koa.git
|
git: https://github.com/hkalexling/koa.git
|
||||||
version: 0.5.0
|
version: 0.5.0
|
||||||
|
|
||||||
|
mangadex:
|
||||||
|
git: https://github.com/hkalexling/mangadex.git
|
||||||
|
version: 0.5.0+git.commit.323110c56c2d5134ce4162b27a9b24ec34137fcb
|
||||||
|
|
||||||
mg:
|
mg:
|
||||||
git: https://github.com/hkalexling/mg.git
|
git: https://github.com/hkalexling/mg.git
|
||||||
version: 0.2.0+git.commit.171c46489d991a8353818e00fc6a3c4e0809ded9
|
version: 0.3.0+git.commit.a19417abf03eece80039f89569926cff1ce3a1a3
|
||||||
|
|
||||||
myhtml:
|
myhtml:
|
||||||
git: https://github.com/kostya/myhtml.git
|
git: https://github.com/kostya/myhtml.git
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.20.0
|
version: 0.20.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@ -43,3 +43,5 @@ dependencies:
|
|||||||
github: epoch/tallboy
|
github: epoch/tallboy
|
||||||
mg:
|
mg:
|
||||||
github: hkalexling/mg
|
github: hkalexling/mg
|
||||||
|
mangadex:
|
||||||
|
github: hkalexling/mangadex
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
Arabic,sa
|
|
||||||
Bengali,bd
|
|
||||||
Bulgarian,bg
|
|
||||||
Burmese,mm
|
|
||||||
Catalan,ct
|
|
||||||
Chinese (Simp),cn
|
|
||||||
Chinese (Trad),hk
|
|
||||||
Czech,cz
|
|
||||||
Danish,dk
|
|
||||||
Dutch,nl
|
|
||||||
English,gb
|
|
||||||
Filipino,ph
|
|
||||||
Finnish,fi
|
|
||||||
French,fr
|
|
||||||
German,de
|
|
||||||
Greek,gr
|
|
||||||
Hebrew,il
|
|
||||||
Hindi,in
|
|
||||||
Hungarian,hu
|
|
||||||
Indonesian,id
|
|
||||||
Italian,it
|
|
||||||
Japanese,jp
|
|
||||||
Korean,kr
|
|
||||||
Lithuanian,lt
|
|
||||||
Malay,my
|
|
||||||
Mongolian,mn
|
|
||||||
Other,
|
|
||||||
Persian,ir
|
|
||||||
Polish,pl
|
|
||||||
Portuguese (Br),br
|
|
||||||
Portuguese (Pt),pt
|
|
||||||
Romanian,ro
|
|
||||||
Russian,ru
|
|
||||||
Serbo-Croatian,rs
|
|
||||||
Spanish (Es),es
|
|
||||||
Spanish (LATAM),mx
|
|
||||||
Swedish,se
|
|
||||||
Thai,th
|
|
||||||
Turkish,tr
|
|
||||||
Ukrainian,ua
|
|
||||||
Vietnames,vn
|
|
|
@ -22,12 +22,13 @@ class Config
|
|||||||
property page_margin : Int32 = 30
|
property page_margin : Int32 = 30
|
||||||
property disable_login = false
|
property disable_login = false
|
||||||
property default_username = ""
|
property default_username = ""
|
||||||
|
property auth_proxy_header_name = ""
|
||||||
property mangadex = Hash(String, String | Int32).new
|
property mangadex = Hash(String, String | Int32).new
|
||||||
|
|
||||||
@[YAML::Field(ignore: true)]
|
@[YAML::Field(ignore: true)]
|
||||||
@mangadex_defaults = {
|
@mangadex_defaults = {
|
||||||
"base_url" => "https://mangadex.org",
|
"base_url" => "https://mangadex.org",
|
||||||
"api_url" => "https://mangadex.org/api",
|
"api_url" => "https://mangadex.org/api/v2",
|
||||||
"download_wait_seconds" => 5,
|
"download_wait_seconds" => 5,
|
||||||
"download_retries" => 4,
|
"download_retries" => 4,
|
||||||
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
"download_queue_db_path" => File.expand_path("~/mango/queue.db",
|
||||||
@ -51,9 +52,9 @@ class Config
|
|||||||
cfg_path = File.expand_path path, home: true
|
cfg_path = File.expand_path path, home: true
|
||||||
if File.exists? cfg_path
|
if File.exists? cfg_path
|
||||||
config = self.from_yaml File.read cfg_path
|
config = self.from_yaml File.read cfg_path
|
||||||
config.preprocess
|
|
||||||
config.path = path
|
config.path = path
|
||||||
config.fill_defaults
|
config.fill_defaults
|
||||||
|
config.preprocess
|
||||||
return config
|
return config
|
||||||
end
|
end
|
||||||
puts "The config file #{cfg_path} does not exist. " \
|
puts "The config file #{cfg_path} does not exist. " \
|
||||||
@ -91,5 +92,16 @@ class Config
|
|||||||
raise "Login is disabled, but default username is not set. " \
|
raise "Login is disabled, but default username is not set. " \
|
||||||
"Please set a default username"
|
"Please set a default username"
|
||||||
end
|
end
|
||||||
|
unless mangadex["api_url"] =~ /\/v2/
|
||||||
|
# `Logger.default` is not available yet
|
||||||
|
Log.setup :debug
|
||||||
|
Log.warn { "It looks like you are using the deprecated MangaDex API " \
|
||||||
|
"v1 in your config file. Please update it to either " \
|
||||||
|
"https://mangadex.org/api/v2 or " \
|
||||||
|
"https://api.mangadex.org/v2 to suppress this warning." }
|
||||||
|
mangadex["api_url"] = "https://mangadex.org/api/v2"
|
||||||
|
end
|
||||||
|
mangadex["api_url"] = mangadex["api_url"].to_s.rstrip "/"
|
||||||
|
mangadex["base_url"] = mangadex["base_url"].to_s.rstrip "/"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -15,7 +15,11 @@ class AuthHandler < Kemal::Handler
|
|||||||
env.response.status_code = 401
|
env.response.status_code = 401
|
||||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
||||||
env.response.print AUTH_MESSAGE
|
env.response.print AUTH_MESSAGE
|
||||||
call_next env
|
end
|
||||||
|
|
||||||
|
def require_auth(env)
|
||||||
|
env.session.string "callback", env.request.path
|
||||||
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_token(env)
|
def validate_token(env)
|
||||||
@ -49,55 +53,50 @@ class AuthHandler < Kemal::Handler
|
|||||||
Storage.default.verify_user username, password
|
Storage.default.verify_user username, password
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_opds_auth(env)
|
def call(env)
|
||||||
if validate_token(env) || validate_auth_header(env)
|
# Skip all authentication if requesting /login, /logout, or a static file
|
||||||
call_next env
|
|
||||||
else
|
|
||||||
env.response.status_code = 401
|
|
||||||
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
|
||||||
env.response.print AUTH_MESSAGE
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_auth(env)
|
|
||||||
if request_path_startswith(env, ["/login", "/logout"]) ||
|
if request_path_startswith(env, ["/login", "/logout"]) ||
|
||||||
requesting_static_file env
|
requesting_static_file env
|
||||||
return call_next(env)
|
return call_next(env)
|
||||||
end
|
end
|
||||||
|
|
||||||
unless validate_token(env) || Config.current.disable_login
|
# Check user is logged in
|
||||||
env.session.string "callback", env.request.path
|
if validate_token env
|
||||||
return redirect env, "/login"
|
# Skip if the request has a valid token
|
||||||
|
elsif Config.current.disable_login
|
||||||
|
# Check default username if login is disabled
|
||||||
|
unless Storage.default.username_exists Config.current.default_username
|
||||||
|
Logger.warn "Default username #{Config.current.default_username} " \
|
||||||
|
"does not exist"
|
||||||
|
return require_auth env
|
||||||
|
end
|
||||||
|
elsif !Config.current.auth_proxy_header_name.empty?
|
||||||
|
# Check auth proxy if present
|
||||||
|
username = env.request.headers[Config.current.auth_proxy_header_name]?
|
||||||
|
unless username && Storage.default.username_exists username
|
||||||
|
Logger.warn "Header #{Config.current.auth_proxy_header_name} unset " \
|
||||||
|
"or is not a valid username"
|
||||||
|
return require_auth env
|
||||||
|
end
|
||||||
|
elsif request_path_startswith env, ["/opds"]
|
||||||
|
# Check auth header if requesting an opds page
|
||||||
|
unless validate_auth_header env
|
||||||
|
return require_basic_auth env
|
||||||
|
end
|
||||||
|
else
|
||||||
|
return require_auth env
|
||||||
end
|
end
|
||||||
|
|
||||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
# Check admin access when requesting an admin page
|
||||||
# The token (if exists) takes precedence over the default user option.
|
if request_path_startswith env, %w(/admin /api/admin /download)
|
||||||
# this is why we check the default username first before checking the
|
unless is_admin? env
|
||||||
# token.
|
|
||||||
should_reject = true
|
|
||||||
if Config.current.disable_login &&
|
|
||||||
Storage.default.username_is_admin Config.current.default_username
|
|
||||||
should_reject = false
|
|
||||||
end
|
|
||||||
if env.session.string? "token"
|
|
||||||
should_reject = !validate_token_admin(env)
|
|
||||||
end
|
|
||||||
if should_reject
|
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
send_error_page "HTTP 403: You are not authorized to visit " \
|
return send_error_page "HTTP 403: You are not authorized to visit " \
|
||||||
"#{env.request.path}"
|
"#{env.request.path}"
|
||||||
return
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Let the request go through if it passes the above checks
|
||||||
call_next env
|
call_next env
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
|
||||||
if request_path_startswith env, ["/opds"]
|
|
||||||
handle_opds_auth env
|
|
||||||
else
|
|
||||||
handle_auth env
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
@ -1,217 +0,0 @@
|
|||||||
require "json"
|
|
||||||
require "csv"
|
|
||||||
require "../rename"
|
|
||||||
|
|
||||||
macro string_properties(names)
|
|
||||||
{% for name in names %}
|
|
||||||
property {{name.id}} = ""
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro parse_strings_from_json(names)
|
|
||||||
{% for name in names %}
|
|
||||||
@{{name.id}} = obj[{{name}}].as_s
|
|
||||||
{% end %}
|
|
||||||
end
|
|
||||||
|
|
||||||
macro properties_to_hash(names)
|
|
||||||
{
|
|
||||||
{% for name in names %}
|
|
||||||
"{{name.id}}" => @{{name.id}}.to_s,
|
|
||||||
{% end %}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
module MangaDex
|
|
||||||
class Chapter
|
|
||||||
string_properties ["lang_code", "title", "volume", "chapter"]
|
|
||||||
property manga : Manga
|
|
||||||
property time = Time.local
|
|
||||||
property id : String
|
|
||||||
property full_title = ""
|
|
||||||
property language = ""
|
|
||||||
property pages = [] of {String, String} # filename, url
|
|
||||||
property groups = [] of {Int32, String} # group_id, group_name
|
|
||||||
|
|
||||||
def initialize(@id, json_obj : JSON::Any, @manga,
|
|
||||||
lang : Hash(String, String))
|
|
||||||
self.parse_json json_obj, lang
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
{% for name in ["id", "title", "volume", "chapter",
|
|
||||||
"language", "full_title"] %}
|
|
||||||
json.field {{name}}, @{{name.id}}
|
|
||||||
{% end %}
|
|
||||||
json.field "time", @time.to_unix.to_s
|
|
||||||
json.field "manga_title", @manga.title
|
|
||||||
json.field "manga_id", @manga.id
|
|
||||||
json.field "groups" do
|
|
||||||
json.object do
|
|
||||||
@groups.each do |gid, gname|
|
|
||||||
json.field gname, gid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_json(obj, lang)
|
|
||||||
parse_strings_from_json ["lang_code", "title", "volume",
|
|
||||||
"chapter"]
|
|
||||||
language = lang[@lang_code]?
|
|
||||||
@language = language if language
|
|
||||||
@time = Time.unix obj["timestamp"].as_i
|
|
||||||
suffixes = ["", "_2", "_3"]
|
|
||||||
suffixes.each do |s|
|
|
||||||
gid = obj["group_id#{s}"].as_i
|
|
||||||
next if gid == 0
|
|
||||||
gname = obj["group_name#{s}"].as_s
|
|
||||||
@groups << {gid, gname}
|
|
||||||
end
|
|
||||||
|
|
||||||
rename_rule = Rename::Rule.new \
|
|
||||||
Config.current.mangadex["chapter_rename_rule"].to_s
|
|
||||||
@full_title = rename rename_rule
|
|
||||||
rescue e
|
|
||||||
raise "failed to parse json: #{e}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
hash = properties_to_hash ["id", "title", "volume", "chapter",
|
|
||||||
"lang_code", "language", "pages"]
|
|
||||||
hash["groups"] = @groups.map { |g| g[1] }.join ","
|
|
||||||
rule.render hash
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Manga
|
|
||||||
string_properties ["cover_url", "description", "title", "author", "artist"]
|
|
||||||
property chapters = [] of Chapter
|
|
||||||
property id : String
|
|
||||||
|
|
||||||
def initialize(@id, json_obj : JSON::Any)
|
|
||||||
self.parse_json json_obj
|
|
||||||
end
|
|
||||||
|
|
||||||
def to_info_json(with_chapters = true)
|
|
||||||
JSON.build do |json|
|
|
||||||
json.object do
|
|
||||||
{% for name in ["id", "title", "description", "author", "artist",
|
|
||||||
"cover_url"] %}
|
|
||||||
json.field {{name}}, @{{name.id}}
|
|
||||||
{% end %}
|
|
||||||
if with_chapters
|
|
||||||
json.field "chapters" do
|
|
||||||
json.array do
|
|
||||||
@chapters.each do |c|
|
|
||||||
json.raw c.to_info_json
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_json(obj)
|
|
||||||
parse_strings_from_json ["cover_url", "description", "title", "author",
|
|
||||||
"artist"]
|
|
||||||
rescue e
|
|
||||||
raise "failed to parse json: #{e}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def rename(rule : Rename::Rule)
|
|
||||||
rule.render properties_to_hash ["id", "title", "author", "artist"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class API
|
|
||||||
use_default
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@base_url = Config.current.mangadex["api_url"].to_s ||
|
|
||||||
"https://mangadex.org/api/"
|
|
||||||
@lang = {} of String => String
|
|
||||||
CSV.each_row {{read_file "src/assets/lang_codes.csv"}} do |row|
|
|
||||||
@lang[row[1]] = row[0]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get(url)
|
|
||||||
headers = HTTP::Headers{
|
|
||||||
"User-agent" => "Mangadex.cr",
|
|
||||||
}
|
|
||||||
res = HTTP::Client.get url, headers
|
|
||||||
raise "Failed to get #{url}. [#{res.status_code}] " \
|
|
||||||
"#{res.status_message}" if !res.success?
|
|
||||||
JSON.parse res.body
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_manga(id)
|
|
||||||
obj = self.get File.join @base_url, "manga/#{id}"
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
manga = Manga.new id, obj["manga"]
|
|
||||||
obj["chapter"].as_h.map do |k, v|
|
|
||||||
chapter = Chapter.new k, v, manga, @lang
|
|
||||||
manga.chapters << chapter
|
|
||||||
end
|
|
||||||
manga
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_chapter(chapter : Chapter)
|
|
||||||
obj = self.get File.join @base_url, "chapter/#{chapter.id}"
|
|
||||||
if obj["status"]? == "external"
|
|
||||||
raise "This chapter is hosted on an external site " \
|
|
||||||
"#{obj["external"]?}, and Mango does not support " \
|
|
||||||
"external chapters."
|
|
||||||
end
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
server = obj["server"].as_s
|
|
||||||
hash = obj["hash"].as_s
|
|
||||||
chapter.pages = obj["page_array"].as_a.map do |fn|
|
|
||||||
{
|
|
||||||
fn.as_s,
|
|
||||||
"#{server}#{hash}/#{fn.as_s}",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_chapter(id : String)
|
|
||||||
obj = self.get File.join @base_url, "chapter/#{id}"
|
|
||||||
if obj["status"]? == "external"
|
|
||||||
raise "This chapter is hosted on an external site " \
|
|
||||||
"#{obj["external"]?}, and Mango does not support " \
|
|
||||||
"external chapters."
|
|
||||||
end
|
|
||||||
if obj["status"]? != "OK"
|
|
||||||
raise "Expecting `OK` in the `status` field. Got `#{obj["status"]?}`"
|
|
||||||
end
|
|
||||||
manga_id = ""
|
|
||||||
begin
|
|
||||||
manga_id = obj["manga_id"].as_i.to_s
|
|
||||||
rescue
|
|
||||||
raise "Failed to parse JSON"
|
|
||||||
end
|
|
||||||
manga = self.get_manga manga_id
|
|
||||||
chapter = manga.chapters.find { |c| c.id == id }.not_nil!
|
|
||||||
self.get_chapter chapter
|
|
||||||
chapter
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
@ -1,5 +1,7 @@
|
|||||||
require "./api"
|
require "mangadex"
|
||||||
require "compress/zip"
|
require "compress/zip"
|
||||||
|
require "../rename"
|
||||||
|
require "./ext"
|
||||||
|
|
||||||
module MangaDex
|
module MangaDex
|
||||||
class PageJob
|
class PageJob
|
||||||
@ -21,7 +23,7 @@ module MangaDex
|
|||||||
use_default
|
use_default
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@api = API.default
|
@client = Client.from_config
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -46,7 +48,7 @@ module MangaDex
|
|||||||
@downloading = true
|
@downloading = true
|
||||||
@queue.set_status Queue::JobStatus::Downloading, job
|
@queue.set_status Queue::JobStatus::Downloading, job
|
||||||
begin
|
begin
|
||||||
chapter = @api.get_chapter(job.id)
|
chapter = @client.chapter job.id
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
@queue.set_status Queue::JobStatus::Error, job
|
@queue.set_status Queue::JobStatus::Error, job
|
||||||
@ -73,8 +75,8 @@ module MangaDex
|
|||||||
# Create a buffered channel. It works as an FIFO queue
|
# Create a buffered channel. It works as an FIFO queue
|
||||||
channel = Channel(PageJob).new chapter.pages.size
|
channel = Channel(PageJob).new chapter.pages.size
|
||||||
spawn do
|
spawn do
|
||||||
chapter.pages.each_with_index do |tuple, i|
|
chapter.pages.each_with_index do |url, i|
|
||||||
fn, url = tuple
|
fn = Path.new(URI.parse(url).path).basename
|
||||||
ext = File.extname fn
|
ext = File.extname fn
|
||||||
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
fn = "#{i.to_s.rjust len, '0'}#{ext}"
|
||||||
page_job = PageJob.new url, fn, writer, @retries
|
page_job = PageJob.new url, fn, writer, @retries
|
||||||
|
60
src/mangadex/ext.cr
Normal file
60
src/mangadex/ext.cr
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
private macro properties_to_hash(names)
|
||||||
|
{
|
||||||
|
{% for name in names %}
|
||||||
|
"{{name.id}}" => {{name.id}}.to_s,
|
||||||
|
{% end %}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Monkey-patch the structures in the `mangadex` shard to suit our needs
|
||||||
|
module MangaDex
|
||||||
|
struct Client
|
||||||
|
@@group_cache = {} of String => Group
|
||||||
|
|
||||||
|
def self.from_config : Client
|
||||||
|
self.new base_url: Config.current.mangadex["base_url"].to_s,
|
||||||
|
api_url: Config.current.mangadex["api_url"].to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Manga
|
||||||
|
def rename(rule : Rename::Rule)
|
||||||
|
rule.render properties_to_hash %w(id title author artist)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_info_json
|
||||||
|
hash = JSON.parse(to_json).as_h
|
||||||
|
_chapters = chapters.map do |c|
|
||||||
|
JSON.parse c.to_info_json
|
||||||
|
end
|
||||||
|
hash["chapters"] = JSON::Any.new _chapters
|
||||||
|
hash.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
struct Chapter
|
||||||
|
def rename(rule : Rename::Rule)
|
||||||
|
hash = properties_to_hash %w(id title volume chapter lang_code language)
|
||||||
|
hash["groups"] = groups.map(&.name).join ","
|
||||||
|
rule.render hash
|
||||||
|
end
|
||||||
|
|
||||||
|
def full_title
|
||||||
|
rule = Rename::Rule.new \
|
||||||
|
Config.current.mangadex["chapter_rename_rule"].to_s
|
||||||
|
rename rule
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_info_json
|
||||||
|
hash = JSON.parse(to_json).as_h
|
||||||
|
hash["language"] = JSON::Any.new language
|
||||||
|
_groups = {} of String => JSON::Any
|
||||||
|
groups.each do |g|
|
||||||
|
_groups[g.name] = JSON::Any.new g.id
|
||||||
|
end
|
||||||
|
hash["groups"] = JSON::Any.new _groups
|
||||||
|
hash["full_title"] = JSON::Any.new full_title
|
||||||
|
hash.to_json
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -8,7 +8,7 @@ require "option_parser"
|
|||||||
require "clim"
|
require "clim"
|
||||||
require "tallboy"
|
require "tallboy"
|
||||||
|
|
||||||
MANGO_VERSION = "0.20.0"
|
MANGO_VERSION = "0.20.1"
|
||||||
|
|
||||||
# From http://www.network-science.de/ascii/
|
# From http://www.network-science.de/ascii/
|
||||||
BANNER = %{
|
BANNER = %{
|
||||||
|
@ -414,8 +414,7 @@ struct APIRouter
|
|||||||
get "/api/admin/mangadex/manga/:id" do |env|
|
get "/api/admin/mangadex/manga/:id" do |env|
|
||||||
begin
|
begin
|
||||||
id = env.params.url["id"]
|
id = env.params.url["id"]
|
||||||
api = MangaDex::API.default
|
manga = MangaDex::Client.from_config.manga id
|
||||||
manga = api.get_manga id
|
|
||||||
send_json env, manga.to_info_json
|
send_json env, manga.to_info_json
|
||||||
rescue e
|
rescue e
|
||||||
Logger.error e
|
Logger.error e
|
||||||
@ -434,12 +433,12 @@ struct APIRouter
|
|||||||
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
chapters = env.params.json["chapters"].as(Array).map { |c| c.as_h }
|
||||||
jobs = chapters.map { |chapter|
|
jobs = chapters.map { |chapter|
|
||||||
Queue::Job.new(
|
Queue::Job.new(
|
||||||
chapter["id"].as_s,
|
chapter["id"].as_i64.to_s,
|
||||||
chapter["manga_id"].as_s,
|
chapter["mangaId"].as_i64.to_s,
|
||||||
chapter["full_title"].as_s,
|
chapter["full_title"].as_s,
|
||||||
chapter["manga_title"].as_s,
|
chapter["mangaTitle"].as_s,
|
||||||
Queue::JobStatus::Pending,
|
Queue::JobStatus::Pending,
|
||||||
Time.unix chapter["time"].as_s.to_i
|
Time.unix chapter["timestamp"].as_i64
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
inserted_count = Queue.default.push jobs
|
inserted_count = Queue.default.push jobs
|
||||||
|
@ -48,14 +48,6 @@ class Storage
|
|||||||
|
|
||||||
user_count = db.query_one "select count(*) from users", as: Int32
|
user_count = db.query_one "select count(*) from users", as: Int32
|
||||||
init_admin if init_user && user_count == 0
|
init_admin if init_user && user_count == 0
|
||||||
|
|
||||||
# Verifies that the default username in config is valid
|
|
||||||
if Config.current.disable_login
|
|
||||||
username = Config.current.default_username
|
|
||||||
unless username_exists username
|
|
||||||
raise "Default username #{username} does not exist"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
unless @auto_close
|
unless @auto_close
|
||||||
@db = DB.open "sqlite3://#{@path}"
|
@db = DB.open "sqlite3://#{@path}"
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
IMGS_PER_PAGE = 5
|
IMGS_PER_PAGE = 5
|
||||||
ENTRIES_IN_HOME_SECTIONS = 8
|
ENTRIES_IN_HOME_SECTIONS = 8
|
||||||
UPLOAD_URL_PREFIX = "/uploads"
|
UPLOAD_URL_PREFIX = "/uploads"
|
||||||
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
STATIC_DIRS = %w(/css /js /img /webfonts /favicon.ico /robots.txt)
|
||||||
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
SUPPORTED_FILE_EXTNAMES = [".zip", ".cbz", ".rar", ".cbr"]
|
||||||
|
|
||||||
def random_str
|
def random_str
|
||||||
@ -23,10 +23,18 @@ end
|
|||||||
|
|
||||||
def register_mime_types
|
def register_mime_types
|
||||||
{
|
{
|
||||||
|
# Comic Archives
|
||||||
".zip" => "application/zip",
|
".zip" => "application/zip",
|
||||||
".rar" => "application/x-rar-compressed",
|
".rar" => "application/x-rar-compressed",
|
||||||
".cbz" => "application/vnd.comicbook+zip",
|
".cbz" => "application/vnd.comicbook+zip",
|
||||||
".cbr" => "application/vnd.comicbook-rar",
|
".cbr" => "application/vnd.comicbook-rar",
|
||||||
|
|
||||||
|
# Favicon
|
||||||
|
".ico" => "image/x-icon",
|
||||||
|
|
||||||
|
# FontAwesome fonts
|
||||||
|
".woff" => "font/woff",
|
||||||
|
".woff2" => "font/woff2",
|
||||||
}.each do |k, v|
|
}.each do |k, v|
|
||||||
MIME.register k, v
|
MIME.register k, v
|
||||||
end
|
end
|
||||||
|
@ -1,23 +1,23 @@
|
|||||||
# Web related helper functions/macros
|
# Web related helper functions/macros
|
||||||
|
|
||||||
# This macro defines `is_admin` when used
|
def is_admin?(env) : Bool
|
||||||
macro check_admin_access
|
|
||||||
is_admin = false
|
is_admin = false
|
||||||
# The token (if exists) takes precedence over the default user option.
|
if !Config.current.auth_proxy_header_name.empty? ||
|
||||||
# this is why we check the default username first before checking the
|
Config.current.disable_login
|
||||||
# token.
|
is_admin = Storage.default.username_is_admin get_username env
|
||||||
if Config.current.disable_login
|
|
||||||
is_admin = Storage.default.
|
|
||||||
username_is_admin Config.current.default_username
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The token (if exists) takes precedence over other authentication methods.
|
||||||
if token = env.session.string? "token"
|
if token = env.session.string? "token"
|
||||||
is_admin = Storage.default.verify_admin token
|
is_admin = Storage.default.verify_admin token
|
||||||
end
|
end
|
||||||
|
|
||||||
|
is_admin
|
||||||
end
|
end
|
||||||
|
|
||||||
macro layout(name)
|
macro layout(name)
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
check_admin_access
|
is_admin = is_admin? env
|
||||||
begin
|
begin
|
||||||
page = {{name}}
|
page = {{name}}
|
||||||
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
render "src/views/#{{{name}}}.html.ecr", "src/views/layout.html.ecr"
|
||||||
@ -32,7 +32,7 @@ end
|
|||||||
macro send_error_page(msg)
|
macro send_error_page(msg)
|
||||||
message = {{msg}}
|
message = {{msg}}
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
check_admin_access
|
is_admin = is_admin? env
|
||||||
page = "Error"
|
page = "Error"
|
||||||
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
html = render "src/views/message.html.ecr", "src/views/layout.html.ecr"
|
||||||
send_file env, html.to_slice, "text/html"
|
send_file env, html.to_slice, "text/html"
|
||||||
@ -49,6 +49,8 @@ macro get_username(env)
|
|||||||
rescue e
|
rescue e
|
||||||
if Config.current.disable_login
|
if Config.current.disable_login
|
||||||
Config.current.default_username
|
Config.current.default_username
|
||||||
|
elsif (header = Config.current.auth_proxy_header_name) && !header.empty?
|
||||||
|
env.request.headers[header]
|
||||||
else
|
else
|
||||||
raise e
|
raise e
|
||||||
end
|
end
|
||||||
|
@ -4,13 +4,10 @@
|
|||||||
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
|
<title>Mango - <%= page.split("-").map(&.capitalize).join(" ") %></title>
|
||||||
<meta name="description" content="Mango - Manga Server and Web Reader">
|
<meta name="description" content="Mango - Manga Server and Web Reader">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="<%= base_url %>css/uikit.css" />
|
|
||||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||||
<link rel="icon" href="<%= base_url %>favicon.ico">
|
<link rel="icon" href="<%= base_url %>favicon.ico">
|
||||||
|
|
||||||
<script src="https://polyfill.io/v3/polyfill.min.js?features=matchMedia%2Cdefault&flags=gated"></script>
|
<script src="https://polyfill.io/v3/polyfill.min.js?features=MutationObserver%2Cdefault%2CmatchMedia&flats=gated"></script>
|
||||||
<script defer src="<%= base_url %>js/fontawesome.min.js"></script>
|
|
||||||
<script defer src="<%= base_url %>js/solid.min.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||||
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
|
<script type="module" src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js"></script>
|
||||||
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
|
<script nomodule src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine-ie11.min.js" defer></script>
|
||||||
|
2
src/views/components/uikit.html.ecr
Normal file
2
src/views/components/uikit.html.ecr
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.5.9/dist/js/uikit-icons.min.js"></script>
|
@ -25,14 +25,14 @@
|
|||||||
<td x-text="job.title"></td>
|
<td x-text="job.title"></td>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!job.plugin_id">
|
<template x-if="!job.plugin_id">
|
||||||
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/chapter/${job.id}`" x-text="job.title"></td>
|
<td><a :href="`<%= mangadex_base_url %>/chapter/${job.id}`" x-text="job.title"></td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-if="job.plugin_id">
|
<template x-if="job.plugin_id">
|
||||||
<td x-text="job.manga_title"></td>
|
<td x-text="job.manga_title"></td>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!job.plugin_id">
|
<template x-if="!job.plugin_id">
|
||||||
<td><a :href="`${'<%= mangadex_base_url %>'.replace(/\/$/, '')}/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
<td><a :href="`<%= mangadex_base_url %>/manga/${job.manga_id}`" x-text="job.manga_title"></td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
<td x-text="`${job.success_count}/${job.pages}`"></td>
|
||||||
|
@ -1,64 +1,77 @@
|
|||||||
<h2 class=uk-title>Download from MangaDex</h2>
|
<h2 class=uk-title>Download from MangaDex</h2>
|
||||||
|
<div x-data="downloadComponent()" x-init="init()">
|
||||||
<div class="uk-grid-small" uk-grid>
|
<div class="uk-grid-small" uk-grid>
|
||||||
<div class="uk-width-3-4">
|
<div class="uk-width-3-4">
|
||||||
<input id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
|
<input class="uk-input" type="text" placeholder="MangaDex manga ID or URL" x-model="searchInput" @keydown.enter.debounce="search()">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-1-4">
|
<div class="uk-width-1-4">
|
||||||
<div id="spinner" uk-spinner class="uk-align-center" hidden></div>
|
<div uk-spinner class="uk-align-center" x-show="loading" x-cloak></div>
|
||||||
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
<button class="uk-button uk-button-default" x-show="!loading" @click="search()">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class"uk-grid-small" uk-grid hidden id="manga-details">
|
|
||||||
|
<div x-show="data && data.chapters" x-cloak>
|
||||||
|
<div class"uk-grid-small" uk-grid style="margin-top:40px">
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<img id="cover">
|
<img :src="data.mainCover">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<p id="title"></p>
|
<p>Title: <a :href="`<%= mangadex_base_url %>/manga/${data.id}`" x-text="data.title"></a></p>
|
||||||
<p id="artist"></p>
|
<p x-text="`Artist: ${data.artist}`"></p>
|
||||||
<p id="author"></p>
|
<p x-text="`Author: ${data.author}`"></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
<div class="uk-form-stacked uk-width-1-2@s" id="filters">
|
||||||
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
<p class="uk-text-lead uk-margin-remove-bottom">Filter Chapters</p>
|
||||||
<p class="uk-text-meta uk-margin-remove-top" id="count-text"></p>
|
<p class="uk-text-meta uk-margin-remove-top" x-text="`${chapters.length} chapters found`"></p>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="lang-select">Language</label>
|
<label class="uk-form-label">Language</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<select class="uk-select filter-field" id="lang-select">
|
<select class="uk-select filter-field" x-model="langChoice" @change="filtersUpdated()">
|
||||||
|
<template x-for="lang in languages" :key="lang">
|
||||||
|
<option x-text="lang"></option>
|
||||||
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="group-select">Group</label>
|
<label class="uk-form-label">Group</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<select class="uk-select filter-field" id="group-select">
|
<select class="uk-select filter-field" x-model="groupChoice" @change="filtersUpdated()">
|
||||||
|
<template x-for="group in groups" :key="group">
|
||||||
|
<option x-text="group"></option>
|
||||||
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="volume-range">Volume</label>
|
<label class="uk-form-label">Volume</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<input class="uk-input filter-field" type="text" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="volumeRange" @keydown.enter="filtersUpdated()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="chapter-range">Chapter</label>
|
<label class="uk-form-label">Chapter</label>
|
||||||
<div class="uk-form-controls">
|
<div class="uk-form-controls">
|
||||||
<input class="uk-input filter-field" type="text" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
<input class="uk-input filter-field" type="text" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty." x-model="chapterRange" @keydown.enter="filtersUpdated()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="selection-controls" class="uk-margin" hidden>
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
<div class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
<button class="uk-button uk-button-default" @click="selectAll()">Select All</button>
|
||||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
<button class="uk-button uk-button-default" @click="clearSelection()">Clear Selections</button>
|
||||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
<button class="uk-button uk-button-primary" @click="download()" x-show="!addingToDownload">Download Selected</button>
|
||||||
|
<div uk-spinner class="uk-margin-left" x-show="addingToDownload"></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
<p class="uk-text-meta">Click on a table row to select the chapter. Drag your mouse over multiple rows to select them all. Hold Ctrl to make multiple non-adjacent selections.</p>
|
||||||
</div>
|
</div>
|
||||||
<p id="filter-notification" hidden></p>
|
<p x-text="`Mango can only list ${chaptersLimit} chapters, but we found ${chapters.length} chapters. Please use the filter options above to narrow down your search.`" x-show="chapters.length > chaptersLimit"></p>
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
|
<table class="uk-table uk-table-striped uk-overflow-auto" x-show="chapters.length <= chaptersLimit">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@ -70,12 +83,33 @@
|
|||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
|
<template x-if="chapters.length <= chaptersLimit">
|
||||||
|
<tbody id="selectable">
|
||||||
|
<template x-for="chp in chapters" :key="chp">
|
||||||
|
<tr class="ui-widget-content">
|
||||||
|
<td><a :href="`<%= mangadex_base_url %>/chapter/${chp.id}`" x-text="chp.id"></a></td>
|
||||||
|
<td x-text="chp.title"></td>
|
||||||
|
<td x-text="chp.language"></td>
|
||||||
|
<td>
|
||||||
|
<template x-for="grp in Object.entries(chp.groups)">
|
||||||
|
<div>
|
||||||
|
<a :href="`<%= mangadex_base_url %>/group/${grp[1]}`" x-text="grp[0]"></a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td x-text="chp.volume"></td>
|
||||||
|
<td x-text="chp.chapter"></td>
|
||||||
|
<td x-text="`${moment.unix(chp.timestamp).fromNow()}`"></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</template>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script>
|
|
||||||
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
|
||||||
</script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
@ -82,9 +82,7 @@
|
|||||||
setTheme();
|
setTheme();
|
||||||
const base_url = "<%= base_url %>";
|
const base_url = "<%= base_url %>";
|
||||||
</script>
|
</script>
|
||||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
<%= render_component "uikit" %>
|
||||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
|
||||||
|
|
||||||
<%= yield_content "script" %>
|
<%= yield_content "script" %>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
@ -30,8 +30,7 @@
|
|||||||
<script>
|
<script>
|
||||||
setTheme();
|
setTheme();
|
||||||
</script>
|
</script>
|
||||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
<%= render_component "uikit" %>
|
||||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
<template x-for="item in items">
|
<template x-for="item in items">
|
||||||
<img
|
<img
|
||||||
uk-img
|
uk-img
|
||||||
class="uk-align-center"
|
:class="{'uk-align-center': true, 'spine': item.width < 50}"
|
||||||
:style="item.style"
|
:style="item.style"
|
||||||
:data-src="item.url"
|
:data-src="item.url"
|
||||||
:width="item.width"
|
:width="item.width"
|
||||||
@ -103,15 +103,14 @@
|
|||||||
const eid = "<%= entry.id %>";
|
const eid = "<%= entry.id %>";
|
||||||
</script>
|
</script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/protonet-jquery.inview/1.1.2/jquery.inview.min.js"></script>
|
||||||
|
<%= render_component "uikit" %>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
<script src="<%= base_url %>js/uikit.min.js"></script>
|
|
||||||
<script src="<%= base_url %>js/uikit-icons.min.js"></script>
|
|
||||||
<script src="<%= base_url %>js/reader.js"></script>
|
<script src="<%= base_url %>js/reader.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
img[data-src][src*='data:image'] { background: white; }
|
img[data-src][src*='data:image'] { background: white; }
|
||||||
img { width: 100%; }
|
img:not(.spine) { width: 100%; }
|
||||||
.reader-bg { background: black; }
|
.reader-bg { background: black; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user