selfhostable, read-only reddit client

Compare changes

Choose any two refs to compare.

+14 -7
.github/workflows/publish-docker.yml
··· 13 13 - name: checkout repository 14 14 uses: actions/checkout@v4 15 15 16 - - name: build docker image 17 - run: docker build -t lurker:latest . 18 - 19 16 - name: log in to github container registry 20 17 uses: docker/login-action@v3 21 18 with: ··· 23 20 username: ${{ github.actor }} 24 21 password: ${{ secrets.GITHUB_TOKEN }} 25 22 26 - - name: publish docker image 27 - run: | 28 - docker tag lurker:latest ghcr.io/${{ github.repository_owner }}/lurker:latest 29 - docker push ghcr.io/${{ github.repository_owner }}/lurker:latest 23 + - name: Set up QEMU 24 + uses: docker/setup-qemu-action@v3 25 + 26 + - name: Set up Docker Buildx 27 + uses: docker/setup-buildx-action@v3 28 + 29 + - name: Build and push 30 + id: build 31 + uses: docker/build-push-action@v6 32 + with: 33 + context: . 34 + platforms: linux/amd64,linux/arm64 35 + push: true 36 + tags: ghcr.io/${{ github.repository_owner }}/lurker:latest
+21 -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 ··· 70 70 $ docker run -v /your/host/lurker-data:/data -p 3000 ghcr.io/oppiliappan/lurker:latest 71 71 ``` 72 72 73 + or with docker compose: 74 + 75 + ```yaml 76 + version: '3' 77 + services: 78 + lurker: 79 + image: ghcr.io/oppiliappan/lurker:latest 80 + container_name: lurker 81 + volumes: 82 + - /your/host/lurker-data:/data 83 + ports: 84 + - "3000:3000" 85 + ``` 86 + 73 87 or with just [bun](https://bun.sh/): 74 88 75 89 ```bash 76 - bun run src/index.js 90 + bun run src/index.js 77 91 ``` 78 92 79 93 ### usage ··· 84 98 username at the top-right to view the dashboard and to 85 99 invite other users to your instance. copy the link and send 86 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`. 87 106 88 107 ### technical 89 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)
+6 -1
src/mixins/comment.pug
··· 1 1 include ../utils 2 + include postUtils 2 3 3 4 mixin infoContainer(data, next_id, prev_id) 4 5 - var hats = (data.is_submitter?['op']:[]).concat(data.distinguished=="moderator"?['mod']:[]) ··· 11 12 |  ·  12 13 if prev_id 13 14 a(href=`#${prev_id}` title="scroll to previous comment").nav-link prev 15 + |  ·  16 + if data.gilded > 0 17 + span.gilded 18 + | #{data.gilded} ☆ 14 19 |  ·  15 20 span(class=`${data.is_submitter ? 'op' : ''}`) 16 21 | u/#{data.author} #{hats.length==0?'':`(${hats.join('|')})`} ··· 47 52 summary.expand-comments 48 53 +infoContainer(data, next_id, prev_id) 49 54 div.comment-body 50 - != data.body_html 55 + != convertInlineImageLinks(data.body_html) 51 56 if hasReplyData 52 57 div.replies 53 58 - var total = data.replies.data.children.length
+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
+73 -84
src/mixins/post.pug
··· 4 4 - var from = encodeURIComponent(currentUrl) 5 5 - var viewQuery = query && query.view ? query.view : 'compact' 6 6 - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 7 - article(class=`post ${p.stickied?"sticky":""}`) 8 - div.post-container(class=`${query.view}`) 9 - div.post-text(class=`${query.view}`) 10 - div.title-container(class=`${query.view}`) 11 - a(class=`${query.view}`, href=`/comments/${p.id}?from=${from}&sort=${sortQuery}&view=${viewQuery}`) 12 - != p.title 13 - span.domain (#{p.domain}) 14 - div.info-container 15 - p 16 - | #{fmtnum(p.ups)} ↑ 17 - span.post-author 7 + article(class=`post`) 8 + div.post-container(class=`${query.view} ${p.stickied?"sticky":""}`) 9 + div.post-info 10 + div.post-text(class=`${query.view}`) 11 + div.title-container(class=`${query.view}`) 12 + a(class=`${query.view}`, href=`/comments/${p.id}?from=${from}&sort=${sortQuery}&view=${viewQuery}`) 13 + != p.title 14 + span.domain (#{p.domain}) 15 + div.info-container 16 + p 17 + | #{fmtnum(p.ups)} ↑ 18 + if p.gilded > 0 19 + |  ·  20 + span.gilded 21 + | #{p.gilded} ☆ 22 + span.post-author 23 + |  ·  24 + | u/#{p.author} 18 25 |  ·  19 - | u/#{p.author} 20 - |  ·  21 - | #{timeDifference(Date.now(), p.created * 1000)} 22 - |  ·  23 - a(href=`/r/${p.subreddit}?sort=${sortQuery}&view=${viewQuery}`) r/#{p.subreddit} 24 - |  ·  25 - a(href=`/comments/${p.id}?from=${from}&sort=${sortQuery}&view=${viewQuery}`) #{fmtnum (p.num_comments)} ↩ 26 - if (query.view == "card" && !isPostGallery(p) && !isPostImage(p) && !isPostVideo(p) && p.selftext_html) 27 - div.self-text-overflow(class='card') 28 - if query.view == "card" && (p.spoiler || p.over_18) 29 - div.spoiler(id=`spoiler_${p.id}`, onclick=`javascript:document.getElementById('spoiler_${p.id}').style.display = 'none';`) 30 - h2 31 - != p.over_18 ? 'nsfw' : 'spoiler' 32 - div.self-text(class='card') 33 - != convertInlineImageLinks(p.selftext_html) 34 - div.media-preview(class=`${query.view}`) 35 - if query.view == "card" && (p.spoiler || p.over_18) && (isPostGallery(p) || isPostImage(p) || isPostVideo(p)) 36 - div.spoiler(id=`spoiler_${p.id}`, onclick=`javascript:document.getElementById('spoiler_${p.id}').style.display = 'none';`) 37 - h2 38 - != p.over_18 ? 'nsfw' : 'spoiler' 39 - if isPostGallery(p) 40 - - var item = postGalleryItems(p)[0] 41 - img(src=item.url onclick=`toggleDetails('${p.id}')`) 42 - else if isPostImage(p) 43 - - var url = query.view == "card" ? p.url : postThumbnail(p) 44 - img(src=url onclick=`toggleDetails('${p.id}')`) 45 - else if isPostVideo(p) 46 - - var decodedVideos = decodePostVideoUrls(p) 47 - if query.view == "card" 48 - video(controls="" muted="" data-dashjs-player="" preload="metadata" poster=decodedVideos[4]) 49 - // HLS 50 - source(src=decodedVideos[0]) 51 - // Dash 52 - source(src=decodedVideos[1]) 53 - // Fallback 54 - source(src=decodedVideos[2]) 55 - else 56 - video(autoplay="" muted="" data-dashjs-player="" onclick=`toggleDetails('${p.id}')` width="100px" height="100px") 57 - // Scrubber 58 - source(src=decodedVideos[3]) 59 - else if isPostLink(p) 60 - a(href=p.url) 61 - if (query.view == 'card') 62 - | #{p.domain} 63 - | ↗ 26 + | #{timeDifference(Date.now(), p.created * 1000)} 27 + |  ·  28 + a(href=`/r/${p.subreddit}?view=${viewQuery}`) r/#{p.subreddit} 29 + |  ·  30 + a(href=`/comments/${p.id}?from=${from}`) #{fmtnum (p.num_comments)} ↩ 31 + if (query.view == "card" && !isPostMedia(p) && p.selftext_html) 32 + div.self-text-overflow.card 33 + if p.spoiler || p.over_18 34 + div.spoiler(id=`spoiler_${p.id}`, onclick=`javascript:document.getElementById('spoiler_${p.id}').style.display = 'none';`) 35 + h2 36 + != p.over_18 ? 'nsfw' : 'spoiler' 37 + div.self-text.card 38 + != convertInlineImageLinks(p.selftext_html) 39 + if query.view != "card" 40 + div.media-preview 41 + - var onclick = `toggleDetails('${p.id}')` 42 + if isPostGallery(p) 43 + - var item = (p.over_18 ? `/nsfw.svg` : p.spoiler ? `/spoiler.svg` : postGalleryItems(p)[0].url) 44 + img(src=item onclick=onclick) 45 + else if isPostImage(p) 46 + - var url = postThumbnail(p) 47 + img(src=url onclick=onclick) 48 + else if isPostVideo(p) 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}')`) 51 + else if isPostLink(p) 52 + a(href=p.url) 53 + | ↗ 64 54 65 - if (isPostGallery(p) || isPostImage(p) || isPostVideo(p)) 66 - details(id=`${p.id}`,class=`${query.view}`) 67 - summary.expand-post expand media 68 - div.image-viewer(class=`${query.view}`) 69 - if isPostGallery(p) 70 - div.gallery(class=`${query.view}`) 71 - each item in postGalleryItems(p) 72 - div.gallery-item(class=`${query.view}`) 73 - div.gallery-item-idx(class=`${query.view}`) 74 - | #{`${item.idx}/${item.total}`} 75 - a(href=`/media/${item.url}`) 76 - img(src=item.url loading="lazy") 77 - else if isPostImage(p) 78 - a(href=`/media/${p.url}`) 79 - img(src=p.url loading="lazy").post-media 80 - else if isPostVideo(p) 81 - video(controls="" muted="" data-dashjs-player="" preload="metadata" playsinline="" poster=decodedVideos[4] objectfit="contain" loading="lazy").post-media 82 - //HLS 83 - source(src=decodedVideos[0]) 84 - // Dash 85 - source(src=decodedVideos[1]) 86 - // Fallback 87 - source(src=decodedVideos[2]) 88 - button(onclick=`toggleDetails('${p.id}')`,class=`${query.view}`) 89 - if (query.view == 'card') 90 - | ╳ 91 - else 92 - | close 55 + details(id=`${p.id}` open=(query.view == "card" && (isPostMedia(p) || isPostLink(p))) class=`${query.view}`) 56 + summary.expand-post expand media 57 + div.image-viewer 58 + if query.view == "card" && (p.spoiler || p.over_18) && isPostMedia(p) 59 + div.spoiler(id=`spoiler_${p.id}`, onclick=`javascript:document.getElementById('spoiler_${p.id}').style.display = 'none';`) 60 + h2 61 + != p.over_18 ? 'nsfw' : 'spoiler' 62 + if isPostGallery(p) 63 + div.gallery 64 + each item in postGalleryItems(p) 65 + div.gallery-item 66 + a(href=`/media/${item.url}`) 67 + img(src=item.url loading="lazy") 68 + div.gallery-item-idx 69 + | #{`${item.idx}/${item.total}`} 70 + else if isPostImage(p) 71 + a(href=`/media/${p.url}`) 72 + img(src=p.url loading="lazy") 73 + else if isPostVideo(p) 74 + - var url = p.secure_media.reddit_video.dash_url 75 + video(src=url controls data-dashjs-player loading="lazy").post-media 76 + else if isPostLink(p) 77 + a(href=p.url) 78 + | #{p.domain} ↗ 79 + if (query.view == "compact") 80 + button(onclick=`toggleDetails('${p.id}')`) 81 + | close
+10 -5
src/mixins/postUtils.pug
··· 1 + - 2 + function isPostMedia(p) { 3 + return isPostImage(p) || isPostGallery(p) || isPostVideo(p); 4 + } 1 5 - 2 6 function isPostGallery(p) { 3 7 return (p.is_gallery && p.is_gallery == true); ··· 53 57 } 54 58 - 55 59 function convertInlineImageLinks(html) { 56 - // Find all anchors that href to https://preview.redd.it 57 - const expression = /<a href="https:\/\/preview\.redd\.it.*?">(.*?)<\/a>/g; 58 - const matches = html.matchAll(expression); 60 + // Find all anchors that href to preview.redd.it, i.redd.it, i.imgur.com 61 + // and contain just a link to the same href 62 + const expression = /<a href="(http[s]?:\/\/(?:preview\.redd\.it|i\.redd\.it|i\.imgur\.com).*?)">\1?<\/a>/g; 63 + const matches = Array.from(html.matchAll(expression)); 59 64 var result = html; 60 65 matches.forEach((match) => { 61 66 // Replace each occurrence with an actual img tag 62 - result = result.replace(match[0], '<a href="' + match[1] + '"><img src="' + match[1] + '"></a>'); 67 + result = result.replace(match[0], '<a href="' + match[1] + '"><img class="inline" src="' + match[1] + '"></a>'); 63 68 }) 64 69 65 70 return result; ··· 80 85 var poster_url = p.preview && p.preview.images ? p.preview.images[0].source.url.replace(expression, '&') : ''; 81 86 82 87 return [hls_url, dash_url, fallback_url, scrubber_url, poster_url]; 83 - } 88 + }
+120 -116
src/public/styles.css
··· 5 5 --text-color: black; 6 6 --text-color-muted: #999; 7 7 --blockquote-color: green; 8 - --sticky-color: lightgreen; 8 + --sticky-color: #dcfeda; 9 + --gilded: darkorange; 9 10 --link-color: #29BC9B; 10 11 --link-visited-color: #999; 11 12 --accent: var(--link-color); 12 13 --error-text-color: red; 14 + --border-radius-card: 0.5vmin; 15 + --border-radius-media: 0.5vmin; 16 + --border-radius-preview: 0.3vmin; 13 17 14 18 font-family: Inter, sans-serif; 15 19 font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'dlig' 1, 'ss01' 1, 'ss07' 1, 'ss08' 1; ··· 23 27 --text-color: white; 24 28 --text-color-muted: #999; 25 29 --blockquote-color: lightgreen; 26 - --sticky-color: #034611; 30 + --sticky-color: #014413; 31 + --gilded: gold; 27 32 --link-color: #79ffe1; 28 33 --link-visited-color: #999; 29 34 --accent: var(--link-color); ··· 45 50 color: var(--text-color); 46 51 } 47 52 48 - body:has(details.card[open]) { 49 - overflow: hidden; 53 + body.media-maximized { 54 + /* Fix for Safari User Agent stylesheet */ 55 + margin: 0; 56 + } 57 + 58 + body.media-maximized.zoom, 59 + div.media-maximized.container.zoom { 60 + overflow: auto; 61 + } 62 + 63 + img.media-maximized { 64 + cursor: zoom-in; 65 + } 66 + 67 + img.media-maximized.zoom { 68 + max-width: unset; 69 + max-height: unset; 70 + cursor: zoom-out; 50 71 } 51 72 52 73 main { ··· 60 81 .info-container a, 61 82 .comment-info-container a, 62 83 .sort-opts a, 84 + .view-opts a, 63 85 .more a 64 86 { 65 87 text-decoration: none; ··· 94 116 align-items: center; 95 117 } 96 118 97 - .sorting { 119 + .sorting, 120 + .viewing { 98 121 margin-top: 20px; 99 122 } 100 123 101 - .sort-opts { 124 + .sort-opts, 125 + .view-opts { 102 126 display: grid; 103 127 margin: 10px; 104 128 } 105 129 106 - .sort-opts { 130 + .sort-opts, 131 + .view-opts { 107 132 grid-template-columns: repeat(2, 1fr); 108 133 grid-template-rows: repeat(5, 1fr); 109 134 grid-auto-flow: column; 135 + } 136 + 137 + .view-opts { 138 + grid-template-rows: repeat(2, 1fr); 110 139 } 111 140 112 141 .footer { ··· 139 168 140 169 .post-container.card { 141 170 border: 1px solid var(--bg-color-muted); 142 - border-radius: 16px; 171 + border-radius: var(--border-radius-card); 143 172 display: block; 144 173 } 145 174 146 175 .post-text.card { 147 176 padding: 0.9rem; 148 - padding-top: 0.3rem; 177 + padding-top: 0.5rem; 178 + padding-bottom: 0.5rem; 179 + overflow-wrap: break-word; 180 + max-width: 95%; 149 181 } 150 182 151 183 .self-text-overflow.card { ··· 156 188 overflow: hidden; 157 189 overflow-wrap: break-word; 158 190 display: block; 191 + max-width: 98%; 159 192 } 160 193 161 194 .self-text.card { ··· 168 201 text-overflow: ellipsis; 169 202 } 170 203 171 - .media-preview.card { 204 + .image-viewer { 172 205 position: relative; 173 - padding: 0.3rem; 174 - padding-bottom: 0.3rem; 175 - } 176 - 177 - .media-preview.card > img { 178 - cursor: pointer; 179 206 } 180 207 181 - .image-viewer.card { 182 - /* Safari on iOS <= 17 */ 183 - -webkit-backdrop-filter: blur(2rem); 184 - backdrop-filter: blur(2rem); 185 - position: fixed; 186 - inset: 0; 187 - box-sizing: border-box; 188 - display: flex; 189 - height: 100%; 190 - width: 100%; 191 - justify-content: center; 192 - align-items: center; 193 - z-index: 100; 194 - } 195 - 196 - .image-viewer.card > button { 197 - position: absolute; 198 - top: 0; 199 - right: 0; 200 - margin: 1rem; 201 - padding: initial; 202 - height: 3rem; 203 - width: 3rem; 204 - font-size: 2rem; 205 - border-radius: 100%; 208 + .image-viewer > img { 206 209 cursor: pointer; 207 210 } 208 211 209 - .image-viewer.card > a > img { 210 - max-width: 100vw; 211 - max-height: 100vh; 212 - width: auto; 213 - height: auto; 214 - } 215 - 216 - .gallery.card { 217 - align-items: center; 218 - } 219 - 220 - .gallery-item.card > a > img { 221 - max-width: 95vw; 222 - max-height: 95vh; 223 - width: auto; 224 - height: auto; 225 - } 226 - 227 212 .spoiler { 228 213 background-color: rbga(var(--bg-color-muted), 0.2); 229 214 /* Safari on iOS <= 17 */ 230 215 -webkit-backdrop-filter: blur(3rem); 231 216 backdrop-filter: blur(3rem); 232 - border-radius: 4px; 217 + border-radius: var(--border-radius-preview); 233 218 234 219 position: absolute; 235 220 top: 0; ··· 244 229 align-items: center; 245 230 246 231 cursor: pointer; 232 + 233 + z-index: 10; 247 234 } 248 235 249 - .gallery-item-idx.card, 236 + .gallery-item-idx, 250 237 .spoiler > h2 { 251 238 text-shadow: 0.1rem 0.1rem 1rem var(--bg-color-muted); 252 239 } ··· 293 280 object-fit: cover; 294 281 width: 4rem; 295 282 height: 4rem; 283 + border-radius: var(--border-radius-preview); 296 284 } 297 285 298 - .media-preview.card img, 299 - .media-preview.card video { 300 - border-radius: 16px; 286 + .image-viewer img, 287 + .image-viewer video { 288 + border-radius: var(--border-radius-media); 301 289 302 - max-height: 40vh; 303 - max-width: 100%; 290 + max-height: 50vh; 291 + max-width: 95%; 304 292 305 293 display: block; 306 - width: initial; 307 - height: initial; 294 + width: unset; 295 + height: unset; 308 296 margin-left: auto; 309 297 margin-right: auto; 310 - margin-bottom: 1rem; 298 + margin-bottom: 0.5rem; 311 299 312 300 object-fit: fill; 313 301 } 314 302 315 - .media-preview.card a { 316 - font-size: 1.5rem; 317 - margin: 1rem; 318 - padding: initial; 303 + .image-viewer.main-content img, 304 + .image-viewer.main-content video { 305 + max-height: 70vh; 319 306 } 320 307 321 - .media-preview a { 322 - font-size: 2rem; 323 - text-decoration: none; 324 - padding: 1rem; 325 - } 326 - 327 - .media-maximized-container { 328 - display: flex; 329 - justify-content: center; 330 - align-items: center; 331 - width: 100vw; 332 - height: 100vh; 333 - overflow: hidden; 308 + .image-viewer a:has(img) { 309 + font-size: 0rem; 310 + padding: unset; 311 + margin: unset; 334 312 } 335 313 336 314 .media-maximized { ··· 341 319 display: block; 342 320 margin: auto; 343 321 object-fit: contain; 322 + } 323 + 324 + .media-maximized.container { 325 + display: flex; 326 + justify-content: center; 327 + align-items: center; 328 + width: 100vw; 329 + height: 100vh; 330 + overflow: hidden; 344 331 } 345 332 346 333 .post-author { ··· 362 349 } 363 350 364 351 @media (min-width: 768px) { 352 + :root { 353 + --border-radius-card: 0.5vmin; 354 + --border-radius-media: 0.5vmin; 355 + --border-radius-preview: 0.3vmin; 356 + } 365 357 .post, .comments-container, .hero, .header, .footer { 366 358 flex: 1 1 90%; 367 359 width: 90%; ··· 372 364 width: 5rem; 373 365 height: 5rem; 374 366 } 375 - .media-preview.card img, 376 - .media-preview.card video 367 + .image-viewer img, 368 + .image-viewer video 377 369 { 378 370 max-height: 50vh; 379 371 } 380 - .media-preview.card a { 381 - font-size: 1rem; 382 - margin: 0.7rem; 383 - padding: initial; 372 + .post-text.card { 373 + max-width: 100%; 384 374 } 385 - .self-text.card { 386 - -webkit-line-clamp: 4; 387 - line-clamp: 4; 375 + .self-text-overflow.card { 376 + max-width: 100%; 388 377 } 389 378 .post-author { 390 379 display: inline ··· 395 384 form { 396 385 width: 40%; 397 386 } 398 - .sort-opts { 387 + .sort-opts, 388 + .view-opts { 399 389 grid-template-columns: repeat(9, 1fr); 400 390 grid-template-rows: repeat(1, 1fr); 401 391 grid-auto-flow: row; ··· 403 393 } 404 394 405 395 @media (min-width: 1080px) { 396 + :root { 397 + --border-radius-card: 0.5vmin; 398 + --border-radius-media: 0.5vmin; 399 + --border-radius-preview: 0.3vmin; 400 + } 406 401 .post, .comments-container, .hero, .header, .footer { 407 402 flex: 1 1 60%; 408 403 width: 60%; ··· 413 408 width: 5rem; 414 409 height: 5rem; 415 410 } 416 - .media-preview.card img, 417 - .media-preview.card video 411 + .image-viewer img, 412 + .image-viewer video 418 413 { 419 - max-height: 30vh; 414 + max-height: 45vh; 420 415 } 421 416 .media-preview a { 422 417 font-size: 2rem; 423 418 padding: 2rem; 424 419 } 425 - .media-preview.card a { 426 - font-size: 1rem; 427 - margin: 0.5rem; 428 - padding: initial; 429 - } 430 420 .self-text.card { 431 - -webkit-line-clamp: 6; 432 - line-clamp: 6; 421 + -webkit-line-clamp: 4; 422 + line-clamp: 4; 433 423 } 434 424 .post-author { 435 425 display: inline ··· 440 430 form { 441 431 width: 20%; 442 432 } 443 - .sort-opts { 433 + .sort-opts, 434 + .view-opts { 444 435 grid-template-columns: repeat(9, 1fr); 445 436 grid-template-rows: repeat(1, 1fr); 446 437 grid-auto-flow: row; ··· 452 443 flex: 1 1 40%; 453 444 width: 40%; 454 445 } 455 - .media-preview.card img, 456 - .media-preview.card video 446 + .image-viewer img, 447 + .image-viewer video 457 448 { 458 - max-height: 20vh; 449 + max-height: 30vh; 459 450 } 460 - .sort-opts { 451 + .sort-opts, 452 + .view-opts { 461 453 grid-template-columns: repeat(9, 1fr); 462 454 grid-template-rows: repeat(1, 1fr); 463 455 grid-auto-flow: row; ··· 481 473 margin-top: 10px; 482 474 } 483 475 484 - .post-container { 476 + .post-info { 485 477 display: flex; 486 478 flex-direction: row; 487 479 align-items: center; ··· 540 532 541 533 .header a, 542 534 .sort-opts a, 535 + .view-opts a, 543 536 .sub-title a { 544 537 color: var(--text-color); 545 538 } ··· 634 627 overflow-x: auto; 635 628 position: relative; 636 629 padding: 5px; 630 + align-items: center; 631 + scroll-snap-type: both mandatory; 637 632 } 638 633 639 634 .gallery-item { 640 635 flex: 0 0 auto; 641 636 margin-right: 10px; 637 + max-width: 100%; 638 + width: 100%; 639 + scroll-snap-align: center; 642 640 } 643 641 644 - .gallery img { 645 - width: auto; 646 - max-height: 500px; 642 + .gallery-item-idx { 643 + text-align: center; 647 644 } 648 645 649 646 .post-title { ··· 652 649 653 650 .op { 654 651 color: var(--accent); 652 + } 653 + 654 + .gilded { 655 + color: var(--gilded); 655 656 } 656 657 657 658 button { ··· 800 801 801 802 .sticky { 802 803 background-color: var(--sticky-color); 803 - border-radius: 2px; 804 + border-radius: var(--border-radius-card); 804 805 border: 4px solid var(--sticky-color); 805 806 } 806 807 808 + .inline { 809 + max-width: 100%; 810 + }
+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 -
+18 -17
src/views/comments.pug
··· 47 47 h2.post-title 48 48 != post.title 49 49 50 - if isPostGallery(post) 51 - div.gallery 52 - each item in postGalleryItems(post) 53 - div.gallery-item 54 - div.gallery-item-idx 55 - | #{`${item.idx}/${item.total}`} 56 - a(href=`/media/${item.url}`) 57 - img(src=item.url loading="lazy") 58 - else if isPostImage(post) 59 - a(href=`/media/${post.url}`) 60 - img(src=post.url).post-media 61 - else if isPostVideo(post) 62 - - var url = post.secure_media.reddit_video.dash_url 63 - video(controls data-dashjs-player src=`${url}`).post-media 64 - else if isPostLink(post) 65 - a(href=post.url) 66 - | #{post.url} 50 + div.image-viewer.main-content 51 + if isPostGallery(post) 52 + div.gallery 53 + each item in postGalleryItems(post) 54 + div.gallery-item 55 + a(href=`/media/${item.url}`) 56 + img(src=item.url loading="lazy") 57 + div.gallery-item-idx 58 + | #{`${item.idx}/${item.total}`} 59 + else if isPostImage(post) 60 + a(href=`/media/${post.url}`) 61 + img(src=post.url).post-media 62 + else if isPostVideo(post) 63 + - var url = post.secure_media.reddit_video.dash_url 64 + video(controls data-dashjs-player src=`${url}`).post-media 65 + else if isPostLink(post) 66 + a(href=post.url) 67 + | #{post.url} 67 68 68 69 if post.selftext_html 69 70 div.self-text
+12 -4
src/views/index.pug
··· 16 16 div.sub-title 17 17 h1 18 18 if isMulti 19 - a(href=`/?${sortQuery}&${viewQuery}`) lurker 19 + a(href=`/?sort=${sortQuery}&view=${viewQuery}`) lurker 20 20 else 21 - a(href=`/r/${subreddit}?${sortQuery}&${viewQuery}`) 21 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=${viewQuery}`) 22 22 | r/#{subreddit} 23 23 if !isMulti 24 24 div#button-container ··· 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 - summary.sorting sorting by #{query.sort + (query.t?' '+query.t:'')}, #{viewQuery} view 44 + summary.sorting sorting by #{query.sort + (query.t?' '+query.t:'')} 39 45 div.sort-opts 40 46 div 41 47 a(href=`/r/${subreddit}?sort=hot&view=${viewQuery}`) hot ··· 55 61 a(href=`/r/${subreddit}?sort=top&t=year&view=${viewQuery}`) top year 56 62 div 57 63 a(href=`/r/${subreddit}?sort=top&t=all&view=${viewQuery}`) top all 58 - div.sort-opts 64 + details.view-details 65 + summary.viewing viewing as #{viewQuery} 66 + div.view-opts 59 67 div 60 68 a(href=`/r/${subreddit}?sort=${sortQuery}&view=compact`) compact 61 69 div
+2 -2
src/views/media.pug
··· 2 2 doctype html 3 3 html 4 4 +head("home") 5 - body 6 - div.media-maximized-container 5 + body.media-maximized 6 + div.media-maximized.container 7 7 if kind == 'img' 8 8 img(src=url).media-maximized 9 9 else