Repos / pytaku / 194d2cfb50
commit 194d2cfb50f8fdcd655e35450b1c02abd1958ee2
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Sat Aug 15 15:24:39 2020 +0700

    basic home page with icon font

diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index ba140a5..89624d9 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -51,13 +51,6 @@
 )
 
 
-@app.route("/")
-def home_view():
-    if session.get("user"):
-        return redirect(url_for("follows_view"))
-    return render_template("old/home.html")
-
-
 @app.route("/following", methods=["GET"])
 @require_login
 def follows_view():
@@ -358,3 +351,19 @@ def ensure_titles(site_title_pairs: List[Tuple[str, str]]):
             title = future.result()
             save_title(title)
             print(f"Saved {title['site']}: {title['name']}")
+
+
+"""
+New Mithril-based SPA views follow
+"""
+
+
+@app.route("/")
+@app.route("/h")
+def home_view():
+    return render_template("spa.html")
+
+
+@app.route("/f")
+def f_view():
+    return render_template("spa.html")
diff --git a/src/pytaku/static/base.css b/src/pytaku/static/base.css
index 63032dc..babcfd1 100644
--- a/src/pytaku/static/base.css
+++ b/src/pytaku/static/base.css
@@ -13,7 +13,7 @@ :root {
   --body-padding: 0.5rem;
 
   font-size: 100%;
-  font-family: "Ubuntu", sans-serif;
+  font-family: sans-serif;
   overflow-y: scroll;
 }
 
