Compare commits

...

66 Commits

Author SHA1 Message Date
Alex Ling 9255de710f Link to Wiki in README 2020-06-09 15:12:23 +00:00
Alex Ling 39b251774f Bump version to v0.6.1 [skip ci] 2020-06-09 15:08:15 +00:00
Alex Ling 156e511d4a Fix failed build (omitted parentheses) 2020-06-09 14:54:23 +00:00
Alex Ling 5cd6f3eacb Fix incorrect login redirect (#64) 2020-06-09 14:46:45 +00:00
Alex Ling a0e5a03052 DRY html modal and head 2020-06-09 10:34:24 +00:00
Alex Ling e53641add1 Handle the rare case when renamed string is ".." 2020-06-09 09:42:28 +00:00
Alex Ling 45cdfd5306 Merge branch 'fix/mangadex-slash' into dev 2020-06-09 09:31:17 +00:00
Alex Ling 3d352ed062 Add test for slash escaping 2020-06-09 09:28:37 +00:00
Alex Ling bac7be5163 Escape slash in filename when downloading (#62) 2020-06-09 09:25:20 +00:00
Alex Ling 717d44e029 Refactor get_recently_added_entries method 2020-06-09 05:37:10 +00:00
Alex Ling 8da4475a74 Remove duplicate title ID (#56) 2020-06-08 15:55:40 +00:00
Alex Ling 680504779f Use component template on home page 2020-06-08 15:51:42 +00:00
Alex Ling 926d0e66a5 Formatting 2020-06-08 15:29:05 +00:00
Alex Ling 0f3dd51d6b Respect base URL 2020-06-08 15:24:35 +00:00
Alex Ling 53c3798691 Merge branch 'feature/home' into dev 2020-06-08 15:11:09 +00:00
Jared Turner 6d4e8ea544 Show config path for empty libraries and link to Admin for manual re-scan 2020-06-08 15:24:51 +01:00
Jared Turner 0bd94a2290 Add config path to Config 2020-06-08 15:24:17 +01:00
Jared Turner cff599f688 refactor get_recently_added_entries, new_user and empty_library 2020-06-08 15:23:36 +01:00
Jared Turner fa85d9834f Onboarding for new libraries and new users 2020-06-07 18:40:31 +01:00
Jared Turner aaf0a3c6af Group Recently Added by neighbouring Title 2020-06-07 18:39:05 +01:00
Jared Turner 5ed2a8affa Add Library link to mobile nav 2020-06-07 18:36:51 +01:00
Alex Ling de690fbf29 Store token and callback URI in memory session 2020-06-07 16:18:34 +00:00
Alex Ling 12c3c3f356 Bump version to v0.6.0 2020-06-06 15:45:44 +00:00
Alex Ling 1ddcabcc12 Use component templates 2020-06-06 12:00:02 +00:00
Alex Ling 8b04f2c96b Remove comment in the OPDS xml file [skip ci] 2020-06-05 16:41:55 +00:00
Alex Ling 66e2fc138a Mention OPDS support in README [skip ci] 2020-06-05 16:15:55 +00:00
Alex Ling 6817113523 Clean up 2020-06-05 15:25:41 +00:00
Alex Ling 6ad4385b18 Respect base URL in OPDS feed 2020-06-05 15:18:46 +00:00
Alex Ling 012fd71ab4 Use a helper function to set token cookie 2020-06-05 14:31:12 +00:00
Alex Ling 373ff6520a Merge branch 'feature/opds' into dev 2020-06-05 14:28:36 +00:00
Alex Ling 8a0e9250c8 Finish OPDS 2020-06-05 14:21:47 +00:00
Alex Ling 871a5fe755 Add render_xml helper function 2020-06-05 14:21:47 +00:00
Alex Ling 1493c3de90 Set token cookie after successful basic auth 2020-06-05 14:21:47 +00:00
Jared Turner 808074e478 Add Recently Added to home 2020-06-05 15:13:19 +01:00
Jared Turner 49193b9b00 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-06-04 19:44:07 +01:00
jaredlt 1cb470fb2d Merge pull request #57 from hkalexling/feature/home-ctime
Add `ctime` helper function
2020-06-04 19:43:46 +01:00
Alex Ling e443176a79 Add ctime helper function 2020-06-04 16:31:49 +00:00
Alex Ling bec257c99f Update HTML description meta tag 2020-06-04 15:07:32 +00:00
Alex Ling f2df493d79 Add Ko-Fi link [skip ci] 2020-06-04 14:54:46 +00:00
Alex Ling b74f61c025 Bump version to v0.5.2 [skip ci] 2020-06-04 14:52:38 +00:00
Alex Ling c76c287e66 Fix URL of uploaded images when using base URL 2020-06-04 12:38:38 +00:00
Alex Ling 8e7eaa680a Fix favicon for base URL (#55) [skip-ci] 2020-06-04 05:43:37 +00:00
Alex Ling 30cdb3ec8f Remove duplicate title ID (#56) 2020-06-04 05:37:20 +00:00
Alex Ling 9c367e7d35 Format HTML files with html-beautify 2020-06-04 05:36:39 +00:00
Jared Turner 4f5e05c008 refactor continue reading into Library class 2020-06-03 13:48:49 +01:00
Alex Ling d2f95e5970 Bump version to v0.5.1 2020-06-03 08:22:05 +00:00
Alex Ling 82bcd03f15 Always create initial user if the DB is empty when started 2020-06-03 08:20:40 +00:00
Alex Ling fe799f30c8 Make the user listing command handles empty DB 2020-06-03 08:19:40 +00:00
Alex Ling 54123917af Empty ARGV before starting Kemal (#53) 2020-06-03 07:55:18 +00:00
Jared Turner 13c0878357 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-06-01 15:29:36 +01:00
Jared Turner 3ef6a7bfc4 continue reading sorted by last read 2020-06-01 15:29:18 +01:00
Alex Ling 60100c51fe Add send_attachment function for direct download 2020-06-01 13:21:10 +00:00
Alex Ling 27c111d273 Handle basic auth for OPDS 2020-06-01 13:20:05 +00:00
Alex Ling 4841f90cc1 Remove edit buttons from home 2020-05-29 15:51:01 +00:00
Jared Turner e99d7b8b29 Merge branch 'feature/home' of github.com:hkalexling/Mango into feature/home 2020-05-29 13:31:00 +01:00
Jared Turner d2ad7fef77 WIP last_read property for Entries 2020-05-29 13:26:47 +01:00
Jared Turner ddb6a860ae add 'jump to title' to home modal 2020-05-24 10:35:35 +01:00
Alex Ling 6a9105605d Fix library link in the breadcrumb menu 2020-05-23 12:16:08 +00:00
Alex Ling c74a01f546 Remove unnecessary JS files from home.ecr 2020-05-21 09:10:46 +00:00
Alex Ling 2aeb38a271 Remove edit button from home screen 2020-05-21 09:06:50 +00:00
Jared Turner a2c7638141 refactor on deck to continue reading and show percentages on home 2020-05-20 10:38:23 +01:00
Alex Ling c35e840694 Refactor the / route 2020-05-19 12:16:32 +00:00
Alex Ling ff6e64f12a Refactor get_on_deck_entry 2020-05-19 12:05:15 +00:00
Jared Turner 16fa27e4f6 update comments 2020-05-18 21:09:11 +01:00
Jared Turner 16734c2c59 rename root to library and add home with on deck WIP 2020-05-18 21:06:14 +01:00
Jared Turner 392b3d8339 fix load_percetage method name typo 2020-05-18 20:32:09 +01:00
36 changed files with 1005 additions and 531 deletions
+1
View File
@@ -1,3 +1,4 @@
# These are supported funding model platforms # These are supported funding model platforms
patreon: hkalexling patreon: hkalexling
ko_fi: hkalexling
+4 -4
View File
@@ -1,6 +1,3 @@
![banner](./public/img/banner-paddings.png) ![banner](./public/img/banner-paddings.png)
# Mango # Mango
@@ -10,6 +7,7 @@
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
- Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar` - Supported formats: `.cbz`, `.zip`, `.cbr` and `.rar`
- Supports nested folders in library - Supports nested folders in library
@@ -18,6 +16,8 @@ Mango is a self-hosted manga server and reader. Its features include
- 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
@@ -50,7 +50,7 @@ The official docker images are available on [Dockerhub](https://hub.docker.com/r
### CLI ### CLI
``` ```
Mango - Manga Server and Web Reader. Version 0.5.0 Mango - Manga Server and Web Reader. Version 0.6.1
Usage: Usage:
+20 -13
View File
@@ -8,17 +8,20 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
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');
@@ -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,7 +69,9 @@ 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}`;
@@ -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);
+4
View File
@@ -28,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
+3 -1
View File
@@ -1,5 +1,5 @@
name: mango name: mango
version: 0.5.0 version: 0.6.1
authors: authors:
- Alex Ling <hkalexling@gmail.com> - Alex Ling <hkalexling@gmail.com>
@@ -15,6 +15,8 @@ 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:
+5
View File
@@ -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
+5
View File
@@ -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
+72 -7
View File
@@ -3,25 +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
cookie = env.request.cookies.find do |c| env.response.print AUTH_MESSAGE
c.name == "token-#{Config.current.port}" call_next env
end end
if cookie.nil? || !@storage.verify_token cookie.value
def validate_token(env)
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
+1 -3
View File
@@ -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?
+160 -6
View File
@@ -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
@@ -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,7 +63,7 @@ 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
@@ -87,6 +89,20 @@ class Entry
end end
img img
end 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
class Title class Title
@@ -233,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
@@ -289,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
@@ -304,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|
@@ -321,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
@@ -337,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 = ""
@@ -440,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
+4 -2
View File
@@ -4,7 +4,7 @@ require "./mangadex/*"
require "option_parser" require "option_parser"
require "clim" require "clim"
MANGO_VERSION = "0.5.0" MANGO_VERSION = "0.6.1"
macro common_option macro common_option
option "-c PATH", "--config=PATH", type: String, option "-c PATH", "--config=PATH", type: String,
@@ -29,6 +29,8 @@ class CLI < Clim
Config.load(opts.config).set_current Config.load(opts.config).set_current
MangaDex::Downloader.default MangaDex::Downloader.default
# empty ARGV so it won't be passed to Kemal
ARGV.clear
server = Server.new server = Server.new
server.start server.start
end end
@@ -75,7 +77,7 @@ class CLI < Clim
password.not_nil!, opts.admin password.not_nil!, opts.admin
when "list" when "list"
users = storage.list_users users = storage.list_users
name_length = users.map(&.[0].size).max name_length = users.map(&.[0].size).max? || 0
l_cell_width = ["username".size, name_length].max l_cell_width = ["username".size, name_length].max
r_cell_width = "admin access".size r_cell_width = "admin access".size
header = " #{"username".ljust l_cell_width} | admin access " header = " #{"username".ljust l_cell_width} | admin access "
+7 -1
View File
@@ -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
+29 -12
View File
@@ -9,10 +9,7 @@ class MainRouter < Router
get "/logout" do |env| get "/logout" do |env|
begin begin
cookie = env.request.cookies.find do |c| env.session.delete_string "token"
c.name == "token-#{Config.current.port}"
end.not_nil!
@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
@@ -26,22 +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-#{Config.current.port}", token env.session.string "token", token
cookie.path = Config.current.base_url
cookie.expires = Time.local.shift years: 1 callback = env.session.string? "callback"
env.response.cookies << cookie if callback
env.session.delete_string "callback"
redirect env, callback
else
redirect env, "/" 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
@@ -53,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
@@ -66,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
+32
View File
@@ -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
+9
View File
@@ -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
+11 -4
View File
@@ -47,12 +47,22 @@ 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)"
if init_user init_admin if init_user
end
end
end
macro init_admin
random_pw = random_str random_pw = random_str
hash = hash_password random_pw hash = hash_password random_pw
db.exec "insert into users values (?, ?, ?, ?)", db.exec "insert into users values (?, ?, ?, ?)",
@@ -60,9 +70,6 @@ class Storage
Logger.log "Initial user created. You can log in with " \ Logger.log "Initial user created. You can log in with " \
"#{{"username" => "admin", "password" => random_pw}}" "#{{"username" => "admin", "password" => random_pw}}"
end end
end
end
end
def verify_user(username, password) def verify_user(username, password)
DB.open "sqlite3://#{@path}" do |db| DB.open "sqlite3://#{@path}" do |db|
+38 -9
View File
@@ -2,17 +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 do |c|
c.name == "token-#{Config.current.port}"
end
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
@@ -28,10 +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 do |c| token = env.session.string "token"
c.name == "token-#{Config.current.port}" (@context.storage.verify_token token).not_nil!
end.not_nil!
(@context.storage.verify_token cookie.value).not_nil!
end end
def send_json(env, json) def send_json(env, json)
@@ -39,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
@@ -125,3 +132,25 @@ def validate_password(password)
raise "password should contain ASCII characters only" raise "password should contain ASCII characters only"
end end
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
+49
View File
@@ -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(&quot;<%= item.encoded_path %>&quot;, '<%= item.pages %>', <%= (progress.not_nil! * 100).round(1) %>, &quot;<%= item.book.encoded_display_name %>&quot;, &quot;<%= item.encoded_display_name %>&quot;, '<%= 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("\"", "&quot;") %>"><%= 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>
+34
View File
@@ -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" %>
&nbsp;
<% 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>
+14
View File
@@ -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>
+8
View File
@@ -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>
+69
View File
@@ -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 %>
-49
View File
@@ -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("\"", "&quot;") %>"><%= 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 %>
+5 -13
View File
@@ -1,18 +1,7 @@
<!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="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> <body>
<div class="uk-offcanvas-content"> <div class="uk-offcanvas-content">
@@ -21,6 +10,7 @@
<div class="uk-offcanvas-bar uk-flex uk-flex-column"> <div class="uk-offcanvas-bar uk-flex uk-flex-column">
<ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical"> <ul class="uk-nav uk-nav-primary uk-nav-center uk-margin-auto-vertical">
<li><a href="<%= base_url %>">Home</a></li> <li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %> <% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li> <li><a href="<%= base_url %>admin">Admin</a></li>
<li><a href="<%= base_url %>download">Download</a></li> <li><a href="<%= base_url %>download">Download</a></li>
@@ -42,6 +32,7 @@
<a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a> <a class="uk-navbar-item uk-logo" href="<%= base_url %>"><img src="<%= base_url %>img/icon.png"></a>
<ul class="uk-navbar-nav"> <ul class="uk-navbar-nav">
<li><a href="<%= base_url %>">Home</a></li> <li><a href="<%= base_url %>">Home</a></li>
<li><a href="<%= base_url %>library">Library</a></li>
<% if is_admin %> <% if is_admin %>
<li><a href="<%= base_url %>admin">Admin</a></li> <li><a href="<%= base_url %>admin">Admin</a></li>
<li><a href="<%= base_url %>download">Download</a></li> <li><a href="<%= base_url %>download">Download</a></li>
@@ -73,4 +64,5 @@
<%= yield_content "script" %> <%= yield_content "script" %>
</body> </body>
</html> </html>
+31
View File
@@ -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 %>
+4 -11
View File
@@ -1,16 +1,8 @@
<!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">
@@ -40,4 +32,5 @@
<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.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-icons.min.js"></script>
</body> </body>
</html> </html>
+22
View File
@@ -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>
+38
View File
@@ -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>
+3 -9
View File
@@ -1,14 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html class="reader-bg"> <html class="reader-bg">
<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="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> <body>
<script src="<%= base_url %>js/theme.js"></script> <script src="<%= base_url %>js/theme.js"></script>
@@ -65,4 +58,5 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/ScrollMagic/2.0.7/ScrollMagic.min.js"></script>
<script src="<%= base_url %>js/reader.js"></script> <script src="<%= base_url %>js/reader.js"></script>
</body> </body>
</html> </html>
+18 -74
View File
@@ -7,7 +7,7 @@
</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 -%>
@@ -22,90 +22,34 @@
</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> </div>
<div id="item-container" class="uk-child-width-1-4@m uk-child-width-1-2" uk-grid> <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| -%> <% title.titles.each_with_index do |item, i| %>
<div class="item" data-mtime="<%= t.mtime.to_unix %>" data-progress="0.0"> <% progress = nil %>
<a class="acard" href="<%= base_url %>book/<%= t.id %>"> <%= render_component "card" %>
<div class="uk-card uk-card-default"> <% end %>
<div class="uk-card-media-top"> <% title.entries.each_with_index do |item, i| %>
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img> <% progress = percentage[i] %>
</div> <%= render_component "card" %>
<div class="uk-card-body"> <% end %>
<h3 class="uk-card-title break-word" data-title="<%= t.display_name.gsub("\"", "&quot;") %>"><%= 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(&quot;<%= e.encoded_path %>&quot;, '<%= e.pages %>', <%= (percentage[i] * 100).round(1) %>, &quot;<%= title.encoded_display_name %>&quot;, &quot;<%= e.encoded_display_name %>&quot;, '<%= 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("\"", "&quot;") %>"><%= e.display_name %></h3>
<p><%= e.pages %> pages</p>
</div>
</div>
</a>
</div>
<%- end -%>
</div> </div>
<div id="modal" class="uk-flex-top" uk-modal> <%= render_component "entry-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>
&nbsp;
<% 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 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">
+2 -10
View File
@@ -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 -%>