Merge branch 'cover' into dev

This commit is contained in:
Alex Ling 2020-04-19 14:39:19 +00:00
commit b449d906ec
16 changed files with 400 additions and 117 deletions

View File

@ -56,6 +56,18 @@
td > .uk-dropdown {
white-space: pre-line;
}
.title-rename > .uk-inline {
#edit-modal .uk-grid > div {
height: 300px;
}
#edit-modal #cover {
height: 100%;
width: 100%;
object-fit: cover;
}
#edit-modal #cover-upload {
height: 100%;
box-sizing: border-box;
}
#edit-modal .uk-modal-body .uk-inline {
width: 100%;
}

View File

@ -32,9 +32,12 @@ function showModal(encodedPath, pages, percentage, encodedeTitle, encodedEntryTi
updateProgress(titleID, entryID, 0);
});
$('.uk-modal-title.break-word > a').attr('onclick', `edit("${entryID}")`);
UIkit.modal($('#modal')).show();
styleModal();
}
function updateProgress(titleID, entryID, page) {
$.post('/api/progress/' + titleID + '/' + entryID + '/' + page, function(data) {
if (data.success) {
@ -47,33 +50,19 @@ function updateProgress(titleID, entryID, page) {
});
}
const rename = ele => {
const h2 = $(ele).parent();
$(h2).attr('hidden', true);
$(h2).next().removeAttr('hidden');
};
const renameSubmit = ele => {
const group = $(ele).closest('.title-rename');
const id = $(group).attr('data-id');
const eid = $(group).attr('data-entry-id');
const name = $(ele).next().val();
const renameSubmit = (name, eid) => {
const upload = $('.upload-field');
const titleId = upload.attr('data-title-id');
console.log(name);
$(group).attr('hidden', true);
$(group).prev().removeAttr('hidden');
if (name.length === 0) {
alert('danger', 'The display name should not be empty');
return;
}
$(group).prev().find('span').text(name);
const query = $.param({ entry: eid });
let url = `/api/admin/display_name/${id}/${name}`;
let url = `/api/admin/display_name/${titleId}/${name}`;
if (eid)
url += `?${query}`;
@ -84,7 +73,6 @@ const renameSubmit = ele => {
dataType: 'json'
})
.done(data => {
console.log(data);
if (data.error) {
alert('danger', `Failed to update display name. Error: ${data.error}`);
return;
@ -96,10 +84,68 @@ const renameSubmit = ele => {
});
};
$(() => {
$('.uk-input.title-rename-field').keyup(event => {
const edit = (eid) => {
const cover = $('#edit-modal #cover');
let url = cover.attr('data-title-cover');
let displayName = $('h2.uk-title > span').text();
if (eid) {
const item = $(`#${eid}`);
url = item.find('img').attr('data-src');
displayName = item.find('.uk-card-title').attr('data-title');
}
cover.attr('data-src', url);
const displayNameField = $('#display-name-field');
displayNameField.attr('value', displayName);
displayNameField.keyup(event => {
if (event.keyCode === 13) {
renameSubmit($(event.currentTarget).prev());
renameSubmit(displayNameField.val(), eid);
}
});
});
displayNameField.siblings('a.uk-form-icon').click(() => {
renameSubmit(displayNameField.val(), eid);
});
setupUpload(eid);
UIkit.modal($('#edit-modal')).show();
styleModal();
};
const setupUpload = (eid) => {
const upload = $('.upload-field');
const bar = $('#upload-progress').get(0);
const titleId = upload.attr('data-title-id');
const queryObj = {title: titleId};
if (eid)
queryObj['entry'] = eid;
const query = $.param(queryObj);
const url = `/api/admin/upload/cover?${query}`;
console.log(url);
UIkit.upload('.upload-field', {
url: url,
name: 'file',
error: (e) => {
alert('danger', `Failed to upload cover image: ${e.toString()}`);
},
loadStart: (e) => {
$(bar).removeAttr('hidden');
bar.max = e.total;
bar.value = e.loaded;
},
progress: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
loadEnd: (e) => {
bar.max = e.total;
bar.value = e.loaded;
},
completeAll: () => {
$(bar).attr('hidden', '');
location.reload();
}
});
};

View File

@ -4,11 +4,14 @@ class Config
include YAML::Serializable
property port : Int32 = 9000
property library_path : String = File.expand_path "~/mango/library", home: true
property library_path : String = File.expand_path "~/mango/library",
home: true
property db_path : String = File.expand_path "~/mango/mango.db", home: true
@[YAML::Field(key: "scan_interval_minutes")]
property scan_interval : Int32 = 5
property log_level : String = "info"
property upload_path : String = File.expand_path "~/mango/uploads",
home: true
property mangadex = Hash(String, String | Int32).new
@[YAML::Field(ignore: true)]

View File

@ -1,6 +1,6 @@
require "kemal"
require "./storage"
require "./util"
require "../storage"
require "../util"
class AuthHandler < Kemal::Handler
def initialize(@storage : Storage)

View File

@ -1,5 +1,5 @@
require "kemal"
require "./logger"
require "../logger"
class LogHandler < Kemal::BaseLogHandler
def initialize(@logger : Logger)

View File

@ -1,16 +1,16 @@
require "baked_file_system"
require "kemal"
require "./util"
require "../util"
class FS
extend BakedFileSystem
{% if flag?(:release) %}
{% if read_file? "#{__DIR__}/../dist/favicon.ico" %}
{% puts "baking ../dist" %}
bake_folder "../dist"
{% if read_file? "#{__DIR__}/../../dist/favicon.ico" %}
{% puts "baking ../../dist" %}
bake_folder "../../dist"
{% else %}
{% puts "baking ../public" %}
bake_folder "../public"
{% puts "baking ../../public" %}
bake_folder "../../public"
{% end %}
{% end %}
end

View File

@ -0,0 +1,24 @@
require "kemal"
require "../util"
class UploadHandler < Kemal::Handler
def initialize(@upload_dir : String)
end
def call(env)
unless request_path_startswith(env, [UPLOAD_URL_PREFIX]) &&
env.request.method == "GET"
return call_next env
end
ary = env.request.path.split(File::SEPARATOR).select { |part| !part.empty? }
ary[0] = @upload_dir
path = File.join ary
if File.exists? path
send_file env, path
else
env.response.status_code = 404
end
end
end

View File

@ -16,9 +16,8 @@ end
class Entry
property zip_path : String, book : Title, title : String,
size : String, pages : Int32, cover_url : String, id : String,
title_id : String, encoded_path : String, encoded_title : String,
mtime : Time
size : String, pages : Int32, id : String, title_id : String,
encoded_path : String, encoded_title : String, mtime : Time
def initialize(path, @book, @title_id, storage)
@zip_path = path
@ -33,17 +32,17 @@ class Entry
end
file.close
@id = storage.get_id @zip_path, false
@cover_url = "/api/page/#{@title_id}/#{@id}/1"
@mtime = File.info(@zip_path).modification_time
end
def to_json(json : JSON::Builder)
json.object do
{% for str in ["zip_path", "title", "size", "cover_url", "id",
"title_id", "encoded_path", "encoded_title"] %}
{% for str in ["zip_path", "title", "size", "id", "title_id",
"encoded_path", "encoded_title"] %}
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", @book.display_name @title
json.field "cover_url", cover_url
json.field "pages" { json.number @pages }
json.field "mtime" { json.number @mtime.to_unix }
end
@ -57,6 +56,17 @@ class Entry
URI.encode display_name
end
def cover_url
url = "/api/page/#{@title_id}/#{@id}/1"
TitleInfo.new @book.dir do |info|
info_url = info.entry_cover_url[@title]?
unless info_url.nil? || info_url.empty?
url = info_url
end
end
url
end
def read_page(page_num)
Zip::File.open @zip_path do |file|
page = file.entries
@ -138,6 +148,7 @@ class Title
json.field {{str}}, @{{str.id}}
{% end %}
json.field "display_name", display_name
json.field "cover_url", cover_url
json.field "mtime" { json.number @mtime.to_unix }
json.field "titles" do
json.raw self.titles.to_json
@ -182,9 +193,12 @@ class Title
end
def display_name
info = TitleInfo.new @dir
dn = info.display_name
dn.empty? ? @title : dn
dn = @title
TitleInfo.new @dir do |info|
info_dn = info.display_name
dn = info_dn unless info_dn.empty?
end
dn
end
def encoded_display_name
@ -192,48 +206,80 @@ class Title
end
def display_name(entry_name)
info = TitleInfo.new @dir
dn = info.entry_display_name[entry_name]?
unless dn.nil? || dn.empty?
return dn
dn = entry_name
TitleInfo.new @dir do |info|
info_dn = info.entry_display_name[entry_name]?
unless info_dn.nil? || info_dn.empty?
dn = info_dn
end
end
entry_name
dn
end
def set_display_name(dn)
info = TitleInfo.new @dir
info.display_name = dn
info.save
TitleInfo.new @dir do |info|
info.display_name = dn
info.save
end
end
def set_display_name(entry_name : String, dn)
info = TitleInfo.new @dir
info.entry_display_name[entry_name] = dn
info.save
TitleInfo.new @dir do |info|
info.entry_display_name[entry_name] = dn
info.save
end
end
def cover_url
url = "img/icon.png"
if @entries.size > 0
url = @entries[0].cover_url
end
TitleInfo.new @dir do |info|
info_url = info.cover_url
unless info_url.nil? || info_url.empty?
url = info_url
end
end
url
end
def set_cover_url(url : String)
TitleInfo.new @dir do |info|
info.cover_url = url
info.save
end
end
def set_cover_url(entry_name : String, url : String)
TitleInfo.new @dir do |info|
info.entry_cover_url[entry_name] = url
info.save
end
end
# For backward backward compatibility with v0.1.0, we save entry titles
# instead of IDs in info.json
def save_progress(username, entry, page)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
info.progress[username] = {entry => page}
TitleInfo.new @dir do |info|
if info.progress[username]?.nil?
info.progress[username] = {entry => page}
else
info.progress[username][entry] = page
end
info.save
return
end
info.progress[username][entry] = page
info.save
end
def load_progress(username, entry)
info = TitleInfo.new @dir
if info.progress[username]?.nil?
return 0
progress = 0
TitleInfo.new @dir do |info|
unless info.progress[username]?.nil? ||
info.progress[username][entry]?.nil?
progress = info.progress[username][entry]
end
end
if info.progress[username][entry]?.nil?
return 0
end
info.progress[username][entry]
progress
end
def load_percetage(username, entry)
@ -264,22 +310,32 @@ class TitleInfo
include JSON::Serializable
property comment = "Generated by Mango. DO NOT EDIT!"
# { user1: { entry1: 10, entry2: 0 } }
property progress = {} of String => Hash(String, Int32)
property display_name = ""
# { entry1 : "display name" }
property entry_display_name = {} of String => String
property cover_url = ""
property entry_cover_url = {} of String => String
@[JSON::Field(ignore: true)]
property dir : String = ""
def initialize(@dir)
json_path = File.join @dir, "info.json"
if File.exists? json_path
info = TitleInfo.from_json File.read json_path
@progress = info.progress.clone
@display_name = info.display_name
@entry_display_name = info.entry_display_name.clone
@@mutex_hash = {} of String => Mutex
def self.new(dir, &)
if @@mutex_hash[dir]?
mutex = @@mutex_hash[dir]
else
mutex = Mutex.new
@@mutex_hash[dir] = mutex
end
mutex.synchronize do
instance = TitleInfo.allocate
json_path = File.join dir, "info.json"
if File.exists? json_path
instance = TitleInfo.from_json File.read json_path
end
instance.dir = dir
yield instance
end
end

View File

@ -1,5 +1,6 @@
require "./router"
require "../mangadex/*"
require "../upload"
class APIRouter < Router
def setup
@ -197,5 +198,59 @@ class APIRouter < Router
}.to_json
end
end
post "/api/admin/upload/:target" do |env|
begin
target = env.params.url["target"]
HTTP::FormData.parse env.request do |part|
next if part.name != "file"
filename = part.filename
if filename.nil?
raise "No file uploaded"
end
case target
when "cover"
title_id = env.params.query["title"]
entry_id = env.params.query["entry"]?
title = @context.library.get_title(title_id).not_nil!
unless ["image/jpeg", "image/png"].includes? \
MIME.from_filename? filename
raise "The uploaded image must be either JPEG or PNG"
end
ext = File.extname filename
upload = Upload.new @context.config.upload_path, @context.logger
url = upload.path_to_url upload.save "img", ext, part.body
if url.nil?
raise "Failed to generate a public URL for the uploaded file"
end
if entry_id.nil?
title.set_cover_url url
else
entry_name = title.get_entry(entry_id).not_nil!.title
title.set_cover_url entry_name, url
end
else
raise "Unkown upload target #{target}"
end
send_json env, {"success" => true}.to_json
env.response.close
end
raise "No part with name `file` found"
rescue e
send_json env, {
"success" => false,
"error" => e.message,
}.to_json
end
end
end
end

View File

@ -33,10 +33,15 @@ class MainRouter < Router
end
get "/" do |env|
titles = @context.library.titles
username = get_username env
percentage = titles.map &.load_percetage username
layout "index"
begin
titles = @context.library.titles
username = get_username env
percentage = titles.map &.load_percetage username
layout "index"
rescue e
@context.error e
env.response.status_code = 500
end
end
get "/book/:title" do |env|

View File

@ -1,8 +1,6 @@
require "kemal"
require "./context"
require "./auth_handler"
require "./static_handler"
require "./log_handler"
require "./handlers/*"
require "./util"
require "./routes/*"
@ -29,6 +27,7 @@ class Server
Kemal.config.logging = false
add_handler LogHandler.new @context.logger
add_handler AuthHandler.new @context.storage
add_handler UploadHandler.new @context.config.upload_path
{% if flag?(:release) %}
# when building for relase, embed the static files in binary
@context.debug "We are in release mode. Using embedded static files."

View File

@ -2,6 +2,7 @@ require "sqlite3"
require "crypto/bcrypt"
require "uuid"
require "base64"
require "./util"
def hash_password(pw)
Crypto::Bcrypt::Password.create(pw).to_s
@ -11,10 +12,6 @@ def verify_password(hash, pw)
(Crypto::Bcrypt::Password.new hash).verify pw
end
def random_str
UUID.random.to_s.gsub "-", ""
end
class Storage
def initialize(@path : String, @logger : Logger)
dir = File.dirname path

60
src/upload.cr Normal file
View File

@ -0,0 +1,60 @@
require "./util"
class Upload
def initialize(@dir : String, @logger : Logger)
unless Dir.exists? @dir
@logger.info "The uploads directory #{@dir} does not exist. " \
"Attempting to create it"
Dir.mkdir_p @dir
end
end
# Writes IO to a file with random filename in the uploads directory and
# returns the full path of created file
# e.g., save("image", ".png", <io>)
# ==> "~/mango/uploads/image/<random string>.png"
def save(sub_dir : String, ext : String, io : IO)
full_dir = File.join @dir, sub_dir
filename = random_str + ext
file_path = File.join full_dir, filename
unless Dir.exists? full_dir
@logger.debug "creating directory #{full_dir}"
Dir.mkdir_p full_dir
end
File.open file_path, "w" do |f|
IO.copy io, f
end
file_path
end
# Converts path to a file in the uploads directory to the URL path for
# accessing the file.
def path_to_url(path : String)
dir_mathed = false
ary = [] of String
# We fill it with parts until it equals to @upload_dir
dir_ary = [] of String
Path.new(path).each_part do |part|
if dir_mathed
ary << part
else
dir_ary << part
if File.same? @dir, File.join dir_ary
dir_mathed = true
end
end
end
if ary.empty?
@logger.warn "File #{path} is not in the upload directory #{@dir}"
return
end
ary.unshift UPLOAD_URL_PREFIX
File.join(ary).to_s
end
end

View File

@ -1,6 +1,7 @@
require "big"
IMGS_PER_PAGE = 5
IMGS_PER_PAGE = 5
UPLOAD_URL_PREFIX = "/uploads"
macro layout(name)
begin
@ -12,7 +13,8 @@ macro layout(name)
render "src/views/#{{{name}}}.ecr", "src/views/layout.ecr"
rescue e
message = e.to_s
render "message"
@context.error message
render "src/views/message.ecr", "src/views/layout.ecr"
end
end
@ -27,9 +29,9 @@ macro get_username(env)
(@context.storage.verify_token cookie.value).not_nil!
end
macro send_json(env, json)
{{env}}.response.content_type = "application/json"
{{json}}
def send_json(env, json)
env.response.content_type = "application/json"
env.response.print json
end
def hash_to_query(hash)
@ -93,3 +95,7 @@ def validate_zip(path : String) : Exception?
rescue e
e
end
def random_str
UUID.random.to_s.gsub "-", ""
end

View File

@ -26,11 +26,7 @@
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<%- if t.entries.size > 0 -%>
<img data-src="<%= t.entries[0].cover_url %>" data-width data-height alt="" uk-img>
<%- else -%>
<img data-src="/img/icon.png" data-width data-height alt="" uk-img>
<%- end -%>
<img data-src="<%= t.cover_url %>" data-width data-height alt="" uk-img>
</div>
<div class="uk-card-body">
<%- if t.entries.size > 0 -%>

View File

@ -2,15 +2,9 @@
<h2 class=uk-title><span><%= title.display_name %></span>
&nbsp;
<% if is_admin %>
<a onclick="rename(this)" class="uk-icon-button" uk-icon="icon:pencil"></a>
<a onclick="edit()" class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h2>
<div class="uk-margin title-rename" data-id="<%= title.id %>" hidden>
<div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" onclick="renameSubmit(this)" uk-icon="icon:check"></a>
<input class="uk-input title-rename-field" type="text" value="<%= title.display_name.gsub("\"", "&quot;") %>">
</div>
</div>
</div>
<ul class="uk-breadcrumb">
<li><a href="/">Library</a></li>
@ -48,11 +42,7 @@
<a class="acard" href="/book/<%= t.id %>">
<div class="uk-card uk-card-default">
<div class="uk-card-media-top">
<%- if t.entries.size > 0 -%>
<img data-src="<%= t.entries[0].cover_url %>" data-width data-height alt="" uk-img>
<%- else -%>
<img data-src="/img/icon.png" data-width data-height alt="" uk-img>
<%- end -%>
<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("\"", "&quot;") %>"><%= t.display_name %></h3>
@ -63,7 +53,7 @@
</div>
<%- end -%>
<%- title.entries.each_with_index do |e, i| -%>
<div class="item" data-mtime="<%= e.mtime.to_unix %>" data-progress="<%= percentage[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">
@ -88,15 +78,9 @@
<h3 class="uk-modal-title break-word" id="modal-title"><span></span>
&nbsp;
<% if is_admin %>
<a onclick="rename(this)" class="uk-icon-button" uk-icon="icon:pencil"></a>
<a class="uk-icon-button" uk-icon="icon:pencil"></a>
<% end %>
</h3>
<div class="uk-margin title-rename" data-id="" data-entry-id="" hidden>
<div class="uk-inline">
<a class="uk-form-icon uk-form-icon-flip" onclick="renameSubmit(this)" uk-icon="icon:check"></a>
<input class="uk-input title-rename-field" type="text" value="">
</div>
</div>
</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>
@ -116,6 +100,46 @@
</div>
</div>
<div id="edit-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">Edit</h3>
</div>
</div>
<div class="uk-modal-body">
<div class="uk-margin">
<label class="uk-form-label" for="display-name">Display Name</label>
<div class="uk-inline">
<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">
</div>
</div>
<label class="uk-form-label">Cover Image</label>
<div class="uk-grid">
<div class="uk-width-1-2@s">
<img id="cover" data-title-cover="<%= title.cover_url %>" alt="" data-width data-height uk-img>
</div>
<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>
<span uk-icon="icon: cloud-upload"></span>
<span class="uk-text-middle">Upload a cover image by dropping it here or</span>
<div uk-form-custom>
<input type="file" accept="image/*">
<span class="uk-link">selecting one</span>
</div>
</div>
</div>
</div>
</div>
<progress id="upload-progress" class="uk-progress" value="0" max="100" hidden></progress>
</div>
</div>
</div>
<% content_for "script" do %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jQuery.dotdotdot/4.0.11/dotdotdot.js"></script>
<script src="/js/dots.js"></script>