Repos / pytaku / b9532a1eb4
commit b9532a1eb4a8abf91f0d820b98ba79eebe0526b3
Author: Bùi Thành Nhân <hi@imnhan.com>
Date: Sun Aug 23 00:06:45 2020 +0700
title route
diff --git a/src/pytaku/decorators.py b/src/pytaku/decorators.py
index 8bcaa84..599dd36 100644
--- a/src/pytaku/decorators.py
+++ b/src/pytaku/decorators.py
@@ -15,27 +15,39 @@ def decorated_function(*args, **kwargs):
return decorated_function
-def require_token(f):
- @wraps(f)
- def decorated_function(*args, **kwargs):
- header = request.headers.get("Authorization")
- if not header or not header.startswith("Bearer "):
- return (
- jsonify({"message": "Missing `Authorization: Bearer <token>` header."}),
- 401,
- )
-
- token = header[len("Bearer ") :]
- user_id = verify_token(token)
- if user_id is None:
- return jsonify({"message": "Invalid token."}), 401
-
- request.token = token
- request.user_id = user_id
-
- return f(*args, **kwargs)
-
- return decorated_function
+def process_token(required=True):
+ def decorator(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ header = request.headers.get("Authorization")
+ if not header or not header.startswith("Bearer "):
+ if required:
+ return (
+ jsonify(
+ {
+ "message": "Missing `Authorization: Bearer <token>` header."
+ }
+ ),
+ 401,
+ )
+ else:
+ request.token = None
+ request.user_id = None
+ return f(*args, **kwargs)
+
+ token = header[len("Bearer ") :]
+ user_id = verify_token(token)
+ if user_id is None:
+ return jsonify({"message": "Invalid token."}), 401
+
+ request.token = token
+ request.user_id = user_id
+
+ return f(*args, **kwargs)
+
+ return decorated_function
+
+ return decorator
def toggle_has_read(f):
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 29b5d01..5ebcdfe 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -19,7 +19,7 @@
)
from .conf import config
-from .decorators import require_login, require_token, toggle_has_read
+from .decorators import process_token, require_login, toggle_has_read
from .persistence import (
create_token,
delete_token,
@@ -170,28 +170,6 @@ def auth_view():
return render_template("old/auth.html")
-@app.route("/m/<site>/<title_id>")
-@toggle_has_read
-def title_view(site, title_id):
- user = session.get("user", None)
- user_id = user["id"] if user else None
- title = load_title(site, title_id, user_id=user_id)
- if not title:
- print("Getting title", title_id)
- title = get_title(site, title_id)
- print("Saving title", title_id, "to db")
- save_title(title)
- else:
- print("Loading title", title_id, "from db")
- title["cover"] = title_cover(site, title_id, title["cover_ext"])
- if site == "mangadex":
- title["cover"] = url_for(
- "proxy_view", b64_url=_encode_proxy_url(title["cover"])
- )
- title["source_url"] = title_source_url(site, title_id)
- return render_template("old/title.html", **title)
-
-
@app.route("/m/<site>/<title_id>/<chapter_id>")
@toggle_has_read
def chapter_view(site, title_id, chapter_id):
@@ -372,6 +350,44 @@ def home_view(query=None):
return render_template("spa.html")
+def _title(site, title_id, user_id=None):
+ title = load_title(site, title_id, user_id=user_id)
+ if not title:
+ print("Getting title", title_id)
+ title = get_title(site, title_id)
+ print("Saving title", title_id, "to db")
+ save_title(title)
+ else:
+ print("Loading title", title_id, "from db")
+ title["cover"] = title_cover(site, title_id, title["cover_ext"])
+ if site == "mangadex":
+ title["cover"] = url_for(
+ "proxy_view", b64_url=_encode_proxy_url(title["cover"])
+ )
+ title["source_url"] = title_source_url(site, title_id)
+ return title
+
+
+@app.route("/m/<site>/<title_id>")
+def spa_title_view(site, title_id):
+ title = _title(site, title_id)
+ return render_template(
+ "spa.html",
+ open_graph={
+ "title": title["name"],
+ "image": title["cover"],
+ "description": "\n".join(title["descriptions"]),
+ },
+ )
+
+
+@app.route("/api/title/<site>/<title_id>", methods=["GET"])
+@process_token(required=False)
+def api_title(site, title_id):
+ title = _title(site, title_id, user_id=request.user_id)
+ return title
+
+
@app.route("/api/register", methods=["POST"])
def api_register():
username = request.json["username"].strip()
@@ -420,13 +436,13 @@ def api_login():
@app.route("/api/verify-token", methods=["GET"])
-@require_token
+@process_token(required=True)
def api_verify_token():
return {"user_id": request.user_id, "username": get_username(request.user_id)}, 200
@app.route("/api/logout", methods=["POST"])
-@require_token
+@process_token(required=True)
def api_logout():
num_deleted = delete_token(request.token)
if num_deleted != 1:
@@ -435,7 +451,7 @@ def api_logout():
@app.route("/api/follows", methods=["GET"])
-@require_token
+@process_token(required=True)
def api_follows():
titles = get_followed_titles(request.user_id)
for title in titles:
diff --git a/src/pytaku/static/js/layout.js b/src/pytaku/static/js/layout.js
index 7698c14..cbd56db 100644
--- a/src/pytaku/static/js/layout.js
+++ b/src/pytaku/static/js/layout.js
@@ -1,4 +1,5 @@
import { Auth, SearchModel } from "./models.js";
+import { Button } from "./utils.js";
function Navbar(initialVNode) {
let isLoggingOut = false;
@@ -8,30 +9,24 @@ function Navbar(initialVNode) {
let userLink;
if (Auth.isLoggedIn()) {
userLink = m("span.nav--greeting", [
- "Welcome, ",
- m("b", Auth.username),
- " ",
- m(
- "button",
- {
- onclick: (ev) => {
- isLoggingOut = true;
- m.redraw();
- Auth.logout()
- .then(() => {
- m.route.set("/");
- })
- .finally(() => {
- isLoggingOut = false;
- });
- },
- disabled: isLoggingOut ? "disabled" : null,
+ m("span", ["Welcome, ", m("b", Auth.username)]),
+ m(Button, {
+ text: isLoggingOut ? " logging out" : " logout",
+ icon: "log-out",
+ color: "red",
+ onclick: (ev) => {
+ isLoggingOut = true;
+ m.redraw();
+ Auth.logout()
+ .then(() => {
+ m.route.set("/");
+ })
+ .finally(() => {
+ isLoggingOut = false;
+ });
},
- [
- m("i.icon.icon-log-out"),
- isLoggingOut ? " logging out" : " logout",
- ]
- ),
+ disabled: isLoggingOut ? "disabled" : null,
+ }),
]);
} else {
userLink = m(m.route.Link, { class: "nav--link", href: "/a" }, [
@@ -66,7 +61,7 @@ function Navbar(initialVNode) {
},
value: SearchModel.query,
}),
- m("button[type=submit]", [m("i.icon.icon-search")]),
+ m(Button, { color: "red", icon: "search", type: "submit" }),
]
),
userLink,
diff --git a/src/pytaku/static/js/main.js b/src/pytaku/static/js/main.js
index 7863e87..68e84ef 100644
--- a/src/pytaku/static/js/main.js
+++ b/src/pytaku/static/js/main.js
@@ -4,6 +4,7 @@ import Authentication from "./routes/authentication.js";
import Home from "./routes/home.js";
import Follows from "./routes/follows.js";
import Search from "./routes/search.js";
+import Title from "./routes/title.js";
Auth.init().then(() => {
const root = document.getElementById("spa-root");
@@ -52,5 +53,15 @@ Auth.init().then(() => {
})
),
},
+ "/m/:site/:titleId": {
+ render: (vnode) =>
+ m(
+ Layout,
+ m(Title, {
+ site: vnode.attrs.site,
+ titleId: vnode.attrs.titleId,
+ })
+ ),
+ },
});
});
diff --git a/src/pytaku/static/js/routes/authentication.js b/src/pytaku/static/js/routes/authentication.js
index bb37d16..30cc850 100644
--- a/src/pytaku/static/js/routes/authentication.js
+++ b/src/pytaku/static/js/routes/authentication.js
@@ -1,4 +1,5 @@
import { Auth } from "../models.js";
+import { Button } from "../utils.js";
function Authentication(initialVNode) {
let loginUsername;
@@ -84,16 +85,13 @@ function Authentication(initialVNode) {
}),
" Remember me",
]),
- m(
- "button[type=submit]",
- {
- disabled: loggingIn ? "disabled" : null,
- },
- [
- m("i.icon.icon-log-in"),
- loggingIn ? " Logging in..." : " Log in",
- ]
- ),
+ m(Button, {
+ type: "submit",
+ disabled: loggingIn ? "disabled" : null,
+ text: loggingIn ? " Logging in..." : " Log in",
+ icon: "log-in",
+ color: "blue",
+ }),
m("p.auth--form--error-message", loginErrorMessage),
]
),
@@ -162,16 +160,13 @@ function Authentication(initialVNode) {
},
}
),
- m(
- "button[type=submit]",
- {
- disabled: registering ? "disabled" : null,
- },
- [
- m("i.icon.icon-user-plus"),
- registering ? " Registering..." : " Register",
- ]
- ),
+ m(Button, {
+ type: "submit",
+ disabled: registering ? "disabled" : null,
+ text: registering ? " Registering..." : " Register",
+ icon: "user-plus",
+ color: "green",
+ }),
m(
"p",
{
diff --git a/src/pytaku/static/js/routes/follows.js b/src/pytaku/static/js/routes/follows.js
index 7097eec..04e2ad4 100644
--- a/src/pytaku/static/js/routes/follows.js
+++ b/src/pytaku/static/js/routes/follows.js
@@ -1,24 +1,10 @@
import { Auth } from "../models.js";
-import { LoadingMessage, truncate } from "../utils.js";
-
-function fullChapterName(chapter) {
- let result = "Chapter " + chapter.num_major;
- if (chapter.num_minor) {
- result += "." + chapter.num_minor;
- }
- if (chapter.volume) {
- result += " Volume " + chapter.volume;
- }
- if (chapter.name) {
- result += " - " + chapter.name;
- }
- return result;
-}
+import { LoadingMessage, fullChapterName, Chapter } from "../utils.js";
const Title = {
view: (vnode) => {
const title = vnode.attrs.title;
- const numChaptersToDisplay = 4;
+ const numChaptersToDisplay = 3;
return m(
"div.follows--title" + (title.chapters.length === 0 ? ".empty" : ""),
@@ -39,21 +25,11 @@ const Title = {
`and ${title.chapters.length - numChaptersToDisplay} more...`
)
: "",
- title.chapters.slice(-numChaptersToDisplay).map((chapter) =>
- m(
- m.route.Link,
- {
- href: `/m/${title.site}/${title.id}/${chapter.id}`,
- class: "follows--chapter",
- },
- [
- fullChapterName(chapter),
- chapter.groups.map((group) => {
- m("span.follows--group", truncate(group, 20));
- }),
- ]
- )
- ),
+ title.chapters
+ .slice(-numChaptersToDisplay)
+ .map((chapter) =>
+ m(Chapter, { site: title.site, titleId: title.id, chapter })
+ ),
]),
]
);
diff --git a/src/pytaku/static/js/routes/title.js b/src/pytaku/static/js/routes/title.js
new file mode 100644
index 0000000..186ae17
--- /dev/null
+++ b/src/pytaku/static/js/routes/title.js
@@ -0,0 +1,65 @@
+import { Auth } from "../models.js";
+import { LoadingMessage, Button, fullChapterName, Chapter } from "../utils.js";
+
+function Title(initialVNode) {
+ let isLoading = false;
+ let title = {};
+
+ return {
+ oninit: (vnode) => {
+ document.title = "Manga";
+ isLoading = true;
+ m.redraw();
+
+ Auth.request({
+ method: "GET",
+ url: "/api/title/:site/:titleId",
+ params: {
+ site: vnode.attrs.site,
+ titleId: vnode.attrs.titleId,
+ },
+ })
+ .then((resp) => {
+ title = resp;
+ document.title = title.name;
+ })
+ .finally(() => {
+ isLoading = false;
+ });
+ },
+ view: (vnode) => {
+ return m(
+ "div.content",
+ isLoading
+ ? m(LoadingMessage)
+ : [
+ m("h1", title.name),
+ m("div.title--details", [
+ Auth.isLoggedIn()
+ ? m(Button, {
+ text: "Follow",
+ icon: "bookmark",
+ color: "green",
+ })
+ : null,
+ " ",
+ m(
+ "a.touch-friendly[title=Go to source site][target=_blank]",
+ { href: title.source_url },
+ [title.site, m("i.icon.icon-arrow-up-right")]
+ ),
+ ]),
+ m("img.title--cover[alt=cover]", { src: title.cover }),
+ title.descriptions.map((desc) => m("p", desc)),
+ title.chapters
+ ? title.chapters.map((chapter) =>
+ m(Chapter, { site: title.site, titleId: title.id, chapter })
+ )
+ : m("p", "This one has no chapters."),
+ ]
+ );
+ },
+ };
+}
+
+export default Title;
diff --git a/src/pytaku/static/js/utils.js b/src/pytaku/static/js/utils.js
index bf0105a..46b877d 100644
--- a/src/pytaku/static/js/utils.js
+++ b/src/pytaku/static/js/utils.js
@@ -2,7 +2,60 @@ const LoadingMessage = {
view: (vnode) => m("h2.blink", [m("i.icon.icon-loader.spin"), " loading..."]),
};
-const truncate = (input, size) =>
- input.length > size ? `${input.substring(0, size)}...` : input;
+const Button = {
+ view: (vnode) =>
+ m(
+ "button",
+ {
+ class: vnode.attrs.color || "",
+ ...vnode.attrs,
+ },
+ [
+ vnode.attrs.icon ? m(`i.icon.icon-${vnode.attrs.icon}`) : null,
+ vnode.attrs.text ? m("span", vnode.attrs.text) : null,
+ ]
+ ),
+};
+
+const Chapter = {
+ view: (vnode) =>
+ m("div.utils--chapter", [
+ m(
+ m.route.Link,
+ {
+ href: `/m/${vnode.attrs.site}/${vnode.attrs.titleId}/${vnode.attrs.chapter.id}`,
+ class: "touch-friendly",
+ },
+ [
+ m("span", fullChapterName(vnode.attrs.chapter)),
+ vnode.attrs.chapter.groups.map((group) =>
+ m("span.utils--chapter--group", truncate(group, 20))
+ ),
+ ]
+ ),
+ ,
+ ]),
+};
+
+function truncate(input, size) {
+ return input.length > size ? `${input.substring(0, size)}...` : input;
+}
+
+function fullChapterName(chapter) {
+ let result = "";
+ if (typeof chapter.num_major !== "undefined") {
+ result += (chapter.volume ? "Ch." : "Chapter ") + chapter.num_major;
+ }
+ if (chapter.num_minor) {
+ result += "." + chapter.num_minor;
+ }
+ if (chapter.volume) {
+ result += " Vol. " + chapter.volume;
+ }
+ if (chapter.name) {
+ result += " - " + chapter.name;
+ }
+ return result;
+}
-export { LoadingMessage, truncate };
+export { LoadingMessage, Button, Chapter, truncate, fullChapterName };
diff --git a/src/pytaku/static/lookandfeel.css b/src/pytaku/static/lookandfeel.css
index bc43b85..018294e 100644
--- a/src/pytaku/static/lookandfeel.css
+++ b/src/pytaku/static/lookandfeel.css
@@ -58,7 +58,8 @@ p {
line-height: 1.5rem;
}
-.button {
+button,
+a.touch-friendly {
display: inline-block;
user-select: none;
margin: 0;
@@ -66,51 +67,59 @@ .button {
align-items: center;
justify-content: center;
vertical-align: bottom;
-
cursor: pointer;
border: 0;
padding: 0.5rem 1rem 0.3rem 1rem;
border-radius: var(--border-radius);
+}
+button {
color: white;
- text-decoration: none;
+ background-color: var(--btn-gray);
+ border-bottom: 4px solid var(--btn-gray-bottom);
}
-.button > * {
+button > i {
margin-right: 0.3rem;
}
-.button > *:last-child {
+button > i:last-child {
margin-right: 0;
}
-.button:hover {
+button:hover {
filter: brightness(110%);
}
-.button:active {
+button:active {
filter: brightness(90%);
}
-.button:focus {
+button:focus {
box-shadow: 0 0 2px black;
}
-.button.red {
+button.red {
background-color: var(--btn-red);
border-bottom: 4px solid var(--btn-red-bottom);
}
-.button.green {
+button.green {
background-color: var(--btn-green);
border-bottom: 4px solid var(--btn-green-bottom);
}
-.button.blue {
+button.blue {
background-color: var(--btn-blue);
border-bottom: 4px solid var(--btn-blue-bottom);
}
-.button.gray {
- background-color: var(--btn-gray);
- border-bottom: 4px solid var(--btn-gray-bottom);
-}
-.button.disabled,
-.button.disabled:hover,
-.button.disabled:active {
+button[disabled],
+button[disabled]:hover,
+button[disabled]:active {
background-color: #aaa;
border-bottom: 4px solid #aaa;
filter: none;
cursor: default;
opacity: 0.5;
}
+a.touch-friendly {
+ background-color: white;
+ color: inherit;
+ border: 1px solid #aaa;
+ text-decoration: none;
+ border-bottom: 4px solid grey;
+}
+a.touch-friendly:hover {
+ background-color: #eee;
+}
diff --git a/src/pytaku/static/spa.css b/src/pytaku/static/spa.css
index a1db482..6fbb584 100644
--- a/src/pytaku/static/spa.css
+++ b/src/pytaku/static/spa.css
@@ -45,7 +45,6 @@ nav {
}
nav > * {
margin: var(--body-padding) 0;
- flex: 1 auto 0;
}
.nav--logo {
width: 150px;
@@ -68,6 +67,11 @@ .nav--greeting {
margin-left: auto;
margin-top: auto;
margin-bottom: auto;
+ display: flex;
+ padding: 0.5rem 0 0.5rem 0.5rem;
+}
+.nav--greeting > button {
+ margin-left: 0.5rem;
}
.nav--link {
color: white;
@@ -165,7 +169,6 @@ .auth--form--message-error {
.follows--title {
display: flex;
flex-direction: row;
- flex-wrap: wrap;
margin-bottom: var(--body-padding);
background-color: #efefef;
}
@@ -221,6 +224,22 @@ .follows--more {
font-style: italic;
}
+@media (max-width: 399px) {
+ .follows--title {
+ flex-direction: column;
+ }
+ .follows--cover {
+ border: none;
+ margin: 0.5rem;
+ margin-bottom: 0;
+ max-width: 100%;
+ max-height: 250px;
+ }
+ .follows--chapters {
+ padding: 0.5rem;
+ }
+}
+
/* Search route */
.search--site-heading {
text-transform: capitalize;
@@ -249,3 +268,31 @@ .search--result span {
.search--result-text {
margin-bottom: 1rem;
}
+
+/* Title route */
+.title--cover {
+ width: 400px;
+ border: 1px solid black;
+}
+
+.title--details {
+ margin: 1rem 0;
+}
+
+/* Components defined in utils */
+.utils--chapter {
+ margin-bottom: 0.5rem;
+}
+.utils--chapter > a {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+.utils--chapter--group {
+ font-size: 0.7rem;
+ line-height: 0.8rem;
+ padding: 0.2rem;
+ margin-left: 0.3rem;
+ border-radius: 2px;
+ border: 1px solid #aaa;
+ background-color: #eee;
+}