selfhostable, read-only reddit client

add login and users and all the pizzazz

+5 -2
package.json
··· 7 7 "pug-cli": "^1.0.0-alpha6" 8 8 }, 9 9 "peerDependencies": { 10 - "typescript": "^5.0.0" 10 + "typescript": "^5.6.3" 11 11 }, 12 12 "dependencies": { 13 - "express": "^4.19.2", 13 + "cookie-parser": "^1.4.7", 14 + "express": "^4.21.1", 15 + "express-rate-limit": "^7.4.1", 14 16 "he": "^1.2.0", 17 + "jsonwebtoken": "^9.0.2", 15 18 "pug": "^3.0.3", 16 19 "timeago.js": "^4.0.2" 17 20 }
+1 -1
readme.txt
··· 13 13 - [x] fix spacing between comments 14 14 - [x] collapse even singular comments 15 15 - [ ] highlights for op, sticky etc. 16 - - [ ] support 'more comments' 16 + - [x] support 'more comments' 17 17 - [ ] avoid js to toggle details in views/index.pug 18 18 - [x] set home to sum of subs 19 19 - [x] details tag on safari
+27
src/auth.js
··· 1 + const jwt = require("jsonwebtoken"); 2 + const { JWT_KEY } = require("./"); 3 + 4 + function authenticateToken(req, res, next) { 5 + if (!req.cookies || !req.cookies.auth_token) { 6 + return res.redirect("/login"); 7 + } 8 + 9 + const token = req.cookies.auth_token; 10 + 11 + // If no token, deny access 12 + if (!token) { 13 + return res.redirect( 14 + `/login?redirect=${encodeURIComponent(req.originalUrl)}`, 15 + ); 16 + } 17 + 18 + try { 19 + const user = jwt.verify(token, JWT_KEY); 20 + req.user = user; 21 + next(); 22 + } catch (error) { 23 + res.redirect(`/login?redirect=${encodeURIComponent(req.originalUrl)}`); 24 + } 25 + } 26 + 27 + module.exports = { authenticateToken };
+24
src/db.js
··· 1 + const { Database } = require("bun:sqlite"); 2 + const db = new Database("readit.db", { 3 + strict: true, 4 + }); 5 + 6 + db.query(` 7 + CREATE TABLE IF NOT EXISTS users ( 8 + id INTEGER PRIMARY KEY AUTOINCREMENT, 9 + username TEXT UNIQUE, 10 + password_hash TEXT 11 + ) 12 + `).run(); 13 + 14 + db.query(` 15 + CREATE TABLE IF NOT EXISTS subscriptions ( 16 + id INTEGER PRIMARY KEY AUTOINCREMENT, 17 + user_id INTEGER, 18 + subreddit TEXT, 19 + FOREIGN KEY(user_id) REFERENCES users(id), 20 + UNIQUE(user_id, subreddit) 21 + ) 22 + `).run(); 23 + 24 + module.exports = { db };
+4 -4
src/geddit.js
··· 18 18 include_over_18: true, 19 19 }; 20 20 21 - subreddit = subreddit ? `/r/${subreddit}` : ""; 21 + const subredditStr = subreddit ? `/r/${subreddit}` : ""; 22 22 23 23 return await fetch( 24 24 `${ 25 - this.host + subreddit 25 + this.host + subredditStr 26 26 }/${sort}.json?${new URLSearchParams(Object.assign(params, options))}`, 27 27 ) 28 28 .then((res) => res.json()) ··· 300 300 301 301 async searchAll(query, subreddit = null, options = {}) { 302 302 options.q = query; 303 - subreddit = subreddit ? `/r/${subreddit}` : ""; 303 + const subredditStr = subreddit ? `/r/${subreddit}` : ""; 304 304 305 305 const params = { 306 306 limit: 25, ··· 310 310 311 311 return await fetch( 312 312 `${ 313 - this.host + subreddit 313 + this.host + subredditStr 314 314 }/search.json?${new URLSearchParams(Object.assign(params, options))}`, 315 315 ) 316 316 .then((res) => res.json())
+16 -28
src/index.js
··· 1 1 const express = require("express"); 2 + const rateLimit = require("express-rate-limit"); 2 3 const path = require("node:path"); 3 4 const geddit = require("./geddit.js"); 4 - const { Database } = require("bun:sqlite"); 5 - 6 - const db = new Database("readit.db"); 7 - 8 - const createUsers = db.query(` 9 - CREATE TABLE IF NOT EXISTS users ( 10 - id INTEGER PRIMARY KEY AUTOINCREMENT, 11 - username TEXT UNIQUE, 12 - password_hash TEXT 13 - ) 14 - `); 15 - 16 - createUsers.run(); 17 - 18 - const createSubs = db.query(` 19 - CREATE TABLE IF NOT EXISTS subscriptions ( 20 - id INTEGER PRIMARY KEY AUTOINCREMENT, 21 - user_id INTEGER, 22 - subreddit TEXT, 23 - FOREIGN KEY(user_id) REFERENCES users(id), 24 - UNIQUE(user_id, subreddit) 25 - ) 26 - `); 27 - 28 - createSubs.run(); 29 - 30 - module.exports = { db }; 31 - 5 + const cookieParser = require("cookie-parser"); 32 6 const app = express(); 7 + const hasher = new Bun.CryptoHasher("sha256", "secret-key"); 8 + const JWT_KEY = hasher.update(Math.random().toString()).digest("hex"); 9 + 10 + module.exports = { JWT_KEY }; 33 11 34 12 app.set("views", path.join(__dirname, "views")); 35 13 app.set("view engine", "pug"); ··· 38 16 app.use(express.json()); 39 17 app.use(express.urlencoded({ extended: true })); 40 18 app.use(express.static(path.join(__dirname, "public"))); 19 + app.use(cookieParser()); 20 + app.use( 21 + rateLimit({ 22 + windowMs: 15 * 60 * 1000, 23 + max: 100, 24 + message: "Too many requests from this IP, please try again later.", 25 + standardHeaders: true, 26 + legacyHeaders: false, 27 + }), 28 + ); 41 29 app.use("/", routes); 42 30 43 31 const port = process.env.READIT_PORT;
+2 -2
src/mixins/head.pug
··· 1 - mixin head() 1 + mixin head(title) 2 2 head 3 3 meta(name="viewport" content="width=device-width, initial-scale=1.0") 4 4 meta(charset='UTF-8') 5 - title reddit 5 + title #{`readit ${title}`} 6 6 link(rel="stylesheet", href="/styles.css") 7 7 link(rel="preconnect" href="https://rsms.me/") 8 8 link(rel="stylesheet" href="https://rsms.me/inter/inter.css")
+9 -1
src/mixins/header.pug
··· 1 - mixin header() 1 + mixin header(user) 2 2 div.header 3 3 div.header-item 4 4 a(href=`/`) home ··· 8 8 a(href=`/r/popular`) popular 9 9 div.header-item 10 10 a(href=`/subs`) subscriptions 11 + if user 12 + div.header-item 13 + | #{user.username} 14 + |  15 + a(href='/logout') (logout) 16 + else 17 + div.header-item 18 + a(href=`/login`) login 11 19
+45 -16
src/public/styles.css
··· 8 8 --link-color: #29BC9B; 9 9 --link-visited-color: #999; 10 10 --accent: var(--link-color); 11 + --error-text-color: red; 11 12 12 13 font-family: Inter, sans-serif; 13 14 font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'dlig' 1, 'ss01' 1, 'ss07' 1, 'ss08' 1; ··· 24 25 --link-color: #79ffe1; 25 26 --link-visited-color: #999; 26 27 --accent: var(--link-color); 28 + --error-text-color: lightcoral; 27 29 } 28 30 } 29 31 ··· 156 158 .info-item, .header-item, .footer-item { 157 159 margin-right: 14px; 158 160 } 159 - 160 - 161 + 161 162 .media-preview img, 162 163 .media-preview video { 163 164 object-fit: cover; ··· 185 186 max-width: 95%; 186 187 padding: 5px; 187 188 } 189 + 190 + form { 191 + display: flex; 192 + flex-direction: column; 193 + align-items: center; 194 + width: 90%; 195 + } 188 196 189 197 @media (min-width: 768px) { 190 198 .post, .comments-container, .hero, .header, .footer { ··· 202 210 } 203 211 .post-media { 204 212 max-width: 50%; 213 + } 214 + form { 215 + width: 40%; 205 216 } 206 217 } 207 218 ··· 226 237 .post-media { 227 238 max-width: 50%; 228 239 } 240 + form { 241 + width: 20%; 242 + } 229 243 } 230 244 231 245 @media (min-width: 2560px) { ··· 233 247 flex: 1 1 40%; 234 248 width: 40%; 235 249 } 236 - } 237 - 238 - .comments-container, .self-text { 239 - text-align: justify; 240 250 } 241 251 242 252 .comment, .more { ··· 320 330 blockquote { 321 331 margin: 0px; 322 332 padding-left: 10px; 323 - border-left: 4px solid var(--blockquote-color); 333 + border-left: 2px solid var(--blockquote-color); 324 334 color: var(--blockquote-color); 325 335 } 326 336 ··· 400 410 } 401 411 402 412 .gallery-item { 403 - flex: 0 0 auto; 404 - margin-right: 10px; 413 + flex: 0 0 auto; 414 + margin-right: 10px; 405 415 } 406 416 407 417 .gallery img { ··· 439 449 color: var(--text-color); 440 450 } 441 451 442 - form { 443 - display: flex; 444 - flex-direction: column; 445 - align-items: center; 446 - } 447 - 448 452 form label { 449 453 width: 100%; 454 + flex-basis: 100%; 450 455 margin: 5px 0; 451 456 color: var(--text-color); 452 457 } 453 458 454 459 form input[type="submit"] { 455 - width: auto; 460 + width: 100%; 456 461 padding: 10px 20px; 457 462 margin-top: 20px; 458 463 background-color: var(--link-color); ··· 466 471 background-color: var(--link-color); 467 472 opacity: 0.8; 468 473 } 474 + 475 + .input-text { 476 + width: 100%; 477 + } 478 + 479 + .submit-button { 480 + margin: 24px 0; 481 + width: 100%; 482 + display: flex; 483 + flex-direction: row; 484 + justify-content: center; 485 + } 486 + 487 + .submit-button button { 488 + width: 100%; 489 + padding: 12px; 490 + background-color: var(--accent); 491 + color: var(--bg-color); 492 + } 493 + 494 + .register-error-message { 495 + flex-flow: row wrap; 496 + color: var(--error-text-color); 497 + }
+149 -85
src/routes/index.js
··· 2 2 const he = require("he"); 3 3 const { hash, compare } = require("bun"); 4 4 const jwt = require("jsonwebtoken"); 5 - const router = express.Router(); 6 - const secretKey = "your_secret_key"; // Replace with your actual secret key 7 5 const geddit = require("../geddit.js"); 8 - const { db } = require("../index"); 6 + const { JWT_KEY } = require("../"); 7 + const { db } = require("../db"); 8 + const { authenticateToken } = require("../auth"); 9 + 10 + const router = express.Router(); 9 11 const G = new geddit.Geddit(); 10 12 11 13 // GET / 12 - router.get("/", async (req, res) => { 14 + router.get("/", authenticateToken, async (req, res) => { 13 15 res.render("home"); 14 16 }); 15 17 16 18 // GET /r/:id 17 - router.get("/r/:subreddit", async (req, res) => { 19 + router.get("/r/:subreddit", authenticateToken, async (req, res) => { 18 20 const subreddit = req.params.subreddit; 19 21 const isMulti = subreddit.includes("+"); 20 22 const query = req.query ? req.query : {}; ··· 22 24 query.sort = "hot"; 23 25 } 24 26 27 + let isSubbed = false; 28 + if (!isMulti) { 29 + isSubbed = 30 + db 31 + .query( 32 + "SELECT * FROM subscriptions WHERE user_id = $id AND subreddit = $subreddit", 33 + ) 34 + .get({ id: req.user.id, subreddit }) !== null; 35 + } 25 36 const postsReq = G.getSubmissions(query.sort, `${subreddit}`, query); 26 37 const aboutReq = G.getSubreddit(`${subreddit}`); 27 38 28 39 const [posts, about] = await Promise.all([postsReq, aboutReq]); 29 40 30 - res.render("index", { subreddit, posts, about, query, isMulti }); 41 + res.render("index", { 42 + subreddit, 43 + posts, 44 + about, 45 + query, 46 + isMulti, 47 + user: req.user, 48 + isSubbed, 49 + }); 31 50 }); 32 51 33 52 // GET /comments/:id 34 - router.get("/comments/:id", async (req, res) => { 53 + router.get("/comments/:id", authenticateToken, async (req, res) => { 35 54 const id = req.params.id; 36 55 37 56 const params = { ··· 39 58 }; 40 59 response = await G.getSubmissionComments(id, params); 41 60 42 - res.render("comments", unescape_submission(response)); 61 + res.render("comments", { 62 + data: unescape_submission(response), 63 + user: req.user, 64 + }); 43 65 }); 44 66 45 67 // GET /comments/:parent_id/comment/:child_id 46 - router.get("/comments/:parent_id/comment/:child_id", async (req, res) => { 47 - const parent_id = req.params.parent_id; 48 - const child_id = req.params.child_id; 68 + router.get( 69 + "/comments/:parent_id/comment/:child_id", 70 + authenticateToken, 71 + async (req, res) => { 72 + const parent_id = req.params.parent_id; 73 + const child_id = req.params.child_id; 49 74 50 - const params = { 51 - limit: 50, 52 - }; 53 - response = await G.getSingleCommentThread(parent_id, child_id, params); 54 - const comments = response.comments; 55 - comments.forEach(unescape_comment); 56 - res.render("single_comment_thread", { comments, parent_id }); 57 - }); 58 - 59 - router.get("/login", async (req, res) => { 60 - res.render("login"); 61 - }); 75 + const params = { 76 + limit: 50, 77 + }; 78 + response = await G.getSingleCommentThread(parent_id, child_id, params); 79 + const comments = response.comments; 80 + comments.forEach(unescape_comment); 81 + res.render("single_comment_thread", { 82 + comments, 83 + parent_id, 84 + user: req.user, 85 + }); 86 + }, 87 + ); 62 88 63 89 // GET /subs 64 - router.get("/subs", async (req, res) => { 65 - res.render("subs"); 90 + router.get("/subs", authenticateToken, async (req, res) => { 91 + const subs = db 92 + .query("SELECT * FROM subscriptions WHERE user_id = $id") 93 + .all({ id: req.user.id }); 94 + res.render("subs", { subs, user: req.user }); 66 95 }); 67 96 68 97 // GET /media 69 - router.get("/media/*", async (req, res) => { 98 + router.get("/media/*", authenticateToken, async (req, res) => { 70 99 const url = req.params[0]; 71 100 const ext = url.split(".").pop().toLowerCase(); 72 101 const kind = ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) ··· 81 110 82 111 router.post("/register", async (req, res) => { 83 112 const { username, password, confirm_password } = req.body; 84 - console.log("Request body:", req.body); 113 + 85 114 if (!username || !password || !confirm_password) { 86 115 return res.status(400).send("All fields are required"); 87 116 } 117 + 118 + const user = db 119 + .query("SELECT * FROM users WHERE username = $username") 120 + .get({ username }); 121 + if (user) { 122 + return res.render("register", { 123 + message: `user by the name "${username}" exists, choose a different username`, 124 + }); 125 + } 126 + 88 127 if (password !== confirm_password) { 89 - return res.status(400).send("Passwords do not match"); 128 + return res.render("register", { 129 + message: "passwords do not match, try again", 130 + }); 90 131 } 132 + 91 133 try { 92 - const hashedPassword = await hash(password); 93 - db.query("INSERT INTO users (username, password_hash) VALUES (?, ?)", [ 94 - username, 95 - hashedPassword, 96 - ]).run(); 97 - res.status(201).redirect("/"); 134 + const hashedPassword = await Bun.password.hash(password); 135 + const insertedRecord = db 136 + .query( 137 + "INSERT INTO users (username, password_hash) VALUES ($username, $hashedPassword)", 138 + ) 139 + .run({ 140 + username, 141 + hashedPassword, 142 + }); 143 + const id = insertedRecord.lastInsertRowid; 144 + const token = jwt.sign({ username, id }, JWT_KEY, { expiresIn: "100h" }); 145 + res 146 + .status(200) 147 + .cookie("auth_token", token, { 148 + httpOnly: true, 149 + maxAge: 2 * 24 * 60 * 60 * 1000, 150 + }) 151 + .redirect("/"); 98 152 } catch (err) { 99 - console.log(err); 100 - res.status(400).send("Error registering user"); 153 + return res.render("register", { 154 + message: "error registering user, try again later", 155 + }); 101 156 } 157 + }); 158 + 159 + router.get("/login", async (req, res) => { 160 + res.render("login", req.query); 102 161 }); 103 162 104 163 // POST /login 105 164 router.post("/login", async (req, res) => { 106 165 const { username, password } = req.body; 107 166 const user = db 108 - .query("SELECT * FROM users WHERE username = ?", [username]) 109 - .get(); 110 - if (user && await compare(password, user.password_hash)) { 111 - res.status(200).redirect("/"); 167 + .query("SELECT * FROM users WHERE username = $username") 168 + .get({ username }); 169 + if (user && (await Bun.password.verify(password, user.password_hash))) { 170 + const token = jwt.sign({ username, id: user.id }, JWT_KEY, { 171 + expiresIn: "1h", 172 + }); 173 + res 174 + .cookie("auth_token", token, { 175 + httpOnly: true, 176 + maxAge: 2 * 24 * 60 * 60 * 1000, 177 + }) 178 + .redirect(req.query.redirect || "/"); 112 179 } else { 113 - res.status(401).send("Invalid credentials"); 180 + res.render("login", { 181 + message: "invalid credentials, try again", 182 + }); 114 183 } 115 184 }); 116 185 186 + // this would be post, but i cant stuff it in a link 187 + router.get("/logout", (req, res) => { 188 + res.clearCookie("auth_token", { 189 + httpOnly: true, 190 + secure: true, 191 + }); 192 + res.redirect("/login"); 193 + }); 194 + 117 195 // POST /subscribe 118 - router.post("/subscribe", async (req, res) => { 119 - const { username, subreddit } = req.body; 120 - const user = db 121 - .query("SELECT * FROM users WHERE username = ?", [username]) 122 - .get(); 123 - if (user) { 124 - const existingSubscription = db 125 - .query( 126 - "SELECT * FROM subscriptions WHERE user_id = ? AND subreddit = ?", 127 - [user.id, subreddit], 128 - ) 129 - .get(); 130 - if (existingSubscription) { 131 - res.status(400).send("Already subscribed to this subreddit"); 132 - } else { 133 - db.query("INSERT INTO subscriptions (user_id, subreddit) VALUES (?, ?)", [ 134 - user.id, 135 - subreddit, 136 - ]).run(); 137 - res.status(201).send("Subscribed successfully"); 138 - } 196 + router.post("/subscribe", authenticateToken, async (req, res) => { 197 + const { subreddit } = req.body; 198 + const user = req.user; 199 + const existingSubscription = db 200 + .query( 201 + "SELECT * FROM subscriptions WHERE user_id = $id AND subreddit = $subreddit", 202 + ) 203 + .get({ id: user.id, subreddit }); 204 + if (existingSubscription) { 205 + res.status(400).send("Already subscribed to this subreddit"); 139 206 } else { 140 - res.status(404).send("User not found"); 207 + db.query( 208 + "INSERT INTO subscriptions (user_id, subreddit) VALUES ($id, $subreddit)", 209 + ).run({ id: user.id, subreddit }); 210 + res.status(201).send("Subscribed successfully"); 141 211 } 142 212 }); 143 213 144 - router.post("/unsubscribe", async (req, res) => { 145 - const { username, subreddit } = req.body; 146 - const user = db 147 - .query("SELECT * FROM users WHERE username = ?", [username]) 148 - .get(); 149 - if (user) { 150 - const existingSubscription = db 151 - .query( 152 - "SELECT * FROM subscriptions WHERE user_id = ? AND subreddit = ?", 153 - [user.id, subreddit], 154 - ) 155 - .get(); 156 - if (existingSubscription) { 157 - db.run("DELETE FROM subscriptions WHERE user_id = ? AND subreddit = ?", [ 158 - user.id, 159 - subreddit, 160 - ]); 161 - res.status(200).send("Unsubscribed successfully"); 162 - } else { 163 - res.status(400).send("Subscription not found"); 164 - } 214 + router.post("/unsubscribe", authenticateToken, async (req, res) => { 215 + const { subreddit } = req.body; 216 + const user = req.user; 217 + const existingSubscription = db 218 + .query( 219 + "SELECT * FROM subscriptions WHERE user_id = $id AND subreddit = $subreddit", 220 + ) 221 + .get({ id: user.id, subreddit }); 222 + if (existingSubscription) { 223 + db.query( 224 + "DELETE FROM subscriptions WHERE user_id = $id AND subreddit = $subreddit", 225 + ).run({ id: user.id, subreddit }); 226 + console.log("done"); 227 + res.status(200).send("Unsubscribed successfully"); 165 228 } else { 166 - res.status(404).send("User not found"); 229 + console.log("not"); 230 + res.status(400).send("Subscription not found"); 167 231 } 168 232 }); 169 233
+6 -4
src/views/comments.pug
··· 3 3 include ../mixins/head 4 4 include ../utils 5 5 6 + - var post = data.post 7 + - var comments = data.comments 6 8 doctype html 7 9 html 8 - +head() 10 + +head(post.title) 9 11 script. 10 12 function toggleDetails(details_id) { 11 13 var detailsElement = document.getElementById(details_id); ··· 16 18 17 19 body 18 20 main#content 19 - +header() 21 + +header(user) 20 22 div.hero 21 23 h3.sub-title 22 24 a(href=`/r/${post.subreddit}`) ← r/#{post.subreddit} ··· 40 42 each item in post.gallery_data.items 41 43 - var url = `https://i.redd.it/${item.media_id}.jpg` 42 44 div.gallery-item 45 + div.gallery-item-idx 46 + | #{`${++idx}/${total}`} 43 47 a(href=`/media/${url}`) 44 48 img(src=url loading="lazy") 45 - div.gallery-item-idx 46 - | #{`${++idx}/${total}`} 47 49 else if post.post_hint == "image" && post.thumbnail && post.thumbnail != "self" && post.thumbnail != "default" 48 50 img(src=post.url).post-media 49 51 else if post.post_hint == 'hosted:video'
+22 -34
src/views/index.pug
··· 9 9 +head("home") 10 10 +subMgmt() 11 11 script(defer). 12 - async function updateButton(sub) { 13 - var b = document.getElementById("button-container"); 14 - b.innerHTML = ''; 15 - 16 - const button = document.createElement("button"); 17 - 18 - if (issub(sub)) { 19 - button.innerText = "unsubscribe"; 20 - button.onclick = async () => await unsubscribe(sub); 21 - } else { 22 - button.innerText = "subscribe"; 23 - button.onclick = async () => await subscribe(sub); 24 - } 25 - b.appendChild(button); 26 - } 27 - 28 12 async function subscribe(sub) { 29 - await postSubscription(sub, true); 30 - updateButton(sub); 13 + await doThing(sub, 'subscribe'); 31 14 } 32 15 33 16 async function unsubscribe(sub) { 34 - await postUnsubscription(sub); 35 - updateButton(sub); 17 + await doThing(sub, 'unsubscribe'); 36 18 } 37 19 38 - async function postUnsubscription(sub) { 39 - const response = await fetch('/unsubscribe', { 20 + function getCookie(name) { 21 + const value = `; ${document.cookie}`; 22 + const parts = value.split(`; ${name}=`); 23 + if (parts.length === 2) return parts.pop().split(";").shift(); 24 + } 25 + 26 + async function doThing(sub, thing) { 27 + const jwtToken = getCookie("auth_token"); 28 + const response = await fetch(`/${thing}`, { 40 29 method: 'POST', 41 30 headers: { 31 + 'Authorization': `Bearer ${jwtToken}`, 42 32 'Content-Type': 'application/json', 43 33 }, 44 34 body: JSON.stringify({ subreddit: sub }), 45 35 }); 46 36 47 - if (!response.ok) { 48 - console.error('Failed to update unsubscription'); 37 + let thinger = document.getElementById('thinger'); 38 + if (thing == 'subscribe') { 39 + thinger.innerText = 'unsubscribe'; 40 + } else { 41 + thinger.innerText = 'subscribe'; 49 42 } 50 - } 51 - const response = await fetch('/subscribe', { 52 - method: 'POST', 53 - headers: { 54 - 'Content-Type': 'application/json', 55 - }, 56 - body: JSON.stringify({ subreddit: sub, subscribe: subscribe }), 57 - }); 58 43 59 44 if (!response.ok) { 60 - console.error('Failed to update subscription'); 45 + console.error(`Failed to do ${thing}`); 61 46 } 62 47 } 63 48 ··· 68 53 } 69 54 } 70 55 71 - document.addEventListener('DOMContentLoaded', () => updateButton("#{subreddit}")); 72 56 body 73 57 main#content 74 58 +header(user) ··· 82 66 | r/#{subreddit} 83 67 if !isMulti 84 68 div#button-container 69 + if isSubbed 70 + button(onclick=`unsubscribe('${subreddit}')`)#thinger unsubscribe 71 + else 72 + button(onclick=`subscribe('${subreddit}')`)#thinger subscribe 85 73 if about 86 74 p #{about.public_description} 87 75 details
+26
src/views/login.pug
··· 1 + include ../mixins/head 2 + 3 + doctype html 4 + html 5 + +head("login") 6 + body 7 + main#content 8 + h1 login 9 + if message 10 + div.register-error-message 11 + | #{message} 12 + - var url = redirect ? `/login?redirect=${redirect}` : '/login' 13 + form(action=url method="post") 14 + div.input-text 15 + label(for="username") username 16 + input(type="text" name="username" required) 17 + div.input-text 18 + label(for="password") password 19 + input(type="password" name="password" required) 20 + div.submit-button 21 + button(type="submit") login 22 + div 23 + p 24 + | don't have an account?  25 + a(href="/register") register 26 +
+28
src/views/register.pug
··· 1 + include ../mixins/head 2 + 3 + doctype html 4 + html 5 + +head("register") 6 + body 7 + main#content 8 + h1 register 9 + if message 10 + div.register-error-message 11 + | #{message} 12 + form(action="/register" method="post") 13 + div.input-text 14 + label(for="username") username 15 + input(type="text" name="username" required) 16 + div.input-text 17 + label(for="password") password 18 + input(type="password" name="password" required) 19 + div.input-text 20 + label(for="confirm_password") confirm password 21 + input(type="password" name="confirm_password" required) 22 + div.submit-button 23 + button(type="submit") register 24 + div 25 + p 26 + | already have an account?  27 + a(href="/login") login 28 +
+1 -1
src/views/single_comment_thread.pug
··· 5 5 6 6 doctype html 7 7 html 8 - +head() 8 + +head("more comments") 9 9 body 10 10 main#content 11 11 +header()
+7 -3
src/views/subs.pug
··· 4 4 5 5 doctype html 6 6 html 7 - +head() 7 + +head("subscriptions") 8 8 +subMgmt() 9 9 script. 10 10 function newSubItem(sub) { ··· 26 26 document.addEventListener('DOMContentLoaded', buildSubList); 27 27 body 28 28 main#content 29 - +header() 29 + +header(user) 30 30 div.hero 31 31 h1 subscriptions 32 - div#subList 32 + p 33 + each s in subs 34 + a(href=`/r/${s.subreddit}`) 35 + | r/#{s.subreddit} 36 + br