selfhostable, read-only reddit client

Compare changes

Choose any two refs to compare.

+7 -2
readme.md
··· 9 9 - no account necessary for over-18 content 10 10 11 11 i host a version for myself and a few friends. reach out to 12 - me if you would like an invite. 12 + me if you would like an invite. 13 13 14 14 ### features 15 15 ··· 87 87 or with just [bun](https://bun.sh/): 88 88 89 89 ```bash 90 - bun run src/index.js 90 + bun run src/index.js 91 91 ``` 92 92 93 93 ### usage ··· 98 98 username at the top-right to view the dashboard and to 99 99 invite other users to your instance. copy the link and send 100 100 it to your friends! 101 + 102 + ### environment variables 103 + 104 + - `LURKER_PORT`: port to listen on, defaults to `3000`. 105 + - `LURKER_THEME`: name of CSS theme file. The file must be present in `src/public`. 101 106 102 107 ### technical 103 108
+63 -15
src/geddit.js
··· 10 10 include_over_18: true, 11 11 type: "sr,link,user", 12 12 }; 13 + this.headers = { 14 + "User-Agent": 15 + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", 16 + }; 13 17 } 14 18 15 19 async getSubmissions(sort = "hot", subreddit = null, options = {}) { ··· 24 28 `${ 25 29 this.host + subredditStr 26 30 }/${sort}.json?${new URLSearchParams(Object.assign(params, options))}`, 31 + { headers: this.headers }, 27 32 ) 28 33 .then((res) => res.json()) 29 34 .then((json) => json.data) ··· 37 42 async getDomainHot(domain, options = this.parameters) { 38 43 return await fetch( 39 44 `${this.host}/domain/${domain}/hot.json?${new URLSearchParams(options)}`, 45 + { headers: this.headers }, 40 46 ) 41 47 .then((res) => res.json()) 42 48 .then((json) => json.data) ··· 50 56 async getDomainBest(domain, options = this.parameters) { 51 57 return await fetch( 52 58 `${this.host}/domain/${domain}/best.json?${new URLSearchParams(options)}`, 59 + { headers: this.headers }, 53 60 ) 54 61 .then((res) => res.json()) 55 62 .then((json) => json.data) ··· 63 70 async getDomainTop(domain, options = this.parameters) { 64 71 return await fetch( 65 72 `${this.host}/domain/${domain}/top.json?${new URLSearchParams(options)}`, 73 + { headers: this.headers }, 66 74 ) 67 75 .then((res) => res.json()) 68 76 .then((json) => json.data) ··· 70 78 after: data.after, 71 79 posts: data.children, 72 80 })) 73 - .catch((err) => null); 81 + .catch((_) => null); 74 82 } 75 83 76 84 async getDomainNew(domain, options = this.parameters) { 77 85 return await fetch( 78 86 `${this.host}/domain/${domain}/new.json?${new URLSearchParams(options)}`, 87 + { headers: this.headers }, 79 88 ) 80 89 .then((res) => res.json()) 81 90 .then((json) => json.data) ··· 89 98 async getDomainRising(domain, options = this.parameters) { 90 99 return await fetch( 91 100 `${this.host}/domain/${domain}/rising.json?${new URLSearchParams(options)}`, 101 + { headers: this.headers }, 92 102 ) 93 103 .then((res) => res.json()) 94 104 .then((json) => json.data) ··· 102 112 async getDomainControversial(domain, options = this.parameters) { 103 113 return await fetch( 104 114 `${this.host}/domain/${domain}/controversial.json?${new URLSearchParams(options)}`, 115 + { headers: this.headers }, 105 116 ) 106 117 .then((res) => res.json()) 107 118 .then((json) => json.data) ··· 113 124 } 114 125 115 126 async getSubreddit(subreddit) { 116 - return await fetch(`${this.host}/r/${subreddit}/about.json`) 127 + return await fetch(`${this.host}/r/${subreddit}/about.json`, { 128 + headers: this.headers, 129 + }) 117 130 .then((res) => res.json()) 118 131 .then((json) => json.data) 119 132 .catch((err) => null); 120 133 } 121 134 122 135 async getSubredditRules(subreddit) { 123 - return await fetch(`${this.host}/r/${subreddit}/about/rules.json`) 136 + return await fetch(`${this.host}/r/${subreddit}/about/rules.json`, { 137 + headers: this.headers, 138 + }) 124 139 .then((res) => res.json()) 125 140 .then((json) => json.data) 126 141 .catch((err) => null); 127 142 } 128 143 129 144 async getSubredditModerators(subreddit) { 130 - return await fetch(`${this.host}/r/${subreddit}/about/moderators.json`) 145 + return await fetch(`${this.host}/r/${subreddit}/about/moderators.json`, { 146 + headers: this.headers, 147 + }) 131 148 .then((res) => res.json()) 132 149 .then((json) => json.data) 133 - .then({ 134 - data: { 135 - users: data.children, 136 - }, 137 - }) 150 + .then((data) => ({ 151 + users: data.children, 152 + })) 138 153 .catch((err) => null); 139 154 } 140 155 141 156 async getSubredditWikiPages(subreddit) { 142 - return await fetch(`${this.host}/r/${subreddit}/wiki/pages.json`) 157 + return await fetch(`${this.host}/r/${subreddit}/wiki/pages.json`, { 158 + headers: this.headers, 159 + }) 143 160 .then((res) => res.json()) 144 161 .then((json) => json.data) 145 162 .catch((err) => null); 146 163 } 147 164 148 165 async getSubredditWikiPage(subreddit, page) { 149 - return await fetch(`${this.host}/r/${subreddit}/wiki/${page}.json`) 166 + return await fetch(`${this.host}/r/${subreddit}/wiki/${page}.json`, { 167 + headers: this.headers, 168 + }) 150 169 .then((res) => res.json()) 151 170 .then((json) => json.data) 152 171 .catch((err) => null); 153 172 } 154 173 155 174 async getSubredditWikiPageRevisions(subreddit, page) { 156 - return await fetch(`${this.host}/r/${subreddit}/wiki/revisions${page}.json`) 175 + return await fetch( 176 + `${this.host}/r/${subreddit}/wiki/revisions${page}.json`, 177 + { headers: this.headers }, 178 + ) 157 179 .then((res) => res.json()) 158 180 .then((json) => json.data.children) 159 181 .catch((err) => null); ··· 162 184 async getPopularSubreddits(options = this.parameters) { 163 185 return await fetch( 164 186 `${this.host}/subreddits/popular.json?${new URLSearchParams(options)}`, 187 + { headers: this.headers }, 165 188 ) 166 189 .then((res) => res.json()) 167 190 .then((json) => json.data) ··· 175 198 async getNewSubreddits(options = this.parameters) { 176 199 return await fetch( 177 200 `${this.host}/subreddits/new.json?${new URLSearchParams(options)}`, 201 + { headers: this.headers }, 178 202 ) 179 203 .then((res) => res.json()) 180 204 .then((json) => json.data) ··· 188 212 async getPremiumSubreddits(options = this.parameters) { 189 213 return await fetch( 190 214 `${this.host}/subreddits/premium.json?${new URLSearchParams(options)}`, 215 + { headers: this.headers }, 191 216 ) 192 217 .then((res) => res.json()) 193 218 .then((json) => json.data) ··· 201 226 async getDefaultSubreddits(options = this.parameters) { 202 227 return await fetch( 203 228 `${this.host}/subreddits/default.json?${new URLSearchParams(options)}`, 229 + { headers: this.headers }, 204 230 ) 205 231 .then((res) => res.json()) 206 232 .then((json) => json.data) ··· 214 240 async getPopularUsers(options = this.parameters) { 215 241 return await fetch( 216 242 `${this.host}/users/popular.json?${new URLSearchParams(options)}`, 243 + { headers: this.headers }, 217 244 ) 218 245 .then((res) => res.json()) 219 246 .then((json) => json.data) ··· 227 254 async getNewUsers(options = this.parameters) { 228 255 return await fetch( 229 256 `${this.host}/users/new.json?${new URLSearchParams(options)}`, 257 + { headers: this.headers }, 230 258 ) 231 259 .then((res) => res.json()) 232 260 .then((json) => json.data) ··· 243 271 244 272 return await fetch( 245 273 `${this.host}/search.json?${new URLSearchParams(options)}`, 274 + { headers: this.headers }, 246 275 ) 247 276 .then((res) => res.json()) 248 277 .then((json) => json.data) ··· 263 292 264 293 return await fetch( 265 294 `${this.host}/subreddits/search.json?${new URLSearchParams(Object.assign(params, options))}`, 295 + { headers: this.headers }, 266 296 ) 267 297 .then((res) => res.json()) 268 298 .then((json) => json.data) ··· 283 313 284 314 return await fetch( 285 315 `${this.host}/users/search.json?${new URLSearchParams(Object.assign(params, options))}`, 316 + { headers: this.headers }, 286 317 ) 287 318 .then((res) => res.json()) 288 319 .then((json) => json.data) ··· 307 338 `${ 308 339 this.host + subredditStr 309 340 }/search.json?${new URLSearchParams(Object.assign(params, options))}`, 341 + { headers: this.headers }, 310 342 ) 311 343 .then((res) => res.json()) 312 344 .then((json) => ··· 324 356 } 325 357 326 358 async getSubmission(id) { 327 - return await fetch(`${this.host}/by_id/${id}.json`) 359 + return await fetch(`${this.host}/by_id/${id}.json`, { 360 + headers: this.headers, 361 + }) 328 362 .then((res) => res.json()) 329 363 .then((json) => json.data.children[0].data) 330 364 .catch((err) => null); ··· 333 367 async getSubmissionComments(id, options = this.parameters) { 334 368 return await fetch( 335 369 `${this.host}/comments/${id}.json?${new URLSearchParams(options)}`, 370 + { headers: this.headers }, 336 371 ) 337 372 .then((res) => res.json()) 338 373 .then((json) => ({ ··· 345 380 async getSingleCommentThread(parent_id, child_id, options = this.parameters) { 346 381 return await fetch( 347 382 `${this.host}/comments/${parent_id}/comment/${child_id}.json?${new URLSearchParams(options)}`, 383 + { headers: this.headers }, 348 384 ) 349 385 .then((res) => res.json()) 350 386 .then((json) => ({ ··· 357 393 async getSubredditComments(subreddit, options = this.parameters) { 358 394 return await fetch( 359 395 `${this.host}/r/${subreddit}/comments.json?${new URLSearchParams(options)}`, 396 + { headers: this.headers }, 360 397 ) 361 398 .then((res) => res.json()) 362 399 .then((json) => json.data.children) ··· 364 401 } 365 402 366 403 async getUser(username) { 367 - return await fetch(`${this.host}/user/${username}/about.json`) 404 + return await fetch(`${this.host}/user/${username}/about.json`, { 405 + headers: this.headers, 406 + }) 368 407 .then((res) => res.json()) 369 408 .then((json) => json.data) 370 409 .catch((err) => null); ··· 373 412 async getUserOverview(username, options = this.parameters) { 374 413 return await fetch( 375 414 `${this.host}/user/${username}/overview.json?${new URLSearchParams(options)}`, 415 + { headers: this.headers }, 376 416 ) 377 417 .then((res) => res.json()) 378 418 .then((json) => json.data) ··· 386 426 async getUserComments(username, options = this.parameters) { 387 427 return await fetch( 388 428 `${this.host}/user/${username}/comments.json?${new URLSearchParams(options)}`, 429 + { headers: this.headers }, 389 430 ) 390 431 .then((res) => res.json()) 391 432 .then((json) => json.data) ··· 399 440 async getUserSubmissions(username, options = this.parameters) { 400 441 return await fetch( 401 442 `${this.host}/user/${username}/submitted.json?${new URLSearchParams(options)}`, 443 + { headers: this.headers }, 402 444 ) 403 445 .then((res) => res.json()) 404 446 .then((json) => json.data) ··· 410 452 } 411 453 412 454 async getLiveThread(id) { 413 - return await fetch(`${this.host}/live/${id}/about.json`) 455 + return await fetch(`${this.host}/live/${id}/about.json`, { 456 + headers: this.headers, 457 + }) 414 458 .then((res) => res.json()) 415 459 .then((json) => json.data) 416 460 .catch((err) => null); ··· 419 463 async getLiveThreadUpdates(id, options = this.parameters) { 420 464 return await fetch( 421 465 `${this.host}/live/${id}.json?${new URLSearchParams(options)}`, 466 + { headers: this.headers }, 422 467 ) 423 468 .then((res) => res.json()) 424 469 .then((json) => json.data.children) ··· 428 473 async getLiveThreadContributors(id, options = this.parameters) { 429 474 return await fetch( 430 475 `${this.host}/live/${id}/contributors.json?${new URLSearchParams(options)}`, 476 + { headers: this.headers }, 431 477 ) 432 478 .then((res) => res.json()) 433 479 .then((json) => json.data.children) ··· 437 483 async getLiveThreadDiscussions(id, options = this.parameters) { 438 484 return await fetch( 439 485 `${this.host}/live/${id}/discussions.json?${new URLSearchParams(options)}`, 486 + { headers: this.headers }, 440 487 ) 441 488 .then((res) => res.json()) 442 489 .then((json) => json.data.children) ··· 446 493 async getLiveThreadsNow(options = this.parameters) { 447 494 return await fetch( 448 495 `${this.host}/live/happening_now.json?${new URLSearchParams(options)}`, 496 + { headers: this.headers }, 449 497 ) 450 498 .then((res) => res.json()) 451 499 .then((json) => json.data.children)
+2 -1
src/mixins/head.pug
··· 4 4 meta(charset='UTF-8') 5 5 title #{`${title} · lurker `} 6 6 link(rel="stylesheet", href="/styles.css") 7 + if theme 8 + link(rel="stylesheet", href=`/${theme}.css`) 7 9 link(rel="preconnect" href="https://rsms.me/") 8 10 link(rel="stylesheet" href="https://rsms.me/inter/inter.css") 9 11 script(src="https://cdn.dashjs.org/latest/dash.all.min.js") 10 -
+5 -6
src/mixins/header.pug
··· 1 1 mixin header(user) 2 2 - var viewQuery = 'view=' + (query && query.view ? query.view : 'compact') 3 - - var sortQuery = 'sort=' + (query ? (query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot') : 'hot') 4 3 div.header 5 4 div.header-item 6 - a(href=`/?${sortQuery}&${viewQuery}`) home 5 + a(href=`/?${viewQuery}`) home 7 6 div.header-item 8 - a(href=`/r/all?${sortQuery}&${viewQuery}`) all 7 + a(href=`/r/all?${viewQuery}`) all 9 8 div.header-item 10 - a(href=`/search?${sortQuery}&${viewQuery}`) search 9 + a(href=`/search?${viewQuery}`) search 11 10 div.header-item 12 - a(href=`/subs?${sortQuery}&${viewQuery}`) subs 11 + a(href=`/subs?${viewQuery}`) subs 13 12 if user 14 13 div.header-item 15 - a(href=`/dashboard?${sortQuery}&${viewQuery}`) #{user.username} 14 + a(href=`/dashboard?${viewQuery}`) #{user.username} 16 15 |  17 16 a(href='/logout') (logout) 18 17 else
+6 -14
src/mixins/post.pug
··· 25 25 |  ·  26 26 | #{timeDifference(Date.now(), p.created * 1000)} 27 27 |  ·  28 - a(href=`/r/${p.subreddit}?sort=${sortQuery}&view=${viewQuery}`) r/#{p.subreddit} 28 + a(href=`/r/${p.subreddit}?view=${viewQuery}`) r/#{p.subreddit} 29 29 |  ·  30 - a(href=`/comments/${p.id}?from=${from}&sort=${sortQuery}&view=${viewQuery}`) #{fmtnum (p.num_comments)} ↩ 30 + a(href=`/comments/${p.id}?from=${from}`) #{fmtnum (p.num_comments)} ↩ 31 31 if (query.view == "card" && !isPostMedia(p) && p.selftext_html) 32 32 div.self-text-overflow.card 33 33 if p.spoiler || p.over_18 ··· 46 46 - var url = postThumbnail(p) 47 47 img(src=url onclick=onclick) 48 48 else if isPostVideo(p) 49 - - var decodedVideos = decodePostVideoUrls(p) 50 - video(autoplay="" playsinline="" muted="" data-dashjs-player="" onclick=`toggleDetails('${p.id}')` poster=decodedVideos[4] width="100px" height="100px") 51 - // Scrubber 52 - source(src=decodedVideos[3]) 49 + - var url = p.secure_media.reddit_video.scrubber_media_url 50 + video(src=url data-dashjs-player width='100px' height='100px' onclick=`toggleDetails('${p.id}')`) 53 51 else if isPostLink(p) 54 52 a(href=p.url) 55 53 | ↗ ··· 73 71 a(href=`/media/${p.url}`) 74 72 img(src=p.url loading="lazy") 75 73 else if isPostVideo(p) 76 - - var decodedVideos = decodePostVideoUrls(p) 77 - video(controls="" muted="" data-dashjs-player="" preload="metadata" playsinline="" poster=decodedVideos[4] objectfit="contain" loading="lazy") 78 - // HLS 79 - source(src=decodedVideos[0]) 80 - // Dash 81 - source(src=decodedVideos[1]) 82 - // Fallback 83 - source(src=decodedVideos[2]) 74 + - var url = p.secure_media.reddit_video.dash_url 75 + video(src=url controls data-dashjs-player loading="lazy").post-media 84 76 else if isPostLink(p) 85 77 a(href=p.url) 86 78 | #{p.domain} ↗
+2 -2
src/mixins/postUtils.pug
··· 60 60 // Find all anchors that href to preview.redd.it, i.redd.it, i.imgur.com 61 61 // and contain just a link to the same href 62 62 const expression = /<a href="(http[s]?:\/\/(?:preview\.redd\.it|i\.redd\.it|i\.imgur\.com).*?)">\1?<\/a>/g; 63 - const matches = html.matchAll(expression); 63 + const matches = Array.from(html.matchAll(expression)); 64 64 var result = html; 65 65 matches.forEach((match) => { 66 66 // Replace each occurrence with an actual img tag ··· 85 85 var poster_url = p.preview && p.preview.images ? p.preview.images[0].source.url.replace(expression, '&') : ''; 86 86 87 87 return [hls_url, dash_url, fallback_url, scrubber_url, poster_url]; 88 - } 88 + }
+9 -32
src/public/styles.css
··· 11 11 --link-visited-color: #999; 12 12 --accent: var(--link-color); 13 13 --error-text-color: red; 14 - --border-radius-card: 2vmin; 15 - --border-radius-media: 1.5vmin; 16 - --border-radius-preview: 1vmin; 14 + --border-radius-card: 0.5vmin; 15 + --border-radius-media: 0.5vmin; 16 + --border-radius-preview: 0.3vmin; 17 17 18 18 font-family: Inter, sans-serif; 19 19 font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'dlig' 1, 'ss01' 1, 'ss07' 1, 'ss08' 1; ··· 203 203 204 204 .image-viewer { 205 205 position: relative; 206 - margin: 0.9rem; 207 206 } 208 207 209 208 .image-viewer > img { ··· 306 305 max-height: 70vh; 307 306 } 308 307 309 - .image-viewer.main-content a { 310 - margin: unset; 311 - } 312 - 313 308 .image-viewer a:has(img) { 314 309 font-size: 0rem; 315 310 padding: unset; 316 311 margin: unset; 317 - } 318 - 319 - .media-preview a, 320 - .image-viewer a { 321 - font-size: 1.5rem; 322 - text-decoration: none; 323 - padding: unset; 324 - margin: 1rem; 325 312 } 326 313 327 314 .media-maximized { ··· 363 350 364 351 @media (min-width: 768px) { 365 352 :root { 366 - --border-radius-card: 1vmin; 367 - --border-radius-media: 1vmin; 368 - --border-radius-preview: 0.5vmin; 353 + --border-radius-card: 0.5vmin; 354 + --border-radius-media: 0.5vmin; 355 + --border-radius-preview: 0.3vmin; 369 356 } 370 357 .post, .comments-container, .hero, .header, .footer { 371 358 flex: 1 1 90%; ··· 380 367 .image-viewer img, 381 368 .image-viewer video 382 369 { 383 - max-height: 45vh; 384 - } 385 - .image-viewer a { 386 - font-size: 1rem; 387 - margin: 0.7rem; 388 - padding: initial; 370 + max-height: 50vh; 389 371 } 390 372 .post-text.card { 391 373 max-width: 100%; ··· 429 411 .image-viewer img, 430 412 .image-viewer video 431 413 { 432 - max-height: 35vh; 414 + max-height: 45vh; 433 415 } 434 416 .media-preview a { 435 417 font-size: 2rem; 436 418 padding: 2rem; 437 - } 438 - .image-viewer a { 439 - font-size: 1rem; 440 - margin: 1rem; 441 - padding: initial; 442 419 } 443 420 .self-text.card { 444 421 -webkit-line-clamp: 4; ··· 824 801 825 802 .sticky { 826 803 background-color: var(--sticky-color); 827 - border-radius: 2px; 804 + border-radius: var(--border-radius-card); 828 805 border: 4px solid var(--sticky-color); 829 806 } 830 807
+36
src/public/theme.css
··· 1 + /* 2 + Uncomment and modify the values in this file to change the theme of the app. 3 + 4 + :root { 5 + --bg-color: white; 6 + --bg-color-muted: #eee; 7 + --text-color: black; 8 + --text-color-muted: #999; 9 + --blockquote-color: green; 10 + --sticky-color: #dcfeda; 11 + --gilded: darkorange; 12 + --link-color: #29bc9b; 13 + --link-visited-color: #999; 14 + --accent: var(--link-color); 15 + --error-text-color: red; 16 + --border-radius-card: 0.5vmin; 17 + --border-radius-media: 0.5vmin; 18 + --border-radius-preview: 0.3vmin; 19 + } 20 + 21 + @media (prefers-color-scheme: dark) { 22 + :root { 23 + --bg-color: black; 24 + --bg-color-muted: #333; 25 + --text-color: white; 26 + --text-color-muted: #999; 27 + --blockquote-color: lightgreen; 28 + --sticky-color: #014413; 29 + --gilded: gold; 30 + --link-color: #79ffe1; 31 + --link-visited-color: #999; 32 + --accent: var(--link-color); 33 + --error-text-color: lightcoral; 34 + } 35 + } 36 + */
+42 -12
src/routes/index.js
··· 6 6 const { db } = require("../db"); 7 7 const { authenticateToken, authenticateAdmin } = require("../auth"); 8 8 const { validateInviteToken } = require("../invite"); 9 - const url = require("url"); 10 9 11 10 const router = express.Router(); 12 11 const G = new geddit.Geddit(); 13 12 13 + const commonRenderOptions = { 14 + theme: process.env.LURKER_THEME, 15 + }; 16 + 14 17 // GET / 15 18 router.get("/", authenticateToken, async (req, res) => { 16 19 const subs = db 17 20 .query("SELECT * FROM subscriptions WHERE user_id = $id") 18 21 .all({ id: req.user.id }); 19 22 20 - const qs = req.query ? ('?' + new URLSearchParams(req.query).toString()) : ''; 23 + const qs = req.query ? "?" + new URLSearchParams(req.query).toString() : ""; 21 24 22 25 if (subs.length === 0) { 23 26 res.redirect(`/r/all${qs}`); ··· 53 56 54 57 const [posts, about] = await Promise.all([postsReq, aboutReq]); 55 58 56 - if (query.view == 'card' && posts && posts.posts) { 59 + if (query.view == "card" && posts && posts.posts) { 57 60 posts.posts.forEach(unescape_selftext); 58 61 } 59 62 ··· 66 69 user: req.user, 67 70 isSubbed, 68 71 currentUrl: req.url, 72 + ...commonRenderOptions, 69 73 }); 70 74 }); 71 75 ··· 82 86 user: req.user, 83 87 from: req.query.from, 84 88 query: req.query, 89 + ...commonRenderOptions, 85 90 }); 86 91 }); 87 92 ··· 103 108 comments, 104 109 parent_id, 105 110 user: req.user, 111 + ...commonRenderOptions, 106 112 }); 107 113 }, 108 114 ); ··· 115 121 ) 116 122 .all({ id: req.user.id }); 117 123 118 - res.render("subs", { subs, user: req.user, query: req.query }); 124 + res.render("subs", { 125 + subs, 126 + user: req.user, 127 + query: req.query, 128 + ...commonRenderOptions, 129 + }); 119 130 }); 120 131 121 132 // GET /search 122 133 router.get("/search", authenticateToken, async (req, res) => { 123 - res.render("search", { user: req.user, query: req.query }); 134 + res.render("search", { 135 + user: req.user, 136 + query: req.query, 137 + ...commonRenderOptions, 138 + }); 124 139 }); 125 140 126 141 // GET /sub-search 127 142 router.get("/sub-search", authenticateToken, async (req, res) => { 128 143 if (!req.query || !req.query.q) { 129 - res.render("sub-search", { user: req.user }); 144 + res.render("sub-search", { user: req.user, ...commonRenderOptions }); 130 145 } else { 131 146 const { items, after } = await G.searchSubreddits(req.query.q); 132 147 const subs = db ··· 145 160 user: req.user, 146 161 original_query: req.query.q, 147 162 query: req.query, 163 + ...commonRenderOptions, 148 164 }); 149 165 } 150 166 }); ··· 152 168 // GET /post-search 153 169 router.get("/post-search", authenticateToken, async (req, res) => { 154 170 if (!req.query || !req.query.q) { 155 - res.render("post-search", { user: req.user }); 171 + res.render("post-search", { user: req.user, ...commonRenderOptions }); 156 172 } else { 157 173 const { items, after } = await G.searchSubmissions(req.query.q); 158 174 const message = ··· 160 176 ? "no results found" 161 177 : `showing ${items.length} results`; 162 178 163 - if (req.query.view == 'card' && items) { 179 + if (req.query.view == "card" && items) { 164 180 items.forEach(unescape_selftext); 165 181 } 166 - 182 + 167 183 res.render("post-search", { 168 184 items, 169 185 after, ··· 172 188 original_query: req.query.q, 173 189 currentUrl: req.url, 174 190 query: req.query, 191 + ...commonRenderOptions, 175 192 }); 176 193 } 177 194 }); ··· 194 211 usedAt: Date.parse(inv.usedAt), 195 212 })); 196 213 } 197 - res.render("dashboard", { invites, isAdmin, user: req.user, query: req.query }); 214 + res.render("dashboard", { 215 + invites, 216 + isAdmin, 217 + user: req.user, 218 + query: req.query, 219 + ...commonRenderOptions, 220 + }); 198 221 }); 199 222 200 223 router.get("/create-invite", authenticateAdmin, async (req, res) => { ··· 233 256 const kind = ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) 234 257 ? "img" 235 258 : "video"; 236 - res.render("media", { kind, url }); 259 + res.render("media", { kind, url, ...commonRenderOptions }); 237 260 }); 238 261 239 262 router.get("/register", validateInviteToken, async (req, res) => { 240 - res.render("register", { isDisabled: false, token: req.query.token }); 263 + res.render("register", { 264 + isDisabled: false, 265 + token: req.query.token, 266 + ...commonRenderOptions, 267 + }); 241 268 }); 242 269 243 270 router.post("/register", validateInviteToken, async (req, res) => { ··· 253 280 if (user) { 254 281 return res.render("register", { 255 282 message: `user by the name "${username}" exists, choose a different username`, 283 + ...commonRenderOptions, 256 284 }); 257 285 } 258 286 259 287 if (password !== confirm_password) { 260 288 return res.render("register", { 261 289 message: "passwords do not match, try again", 290 + ...commonRenderOptions, 262 291 }); 263 292 } 264 293 ··· 294 323 } catch (err) { 295 324 return res.render("register", { 296 325 message: "error registering user, try again later", 326 + ...commonRenderOptions, 297 327 }); 298 328 } 299 329 });
+6 -1
src/utils.pug
··· 1 - - var fmtnum = (n)=>n>=1000?(n/1000).toFixed(1)+'k':n; 1 + - 2 + function fmtnum(n) { 3 + return n >= 1e6 ? (n / 1e6).toFixed(1) + 'mil' : 4 + n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : 5 + n; 6 + } 2 7 - var fmttxt = (n,t)=>`${t}${n==1?'':'s'}` 3 8 - var stripPrefix = (s, p) => s.startsWith(p) ? s.slice(p.length) : s; 4 9 -
+1 -1
src/views/comments.pug
··· 64 64 video(controls data-dashjs-player src=`${url}`).post-media 65 65 else if isPostLink(post) 66 66 a(href=post.url) 67 - | #{post.domain} ↗ 67 + | #{post.url} 68 68 69 69 if post.selftext_html 70 70 div.self-text
+6
src/views/index.pug
··· 33 33 | consider donating to&nbsp; 34 34 a(href="https://donate.stripe.com/dR62bTaZH1295Da4gg") oppiliappan 35 35 |, author of lurker 36 + if about && !isMulti 37 + div.info-container 38 + p 39 + | #{fmtnum(about.accounts_active)} active 40 + | &nbsp;·&nbsp; 41 + | #{fmtnum(about.subscribers)} subscribers 36 42 hr 37 43 details.sort-details 38 44 summary.sorting sorting by #{query.sort + (query.t?' '+query.t:'')}
+1 -6
src/views/media.pug
··· 2 2 doctype html 3 3 html 4 4 +head("home") 5 - script(type='text/javascript'). 6 - function toggleZoom() { 7 - Array.from(document.getElementsByClassName('media-maximized')).forEach(element => element.classList.toggle('zoom')); 8 - } 9 - 10 5 body.media-maximized 11 6 div.media-maximized.container 12 7 if kind == 'img' 13 - img(src=url onclick=`toggleZoom()`).media-maximized 8 + img(src=url).media-maximized 14 9 else 15 10 video(src=url controls).media-maximized