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;
+}