mirror of
https://github.com/hkalexling/Mango.git
synced 2026-04-25 00:00:52 -04:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9255de710f | |||
| 39b251774f | |||
| 156e511d4a | |||
| 5cd6f3eacb | |||
| a0e5a03052 | |||
| e53641add1 | |||
| 45cdfd5306 | |||
| 3d352ed062 | |||
| bac7be5163 | |||
| 717d44e029 | |||
| 8da4475a74 | |||
| 680504779f | |||
| 926d0e66a5 | |||
| 0f3dd51d6b | |||
| 53c3798691 | |||
| 6d4e8ea544 | |||
| 0bd94a2290 | |||
| cff599f688 | |||
| fa85d9834f | |||
| aaf0a3c6af | |||
| 5ed2a8affa | |||
| de690fbf29 | |||
| 12c3c3f356 | |||
| 1ddcabcc12 | |||
| 8b04f2c96b | |||
| 66e2fc138a | |||
| 6817113523 | |||
| 6ad4385b18 | |||
| 012fd71ab4 | |||
| 373ff6520a | |||
| 8a0e9250c8 | |||
| 871a5fe755 | |||
| 1493c3de90 | |||
| 808074e478 | |||
| 49193b9b00 | |||
| 1cb470fb2d | |||
| e443176a79 | |||
| bec257c99f | |||
| f2df493d79 | |||
| b74f61c025 | |||
| c76c287e66 | |||
| 8e7eaa680a | |||
| 30cdb3ec8f | |||
| 9c367e7d35 | |||
| 4f5e05c008 | |||
| d2f95e5970 | |||
| 82bcd03f15 | |||
| fe799f30c8 | |||
| 54123917af | |||
| 3b737c0bee | |||
| 14bf4da06c | |||
| a72dfcecd3 | |||
| 160a249dc6 | |||
| f9a2534f80 | |||
| 06fe2ccf16 | |||
| 13c0878357 | |||
| 3ef6a7bfc4 | |||
| e214e00dfb | |||
| 9b5aea223d | |||
| 60100c51fe | |||
| 27c111d273 | |||
| 1b9d83f367 | |||
| 96b8186add | |||
| 27dab3c989 | |||
| bcb95d1462 | |||
| 4371c7877d | |||
| d72d635c68 | |||
| b724b4d508 | |||
| 8bbbe650f1 | |||
| 651bd17612 | |||
| dd01e632a2 | |||
| 43ee8f3b85 | |||
| 4841f90cc1 | |||
| bedcac4e35 | |||
| 5260a82e88 | |||
| 1efb300988 | |||
| 6b43ee7fe5 | |||
| e99d7b8b29 | |||
| d2ad7fef77 | |||
| ddb6a860ae | |||
| 6a9105605d | |||
| c74a01f546 | |||
| 2aeb38a271 | |||
| a2c7638141 | |||
| c35e840694 | |||
| ff6e64f12a | |||
| 16fa27e4f6 | |||
| 16734c2c59 | |||
| 392b3d8339 |
@@ -1,3 +1,4 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
patreon: hkalexling
|
patreon: hkalexling
|
||||||
|
ko_fi: hkalexling
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: apk add --no-cache yarn yaml sqlite-static
|
run: apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make static
|
run: make static
|
||||||
- name: Linter
|
- name: Linter
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ WORKDIR /Mango
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY package*.json .
|
COPY package*.json .
|
||||||
RUN apk add --no-cache yarn yaml sqlite-static \
|
RUN apk add --no-cache yarn yaml sqlite-static libarchive-dev libarchive-static acl-static expat-static zstd-static lz4-static bzip2-static \
|
||||||
&& make static
|
&& make static
|
||||||
|
|
||||||
FROM library/alpine
|
FROM library/alpine
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ static: uglify | libs
|
|||||||
crystal build src/mango.cr --release --progress --static
|
crystal build src/mango.cr --release --progress --static
|
||||||
|
|
||||||
libs:
|
libs:
|
||||||
shards install
|
shards install --production
|
||||||
|
|
||||||
run:
|
run:
|
||||||
crystal run src/mango.cr --error-trace
|
crystal run src/mango.cr --error-trace
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
# Mango
|
# Mango
|
||||||
@@ -10,14 +7,17 @@
|
|||||||
Mango is a self-hosted manga server and reader. Its features include
|
Mango is a self-hosted manga server and reader. Its features include
|
||||||
|
|
||||||
- Multi-user support
|
- Multi-user support
|
||||||
|
- OPDS support
|
||||||
- Dark/light mode switch
|
- Dark/light mode switch
|
||||||
- Supports both `.zip` and `.cbz` formats
|
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
|
||||||
- Supports nested folders in library
|
- Supports nested folders in library
|
||||||
- Automatically stores reading progress
|
- Automatically stores reading progress
|
||||||
- Built-in [MangaDex](https://mangadex.org/) downloader
|
- Built-in [MangaDex](https://mangadex.org/) downloader
|
||||||
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
- The web reader is responsive and works well on mobile, so there is no need for a mobile app
|
||||||
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
- All the static files are embedded in the binary, so the deployment process is easy and painless
|
||||||
|
|
||||||
|
Please check the [Wiki](https://github.com/hkalexling/Mango/wiki) for more information.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Pre-built Binary
|
### Pre-built Binary
|
||||||
@@ -39,7 +39,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
|
|
||||||
### Build from source
|
### Build from source
|
||||||
|
|
||||||
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers for `libsqlite3` and `libyaml`.
|
1. Make sure you have `crystal`, `shards` and `yarn` installed. You might also need to install the development headers of some libraries. Please see the [Dockerfile](https://github.com/hkalexling/Mango/blob/master/Dockerfile) for the full list of dependencies
|
||||||
2. Clone the repository
|
2. Clone the repository
|
||||||
3. `make && sudo make install`
|
3. `make && sudo make install`
|
||||||
4. Start Mango by running the command `mango`
|
4. Start Mango by running the command `mango`
|
||||||
@@ -50,11 +50,21 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
|
|||||||
### CLI
|
### CLI
|
||||||
|
|
||||||
```
|
```
|
||||||
Mango e-manga server/reader. Version 0.4.0
|
Mango - Manga Server and Web Reader. Version 0.6.1
|
||||||
|
|
||||||
-v, --version Show version
|
Usage:
|
||||||
-h, --help Show help
|
|
||||||
-c PATH, --config=PATH Path to the config file. Default is `~/.config/mango/config.yml`
|
mango [sub_command] [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
-c PATH, --config=PATH Path to the config file [type:String]
|
||||||
|
-h, --help Show this help.
|
||||||
|
-v, --version Show version.
|
||||||
|
|
||||||
|
Sub Commands:
|
||||||
|
|
||||||
|
admin Run admin tools
|
||||||
```
|
```
|
||||||
|
|
||||||
### Config
|
### Config
|
||||||
@@ -65,6 +75,7 @@ The default config file location is `~/.config/mango/config.yml`. It might be di
|
|||||||
---
|
---
|
||||||
port: 9000
|
port: 9000
|
||||||
base_url: /
|
base_url: /
|
||||||
|
library_path: ~/mango/library
|
||||||
db_path: ~/mango/mango.db
|
db_path: ~/mango/mango.db
|
||||||
scan_interval_minutes: 5
|
scan_interval_minutes: 5
|
||||||
log_level: info
|
log_level: info
|
||||||
@@ -84,7 +95,7 @@ mangadex:
|
|||||||
|
|
||||||
### Library Structure
|
### Library Structure
|
||||||
|
|
||||||
You can organize your `.cbz/.zip` files in nested folders in the library directory. Here's an example:
|
You can organize your archive files in nested folders in the library directory. Here's an example:
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
|
|||||||
+10
-1
@@ -242,7 +242,10 @@ const buildTable = () => {
|
|||||||
Object.entries(filters).forEach(([k, v]) => {
|
Object.entries(filters).forEach(([k, v]) => {
|
||||||
if (v === 'All') return;
|
if (v === 'All') return;
|
||||||
if (k === 'group') {
|
if (k === 'group') {
|
||||||
chapters = chapters.filter(c => v in c.groups);
|
chapters = chapters.filter(c => {
|
||||||
|
unescaped_groups = Object.entries(c.groups).map(([g, id]) => unescapeHTML(g));
|
||||||
|
return unescaped_groups.indexOf(v) >= 0;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (k === 'lang') {
|
if (k === 'lang') {
|
||||||
@@ -297,3 +300,9 @@ const buildTable = () => {
|
|||||||
});
|
});
|
||||||
$('#selection-controls').removeAttr('hidden');
|
$('#selection-controls').removeAttr('hidden');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const unescapeHTML = (str) => {
|
||||||
|
var elt = document.createElement("span");
|
||||||
|
elt.innerHTML = str;
|
||||||
|
return elt.innerText;
|
||||||
|
};
|
||||||
|
|||||||
+38
-31
@@ -2,33 +2,36 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
const zipPath = decodeURIComponent(encodedPath);
|
const zipPath = decodeURIComponent(encodedPath);
|
||||||
const title = decodeURIComponent(encodedeTitle);
|
const title = decodeURIComponent(encodedeTitle);
|
||||||
const entry = decodeURIComponent(encodedEntryTitle);
|
const entry = decodeURIComponent(encodedEntryTitle);
|
||||||
$('#modal button, #modal a').each(function(){
|
$('#modal button, #modal a').each(function() {
|
||||||
$(this).removeAttr('hidden');
|
$(this).removeAttr('hidden');
|
||||||
});
|
});
|
||||||
if (percentage === 0) {
|
if (percentage === 0) {
|
||||||
$('#continue-btn').attr('hidden', '');
|
$('#continue-btn').attr('hidden', '');
|
||||||
$('#unread-btn').attr('hidden', '');
|
$('#unread-btn').attr('hidden', '');
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$('#continue-btn').text('Continue from ' + percentage + '%');
|
$('#continue-btn').text('Continue from ' + percentage + '%');
|
||||||
}
|
}
|
||||||
if (percentage === 100) {
|
if (percentage === 100) {
|
||||||
$('#read-btn').attr('hidden', '');
|
$('#read-btn').attr('hidden', '');
|
||||||
}
|
}
|
||||||
$('#modal-title').find('span').text(entry);
|
|
||||||
$('#modal-title').next().attr('data-id', titleID);
|
$('#modal-title-link').text(title);
|
||||||
$('#modal-title').next().attr('data-entry-id', entryID);
|
$('#modal-title-link').attr('href', `${base_url}book/${titleID}`);
|
||||||
$('#modal-title').next().find('.title-rename-field').val(entry);
|
|
||||||
|
$('#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);
|
$('#path-text').text(zipPath);
|
||||||
$('#pages-text').text(pages + ' pages');
|
$('#pages-text').text(pages + ' pages');
|
||||||
|
|
||||||
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
|
$('#beginning-btn').attr('href', `${base_url}reader/${titleID}/${entryID}/1`);
|
||||||
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
|
$('#continue-btn').attr('href', `${base_url}reader/${titleID}/${entryID}`);
|
||||||
|
|
||||||
$('#read-btn').click(function(){
|
$('#read-btn').click(function() {
|
||||||
updateProgress(titleID, entryID, pages);
|
updateProgress(titleID, entryID, pages);
|
||||||
});
|
});
|
||||||
$('#unread-btn').click(function(){
|
$('#unread-btn').click(function() {
|
||||||
updateProgress(titleID, entryID, 0);
|
updateProgress(titleID, entryID, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,14 +43,15 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
|
|||||||
|
|
||||||
const updateProgress = (tid, eid, page) => {
|
const updateProgress = (tid, eid, page) => {
|
||||||
let url = `${base_url}api/progress/${tid}/${page}`
|
let url = `${base_url}api/progress/${tid}/${page}`
|
||||||
const query = $.param({entry: eid});
|
const query = $.param({
|
||||||
|
entry: eid
|
||||||
|
});
|
||||||
if (eid)
|
if (eid)
|
||||||
url += `?${query}`;
|
url += `?${query}`;
|
||||||
$.post(url, (data) => {
|
$.post(url, (data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
error = data.error;
|
error = data.error;
|
||||||
alert('danger', error);
|
alert('danger', error);
|
||||||
}
|
}
|
||||||
@@ -65,27 +69,29 @@ const renameSubmit = (name, eid) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = $.param({ entry: eid });
|
const query = $.param({
|
||||||
|
entry: eid
|
||||||
|
});
|
||||||
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
let url = `${base_url}api/admin/display_name/${titleId}/${name}`;
|
||||||
if (eid)
|
if (eid)
|
||||||
url += `?${query}`;
|
url += `?${query}`;
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
url: url,
|
url: url,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
dataType: 'json'
|
dataType: 'json'
|
||||||
})
|
})
|
||||||
.done(data => {
|
.done(data => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
alert('danger', `Failed to update display name. Error: ${data.error}`);
|
alert('danger', `Failed to update display name. Error: ${data.error}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
location.reload();
|
location.reload();
|
||||||
})
|
})
|
||||||
.fail((jqXHR, status) => {
|
.fail((jqXHR, status) => {
|
||||||
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
alert('danger', `Failed to update display name. Error: [${jqXHR.status}] ${jqXHR.statusText}`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const edit = (eid) => {
|
const edit = (eid) => {
|
||||||
@@ -98,8 +104,7 @@ const edit = (eid) => {
|
|||||||
url = item.find('img').attr('data-src');
|
url = item.find('img').attr('data-src');
|
||||||
displayName = item.find('.uk-card-title').attr('data-title');
|
displayName = item.find('.uk-card-title').attr('data-title');
|
||||||
$('#title-progress-control').attr('hidden', '');
|
$('#title-progress-control').attr('hidden', '');
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$('#title-progress-control').removeAttr('hidden');
|
$('#title-progress-control').removeAttr('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +131,9 @@ const setupUpload = (eid) => {
|
|||||||
const upload = $('.upload-field');
|
const upload = $('.upload-field');
|
||||||
const bar = $('#upload-progress').get(0);
|
const bar = $('#upload-progress').get(0);
|
||||||
const titleId = upload.attr('data-title-id');
|
const titleId = upload.attr('data-title-id');
|
||||||
const queryObj = {title: titleId};
|
const queryObj = {
|
||||||
|
title: titleId
|
||||||
|
};
|
||||||
if (eid)
|
if (eid)
|
||||||
queryObj['entry'] = eid;
|
queryObj['entry'] = eid;
|
||||||
const query = $.param(queryObj);
|
const query = $.param(queryObj);
|
||||||
|
|||||||
+13
-1
@@ -2,12 +2,20 @@ version: 1.0
|
|||||||
shards:
|
shards:
|
||||||
ameba:
|
ameba:
|
||||||
github: crystal-ameba/ameba
|
github: crystal-ameba/ameba
|
||||||
version: 0.12.0
|
version: 0.12.1
|
||||||
|
|
||||||
|
archive:
|
||||||
|
github: hkalexling/archive.cr
|
||||||
|
version: 0.2.0
|
||||||
|
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
github: schovi/baked_file_system
|
||||||
version: 0.9.8
|
version: 0.9.8
|
||||||
|
|
||||||
|
clim:
|
||||||
|
github: at-grandpa/clim
|
||||||
|
version: 0.12.0
|
||||||
|
|
||||||
db:
|
db:
|
||||||
github: crystal-lang/crystal-db
|
github: crystal-lang/crystal-db
|
||||||
version: 0.9.0
|
version: 0.9.0
|
||||||
@@ -20,6 +28,10 @@ shards:
|
|||||||
github: kemalcr/kemal
|
github: kemalcr/kemal
|
||||||
version: 0.26.1
|
version: 0.26.1
|
||||||
|
|
||||||
|
kemal-session:
|
||||||
|
github: kemalcr/kemal-session
|
||||||
|
version: 0.12.1
|
||||||
|
|
||||||
kilt:
|
kilt:
|
||||||
github: jeromegn/kilt
|
github: jeromegn/kilt
|
||||||
version: 0.4.0
|
version: 0.4.0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: mango
|
name: mango
|
||||||
version: 0.4.0
|
version: 0.6.1
|
||||||
|
|
||||||
authors:
|
authors:
|
||||||
- Alex Ling <hkalexling@gmail.com>
|
- Alex Ling <hkalexling@gmail.com>
|
||||||
@@ -15,11 +15,15 @@ license: MIT
|
|||||||
dependencies:
|
dependencies:
|
||||||
kemal:
|
kemal:
|
||||||
github: kemalcr/kemal
|
github: kemalcr/kemal
|
||||||
|
kemal-session:
|
||||||
|
github: kemalcr/kemal-session
|
||||||
sqlite3:
|
sqlite3:
|
||||||
github: crystal-lang/crystal-sqlite3
|
github: crystal-lang/crystal-sqlite3
|
||||||
baked_file_system:
|
baked_file_system:
|
||||||
github: schovi/baked_file_system
|
github: schovi/baked_file_system
|
||||||
|
archive:
|
||||||
development_dependencies:
|
github: hkalexling/archive.cr
|
||||||
ameba:
|
ameba:
|
||||||
github: crystal-ameba/ameba
|
github: crystal-ameba/ameba
|
||||||
|
clim:
|
||||||
|
github: at-grandpa/clim
|
||||||
|
|||||||
@@ -68,4 +68,9 @@ describe Rule do
|
|||||||
.should eq "Ch. CH ID testing"
|
.should eq "Ch. CH ID testing"
|
||||||
rule.render({} of String => String).should eq "testing"
|
rule.render({} of String => String).should eq "testing"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "escapes slash" do
|
||||||
|
rule = Rule.new "{id}"
|
||||||
|
rule.render({"id" => "/hello/world"}).should eq "_hello_world"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ end
|
|||||||
def with_storage
|
def with_storage
|
||||||
with_default_config do
|
with_default_config do
|
||||||
temp_db = get_tempfile "mango-test-db"
|
temp_db = get_tempfile "mango-test-db"
|
||||||
storage = Storage.new temp_db.path
|
storage = Storage.new temp_db.path, false
|
||||||
clear = yield storage, temp_db.path
|
clear = yield storage, temp_db.path
|
||||||
if clear == true
|
if clear == true
|
||||||
temp_db.delete
|
temp_db.delete
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
require "zip"
|
||||||
|
require "archive"
|
||||||
|
|
||||||
|
# A unified class to handle all supported archive formats. It uses the ::Zip
|
||||||
|
# module in crystal standard library if the target file is a zip archive.
|
||||||
|
# Otherwise it uses `archive.cr`.
|
||||||
|
class ArchiveFile
|
||||||
|
def initialize(@filename : String)
|
||||||
|
if [".cbz", ".zip"].includes? File.extname filename
|
||||||
|
@archive_file = Zip::File.new filename
|
||||||
|
else
|
||||||
|
@archive_file = Archive::File.new filename
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.open(filename : String, &)
|
||||||
|
s = self.new filename
|
||||||
|
yield s
|
||||||
|
s.close
|
||||||
|
end
|
||||||
|
|
||||||
|
def close
|
||||||
|
if @archive_file.is_a? Zip::File
|
||||||
|
@archive_file.as(Zip::File).close
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Lists all file entries
|
||||||
|
def entries
|
||||||
|
ary = [] of Zip::File::Entry | Archive::Entry
|
||||||
|
@archive_file.entries.map do |e|
|
||||||
|
if (e.is_a? Zip::File::Entry && e.file?) ||
|
||||||
|
(e.is_a? Archive::Entry && e.info.file?)
|
||||||
|
ary.push e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
ary
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_entry(e : Zip::File::Entry | Archive::Entry) : Bytes?
|
||||||
|
if e.is_a? Zip::File::Entry
|
||||||
|
data = nil
|
||||||
|
e.open do |io|
|
||||||
|
slice = Bytes.new e.uncompressed_size
|
||||||
|
bytes_read = io.read_fully? slice
|
||||||
|
data = slice if bytes_read
|
||||||
|
end
|
||||||
|
data
|
||||||
|
else
|
||||||
|
e.read
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check
|
||||||
|
if @archive_file.is_a? Archive::File
|
||||||
|
@archive_file.as(Archive::File).check
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,8 +3,11 @@ require "yaml"
|
|||||||
class Config
|
class Config
|
||||||
include YAML::Serializable
|
include YAML::Serializable
|
||||||
|
|
||||||
|
@[YAML::Field(ignore: true)]
|
||||||
|
property path : String = ""
|
||||||
property port : Int32 = 9000
|
property port : Int32 = 9000
|
||||||
property base_url : String = "/"
|
property base_url : String = "/"
|
||||||
|
property session_secret : String = "mango-session-secret"
|
||||||
property library_path : String = File.expand_path "~/mango/library",
|
property library_path : String = File.expand_path "~/mango/library",
|
||||||
home: true
|
home: true
|
||||||
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
property db_path : String = File.expand_path "~/mango/mango.db", home: true
|
||||||
@@ -43,6 +46,7 @@ class Config
|
|||||||
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.preprocess
|
||||||
|
config.path = path
|
||||||
config.fill_defaults
|
config.fill_defaults
|
||||||
return config
|
return config
|
||||||
end
|
end
|
||||||
@@ -53,6 +57,7 @@ class Config
|
|||||||
abort "Aborting..."
|
abort "Aborting..."
|
||||||
end
|
end
|
||||||
default = self.allocate
|
default = self.allocate
|
||||||
|
default.path = path
|
||||||
default.fill_defaults
|
default.fill_defaults
|
||||||
cfg_dir = File.dirname cfg_path
|
cfg_dir = File.dirname cfg_path
|
||||||
unless Dir.exists? cfg_dir
|
unless Dir.exists? cfg_dir
|
||||||
|
|||||||
@@ -3,23 +3,90 @@ require "../storage"
|
|||||||
require "../util"
|
require "../util"
|
||||||
|
|
||||||
class AuthHandler < Kemal::Handler
|
class AuthHandler < Kemal::Handler
|
||||||
|
# Some of the code is copied form kemalcr/kemal-basic-auth on GitHub
|
||||||
|
|
||||||
|
BASIC = "Basic"
|
||||||
|
AUTH = "Authorization"
|
||||||
|
AUTH_MESSAGE = "Could not verify your access level for that URL.\n" \
|
||||||
|
"You have to login with proper credentials"
|
||||||
|
HEADER_LOGIN_REQUIRED = "Basic realm=\"Login Required\""
|
||||||
|
|
||||||
def initialize(@storage : Storage)
|
def initialize(@storage : Storage)
|
||||||
end
|
end
|
||||||
|
|
||||||
def call(env)
|
def require_basic_auth(env)
|
||||||
return call_next(env) if request_path_startswith env, ["/login", "/logout"]
|
env.response.status_code = 401
|
||||||
|
env.response.headers["WWW-Authenticate"] = HEADER_LOGIN_REQUIRED
|
||||||
|
env.response.print AUTH_MESSAGE
|
||||||
|
call_next env
|
||||||
|
end
|
||||||
|
|
||||||
cookie = env.request.cookies.find { |c| c.name == "token" }
|
def validate_token(env)
|
||||||
if cookie.nil? || !@storage.verify_token cookie.value
|
token = env.session.string? "token"
|
||||||
|
!token.nil? && @storage.verify_token token
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_token_admin(env)
|
||||||
|
token = env.session.string? "token"
|
||||||
|
!token.nil? && @storage.verify_admin token
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_auth_header(env)
|
||||||
|
if env.request.headers[AUTH]?
|
||||||
|
if value = env.request.headers[AUTH]
|
||||||
|
if value.size > 0 && value.starts_with?(BASIC)
|
||||||
|
token = verify_user value
|
||||||
|
return false if token.nil?
|
||||||
|
|
||||||
|
env.session.string "token", token
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify_user(value)
|
||||||
|
username, password = Base64.decode_string(value[BASIC.size + 1..-1])
|
||||||
|
.split(":")
|
||||||
|
@storage.verify_user username, password
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_opds_auth(env)
|
||||||
|
if validate_token(env) || validate_auth_header(env)
|
||||||
|
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"]) ||
|
||||||
|
requesting_static_file env
|
||||||
|
return call_next(env)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless validate_token env
|
||||||
|
env.session.string "callback", env.request.path
|
||||||
return redirect env, "/login"
|
return redirect env, "/login"
|
||||||
end
|
end
|
||||||
|
|
||||||
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
if request_path_startswith env, ["/admin", "/api/admin", "/download"]
|
||||||
unless @storage.verify_admin cookie.value
|
unless validate_token_admin env
|
||||||
env.response.status_code = 403
|
env.response.status_code = 403
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ class FS
|
|||||||
end
|
end
|
||||||
|
|
||||||
class StaticHandler < Kemal::Handler
|
class StaticHandler < Kemal::Handler
|
||||||
@dirs = ["/css", "/js", "/img", "/favicon.ico"]
|
|
||||||
|
|
||||||
def call(env)
|
def call(env)
|
||||||
if request_path_startswith env, @dirs
|
if requesting_static_file env
|
||||||
file = FS.get? env.request.path
|
file = FS.get? env.request.path
|
||||||
return call_next env if file.nil?
|
return call_next env if file.nil?
|
||||||
|
|
||||||
|
|||||||
+179
-23
@@ -1,8 +1,8 @@
|
|||||||
require "zip"
|
|
||||||
require "mime"
|
require "mime"
|
||||||
require "json"
|
require "json"
|
||||||
require "uri"
|
require "uri"
|
||||||
require "./util"
|
require "./util"
|
||||||
|
require "./archive"
|
||||||
|
|
||||||
struct Image
|
struct Image
|
||||||
property data : Bytes
|
property data : Bytes
|
||||||
@@ -17,7 +17,8 @@ end
|
|||||||
class Entry
|
class Entry
|
||||||
property zip_path : String, book : Title, title : String,
|
property zip_path : String, book : Title, title : String,
|
||||||
size : String, pages : Int32, id : String, title_id : String,
|
size : String, pages : Int32, id : String, title_id : String,
|
||||||
encoded_path : String, encoded_title : String, mtime : Time
|
encoded_path : String, encoded_title : String, mtime : Time,
|
||||||
|
date_added : Time
|
||||||
|
|
||||||
def initialize(path, @book, @title_id, storage)
|
def initialize(path, @book, @title_id, storage)
|
||||||
@zip_path = path
|
@zip_path = path
|
||||||
@@ -25,7 +26,7 @@ class Entry
|
|||||||
@title = File.basename path, File.extname path
|
@title = File.basename path, File.extname path
|
||||||
@encoded_title = URI.encode @title
|
@encoded_title = URI.encode @title
|
||||||
@size = (File.size path).humanize_bytes
|
@size = (File.size path).humanize_bytes
|
||||||
file = Zip::File.new path
|
file = ArchiveFile.new path
|
||||||
@pages = file.entries.count do |e|
|
@pages = file.entries.count do |e|
|
||||||
["image/jpeg", "image/png"].includes? \
|
["image/jpeg", "image/png"].includes? \
|
||||||
MIME.from_filename? e.filename
|
MIME.from_filename? e.filename
|
||||||
@@ -33,6 +34,7 @@ class Entry
|
|||||||
file.close
|
file.close
|
||||||
@id = storage.get_id @zip_path, false
|
@id = storage.get_id @zip_path, false
|
||||||
@mtime = File.info(@zip_path).modification_time
|
@mtime = File.info(@zip_path).modification_time
|
||||||
|
@date_added = load_date_added
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_json(json : JSON::Builder)
|
def to_json(json : JSON::Builder)
|
||||||
@@ -61,14 +63,15 @@ class Entry
|
|||||||
TitleInfo.new @book.dir do |info|
|
TitleInfo.new @book.dir do |info|
|
||||||
info_url = info.entry_cover_url[@title]?
|
info_url = info.entry_cover_url[@title]?
|
||||||
unless info_url.nil? || info_url.empty?
|
unless info_url.nil? || info_url.empty?
|
||||||
url = info_url
|
url = File.join Config.current.base_url, info_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
url
|
url
|
||||||
end
|
end
|
||||||
|
|
||||||
def read_page(page_num)
|
def read_page(page_num)
|
||||||
Zip::File.open @zip_path do |file|
|
img = nil
|
||||||
|
ArchiveFile.open @zip_path do |file|
|
||||||
page = file.entries
|
page = file.entries
|
||||||
.select { |e|
|
.select { |e|
|
||||||
["image/jpeg", "image/png"].includes? \
|
["image/jpeg", "image/png"].includes? \
|
||||||
@@ -78,16 +81,27 @@ class Entry
|
|||||||
compare_alphanumerically a.filename, b.filename
|
compare_alphanumerically a.filename, b.filename
|
||||||
}
|
}
|
||||||
.[page_num - 1]
|
.[page_num - 1]
|
||||||
page.open do |io|
|
data = file.read_entry page
|
||||||
slice = Bytes.new page.uncompressed_size
|
if data
|
||||||
bytes_read = io.read_fully? slice
|
img = Image.new data, MIME.from_filename(page.filename), page.filename,
|
||||||
unless bytes_read
|
data.size
|
||||||
return nil
|
|
||||||
end
|
|
||||||
return Image.new slice, MIME.from_filename(page.filename),
|
|
||||||
page.filename, bytes_read
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
img
|
||||||
|
end
|
||||||
|
|
||||||
|
private def load_date_added
|
||||||
|
date_added = nil
|
||||||
|
TitleInfo.new @book.dir do |info|
|
||||||
|
info_da = info.date_added[@title]?
|
||||||
|
if info_da.nil?
|
||||||
|
date_added = info.date_added[@title] = ctime @zip_path
|
||||||
|
info.save
|
||||||
|
else
|
||||||
|
date_added = info_da
|
||||||
|
end
|
||||||
|
end
|
||||||
|
date_added.not_nil! # is it ok to set not_nil! here?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -115,12 +129,16 @@ class Title
|
|||||||
@title_ids << title.id
|
@title_ids << title.id
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
if [".zip", ".cbz"].includes? File.extname path
|
if [".zip", ".cbz", ".rar", ".cbr"].includes? File.extname path
|
||||||
zip_exception = validate_zip path
|
unless File.readable? path
|
||||||
unless zip_exception.nil?
|
Logger.warn "File #{path} is not readable. Please make sure the " \
|
||||||
Logger.warn "File #{path} is corrupted or is not a valid zip " \
|
"file permission is configured correctly."
|
||||||
"archive. Ignoring it."
|
next
|
||||||
Logger.debug "Zip error: #{zip_exception}"
|
end
|
||||||
|
archive_exception = validate_archive path
|
||||||
|
unless archive_exception.nil?
|
||||||
|
Logger.warn "Unable to extract archive #{path}. Ignoring it. " \
|
||||||
|
"Archive error: #{archive_exception}"
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
entry = Entry.new path, self, @id, storage
|
entry = Entry.new path, self, @id, storage
|
||||||
@@ -231,14 +249,14 @@ class Title
|
|||||||
end
|
end
|
||||||
|
|
||||||
def cover_url
|
def cover_url
|
||||||
url = "img/icon.png"
|
url = "#{Config.current.base_url}img/icon.png"
|
||||||
if @entries.size > 0
|
if @entries.size > 0
|
||||||
url = @entries[0].cover_url
|
url = @entries[0].cover_url
|
||||||
end
|
end
|
||||||
TitleInfo.new @dir do |info|
|
TitleInfo.new @dir do |info|
|
||||||
info_url = info.cover_url
|
info_url = info.cover_url
|
||||||
unless info_url.nil? || info_url.empty?
|
unless info_url.nil? || info_url.empty?
|
||||||
url = info_url
|
url = File.join Config.current.base_url, info_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
url
|
url
|
||||||
@@ -287,6 +305,12 @@ class Title
|
|||||||
else
|
else
|
||||||
info.progress[username][entry] = page
|
info.progress[username][entry] = page
|
||||||
end
|
end
|
||||||
|
# save last_read timestamp
|
||||||
|
if info.last_read[username]?.nil?
|
||||||
|
info.last_read[username] = {entry => Time.utc}
|
||||||
|
else
|
||||||
|
info.last_read[username][entry] = Time.utc
|
||||||
|
end
|
||||||
info.save
|
info.save
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -302,14 +326,14 @@ class Title
|
|||||||
progress
|
progress
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_percetage(username, entry)
|
def load_percentage(username, entry)
|
||||||
page = load_progress username, entry
|
page = load_progress username, entry
|
||||||
entry_obj = @entries.find { |e| e.title == entry }
|
entry_obj = @entries.find { |e| e.title == entry }
|
||||||
return 0.0 if entry_obj.nil?
|
return 0.0 if entry_obj.nil?
|
||||||
page / entry_obj.pages
|
page / entry_obj.pages
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_percetage(username)
|
def load_percentage(username)
|
||||||
return 0.0 if @entries.empty?
|
return 0.0 if @entries.empty?
|
||||||
read_pages = total_pages = 0
|
read_pages = total_pages = 0
|
||||||
@entries.each do |e|
|
@entries.each do |e|
|
||||||
@@ -319,11 +343,54 @@ class Title
|
|||||||
read_pages / total_pages
|
read_pages / total_pages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def load_last_read(username, entry)
|
||||||
|
last_read = nil
|
||||||
|
TitleInfo.new @dir do |info|
|
||||||
|
unless info.last_read[username]?.nil? ||
|
||||||
|
info.last_read[username][entry]?.nil?
|
||||||
|
last_read = info.last_read[username][entry]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
last_read
|
||||||
|
end
|
||||||
|
|
||||||
def next_entry(current_entry_obj)
|
def next_entry(current_entry_obj)
|
||||||
idx = @entries.index current_entry_obj
|
idx = @entries.index current_entry_obj
|
||||||
return nil if idx.nil? || idx == @entries.size - 1
|
return nil if idx.nil? || idx == @entries.size - 1
|
||||||
@entries[idx + 1]
|
@entries[idx + 1]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def previous_entry(current_entry_obj)
|
||||||
|
idx = @entries.index current_entry_obj
|
||||||
|
return nil if idx.nil? || idx == 0
|
||||||
|
@entries[idx - 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_continue_reading_entry(username)
|
||||||
|
in_progress_entries = @entries.select do |e|
|
||||||
|
load_progress(username, e.title) > 0
|
||||||
|
end
|
||||||
|
return nil if in_progress_entries.empty?
|
||||||
|
|
||||||
|
latest_read_entry = in_progress_entries[-1]
|
||||||
|
if load_progress(username, latest_read_entry.title) ==
|
||||||
|
latest_read_entry.pages
|
||||||
|
next_entry latest_read_entry
|
||||||
|
else
|
||||||
|
latest_read_entry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: More concise title?
|
||||||
|
def get_last_read_for_continue_reading(username, entry_obj)
|
||||||
|
last_read = load_last_read username, entry_obj.title
|
||||||
|
# grab from previous entry if current entry hasn't been started yet
|
||||||
|
if last_read.nil?
|
||||||
|
previous_entry = previous_entry(entry_obj)
|
||||||
|
return load_last_read username, previous_entry.title if previous_entry
|
||||||
|
end
|
||||||
|
last_read
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class TitleInfo
|
class TitleInfo
|
||||||
@@ -335,6 +402,8 @@ class TitleInfo
|
|||||||
property entry_display_name = {} of String => String
|
property entry_display_name = {} of String => String
|
||||||
property cover_url = ""
|
property cover_url = ""
|
||||||
property entry_cover_url = {} of String => String
|
property entry_cover_url = {} of String => String
|
||||||
|
property last_read = {} of String => Hash(String, Time)
|
||||||
|
property date_added = {} of String => Time
|
||||||
|
|
||||||
@[JSON::Field(ignore: true)]
|
@[JSON::Field(ignore: true)]
|
||||||
property dir : String = ""
|
property dir : String = ""
|
||||||
@@ -438,4 +507,91 @@ class Library
|
|||||||
end
|
end
|
||||||
Logger.debug "Scan completed"
|
Logger.debug "Scan completed"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_continue_reading_entries(username)
|
||||||
|
# map: get the continue-reading entry or nil for each Title
|
||||||
|
# select: select only entries (and ignore Nil's) from the array
|
||||||
|
# produced by map
|
||||||
|
continue_reading_entries = titles.map { |t|
|
||||||
|
get_continue_reading_entry username, t
|
||||||
|
}.select Entry
|
||||||
|
|
||||||
|
continue_reading = continue_reading_entries.map { |e|
|
||||||
|
{
|
||||||
|
entry: e,
|
||||||
|
percentage: e.book.load_percentage(username, e.title),
|
||||||
|
last_read: get_relevant_last_read(username, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Sort by by last_read, most recent first (nils at the end)
|
||||||
|
continue_reading.sort! { |a, b|
|
||||||
|
next 0 if a[:last_read].nil? && b[:last_read].nil?
|
||||||
|
next 1 if a[:last_read].nil?
|
||||||
|
next -1 if b[:last_read].nil?
|
||||||
|
b[:last_read].not_nil! <=> a[:last_read].not_nil!
|
||||||
|
}[0..11]
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_recently_added_entries(username)
|
||||||
|
# Get all entries added within the last three months
|
||||||
|
entries = titles.map { |t| t.entries }
|
||||||
|
.flatten
|
||||||
|
.select { |e| e.date_added > 3.months.ago }
|
||||||
|
|
||||||
|
# Group entries in a Hash by title ID
|
||||||
|
grouped_entries = {} of String => Array(Entry)
|
||||||
|
entries.each do |e|
|
||||||
|
if grouped_entries.has_key? e.title_id
|
||||||
|
grouped_entries[e.title_id].push e
|
||||||
|
else
|
||||||
|
grouped_entries[e.title_id] = [e]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cast the Hash to an Array of Tuples and sort it by date_added
|
||||||
|
grouped_ary = grouped_entries.to_a.sort do |a, b|
|
||||||
|
date_added_a = a[1].map { |e| e.date_added }.max
|
||||||
|
date_added_b = b[1].map { |e| e.date_added }.max
|
||||||
|
date_added_b <=> date_added_a
|
||||||
|
end
|
||||||
|
|
||||||
|
recently_added = grouped_ary.map do |_, ary|
|
||||||
|
# Get the most recently added entry in the group
|
||||||
|
entry = ary.sort { |a, b| a.date_added <=> b.date_added }.last
|
||||||
|
{
|
||||||
|
entry: entry,
|
||||||
|
percentage: entry.book.load_percentage(username, entry.title),
|
||||||
|
grouped_count: ary.size,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
recently_added[0..11]
|
||||||
|
end
|
||||||
|
|
||||||
|
private def get_continue_reading_entry(username, title)
|
||||||
|
in_progress_entries = title.entries.select do |e|
|
||||||
|
title.load_progress(username, e.title) > 0
|
||||||
|
end
|
||||||
|
return nil if in_progress_entries.empty?
|
||||||
|
|
||||||
|
latest_read_entry = in_progress_entries[-1]
|
||||||
|
if title.load_progress(username, latest_read_entry.title) ==
|
||||||
|
latest_read_entry.pages
|
||||||
|
title.next_entry latest_read_entry
|
||||||
|
else
|
||||||
|
latest_read_entry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private def get_relevant_last_read(username, entry_obj)
|
||||||
|
last_read = entry_obj.book.load_last_read username, entry_obj.title
|
||||||
|
# grab from previous entry if current entry hasn't been started yet
|
||||||
|
if last_read.nil?
|
||||||
|
previous_entry = entry_obj.book.previous_entry(entry_obj)
|
||||||
|
return entry_obj.book.load_last_read username, previous_entry.title \
|
||||||
|
if previous_entry
|
||||||
|
end
|
||||||
|
last_read
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ module MangaDex
|
|||||||
writer.close
|
writer.close
|
||||||
Logger.debug "cbz File created at #{zip_path}"
|
Logger.debug "cbz File created at #{zip_path}"
|
||||||
|
|
||||||
zip_exception = validate_zip zip_path
|
zip_exception = validate_archive zip_path
|
||||||
if !zip_exception.nil?
|
if !zip_exception.nil?
|
||||||
@queue.add_message "The downloaded archive is corrupted. " \
|
@queue.add_message "The downloaded archive is corrupted. " \
|
||||||
"Error: #{zip_exception}", job
|
"Error: #{zip_exception}", job
|
||||||
|
|||||||
+92
-21
@@ -2,31 +2,102 @@ require "./config"
|
|||||||
require "./server"
|
require "./server"
|
||||||
require "./mangadex/*"
|
require "./mangadex/*"
|
||||||
require "option_parser"
|
require "option_parser"
|
||||||
|
require "clim"
|
||||||
|
|
||||||
VERSION = "0.4.0"
|
MANGO_VERSION = "0.6.1"
|
||||||
|
|
||||||
config_path = nil
|
macro common_option
|
||||||
|
option "-c PATH", "--config=PATH", type: String,
|
||||||
|
desc: "Path to the config file"
|
||||||
|
end
|
||||||
|
|
||||||
OptionParser.parse do |parser|
|
macro throw(msg)
|
||||||
parser.banner = "Mango e-manga server/reader. Version #{VERSION}\n"
|
puts "ERROR: #{{{msg}}}"
|
||||||
|
puts
|
||||||
|
puts "Please see the `--help`."
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
parser.on "-v", "--version", "Show version" do
|
class CLI < Clim
|
||||||
puts "Version #{VERSION}"
|
main do
|
||||||
exit
|
desc "Mango - Manga Server and Web Reader. Version #{MANGO_VERSION}"
|
||||||
end
|
usage "mango [sub_command] [options]"
|
||||||
parser.on "-h", "--help", "Show help" do
|
help short: "-h"
|
||||||
puts parser
|
version "Version #{MANGO_VERSION}", short: "-v"
|
||||||
exit
|
common_option
|
||||||
end
|
run do |opts|
|
||||||
parser.on "-c PATH", "--config=PATH",
|
Config.load(opts.config).set_current
|
||||||
"Path to the config file. " \
|
MangaDex::Downloader.default
|
||||||
"Default is `~/.config/mango/config.yml`" do |path|
|
|
||||||
config_path = path
|
# empty ARGV so it won't be passed to Kemal
|
||||||
|
ARGV.clear
|
||||||
|
server = Server.new
|
||||||
|
server.start
|
||||||
|
end
|
||||||
|
|
||||||
|
sub "admin" do
|
||||||
|
desc "Run admin tools"
|
||||||
|
usage "mango admin [tool]"
|
||||||
|
help short: "-h"
|
||||||
|
run do |opts|
|
||||||
|
puts opts.help_string
|
||||||
|
end
|
||||||
|
sub "user" do
|
||||||
|
desc "User management tool"
|
||||||
|
usage "mango admin user [arguments] [options]"
|
||||||
|
help short: "-h"
|
||||||
|
argument "action", type: String,
|
||||||
|
desc: "Action to perform. Can be add/delete/update/list"
|
||||||
|
argument "username", type: String,
|
||||||
|
desc: "Username to update or delete"
|
||||||
|
option "-u USERNAME", "--username=USERNAME", type: String,
|
||||||
|
desc: "Username"
|
||||||
|
option "-p PASSWORD", "--password=PASSWORD", type: String,
|
||||||
|
desc: "Password"
|
||||||
|
option "-a", "--admin", desc: "Admin flag", type: Bool, default: false
|
||||||
|
common_option
|
||||||
|
run do |opts, args|
|
||||||
|
Config.load(opts.config).set_current
|
||||||
|
storage = Storage.new nil, false
|
||||||
|
|
||||||
|
case args.action
|
||||||
|
when "add"
|
||||||
|
throw "Options `-u` and `-p` required." if opts.username.nil? ||
|
||||||
|
opts.password.nil?
|
||||||
|
storage.new_user opts.username.not_nil!,
|
||||||
|
opts.password.not_nil!, opts.admin
|
||||||
|
when "delete"
|
||||||
|
throw "Argument `username` required." if args.username.nil?
|
||||||
|
storage.delete_user args.username
|
||||||
|
when "update"
|
||||||
|
throw "Argument `username` required." if args.username.nil?
|
||||||
|
username = opts.username || args.username
|
||||||
|
password = opts.password || ""
|
||||||
|
storage.update_user args.username, username.not_nil!,
|
||||||
|
password.not_nil!, opts.admin
|
||||||
|
when "list"
|
||||||
|
users = storage.list_users
|
||||||
|
name_length = users.map(&.[0].size).max? || 0
|
||||||
|
l_cell_width = ["username".size, name_length].max
|
||||||
|
r_cell_width = "admin access".size
|
||||||
|
header = " #{"username".ljust l_cell_width} | admin access "
|
||||||
|
puts "-" * header.size
|
||||||
|
puts header
|
||||||
|
puts "-" * header.size
|
||||||
|
users.each do |name, admin|
|
||||||
|
puts " #{name.ljust l_cell_width} | " \
|
||||||
|
"#{admin.to_s.ljust r_cell_width} "
|
||||||
|
end
|
||||||
|
puts "-" * header.size
|
||||||
|
when nil
|
||||||
|
puts opts.help_string
|
||||||
|
else
|
||||||
|
throw "Unknown action \"#{args.action}\"."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Config.load(config_path).set_current
|
CLI.start(ARGV)
|
||||||
MangaDex::Downloader.default
|
|
||||||
|
|
||||||
server = Server.new
|
|
||||||
server.start
|
|
||||||
|
|||||||
+7
-1
@@ -129,13 +129,19 @@ module Rename
|
|||||||
end
|
end
|
||||||
|
|
||||||
def render(hash : VHash)
|
def render(hash : VHash)
|
||||||
@ary.map do |e|
|
str = @ary.map do |e|
|
||||||
if e.is_a? String
|
if e.is_a? String
|
||||||
e
|
e
|
||||||
else
|
else
|
||||||
e.render hash
|
e.render hash
|
||||||
end
|
end
|
||||||
end.join.strip
|
end.join.strip
|
||||||
|
post_process str
|
||||||
|
end
|
||||||
|
|
||||||
|
private def post_process(str)
|
||||||
|
return "_" if str == ".."
|
||||||
|
str.gsub "/", "_"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -32,20 +32,6 @@ class AdminRouter < Router
|
|||||||
# would not contain `admin`
|
# would not contain `admin`
|
||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
|
|
||||||
if username.size < 3
|
|
||||||
raise "Username should contain at least 3 characters"
|
|
||||||
end
|
|
||||||
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
|
||||||
raise "Username should contain alphanumeric characters " \
|
|
||||||
"and underscores only"
|
|
||||||
end
|
|
||||||
if password.size < 6
|
|
||||||
raise "Password should contain at least 6 characters"
|
|
||||||
end
|
|
||||||
if (password =~ /^[[:ascii:]]+$/).nil?
|
|
||||||
raise "password should contain ASCII characters only"
|
|
||||||
end
|
|
||||||
|
|
||||||
@context.storage.new_user username, password, admin
|
@context.storage.new_user username, password, admin
|
||||||
|
|
||||||
redirect env, "/admin/user"
|
redirect env, "/admin/user"
|
||||||
@@ -65,23 +51,6 @@ class AdminRouter < Router
|
|||||||
admin = !env.params.body["admin"]?.nil?
|
admin = !env.params.body["admin"]?.nil?
|
||||||
original_username = env.params.url["original_username"]
|
original_username = env.params.url["original_username"]
|
||||||
|
|
||||||
if username.size < 3
|
|
||||||
raise "Username should contain at least 3 characters"
|
|
||||||
end
|
|
||||||
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
|
||||||
raise "Username should contain alphanumeric characters " \
|
|
||||||
"and underscores only"
|
|
||||||
end
|
|
||||||
|
|
||||||
if password.size != 0
|
|
||||||
if password.size < 6
|
|
||||||
raise "Password should contain at least 6 characters"
|
|
||||||
end
|
|
||||||
if (password =~ /^[[:ascii:]]+$/).nil?
|
|
||||||
raise "password should contain ASCII characters only"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@context.storage.update_user \
|
@context.storage.update_user \
|
||||||
original_username, username, password, admin
|
original_username, username, password, admin
|
||||||
|
|
||||||
|
|||||||
+30
-10
@@ -9,8 +9,7 @@ class MainRouter < Router
|
|||||||
|
|
||||||
get "/logout" do |env|
|
get "/logout" do |env|
|
||||||
begin
|
begin
|
||||||
cookie = env.request.cookies.find { |c| c.name == "token" }.not_nil!
|
env.session.delete_string "token"
|
||||||
@context.storage.logout cookie.value
|
|
||||||
rescue e
|
rescue e
|
||||||
@context.error "Error when attempting to log out: #{e}"
|
@context.error "Error when attempting to log out: #{e}"
|
||||||
ensure
|
ensure
|
||||||
@@ -24,21 +23,26 @@ class MainRouter < Router
|
|||||||
password = env.params.body["password"]
|
password = env.params.body["password"]
|
||||||
token = @context.storage.verify_user(username, password).not_nil!
|
token = @context.storage.verify_user(username, password).not_nil!
|
||||||
|
|
||||||
cookie = HTTP::Cookie.new "token", token
|
env.session.string "token", token
|
||||||
cookie.expires = Time.local.shift years: 1
|
|
||||||
env.response.cookies << cookie
|
callback = env.session.string? "callback"
|
||||||
redirect env, "/"
|
if callback
|
||||||
|
env.session.delete_string "callback"
|
||||||
|
redirect env, callback
|
||||||
|
else
|
||||||
|
redirect env, "/"
|
||||||
|
end
|
||||||
rescue
|
rescue
|
||||||
redirect env, "/login"
|
redirect env, "/login"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/" do |env|
|
get "/library" do |env|
|
||||||
begin
|
begin
|
||||||
titles = @context.library.titles
|
titles = @context.library.titles
|
||||||
username = get_username env
|
username = get_username env
|
||||||
percentage = titles.map &.load_percetage username
|
percentage = titles.map &.load_percentage username
|
||||||
layout "index"
|
layout "library"
|
||||||
rescue e
|
rescue e
|
||||||
@context.error e
|
@context.error e
|
||||||
env.response.status_code = 500
|
env.response.status_code = 500
|
||||||
@@ -50,7 +54,7 @@ class MainRouter < Router
|
|||||||
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||||
username = get_username env
|
username = get_username env
|
||||||
percentage = title.entries.map { |e|
|
percentage = title.entries.map { |e|
|
||||||
title.load_percetage username, e.title
|
title.load_percentage username, e.title
|
||||||
}
|
}
|
||||||
layout "title"
|
layout "title"
|
||||||
rescue e
|
rescue e
|
||||||
@@ -63,5 +67,21 @@ class MainRouter < Router
|
|||||||
mangadex_base_url = Config.current.mangadex["base_url"]
|
mangadex_base_url = Config.current.mangadex["base_url"]
|
||||||
layout "download"
|
layout "download"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
get "/" do |env|
|
||||||
|
begin
|
||||||
|
username = get_username env
|
||||||
|
continue_reading = @context
|
||||||
|
.library.get_continue_reading_entries username
|
||||||
|
recently_added = @context.library.get_recently_added_entries username
|
||||||
|
titles = @context.library.titles
|
||||||
|
new_user = !titles.any? { |t| t.load_percentage(username) > 0 }
|
||||||
|
empty_library = titles.size == 0
|
||||||
|
layout "home"
|
||||||
|
rescue e
|
||||||
|
@context.error e
|
||||||
|
env.response.status_code = 500
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
require "./router"
|
||||||
|
|
||||||
|
class OPDSRouter < Router
|
||||||
|
def initialize
|
||||||
|
get "/opds" do |env|
|
||||||
|
titles = @context.library.titles
|
||||||
|
render_xml "src/views/opds/index.ecr"
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/opds/book/:title_id" do |env|
|
||||||
|
begin
|
||||||
|
title = @context.library.get_title(env.params.url["title_id"]).not_nil!
|
||||||
|
render_xml "src/views/opds/title.ecr"
|
||||||
|
rescue e
|
||||||
|
@context.error e
|
||||||
|
env.response.status_code = 404
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
get "/opds/download/:title/:entry" do |env|
|
||||||
|
begin
|
||||||
|
title = (@context.library.get_title env.params.url["title"]).not_nil!
|
||||||
|
entry = (title.get_entry env.params.url["entry"]).not_nil!
|
||||||
|
|
||||||
|
send_attachment env, entry.zip_path
|
||||||
|
rescue e
|
||||||
|
@context.error e
|
||||||
|
env.response.status_code = 404
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
require "kemal"
|
require "kemal"
|
||||||
|
require "kemal-session"
|
||||||
require "./library"
|
require "./library"
|
||||||
require "./handlers/*"
|
require "./handlers/*"
|
||||||
require "./util"
|
require "./util"
|
||||||
@@ -53,6 +54,7 @@ class Server
|
|||||||
AdminRouter.new
|
AdminRouter.new
|
||||||
ReaderRouter.new
|
ReaderRouter.new
|
||||||
APIRouter.new
|
APIRouter.new
|
||||||
|
OPDSRouter.new
|
||||||
|
|
||||||
Kemal.config.logging = false
|
Kemal.config.logging = false
|
||||||
add_handler LogHandler.new
|
add_handler LogHandler.new
|
||||||
@@ -64,6 +66,13 @@ class Server
|
|||||||
serve_static false
|
serve_static false
|
||||||
add_handler StaticHandler.new
|
add_handler StaticHandler.new
|
||||||
{% end %}
|
{% end %}
|
||||||
|
|
||||||
|
Kemal::Session.config do |c|
|
||||||
|
c.timeout = 365.days
|
||||||
|
c.secret = Config.current.session_secret
|
||||||
|
c.cookie_name = "mango-sessid-#{Config.current.port}"
|
||||||
|
c.path = Config.current.base_url
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def start
|
def start
|
||||||
|
|||||||
+22
-8
@@ -22,7 +22,7 @@ class Storage
|
|||||||
@@default.not_nil!
|
@@default.not_nil!
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize(db_path : String? = nil)
|
def initialize(db_path : String? = nil, init_user = true)
|
||||||
@path = db_path || Config.current.db_path
|
@path = db_path || Config.current.db_path
|
||||||
dir = File.dirname @path
|
dir = File.dirname @path
|
||||||
unless Dir.exists? dir
|
unless Dir.exists? dir
|
||||||
@@ -47,20 +47,30 @@ class Storage
|
|||||||
Logger.fatal "Error when checking tables in DB: #{e}"
|
Logger.fatal "Error when checking tables in DB: #{e}"
|
||||||
raise e
|
raise e
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# If the DB is initialized through CLI but no user is added, we need
|
||||||
|
# to create the admin user when first starting the app
|
||||||
|
user_count = db.query_one "select count(*) from users", as: Int32
|
||||||
|
init_admin if init_user && user_count == 0
|
||||||
else
|
else
|
||||||
Logger.debug "Creating DB file at #{@path}"
|
Logger.debug "Creating DB file at #{@path}"
|
||||||
db.exec "create unique index username_idx on users (username)"
|
db.exec "create unique index username_idx on users (username)"
|
||||||
db.exec "create unique index token_idx on users (token)"
|
db.exec "create unique index token_idx on users (token)"
|
||||||
random_pw = random_str
|
|
||||||
hash = hash_password random_pw
|
init_admin if init_user
|
||||||
db.exec "insert into users values (?, ?, ?, ?)",
|
|
||||||
"admin", hash, nil, 1
|
|
||||||
Logger.log "Initial user created. You can log in with " \
|
|
||||||
"#{{"username" => "admin", "password" => random_pw}}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
macro init_admin
|
||||||
|
random_pw = random_str
|
||||||
|
hash = hash_password random_pw
|
||||||
|
db.exec "insert into users values (?, ?, ?, ?)",
|
||||||
|
"admin", hash, nil, 1
|
||||||
|
Logger.log "Initial user created. You can log in with " \
|
||||||
|
"#{{"username" => "admin", "password" => random_pw}}"
|
||||||
|
end
|
||||||
|
|
||||||
def verify_user(username, password)
|
def verify_user(username, password)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
begin
|
begin
|
||||||
@@ -124,6 +134,8 @@ class Storage
|
|||||||
end
|
end
|
||||||
|
|
||||||
def new_user(username, password, admin)
|
def new_user(username, password, admin)
|
||||||
|
validate_username username
|
||||||
|
validate_password password
|
||||||
admin = (admin ? 1 : 0)
|
admin = (admin ? 1 : 0)
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
hash = hash_password password
|
hash = hash_password password
|
||||||
@@ -134,8 +146,10 @@ class Storage
|
|||||||
|
|
||||||
def update_user(original_username, username, password, admin)
|
def update_user(original_username, username, password, admin)
|
||||||
admin = (admin ? 1 : 0)
|
admin = (admin ? 1 : 0)
|
||||||
|
validate_username username
|
||||||
|
validate_password password unless password.empty?
|
||||||
DB.open "sqlite3://#{@path}" do |db|
|
DB.open "sqlite3://#{@path}" do |db|
|
||||||
if password.size == 0
|
if password.empty?
|
||||||
db.exec "update users set username = (?), admin = (?) " \
|
db.exec "update users set username = (?), admin = (?) " \
|
||||||
"where username = (?)",
|
"where username = (?)",
|
||||||
username, admin, original_username
|
username, admin, original_username
|
||||||
|
|||||||
+60
-11
@@ -2,15 +2,20 @@ require "big"
|
|||||||
|
|
||||||
IMGS_PER_PAGE = 5
|
IMGS_PER_PAGE = 5
|
||||||
UPLOAD_URL_PREFIX = "/uploads"
|
UPLOAD_URL_PREFIX = "/uploads"
|
||||||
|
STATIC_DIRS = ["/css", "/js", "/img", "/favicon.ico"]
|
||||||
|
|
||||||
|
def requesting_static_file(env)
|
||||||
|
request_path_startswith env, STATIC_DIRS
|
||||||
|
end
|
||||||
|
|
||||||
macro layout(name)
|
macro layout(name)
|
||||||
base_url = Config.current.base_url
|
base_url = Config.current.base_url
|
||||||
begin
|
begin
|
||||||
cookie = env.request.cookies.find { |c| c.name == "token" }
|
|
||||||
is_admin = false
|
is_admin = false
|
||||||
unless cookie.nil?
|
if token = env.session.string? "token"
|
||||||
is_admin = @context.storage.verify_admin cookie.value
|
is_admin = @context.storage.verify_admin token
|
||||||
end
|
end
|
||||||
|
page = {{name}}
|
||||||
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
|
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
|
||||||
rescue e
|
rescue e
|
||||||
message = e.to_s
|
message = e.to_s
|
||||||
@@ -26,8 +31,8 @@ end
|
|||||||
macro get_username(env)
|
macro get_username(env)
|
||||||
# if the request gets here, it has gone through the auth handler, and
|
# if the request gets here, it has gone through the auth handler, and
|
||||||
# we can be sure that a valid token exists, so we can use not_nil! here
|
# we can be sure that a valid token exists, so we can use not_nil! here
|
||||||
cookie = {{env}}.request.cookies.find { |c| c.name == "token" }.not_nil!
|
token = env.session.string "token"
|
||||||
(@context.storage.verify_token cookie.value).not_nil!
|
(@context.storage.verify_token token).not_nil!
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_json(env, json)
|
def send_json(env, json)
|
||||||
@@ -35,6 +40,12 @@ def send_json(env, json)
|
|||||||
env.response.print json
|
env.response.print json
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def send_attachment(env, path)
|
||||||
|
MIME.register ".cbz", "application/vnd.comicbook+zip"
|
||||||
|
MIME.register ".cbr", "application/vnd.comicbook-rar"
|
||||||
|
send_file env, path, filename: File.basename(path), disposition: "attachment"
|
||||||
|
end
|
||||||
|
|
||||||
def hash_to_query(hash)
|
def hash_to_query(hash)
|
||||||
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
hash.map { |k, v| "#{k}=#{v}" }.join("&")
|
||||||
end
|
end
|
||||||
@@ -85,12 +96,9 @@ def compare_alphanumerically(a : String, b : String)
|
|||||||
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
|
compare_alphanumerically split_by_alphanumeric(a), split_by_alphanumeric(b)
|
||||||
end
|
end
|
||||||
|
|
||||||
# When downloading from MangaDex, the zip/cbz file would not be valid
|
def validate_archive(path : String) : Exception?
|
||||||
# before the download is completed. If we scan the zip file,
|
file = ArchiveFile.new path
|
||||||
# Entry.new would throw, so we use this method to check before
|
file.check
|
||||||
# constructing Entry
|
|
||||||
def validate_zip(path : String) : Exception?
|
|
||||||
file = Zip::File.new path
|
|
||||||
file.close
|
file.close
|
||||||
return
|
return
|
||||||
rescue e
|
rescue e
|
||||||
@@ -105,3 +113,44 @@ def redirect(env, path)
|
|||||||
base = Config.current.base_url
|
base = Config.current.base_url
|
||||||
env.redirect File.join base, path
|
env.redirect File.join base, path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_username(username)
|
||||||
|
if username.size < 3
|
||||||
|
raise "Username should contain at least 3 characters"
|
||||||
|
end
|
||||||
|
if (username =~ /^[A-Za-z0-9_]+$/).nil?
|
||||||
|
raise "Username should contain alphanumeric characters " \
|
||||||
|
"and underscores only"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_password(password)
|
||||||
|
if password.size < 6
|
||||||
|
raise "Password should contain at least 6 characters"
|
||||||
|
end
|
||||||
|
if (password =~ /^[[:ascii:]]+$/).nil?
|
||||||
|
raise "password should contain ASCII characters only"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
macro render_xml(path)
|
||||||
|
base_url = Config.current.base_url
|
||||||
|
send_file env, ECR.render({{path}}).to_slice, "application/xml"
|
||||||
|
end
|
||||||
|
|
||||||
|
macro render_component(filename)
|
||||||
|
render "src/views/components/#{{{filename}}}.ecr"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Works in all Unix systems. Follows https://github.com/crystal-lang/crystal/
|
||||||
|
# blob/master/src/crystal/system/unix/file_info.cr#L42-L48
|
||||||
|
def ctime(file_path : String) : Time
|
||||||
|
res = LibC.stat(file_path, out stat)
|
||||||
|
raise "Unable to get ctime of file #{file_path}" if res != 0
|
||||||
|
|
||||||
|
{% if flag?(:darwin) %}
|
||||||
|
Time.new stat.st_ctimespec, Time::Location::UTC
|
||||||
|
{% else %}
|
||||||
|
Time.new stat.st_ctim, Time::Location::UTC
|
||||||
|
{% end %}
|
||||||
|
end
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<% if item.is_a? NamedTuple(entry: Entry, percentage: Float64, grouped_count: Int32) %>
|
||||||
|
<% grouped_count = item[:grouped_count] %>
|
||||||
|
<% if grouped_count == 1 %>
|
||||||
|
<% item = item[:entry] %>
|
||||||
|
<% else %>
|
||||||
|
<% item = item[:entry].book %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<% grouped_count = 1 %>
|
||||||
|
<% end %>
|
||||||
|
<div class="item" data-mtime="<%= item.mtime.to_unix %>" data-progress="<%= progress || 0.0 %>"
|
||||||
|
<% if item.is_a? Entry %>
|
||||||
|
id="<%= item.id %>"
|
||||||
|
<% end %>>
|
||||||
|
|
||||||
|
<a class="acard"
|
||||||
|
<% unless item.is_a? Entry %>
|
||||||
|
href="<%= base_url %>book/<%= item.id %>"
|
||||||
|
<% end %>>
|
||||||
|
|
||||||
|
<div class="uk-card uk-card-default"
|
||||||
|
<% if item.is_a? Entry %>
|
||||||
|
onclick="showModal("<%= item.encoded_path %>", '<%= item.pages %>', <%= (progress.not_nil! * 100).round(1) %>, "<%= item.book.encoded_display_name %>", "<%= item.encoded_display_name %>", '<%= item.title_id %>', '<%= item.id %>')"
|
||||||
|
<% end %>>
|
||||||
|
|
||||||
|
<div class="uk-card-media-top">
|
||||||
|
<img data-src="<%= item.cover_url %>" data-width data-height alt="" uk-img>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uk-card-body">
|
||||||
|
<% unless (item.is_a? Title && item.entries.size == 0) || progress.nil? %>
|
||||||
|
<div class="uk-card-badge uk-label"><%= (progress * 100).round(1) %>%</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h3 class="uk-card-title break-word" data-title="<%= item.display_name.gsub("\"", """) %>"><%= item.display_name %></h3>
|
||||||
|
<% if item.is_a? Entry %>
|
||||||
|
<p><%= item.pages %> pages</p>
|
||||||
|
<% end %>
|
||||||
|
<% if item.is_a? Title %>
|
||||||
|
<% if grouped_count == 1 %>
|
||||||
|
<p><%= item.size %> entries</p>
|
||||||
|
<% else %>
|
||||||
|
<p><%= grouped_count %> new entries</p>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<div id="modal" class="uk-flex-top" uk-modal>
|
||||||
|
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||||
|
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||||
|
<div class="uk-modal-header">
|
||||||
|
<div>
|
||||||
|
<% if page == "home" %>
|
||||||
|
<h4 class="uk-margin-remove-bottom"><a id="modal-title-link"></a></h4>
|
||||||
|
<% end %>
|
||||||
|
<h3 class="uk-modal-title break-word uk-margin-remove-top" id="modal-entry-title"><span></span>
|
||||||
|
<% unless page == "home" %>
|
||||||
|
|
||||||
|
<% if is_admin %>
|
||||||
|
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
|
||||||
|
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
|
||||||
|
</div>
|
||||||
|
<div class="uk-modal-body">
|
||||||
|
<p>Read</p>
|
||||||
|
<p uk-margin>
|
||||||
|
<a id="beginning-btn" class="uk-button uk-button-default">From beginning</a>
|
||||||
|
<a id="continue-btn" class="uk-button uk-button-primary"></a>
|
||||||
|
</p>
|
||||||
|
<p>Progress</p>
|
||||||
|
<p uk-margin>
|
||||||
|
<button id="read-btn" class="uk-button uk-button-default">Mark as read (100%)</button>
|
||||||
|
<button id="unread-btn" class="uk-button uk-button-default">Mark as unread (0%)</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Mango</title>
|
||||||
|
<meta name="description" content="Mango - Manga Server and Web Reader">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
||||||
|
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
||||||
|
<link rel="icon" href="<%= base_url %>favicon.ico">
|
||||||
|
<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="<%= base_url %>js/theme.js"></script>
|
||||||
|
</head>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="uk-form-horizontal">
|
||||||
|
<select class="uk-select" id="sort-select">
|
||||||
|
<% hash.each do |k, v| %>
|
||||||
|
<option id="<%= k %>-up">â–˛ <%= v %></option>
|
||||||
|
<option id="<%= k %>-down">â–Ľ <%= v %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
@@ -1,30 +1,30 @@
|
|||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<div id="actions" class="uk-margin">
|
<div id="actions" class="uk-margin">
|
||||||
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button>
|
<button class="uk-button uk-button-default" onclick="remove()">Delete Completed Tasks</button>
|
||||||
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button>
|
<button class="uk-button uk-button-default" onclick="refresh()">Retry Failed Tasks</button>
|
||||||
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button>
|
<button class="uk-button uk-button-default" onclick="load()">Refresh Queue</button>
|
||||||
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
|
<button class="uk-button uk-button-default" onclick="toggle()" id="pause-resume-btn" hidden></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="config" class="uk-margin">
|
<div id="config" class="uk-margin">
|
||||||
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
|
<label><input id="auto-refresh" class="uk-checkbox" type="checkbox" checked> Auto Refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto">
|
<table class="uk-table uk-table-striped uk-overflow-auto">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Chapter</th>
|
<th>Chapter</th>
|
||||||
<th>Manga</th>
|
<th>Manga</th>
|
||||||
<th>Progress</th>
|
<th>Progress</th>
|
||||||
<th>Time</th>
|
<th>Time</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script>
|
<script>
|
||||||
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
||||||
</script>
|
</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="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
|||||||
+64
-64
@@ -1,80 +1,80 @@
|
|||||||
<h2 class=uk-title>Download from MangaDex</h2>
|
<h2 class=uk-title>Download from MangaDex</h2>
|
||||||
<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 id="search-input" class="uk-input" type="text" placeholder="MangaDex manga ID or URL">
|
||||||
</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 id="spinner" uk-spinner class="uk-align-center" hidden></div>
|
||||||
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
<button id="search-btn" class="uk-button uk-button-default" onclick="search()">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class"uk-grid-small" uk-grid hidden id="manga-details">
|
<div class"uk-grid-small" uk-grid hidden id="manga-details">
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<img id="cover">
|
<img id="cover">
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-1-4@s">
|
<div class="uk-width-1-4@s">
|
||||||
<p id="title"></p>
|
<p id="title"></p>
|
||||||
<p id="artist"></p>
|
<p id="artist"></p>
|
||||||
<p id="author"></p>
|
<p id="author"></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
<div id="filter-form" class="uk-form-stacked uk-width-1-2@s" hidden>
|
||||||
<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" id="count-text"></p>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="lang-select">Language</label>
|
<label class="uk-form-label" for="lang-select">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" id="lang-select">
|
||||||
</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" for="group-select">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" id="group-select">
|
||||||
</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" for="volume-range">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" id="volume-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||||
</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" for="chapter-range">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" id="chapter-range" placeholder="e.g., 127, 10-14, >30, <=212, or leave it empty.">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="selection-controls" class="uk-margin" hidden>
|
<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>
|
<button class="uk-button uk-button-default" onclick="selectAll()">Select All</button>
|
||||||
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
<button class="uk-button uk-button-default" onclick="unselect()">Clear Selections</button>
|
||||||
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
<button class="uk-button uk-button-primary" id="download-btn" onclick="download()">Download Selected</button>
|
||||||
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></div>
|
<div id="download-spinner" uk-spinner class="uk-margin-left" hidden></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 id="filter-notification" hidden></p>
|
||||||
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
|
<table class="uk-table uk-table-striped uk-overflow-auto" hidden>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Title</th>
|
<th>Title</th>
|
||||||
<th>Language</th>
|
<th>Language</th>
|
||||||
<th>Group</th>
|
<th>Group</th>
|
||||||
<th>Volume</th>
|
<th>Volume</th>
|
||||||
<th>Chapter</th>
|
<th>Chapter</th>
|
||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<% content_for "script" do %>
|
<% content_for "script" do %>
|
||||||
<script>
|
<script>
|
||||||
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
var baseURL = "<%= mangadex_base_url %>".replace(/\/$/, "");
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<%- if new_user && empty_library -%>
|
||||||
|
|
||||||
|
<div class="uk-container uk-text-center">
|
||||||
|
<i class="fas fa-plus" style="font-size: 80px;"></i>
|
||||||
|
<h2>Add your first manga</h2>
|
||||||
|
<p style="margin-bottom: 40px;">We can't find any files yet. Add some to your library and they'll appear here.</p>
|
||||||
|
<dl class="uk-description-list">
|
||||||
|
<dt style="font-weight: 500;">Current library path</dt>
|
||||||
|
<dd><code><%= Config.current.library_path %></code></dd>
|
||||||
|
<dt style="font-weight: 500;">Want to change your library path?</dt>
|
||||||
|
<dd>Update <code>config.yml</code> located at: <code><%= Config.current.path %></code></dd>
|
||||||
|
<dt style="font-weight: 500;">Can't see your files yet?</dt>
|
||||||
|
<dd>You must wait <%= Config.current.scan_interval %> minutes for the library scan to complete
|
||||||
|
<% if is_admin %>, or manually re-scan from <a href="<%= base_url %>admin">Admin</a><% end %>.</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- elsif new_user && empty_library == false -%>
|
||||||
|
|
||||||
|
<div class="uk-container uk-text-center">
|
||||||
|
<i class="fas fa-book-open" style="font-size: 80px;"></i>
|
||||||
|
<h2>Read your first manga</h2>
|
||||||
|
<p>Once you start reading, Mango will remember where you left off
|
||||||
|
and show your entries here.</p>
|
||||||
|
<a href="<%= base_url %>library" class="uk-button uk-button-default">View library</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- elsif new_user == false && empty_library == false -%>
|
||||||
|
|
||||||
|
<%- if continue_reading.empty? && recently_added.empty? -%>
|
||||||
|
<div class="uk-container uk-text-center">
|
||||||
|
<img src="<%= base_url %>img/banner.png" style="max-width: 400px; padding: 0 20px;">
|
||||||
|
<p>A self-hosted manga server and reader</p>
|
||||||
|
<a href="<%= base_url %>library" class="uk-button uk-button-default">View library</a>
|
||||||
|
</div>
|
||||||
|
<%- end -%>
|
||||||
|
|
||||||
|
<%- unless continue_reading.empty? -%>
|
||||||
|
<h2 class="uk-title home-headings">Continue Reading</h2>
|
||||||
|
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
|
<%- continue_reading.each do |cr| -%>
|
||||||
|
<% item = cr[:entry] %>
|
||||||
|
<% progress = cr[:percentage] %>
|
||||||
|
<%= render_component "card" %>
|
||||||
|
<%- end -%>
|
||||||
|
</div>
|
||||||
|
<%- end -%>
|
||||||
|
|
||||||
|
<%- unless recently_added.empty? -%>
|
||||||
|
<h2 class="uk-title home-headings">Recently Added</h2>
|
||||||
|
<div id="item-container-continue" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
|
<%- recently_added.each do |ra| -%>
|
||||||
|
<% item = ra %>
|
||||||
|
<% progress = ra[:percentage] %>
|
||||||
|
<%= render_component "card" %>
|
||||||
|
<%- end -%>
|
||||||
|
</div>
|
||||||
|
<%- end -%>
|
||||||
|
|
||||||
|
<%= render_component "entry-modal" %>
|
||||||
|
|
||||||
|
<%- end -%>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||||
|
<script src="<%= base_url %>js/dots.js"></script>
|
||||||
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
<script src="<%= base_url %>js/title.js"></script>
|
||||||
|
<% end %>
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<h2 class=uk-title>Library</h2>
|
|
||||||
<p class="uk-text-meta"><%= titles.size %> titles found</p>
|
|
||||||
<div class="uk-grid-small" uk-grid>
|
|
||||||
<div class="uk-margin-bottom uk-width-3-4@s">
|
|
||||||
<form class="uk-search uk-search-default">
|
|
||||||
<span uk-search-icon></span>
|
|
||||||
<input class="uk-search-input" type="search" placeholder="Search">
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
|
||||||
<div class="uk-form-horizontal">
|
|
||||||
<select class="uk-select" id="sort-select">
|
|
||||||
<option id="name-up">â–˛ Name</option>
|
|
||||||
<option id="name-down">â–Ľ Name</option>
|
|
||||||
<option id="date-up">â–˛ Date Modified</option>
|
|
||||||
<option id="date-down">â–Ľ Date Modified</option>
|
|
||||||
<option id="progress-up">â–˛ Progress</option>
|
|
||||||
<option id="progress-down">â–Ľ Progress</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
|
||||||
<%- titles.each_with_index do |t, i| -%>
|
|
||||||
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="<%= percentage[i] %>">
|
|
||||||
<a class="acard" href="<%= base_url %>book/<%= t.id %>">
|
|
||||||
<div class="uk-card uk-card-default">
|
|
||||||
<div class="uk-card-media-top">
|
|
||||||
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
|
|
||||||
</div>
|
|
||||||
<div class="uk-card-body">
|
|
||||||
<%- if t.entries.size > 0 -%>
|
|
||||||
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
|
|
||||||
<%- end -%>
|
|
||||||
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", """) %>"><%= t.display_name %></h3>
|
|
||||||
<p><%= t.size %> entries</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<%- end -%>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% content_for "script" do %>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
|
||||||
<script src="<%= base_url %>js/dots.js"></script>
|
|
||||||
<script src="<%= base_url %>js/search.js"></script>
|
|
||||||
<script src="<%= base_url %>js/sort-items.js"></script>
|
|
||||||
<% end %>
|
|
||||||
+57
-65
@@ -1,76 +1,68 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="X-UA-Compatible" content="IE=edge">
|
|
||||||
<title>Mango</title>
|
|
||||||
<meta name="description" content="Mango Manga Server">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
|
||||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
|
||||||
<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="<%= base_url %>js/theme.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
<%= render_component "head" %>
|
||||||
<div class="uk-offcanvas-content">
|
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
<body>
|
||||||
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
<div class="uk-offcanvas-content">
|
||||||
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
|
<div id="mobile-nav" uk-offcanvas="overlay: true">
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
<div class="uk-offcanvas-bar uk-flex uk-flex-column">
|
||||||
<% if is_admin %>
|
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
<li><a href="<%= base_url %>download">Download</a></li>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
<% end %>
|
<% if is_admin %>
|
||||||
<hr uk-divider>
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
<li><a href="<%= base_url %>download">Download</a></li>
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
<% end %>
|
||||||
</ul>
|
<hr uk-divider>
|
||||||
</div>
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
</div>
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="uk-position-top">
|
|
||||||
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
|
||||||
<div class="uk-navbar-left uk-hidden@s">
|
|
||||||
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
|
||||||
</div>
|
|
||||||
<div class="uk-navbar-left uk-visible@s">
|
|
||||||
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
|
|
||||||
<ul class="uk-navbar-nav">
|
|
||||||
<li><a href="<%= base_url %>">Home</a></li>
|
|
||||||
<% if is_admin %>
|
|
||||||
<li><a href="<%= base_url %>admin">Admin</a></li>
|
|
||||||
<li><a href="<%= base_url %>download">Download</a></li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="uk-navbar-right uk-visible@s">
|
|
||||||
<ul class="uk-navbar-nav">
|
|
||||||
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
|
||||||
<li><a href="<%= base_url %>logout">Logout</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-section uk-section-small">
|
</div>
|
||||||
</div>
|
<div class="uk-position-top">
|
||||||
<div class="uk-section uk-section-small">
|
<div class="uk-navbar-container uk-navbar-transparent" uk-navbar="uk-navbar">
|
||||||
<div class="uk-container uk-container-small">
|
<div class="uk-navbar-left uk-hidden@s">
|
||||||
<div id="alert"></div>
|
<div class="uk-navbar-toggle" uk-navbar-toggle-icon="uk-navbar-toggle-icon" uk-toggle="target: #mobile-nav"></div>
|
||||||
<%= content %>
|
</div>
|
||||||
|
<div class="uk-navbar-left uk-visible@s">
|
||||||
|
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
|
||||||
|
<ul class="uk-navbar-nav">
|
||||||
|
<li><a href="<%= base_url %>">Home</a></li>
|
||||||
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
|
<% if is_admin %>
|
||||||
|
<li><a href="<%= base_url %>admin">Admin</a></li>
|
||||||
|
<li><a href="<%= base_url %>download">Download</a></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="uk-navbar-right uk-visible@s">
|
||||||
|
<ul class="uk-navbar-nav">
|
||||||
|
<li><a onclick="toggleTheme()"><i class="fas fa-adjust"></i></a></li>
|
||||||
|
<li><a href="<%= base_url %>logout">Logout</a></li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
</div>
|
||||||
setTheme(getTheme());
|
<div class="uk-section uk-section-small">
|
||||||
const base_url = "<%= base_url %>";
|
</div>
|
||||||
</script>
|
<div class="uk-section uk-section-small">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
<div class="uk-container uk-container-small">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
<div id="alert"></div>
|
||||||
|
<%= content %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
setTheme(getTheme());
|
||||||
|
const base_url = "<%= base_url %>";
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||||
|
|
||||||
|
<%= yield_content "script" %>
|
||||||
|
</body>
|
||||||
|
|
||||||
<%= yield_content "script" %>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<h2 class=uk-title>Library</h2>
|
||||||
|
<p class="uk-text-meta"><%= titles.size %> titles found</p>
|
||||||
|
<div class="uk-grid-small" uk-grid>
|
||||||
|
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||||
|
<form class="uk-search uk-search-default">
|
||||||
|
<span uk-search-icon></span>
|
||||||
|
<input class="uk-search-input" type="search" placeholder="Search">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||||
|
<% hash = {
|
||||||
|
"name" => "Name",
|
||||||
|
"date" => "Date Modified",
|
||||||
|
"progress" => "Progress"
|
||||||
|
} %>
|
||||||
|
<%= render_component "sort-form" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
|
<% titles.each_with_index do |item, i| %>
|
||||||
|
<% progress = percentage[i] %>
|
||||||
|
<%= render_component "card" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% content_for "script" do %>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
|
||||||
|
<script src="<%= base_url %>js/dots.js"></script>
|
||||||
|
<script src="<%= base_url %>js/search.js"></script>
|
||||||
|
<script src="<%= base_url %>js/sort-items.js"></script>
|
||||||
|
<% end %>
|
||||||
+27
-34
@@ -1,43 +1,36 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
<%= render_component "head" %>
|
||||||
<meta name="X-UA-Compatible" content="IE=edge">
|
|
||||||
<title>Mango</title>
|
|
||||||
<meta name="description" content="Mango Manga Server">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
|
||||||
<script src="<%= base_url %>js/theme.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
<body>
|
||||||
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
|
<div class="uk-section uk-flex uk-flex-middle uk-animation-fade" uk-height-viewport="">
|
||||||
<div class="uk-width-1-1">
|
<div class="uk-width-1-1">
|
||||||
<div class="uk-container">
|
<div class="uk-container">
|
||||||
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
|
<div class="uk-grid-margin uk-grid uk-grid-stack" uk-grid="">
|
||||||
<div class="uk-width-1-1@m">
|
<div class="uk-width-1-1@m">
|
||||||
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
|
<div class="uk-margin uk-width-large uk-margin-auto uk-card uk-card-default uk-card-body uk-box-shadow-large">
|
||||||
<h3 class="uk-card-title uk-text-center">Log In</h3>
|
<h3 class="uk-card-title uk-text-center">Log In</h3>
|
||||||
<form action="<%= base_url %>login" method="post">
|
<form action="<%= base_url %>login" method="post">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" name="username"></div>
|
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:user"></span><input class="uk-input uk-form-large" type="text" name="username"></div>
|
||||||
</div>
|
|
||||||
<div class="uk-margin">
|
|
||||||
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" name="password"></div>
|
|
||||||
</div>
|
|
||||||
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-inline uk-width-1-1"><span class="uk-form-icon" uk-icon="icon:lock"></span><input class="uk-input uk-form-large" type="password" name="password"></div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin"><button class="uk-button uk-button-primary uk-button-large uk-width-1-1">Login</button></div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
</div>
|
||||||
setTheme(getTheme());
|
<script>
|
||||||
</script>
|
setTheme(getTheme());
|
||||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<id>urn:mango:index</id>
|
||||||
|
|
||||||
|
<link rel="self" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
|
||||||
|
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
|
||||||
|
|
||||||
|
<title>Library</title>
|
||||||
|
|
||||||
|
<author>
|
||||||
|
<name>Mango</name>
|
||||||
|
<uri>https://github.com/hkalexling/Mango</uri>
|
||||||
|
</author>
|
||||||
|
|
||||||
|
<% titles.each do |t| %>
|
||||||
|
<entry>
|
||||||
|
<title><%= t.display_name %></title>
|
||||||
|
<id>urn:mango:<%= t.id %></id>
|
||||||
|
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
|
||||||
|
</entry>
|
||||||
|
<% end %>
|
||||||
|
</feed>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
|
<id>urn:mango:<%= title.id %></id>
|
||||||
|
|
||||||
|
<link rel="self" href="<%= base_url %>opds/book/<%= title.id %>" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
|
||||||
|
<link rel="start" href="<%= base_url %>opds/" type="application/atom+xml;profile=opds-catalog;kind=navigation" />
|
||||||
|
|
||||||
|
<title><%= title.display_name %></title>
|
||||||
|
|
||||||
|
<author>
|
||||||
|
<name>Mango</name>
|
||||||
|
<uri>https://github.com/hkalexling/Mango</uri>
|
||||||
|
</author>
|
||||||
|
|
||||||
|
<% title.titles.each do |t| %>
|
||||||
|
<entry>
|
||||||
|
<title><%= t.display_name %></title>
|
||||||
|
<id>urn:mango:<%= t.id %></id>
|
||||||
|
<link type="application/atom+xml;profile=opds-catalog;kind=navigation" rel="subsection" href="<%= base_url %>opds/book/<%= t.id %>" />
|
||||||
|
</entry>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% title.entries.each do |e| %>
|
||||||
|
<entry>
|
||||||
|
<title><%= e.display_name %></title>
|
||||||
|
<id>urn:mango:<%= e.id %></id>
|
||||||
|
|
||||||
|
<link rel="http://opds-spec.org/image" href="<%= e.cover_url %>" />
|
||||||
|
<link rel="http://opds-spec.org/image/thumbnail" href="<%= e.cover_url %>" />
|
||||||
|
|
||||||
|
<link rel="http://opds-spec.org/acquisition" href="<%= base_url %>opds/download/<%= e.title_id %>/<%= e.id %>" title="Read" type="<%= MIME.from_filename e.zip_path %>" />
|
||||||
|
|
||||||
|
<link type="text/html" rel="alternate" title="Read in Mango" href="<%= base_url %>reader/<%= e.title_id %>/<%= e.id %>" />
|
||||||
|
<link type="text/html" rel="alternate" title="Open in Mango" href="<%= base_url %>book/<%= e.title_id %>" />
|
||||||
|
</entry>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
</feed>
|
||||||
+49
-55
@@ -1,68 +1,62 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html class="reader-bg">
|
<html class="reader-bg">
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="X-UA-Compatible" content="IE=edge">
|
|
||||||
<title>Mango</title>
|
|
||||||
<meta name="description" content="Mango Manga Server">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/css/uikit.min.css" />
|
|
||||||
<link rel="stylesheet" href="<%= base_url %>css/mango.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
<%= render_component "head" %>
|
||||||
<script src="<%= base_url %>js/theme.js"></script>
|
|
||||||
<div class="uk-section uk-section-default uk-section-small reader-bg">
|
<body>
|
||||||
<div class="uk-container uk-container-small">
|
<script src="<%= base_url %>js/theme.js"></script>
|
||||||
<%- urls.each_with_index do |url, i| -%>
|
<div class="uk-section uk-section-default uk-section-small reader-bg">
|
||||||
<img class="uk-align-center" data-src="<%= url %>" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
|
<div class="uk-container uk-container-small">
|
||||||
<%- end -%>
|
<%- urls.each_with_index do |url, i| -%>
|
||||||
<%- if next_url -%>
|
<img class="uk-align-center" data-src="<%= url %>" data-width data-height uk-img id="<%= reader_urls[i] %>" onclick="showControl(<%= pages.to_a[i] %>);">
|
||||||
<a class="next-url" href="<%= next_url %>"></a>
|
<%- end -%>
|
||||||
<%- end -%>
|
<%- if next_url -%>
|
||||||
</div>
|
<a class="next-url" href="<%= next_url %>"></a>
|
||||||
<%- if next_entry_url -%>
|
|
||||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
|
|
||||||
<%- else -%>
|
|
||||||
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
</div>
|
</div>
|
||||||
|
<%- if next_entry_url -%>
|
||||||
|
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= next_entry_url %>')">Next Entry</button>
|
||||||
|
<%- else -%>
|
||||||
|
<button id="next-btn" class="uk-align-center uk-button uk-button-primary" hidden onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
||||||
|
<%- end -%>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="hidden" hidden></div>
|
<div id="hidden" hidden></div>
|
||||||
|
|
||||||
<div id="modal-sections" class="uk-flex-top" uk-modal>
|
<div id="modal-sections" class="uk-flex-top" uk-modal>
|
||||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||||
<div class="uk-modal-header">
|
<div class="uk-modal-header">
|
||||||
<h3 class="uk-modal-title">Options</h3>
|
<h3 class="uk-modal-title">Options</h3>
|
||||||
|
</div>
|
||||||
|
<div class="uk-modal-body">
|
||||||
|
<div class="uk-margin">
|
||||||
|
<p id="progress-label"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-body">
|
<div class="uk-margin">
|
||||||
<div class="uk-margin">
|
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
|
||||||
<p id="progress-label"></p>
|
<div class="uk-form-controls">
|
||||||
</div>
|
<select id="page-select" class="uk-select">
|
||||||
<div class="uk-margin">
|
<%- (1..entry.pages).each do |p| -%>
|
||||||
<label class="uk-form-label" for="form-stacked-select">Jump to page</label>
|
<option value="<%= p %>"><%= p %></option>
|
||||||
<div class="uk-form-controls">
|
<%- end -%>
|
||||||
<select id="page-select" class="uk-select">
|
</select>
|
||||||
<%- (1..entry.pages).each do |p| -%>
|
|
||||||
<option value="<%= p %>"><%= p %></option>
|
|
||||||
<%- end -%>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-footer uk-text-right">
|
</div>
|
||||||
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
<div class="uk-modal-footer uk-text-right">
|
||||||
</div>
|
<button class="uk-button uk-button-danger" type="button" onclick="redirect('<%= exit_url %>')">Exit Reader</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
</div>
|
||||||
const base_url = "<%= base_url %>"
|
<script>
|
||||||
</script>
|
const base_url = "<%= base_url %>"
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/uikit@3.3.1/dist/js/uikit-icons.min.js"></script>
|
||||||
<script src="<%= base_url %>js/reader.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
|
||||||
</body>
|
<script src="<%= base_url %>js/reader.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+75
-131
@@ -1,150 +1,94 @@
|
|||||||
<div>
|
<div>
|
||||||
<h2 class=uk-title><span><%= title.display_name %></span>
|
<h2 class=uk-title><span><%= title.display_name %></span>
|
||||||
|
|
||||||
<% if is_admin %>
|
<% if is_admin %>
|
||||||
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
|
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
|
||||||
<% end %>
|
<% end %>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<ul class="uk-breadcrumb">
|
<ul class="uk-breadcrumb">
|
||||||
<li><a href="<%= base_url %>">Library</a></li>
|
<li><a href="<%= base_url %>library">Library</a></li>
|
||||||
<%- title.parents.each do |t| -%>
|
<%- title.parents.each do |t| -%>
|
||||||
<li><a href="<%= base_url %>book/<%= t.id %>"><%= t.display_name %></a></li>
|
<li><a href="<%= base_url %>book/<%= t.id %>"><%= t.display_name %></a></li>
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
<li class="uk-disabled"><a><%= title.display_name %></a></li>
|
<li class="uk-disabled"><a><%= title.display_name %></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="uk-text-meta"><%= title.size %> entries found</p>
|
<p class="uk-text-meta"><%= title.size %> entries found</p>
|
||||||
<div class="uk-grid-small" uk-grid>
|
<div class="uk-grid-small" uk-grid>
|
||||||
<div class="uk-margin-bottom uk-width-3-4@s">
|
<div class="uk-margin-bottom uk-width-3-4@s">
|
||||||
<form class="uk-search uk-search-default">
|
<form class="uk-search uk-search-default">
|
||||||
<span uk-search-icon></span>
|
<span uk-search-icon></span>
|
||||||
<input class="uk-search-input" type="search" placeholder="Search">
|
<input class="uk-search-input" type="search" placeholder="Search">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin-bottom uk-width-1-4@s">
|
<div class="uk-margin-bottom uk-width-1-4@s">
|
||||||
<div class="uk-form-horizontal">
|
<% hash = {
|
||||||
<select class="uk-select" id="sort-select">
|
"auto" => "Auto",
|
||||||
<option id="auto-up">â–˛ Auto</option>
|
"name" => "Name",
|
||||||
<option id="auto-down">â–Ľ Auto</option>
|
"date" => "Date Modified",
|
||||||
<option id="name-up">â–˛ Name</option>
|
"progress" => "Progress"
|
||||||
<option id="name-down">â–Ľ Name</option>
|
} %>
|
||||||
<option id="date-up">â–˛ Date Modified</option>
|
<%= render_component "sort-form" %>
|
||||||
<option id="date-down">â–Ľ Date Modified</option>
|
|
||||||
<option id="progress-up">â–˛ Progress</option>
|
|
||||||
<option id="progress-down">â–Ľ Progress</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
|
||||||
<%- title.titles.each_with_index do |t, i| -%>
|
|
||||||
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0">
|
|
||||||
<a class="acard" href="<%= base_url %>book/<%= t.id %>">
|
|
||||||
<div class="uk-card uk-card-default">
|
|
||||||
<div class="uk-card-media-top">
|
|
||||||
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
|
|
||||||
</div>
|
|
||||||
<div class="uk-card-body">
|
|
||||||
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", """) %>"><%= t.display_name %></h3>
|
|
||||||
<p><%= t.size %> entries</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<%- end -%>
|
|
||||||
<%- title.entries.each_with_index do |e, i| -%>
|
|
||||||
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[i] %>" id="<%= e.id %>">
|
|
||||||
<a class="acard">
|
|
||||||
<div class="uk-card uk-card-default" onclick="showModal("<%= e.encoded_path %>", '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, "<%= title.encoded_display_name %>", "<%= e.encoded_display_name %>", '<%= e.title_id %>', '<%= e.id %>')">
|
|
||||||
<div class="uk-card-media-top">
|
|
||||||
<img data-src="<%= e.cover_url %>" alt="" data-width data-height uk-img>
|
|
||||||
</div>
|
|
||||||
<div class="uk-card-body">
|
|
||||||
<div class="uk-card-badge uk-label"><%= (percentage[i] * 100).round(1) %>%</div>
|
|
||||||
<h3 class="uk-card-title break-word" data-title="<%= e.display_name.gsub("\"", """) %>"><%= e.display_name %></h3>
|
|
||||||
<p><%= e.pages %> pages</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<%- end -%>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="modal" class="uk-flex-top" uk-modal>
|
|
||||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
|
||||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
|
||||||
<div class="uk-modal-header">
|
|
||||||
<div>
|
|
||||||
<h3 class="uk-modal-title break-word" id="modal-title"><span></span>
|
|
||||||
|
|
||||||
<% if is_admin %>
|
|
||||||
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
|
|
||||||
<% end %>
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p class="uk-text-meta uk-margin-remove-bottom break-word" id="path-text"></p>
|
|
||||||
<p class="uk-text-meta uk-margin-remove-top" id="pages-text"></p>
|
|
||||||
</div>
|
|
||||||
<div class="uk-modal-body">
|
|
||||||
<p>Read</p>
|
|
||||||
<p uk-margin>
|
|
||||||
<a id="beginning-btn" class="uk-button uk-button-default">From beginning</a>
|
|
||||||
<a id="continue-btn" class="uk-button uk-button-primary"></a>
|
|
||||||
</p>
|
|
||||||
<p>Progress</p>
|
|
||||||
<p uk-margin>
|
|
||||||
<button id="read-btn" class="uk-button uk-button-default">Mark as read (100%)</button>
|
|
||||||
<button id="unread-btn" class="uk-button uk-button-default">Mark as unread (0%)</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid>
|
||||||
|
<% title.titles.each_with_index do |item, i| %>
|
||||||
|
<% progress = nil %>
|
||||||
|
<%= render_component "card" %>
|
||||||
|
<% end %>
|
||||||
|
<% title.entries.each_with_index do |item, i| %>
|
||||||
|
<% progress = percentage[i] %>
|
||||||
|
<%= render_component "card" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= render_component "entry-modal" %>
|
||||||
|
|
||||||
<div id="edit-modal" class="uk-flex-top" uk-modal>
|
<div id="edit-modal" class="uk-flex-top" uk-modal>
|
||||||
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
<div class="uk-modal-dialog uk-margin-auto-vertical">
|
||||||
<button class="uk-modal-close-default" type="button" uk-close></button>
|
<button class="uk-modal-close-default" type="button" uk-close></button>
|
||||||
<div class="uk-modal-header">
|
<div class="uk-modal-header">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="uk-modal-title break-word" id="modal-title">Edit</h3>
|
<h3 class="uk-modal-title break-word">Edit</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-modal-body">
|
<div class="uk-modal-body">
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="display-name">Display Name</label>
|
<label class="uk-form-label" for="display-name">Display Name</label>
|
||||||
<div class="uk-inline">
|
<div class="uk-inline">
|
||||||
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
|
<a class="uk-form-icon uk-form-icon-flip" uk-icon="icon:check"></a>
|
||||||
<input class="uk-input" type="text" name="display-name" id="display-name-field">
|
<input class="uk-input" type="text" name="display-name" id="display-name-field">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label">Cover Image</label>
|
<label class="uk-form-label">Cover Image</label>
|
||||||
<div class="uk-grid">
|
<div class="uk-grid">
|
||||||
<div class="uk-width-1-2@s">
|
<div class="uk-width-1-2@s">
|
||||||
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
|
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-width-1-2@s">
|
<div class="uk-width-1-2@s">
|
||||||
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
|
<div id="cover-upload" class="upload-field uk-placeholder uk-text-center uk-flex uk-flex-middle" data-title-id="<%= title.id %>">
|
||||||
<div>
|
<div>
|
||||||
<span uk-icon="icon: cloud-upload"></span>
|
<span uk-icon="icon: cloud-upload"></span>
|
||||||
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
|
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
|
||||||
<div uk-form-custom>
|
<div uk-form-custom>
|
||||||
<input type="file" accept="image/jpeg, image/png">
|
<input type="file" accept="image/jpeg, image/png">
|
||||||
<span class="uk-link">selecting one</span>
|
<span class="uk-link">selecting one</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
|
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
|
||||||
</div>
|
</div>
|
||||||
<div id="title-progress-control" hidden>
|
<div id="title-progress-control" hidden>
|
||||||
<label class="uk-form-label">Progress</label>
|
<label class="uk-form-label">Progress</label>
|
||||||
<p class="uk-margin-remove-vertical">
|
<p class="uk-margin-remove-vertical">
|
||||||
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
|
<button id="read-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 1)">Mark all as read (100%)</button>
|
||||||
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
|
<button id="unread-btn" class="uk-button uk-button-default" onclick="updateProgress('<%= title.id %>', null, 0)">Mark all as unread (0%)</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-13
@@ -2,11 +2,7 @@
|
|||||||
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="form-stacked-text">Username</label>
|
<label class="uk-form-label" for="form-stacked-text">Username</label>
|
||||||
<input class="uk-input" type="text" name="username"
|
<input class="uk-input" type="text" name="username" <%- if username -%> value=<%= username %> <%- end -%>>
|
||||||
<%- if username -%>
|
|
||||||
value=<%= username %>
|
|
||||||
<%- end -%>
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<%- if new_user -%>
|
<%- if new_user -%>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
@@ -16,11 +12,7 @@
|
|||||||
<%- end -%>
|
<%- end -%>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label class="uk-form-label" for="form-stacked-text">Admin Access</label>
|
<label class="uk-form-label" for="form-stacked-text">Admin Access</label>
|
||||||
<input class="uk-checkbox" type="checkbox" name="admin"
|
<input class="uk-checkbox" type="checkbox" name="admin" <%- if admin == true -%> checked <%- end -%>>
|
||||||
<%- if admin == true -%>
|
|
||||||
checked
|
|
||||||
<%- end -%>
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%- if !new_user -%>
|
<%- if !new_user -%>
|
||||||
@@ -43,10 +35,10 @@
|
|||||||
var username;
|
var username;
|
||||||
var error;
|
var error;
|
||||||
<%- if !new_user -%>
|
<%- if !new_user -%>
|
||||||
username = '/<%= username %>';
|
username = '/<%= username %>';
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
<%- if error -%>
|
<%- if error -%>
|
||||||
error = '<%= error %>';
|
error = '<%= error %>';
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
</script>
|
</script>
|
||||||
<script src="<%= base_url %>js/alert.js"></script>
|
<script src="<%= base_url %>js/alert.js"></script>
|
||||||
|
|||||||
+10
-10
@@ -8,16 +8,16 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<%- users.each do |u| -%>
|
<%- users.each do |u| -%>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= u[0] %></td>
|
<td><%= u[0] %></td>
|
||||||
<td><%= u[1] %></td>
|
<td><%= u[1] %></td>
|
||||||
<td>
|
<td>
|
||||||
<a href="<%= base_url %>admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
|
<a href="<%= base_url %>admin/user/edit?username=<%= u[0] %>&admin=<%= u[1] %>" uk-icon="file-edit"></a>
|
||||||
<%- if u[0] != username %>
|
<%- if u[0] != username %>
|
||||||
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
|
<a href="#" onclick="remove('<%= u[0] %>');return false;" uk-icon="trash"></a>
|
||||||
<%- end %>
|
<%- end %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<%- end -%>
|
<%- end -%>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user