diff --git a/src/pytaku/static/feathericons/LICENSES b/src/pytaku/static/feathericons/LICENSES
new file mode 100644
index 0000000..ee84884
--- /dev/null
+++ b/src/pytaku/static/feathericons/LICENSES
@@ -0,0 +1,48 @@
+# Feature Icons: https://github.com/feathericons/feather
+
+The MIT License (MIT)
+
+Copyright (c) 2013-2017 Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+# Feature Icons - icon font version: https://github.com/AT-UI/feather-font
+
+The MIT License (MIT)
+
+Copyright (c) 2013-2017 Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/pytaku/static/feathericons/font.ttf b/src/pytaku/static/feathericons/font.ttf
new file mode 100644
index 0000000..2657c1c
Binary files /dev/null and b/src/pytaku/static/feathericons/font.ttf differ
diff --git a/src/pytaku/static/feathericons/font.woff b/src/pytaku/static/feathericons/font.woff
new file mode 100644
index 0000000..2c2092e
Binary files /dev/null and b/src/pytaku/static/feathericons/font.woff differ
diff --git a/src/pytaku/static/feathericons/iconfont.css b/src/pytaku/static/feathericons/iconfont.css
new file mode 100644
index 0000000..a467283
--- /dev/null
+++ b/src/pytaku/static/feathericons/iconfont.css
@@ -0,0 +1,696 @@
+/* Feather iconfont version: https://github.com/AT-UI/feather-font */
+@font-face {
+  font-family: "icons";
+  src: url("font.woff") format("woff"), url("font.ttf") format("truetype");
+}
+.icon {
+  font-family: "icons" !important;
+  font-size: inherit;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+.icon-alert-octagon:before {
+  content: "\e81b";
+}
+.icon-alert-circle:before {
+  content: "\e81c";
+}
+.icon-activity:before {
+  content: "\e81d";
+}
+.icon-alert-triangle:before {
+  content: "\e81e";
+}
+.icon-align-center:before {
+  content: "\e81f";
+}
+.icon-airplay:before {
+  content: "\e820";
+}
+.icon-align-justify:before {
+  content: "\e821";
+}
+.icon-align-left:before {
+  content: "\e822";
+}
+.icon-align-right:before {
+  content: "\e823";
+}
+.icon-arrow-down-left:before {
+  content: "\e824";
+}
+.icon-arrow-down-right:before {
+  content: "\e825";
+}
+.icon-anchor:before {
+  content: "\e826";
+}
+.icon-aperture:before {
+  content: "\e827";
+}
+.icon-arrow-left:before {
+  content: "\e828";
+}
+.icon-arrow-right:before {
+  content: "\e829";
+}
+.icon-arrow-down:before {
+  content: "\e82a";
+}
+.icon-arrow-up-left:before {
+  content: "\e82b";
+}
+.icon-arrow-up-right:before {
+  content: "\e82c";
+}
+.icon-arrow-up:before {
+  content: "\e82d";
+}
+.icon-award:before {
+  content: "\e82e";
+}
+.icon-bar-chart:before {
+  content: "\e82f";
+}
+.icon-at-sign:before {
+  content: "\e830";
+}
+.icon-bar-chart-:before {
+  content: "\e831";
+}
+.icon-battery-charging:before {
+  content: "\e832";
+}
+.icon-bell-off:before {
+  content: "\e833";
+}
+.icon-battery:before {
+  content: "\e834";
+}
+.icon-bluetooth:before {
+  content: "\e835";
+}
+.icon-bell:before {
+  content: "\e836";
+}
+.icon-book:before {
+  content: "\e837";
+}
+.icon-briefcase:before {
+  content: "\e838";
+}
+.icon-camera-off:before {
+  content: "\e839";
+}
+.icon-calendar:before {
+  content: "\e83a";
+}
+.icon-bookmark:before {
+  content: "\e83b";
+}
+.icon-box:before {
+  content: "\e83c";
+}
+.icon-camera:before {
+  content: "\e83d";
+}
+.icon-check-circle:before {
+  content: "\e83e";
+}
+.icon-check:before {
+  content: "\e83f";
+}
+.icon-check-square:before {
+  content: "\e840";
+}
+.icon-cast:before {
+  content: "\e841";
+}
+.icon-chevron-down:before {
+  content: "\e842";
+}
+.icon-chevron-left:before {
+  content: "\e843";
+}
+.icon-chevron-right:before {
+  content: "\e844";
+}
+.icon-chevron-up:before {
+  content: "\e845";
+}
+.icon-chevrons-down:before {
+  content: "\e846";
+}
+.icon-chevrons-right:before {
+  content: "\e847";
+}
+.icon-chevrons-up:before {
+  content: "\e848";
+}
+.icon-chevrons-left:before {
+  content: "\e849";
+}
+.icon-circle:before {
+  content: "\e84a";
+}
+.icon-clipboard:before {
+  content: "\e84b";
+}
+.icon-chrome:before {
+  content: "\e84c";
+}
+.icon-clock:before {
+  content: "\e84d";
+}
+.icon-cloud-lightning:before {
+  content: "\e84e";
+}
+.icon-cloud-drizzle:before {
+  content: "\e84f";
+}
+.icon-cloud-rain:before {
+  content: "\e850";
+}
+.icon-cloud-off:before {
+  content: "\e851";
+}
+.icon-codepen:before {
+  content: "\e852";
+}
+.icon-cloud-snow:before {
+  content: "\e853";
+}
+.icon-compass:before {
+  content: "\e854";
+}
+.icon-copy:before {
+  content: "\e855";
+}
+.icon-corner-down-right:before {
+  content: "\e856";
+}
+.icon-corner-down-left:before {
+  content: "\e857";
+}
+.icon-corner-left-down:before {
+  content: "\e858";
+}
+.icon-corner-left-up:before {
+  content: "\e859";
+}
+.icon-corner-up-left:before {
+  content: "\e85a";
+}
+.icon-corner-up-right:before {
+  content: "\e85b";
+}
+.icon-corner-right-down:before {
+  content: "\e85c";
+}
+.icon-corner-right-up:before {
+  content: "\e85d";
+}
+.icon-cpu:before {
+  content: "\e85e";
+}
+.icon-credit-card:before {
+  content: "\e85f";
+}
+.icon-crosshair:before {
+  content: "\e860";
+}
+.icon-disc:before {
+  content: "\e861";
+}
+.icon-delete:before {
+  content: "\e862";
+}
+.icon-download-cloud:before {
+  content: "\e863";
+}
+.icon-download:before {
+  content: "\e864";
+}
+.icon-droplet:before {
+  content: "\e865";
+}
+.icon-edit-:before {
+  content: "\e866";
+}
+.icon-edit:before {
+  content: "\e867";
+}
+.icon-edit-1:before {
+  content: "\e868";
+}
+.icon-external-link:before {
+  content: "\e869";
+}
+.icon-eye:before {
+  content: "\e86a";
+}
+.icon-feather:before {
+  content: "\e86b";
+}
+.icon-facebook:before {
+  content: "\e86c";
+}
+.icon-file-minus:before {
+  content: "\e86d";
+}
+.icon-eye-off:before {
+  content: "\e86e";
+}
+.icon-fast-forward:before {
+  content: "\e86f";
+}
+.icon-file-text:before {
+  content: "\e870";
+}
+.icon-film:before {
+  content: "\e871";
+}
+.icon-file:before {
+  content: "\e872";
+}
+.icon-file-plus:before {
+  content: "\e873";
+}
+.icon-folder:before {
+  content: "\e874";
+}
+.icon-filter:before {
+  content: "\e875";
+}
+.icon-flag:before {
+  content: "\e876";
+}
+.icon-globe:before {
+  content: "\e877";
+}
+.icon-grid:before {
+  content: "\e878";
+}
+.icon-heart:before {
+  content: "\e879";
+}
+.icon-home:before {
+  content: "\e87a";
+}
+.icon-github:before {
+  content: "\e87b";
+}
+.icon-image:before {
+  content: "\e87c";
+}
+.icon-inbox:before {
+  content: "\e87d";
+}
+.icon-layers:before {
+  content: "\e87e";
+}
+.icon-info:before {
+  content: "\e87f";
+}
+.icon-instagram:before {
+  content: "\e880";
+}
+.icon-layout:before {
+  content: "\e881";
+}
+.icon-link-:before {
+  content: "\e882";
+}
+.icon-life-buoy:before {
+  content: "\e883";
+}
+.icon-link:before {
+  content: "\e884";
+}
+.icon-log-in:before {
+  content: "\e885";
+}
+.icon-list:before {
+  content: "\e886";
+}
+.icon-lock:before {
+  content: "\e887";
+}
+.icon-log-out:before {
+  content: "\e888";
+}
+.icon-loader:before {
+  content: "\e889";
+}
+.icon-mail:before {
+  content: "\e88a";
+}
+.icon-maximize-:before {
+  content: "\e88b";
+}
+.icon-map:before {
+  content: "\e88c";
+}
+.icon-maximize:before {
+  content: "\e88d";
+}
+.icon-map-pin:before {
+  content: "\e88e";
+}
+.icon-menu:before {
+  content: "\e88f";
+}
+.icon-message-circle:before {
+  content: "\e890";
+}
+.icon-message-square:before {
+  content: "\e891";
+}
+.icon-minimize-:before {
+  content: "\e892";
+}
+.icon-mic-off:before {
+  content: "\e893";
+}
+.icon-minus-circle:before {
+  content: "\e894";
+}
+.icon-mic:before {
+  content: "\e895";
+}
+.icon-minus-square:before {
+  content: "\e896";
+}
+.icon-minus:before {
+  content: "\e897";
+}
+.icon-moon:before {
+  content: "\e898";
+}
+.icon-monitor:before {
+  content: "\e899";
+}
+.icon-more-vertical:before {
+  content: "\e89a";
+}
+.icon-more-horizontal:before {
+  content: "\e89b";
+}
+.icon-move:before {
+  content: "\e89c";
+}
+.icon-music:before {
+  content: "\e89d";
+}
+.icon-navigation-:before {
+  content: "\e89e";
+}
+.icon-navigation:before {
+  content: "\e89f";
+}
+.icon-octagon:before {
+  content: "\e8a0";
+}
+.icon-package:before {
+  content: "\e8a1";
+}
+.icon-pause-circle:before {
+  content: "\e8a2";
+}
+.icon-pause:before {
+  content: "\e8a3";
+}
+.icon-percent:before {
+  content: "\e8a4";
+}
+.icon-phone-call:before {
+  content: "\e8a5";
+}
+.icon-phone-forwarded:before {
+  content: "\e8a6";
+}
+.icon-phone-missed:before {
+  content: "\e8a7";
+}
+.icon-phone-off:before {
+  content: "\e8a8";
+}
+.icon-phone-incoming:before {
+  content: "\e8a9";
+}
+.icon-phone:before {
+  content: "\e8aa";
+}
+.icon-phone-outgoing:before {
+  content: "\e8ab";
+}
+.icon-pie-chart:before {
+  content: "\e8ac";
+}
+.icon-play-circle:before {
+  content: "\e8ad";
+}
+.icon-play:before {
+  content: "\e8ae";
+}
+.icon-plus-square:before {
+  content: "\e8af";
+}
+.icon-plus-circle:before {
+  content: "\e8b0";
+}
+.icon-plus:before {
+  content: "\e8b1";
+}
+.icon-pocket:before {
+  content: "\e8b2";
+}
+.icon-printer:before {
+  content: "\e8b3";
+}
+.icon-power:before {
+  content: "\e8b4";
+}
+.icon-radio:before {
+  content: "\e8b5";
+}
+.icon-repeat:before {
+  content: "\e8b6";
+}
+.icon-refresh-ccw:before {
+  content: "\e8b7";
+}
+.icon-rewind:before {
+  content: "\e8b8";
+}
+.icon-rotate-ccw:before {
+  content: "\e8b9";
+}
+.icon-refresh-cw:before {
+  content: "\e8ba";
+}
+.icon-rotate-cw:before {
+  content: "\e8bb";
+}
+.icon-save:before {
+  content: "\e8bc";
+}
+.icon-search:before {
+  content: "\e8bd";
+}
+.icon-server:before {
+  content: "\e8be";
+}
+.icon-scissors:before {
+  content: "\e8bf";
+}
+.icon-share-:before {
+  content: "\e8c0";
+}
+.icon-share:before {
+  content: "\e8c1";
+}
+.icon-shield:before {
+  content: "\e8c2";
+}
+.icon-settings:before {
+  content: "\e8c3";
+}
+.icon-skip-back:before {
+  content: "\e8c4";
+}
+.icon-shuffle:before {
+  content: "\e8c5";
+}
+.icon-sidebar:before {
+  content: "\e8c6";
+}
+.icon-skip-forward:before {
+  content: "\e8c7";
+}
+.icon-slack:before {
+  content: "\e8c8";
+}
+.icon-slash:before {
+  content: "\e8c9";
+}
+.icon-smartphone:before {
+  content: "\e8ca";
+}
+.icon-square:before {
+  content: "\e8cb";
+}
+.icon-speaker:before {
+  content: "\e8cc";
+}
+.icon-star:before {
+  content: "\e8cd";
+}
+.icon-stop-circle:before {
+  content: "\e8ce";
+}
+.icon-sun:before {
+  content: "\e8cf";
+}
+.icon-sunrise:before {
+  content: "\e8d0";
+}
+.icon-tablet:before {
+  content: "\e8d1";
+}
+.icon-tag:before {
+  content: "\e8d2";
+}
+.icon-sunset:before {
+  content: "\e8d3";
+}
+.icon-target:before {
+  content: "\e8d4";
+}
+.icon-thermometer:before {
+  content: "\e8d5";
+}
+.icon-thumbs-up:before {
+  content: "\e8d6";
+}
+.icon-thumbs-down:before {
+  content: "\e8d7";
+}
+.icon-toggle-left:before {
+  content: "\e8d8";
+}
+.icon-toggle-right:before {
+  content: "\e8d9";
+}
+.icon-trash-:before {
+  content: "\e8da";
+}
+.icon-trash:before {
+  content: "\e8db";
+}
+.icon-trending-up:before {
+  content: "\e8dc";
+}
+.icon-trending-down:before {
+  content: "\e8dd";
+}
+.icon-triangle:before {
+  content: "\e8de";
+}
+.icon-type:before {
+  content: "\e8df";
+}
+.icon-twitter:before {
+  content: "\e8e0";
+}
+.icon-upload:before {
+  content: "\e8e1";
+}
+.icon-umbrella:before {
+  content: "\e8e2";
+}
+.icon-upload-cloud:before {
+  content: "\e8e3";
+}
+.icon-unlock:before {
+  content: "\e8e4";
+}
+.icon-user-check:before {
+  content: "\e8e5";
+}
+.icon-user-minus:before {
+  content: "\e8e6";
+}
+.icon-user-plus:before {
+  content: "\e8e7";
+}
+.icon-user-x:before {
+  content: "\e8e8";
+}
+.icon-user:before {
+  content: "\e8e9";
+}
+.icon-users:before {
+  content: "\e8ea";
+}
+.icon-video-off:before {
+  content: "\e8eb";
+}
+.icon-video:before {
+  content: "\e8ec";
+}
+.icon-voicemail:before {
+  content: "\e8ed";
+}
+.icon-volume-x:before {
+  content: "\e8ee";
+}
+.icon-volume-:before {
+  content: "\e8ef";
+}
+.icon-volume-1:before {
+  content: "\e8f0";
+}
+.icon-volume:before {
+  content: "\e8f1";
+}
+.icon-watch:before {
+  content: "\e8f2";
+}
+.icon-wifi:before {
+  content: "\e8f3";
+}
+.icon-x-square:before {
+  content: "\e8f4";
+}
+.icon-wind:before {
+  content: "\e8f5";
+}
+.icon-x:before {
+  content: "\e8f6";
+}
+.icon-x-circle:before {
+  content: "\e8f7";
+}
+.icon-zap:before {
+  content: "\e8f8";
+}
+.icon-zoom-in:before {
+  content: "\e8f9";
+}
+.icon-zoom-out:before {
+  content: "\e8fa";
+}
+.icon-command:before {
+  content: "\e8fb";
+}
+.icon-cloud:before {
+  content: "\e8fc";
+}
+.icon-hash:before {
+  content: "\e8fd";
+}
+.icon-headphones:before {
+  content: "\e8fe";
+}
diff --git a/src/pytaku/static/lookandfeel.css b/src/pytaku/static/lookandfeel.css
new file mode 100644
index 0000000..bc43b85
--- /dev/null
+++ b/src/pytaku/static/lookandfeel.css
@@ -0,0 +1,116 @@
+:root {
+  --btn-red: #ef4f39;
+  --btn-red-bottom: #b94434;
+  --btn-green: #3ba60a;
+  --btn-green-bottom: #338a0d;
+  --btn-blue: #009ee8;
+  --btn-blue-bottom: #26789f;
+  --btn-gray: #444;
+  --btn-gray-bottom: #555;
+  --bg-black: #231f20;
+  --border-radius: 3px;
+  --body-padding: 0.5rem;
+
+  font-size: 100%;
+  font-family: "Ubuntu", sans-serif;
+  overflow-y: scroll;
+  line-height: 1.2rem;
+}
+
+h1,
+h2,
+h3,
+h4 {
+  margin-top: 1rem;
+  margin-bottom: 0.5rem;
+  font-weight: bold;
+}
+
+h1 {
+  font-size: 2rem;
+}
+
+h2 {
+  font-size: 1.5rem;
+}
+
+table {
+  border-collapse: collapse;
+}
+td,
+th {
+  border: 1px solid black;
+  padding: 0.5em;
+}
+tr:nth-child(even) {
+  background-color: #eee;
+}
+th {
+  background-color: #777;
+  color: white;
+}
+
+input {
+  padding: 0.3rem;
+}
+
+p {
+  line-height: 1.5rem;
+}
+
+.button {
+  display: inline-block;
+  user-select: none;
+  margin: 0;
+  display: inline-flex;
+  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);
+  color: white;
+  text-decoration: none;
+}
+.button > * {
+  margin-right: 0.3rem;
+}
+.button > *:last-child {
+  margin-right: 0;
+}
+.button:hover {
+  filter: brightness(110%);
+}
+.button:active {
+  filter: brightness(90%);
+}
+.button:focus {
+  box-shadow: 0 0 2px black;
+}
+.button.red {
+  background-color: var(--btn-red);
+  border-bottom: 4px solid var(--btn-red-bottom);
+}
+.button.green {
+  background-color: var(--btn-green);
+  border-bottom: 4px solid var(--btn-green-bottom);
+}
+.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 {
+  background-color: #aaa;
+  border-bottom: 4px solid #aaa;
+  filter: none;
+  cursor: default;
+  opacity: 0.5;
+}
diff --git a/src/pytaku/static/main.js b/src/pytaku/static/main.js
new file mode 100644
index 0000000..f9e74f8
--- /dev/null
+++ b/src/pytaku/static/main.js
@@ -0,0 +1,49 @@
+/* Top-level Components */
+
+let Home = {
+  view: (vnode) => {
+    return m("div", { class: "main" }, [
+      m(Navbar),
+      m("div.content", [
+        m("p", "Try searching for some manga title using the box above."),
+        m("p", "Logging in allows you to follow manga titles."),
+      ]),
+    ]);
+  },
+};
+
+let Following = {
+  view: (vnode) => {
+    return "Follows";
+  },
+};
+
+let Navbar = {
+  view: (vnode) => {
+    return m("nav", [
+      m(m.route.Link, { class: "nav--logo", href: "/" }, [
+        m("img.nav--logo--img", {
+          src: "/static/pytaku.svg",
+          alt: "home",
+        }),
+      ]),
+      m("form.nav--search-form", [
+        m("input", { placeholder: "search title name" }),
+        m("button", { type: "submit" }, [m("i.icon.icon-search")]),
+      ]),
+      m(m.route.Link, { class: "nav--link", href: "/l" }, [
+        m("i.icon.icon-log-in"),
+        "login / register",
+      ]),
+    ]);
+  },
+};
+
+/* Entry point */
+
+root = document.getElementById("spa-root");
+m.route.prefix = "";
+m.route(root, "/h", {
+  "/h": Home,
+  "/f": Following,
+});
diff --git a/src/pytaku/static/spa.css b/src/pytaku/static/spa.css
new file mode 100644
index 0000000..a1b33d2
--- /dev/null
+++ b/src/pytaku/static/spa.css
@@ -0,0 +1,70 @@
+/* Navbar */
+nav {
+  background-color: var(--bg-black);
+  padding: var(--body-padding);
+  display: flex;
+  flex-wrap: wrap;
+}
+.nav--logo {
+  width: 150px;
+  margin-right: var(--body-padding);
+}
+.nav--logo--img {
+  max-width: 100%;
+  display: block;
+}
+.nav--search-form {
+  display: inline-flex;
+  align-items: stretch;
+}
+.nav--link {
+  color: white;
+  text-decoration: none;
+  display: inline-flex;
+  align-items: center;
+}
+.nav--link:hover {
+  text-decoration: underline;
+}
+.nav--link:last-child {
+  margin-left: auto;
+}
+.nav--link i {
+  margin-right: 0.3rem;
+}
+
+/* Route content common styling */
+.content {
+  padding: var(--body-padding);
+}
+.content > * {
+  display: block;
+  margin-top: 0.5rem;
+  margin-bottom: 0.5rem;
+}
+
+/* Sticky footer */
+footer {
+  padding: var(--body-padding);
+  line-height: 1.5em;
+  opacity: 0.7;
+  border-top: 1px solid #ccc;
+  background-color: #eee;
+}
+footer a {
+  color: inherit;
+}
+html,
+body {
+  height: 100%;
+}
+body {
+  display: flex;
+  flex-direction: column;
+}
+#spa-root {
+  flex: 1 0 auto;
+}
+footer {
+  flex-shrink: 0;
+}
diff --git a/src/pytaku/templates/spa.html b/src/pytaku/templates/spa.html
new file mode 100644
index 0000000..ce3b087
--- /dev/null
+++ b/src/pytaku/templates/spa.html
@@ -0,0 +1,49 @@
+{# vim: ft=htmldjango
+#}<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
+    <link rel="alternate icon" type="image/png" href="/static/favicon.png">
+
+    <title>{% block title %} {% endblock %} - Pytaku</title>
+
+    <link rel="stylesheet" href="{{ url_for('static', filename='minireset.css') }}">
+    <link rel="stylesheet" href="{{ url_for('static', filename='feathericons/iconfont.css') }}">
+    <link rel="stylesheet" href="{{ url_for('static', filename='lookandfeel.css') }}">
+    <link rel="stylesheet" href="{{ url_for('static', filename='spa.css') }}">
+
+    {% if open_graph %}
+    <meta property="og:title" content="{{ open_graph['title'] }}">
+    <meta property="og:image" content="{{ open_graph['image'] }}">
+    <meta property="og:description" content="{{ open_graph['description'] }}">
+    {% endif %}
+
+    {% block head %}{% endblock %}
+  </head>
+
+  <body>
+    <div id="spa-root">
+      <noscript>
+        <p>Please enable JavaScript to use Pytaku.</p>
+        <p>It's snappy and doesn't do anything spooky to your browser. Pinky promise!</p>
+      </noscript>
+    </div>
+
+    <footer>
+      <p>Pytaku is <a href="https://git.sr.ht/~nhanb/pytaku">free and open source software</a>.</p>
+      <p>
+        <strong>This is a test instance.</strong>
+        Database may be wiped. Bugs may be present.<br>
+        Your favorite mangaka may never recover from his
+        <a href="https://junk.imnhan.com/hunter_x_idol.jpg">idol addiction</a>
+        and your favorite series may never finish.
+      </p>
+    </footer>
+
+    <script>const initialState = "{{ initial_state }}";</script>
+    <script src="https://unpkg.com/mithril/mithril.js"></script>
+    <script src="{{ url_for('static', filename='main.js') }}"></script>
+  </body>
+</html>