selfhostable, read-only reddit client

Compare changes

Choose any two refs to compare.

+14 -13
.github/workflows/publish-docker.yml
··· 13 13 - name: checkout repository 14 14 uses: actions/checkout@v4 15 15 16 - - name: install nix 17 - uses: cachix/install-nix-action@v27 18 - with: 19 - github_access_token: ${{ secrets.GITHUB_TOKEN }} 20 - 21 - - name: build docker image 22 - run: nix build -L .#dockerImage 23 - 24 16 - name: log in to github container registry 25 17 uses: docker/login-action@v3 26 18 with: ··· 28 20 username: ${{ github.actor }} 29 21 password: ${{ secrets.GITHUB_TOKEN }} 30 22 31 - - name: publish docker image 32 - run: | 33 - docker load < result 34 - docker tag lurker:latest ghcr.io/${{ github.repository_owner }}/lurker:latest 35 - 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
+3
.gitignore
··· 179 179 result 180 180 181 181 .direnv 182 + 183 + # Database 184 + *.db
+5
Dockerfile
··· 1 + FROM oven/bun:latest 2 + ADD ./ ./ 3 + RUN mkdir -p /data 4 + WORKDIR /data 5 + CMD ["bun", "run", "/home/bun/app/src/index.js"]
+2 -23
flake.nix
··· 41 41 cp -R ./node_modules/* $out/node_modules 42 42 ls -la $out/node_modules 43 43 ''; 44 - outputHash = "sha256-UiD/gqwaU1+qLNkeds2i7kVgCjlrgxsTcqQDbO8+gG8="; 44 + outputHash = "sha256-wCMsk/gR+U5fCHcRj7Mxvh9Lg6wZAtMn7CvjyCPar+g="; 45 45 outputHashAlgo = "sha256"; 46 46 outputHashMode = "recursive"; 47 47 }; ··· 73 73 74 74 ''; 75 75 }; 76 - dockerImage = with final; 77 - final.dockerTools.buildImage { 78 - name = pname; 79 - tag = "latest"; 80 - 81 - copyToRoot = final.buildEnv { 82 - name = "image-root"; 83 - paths = [final.lurker]; 84 - pathsToLink = ["/bin"]; 85 - }; 86 - 87 - runAsRoot = '' 88 - mkdir -p /data 89 - ''; 90 - 91 - config = { 92 - Cmd = ["/bin/${pname}"]; 93 - WorkingDir = "/data"; 94 - Volumes = {"/data" = {};}; 95 - }; 96 - }; 97 76 }; 98 77 99 78 devShell = forAllSystems (system: let ··· 108 87 }); 109 88 110 89 packages = forAllSystems (system: { 111 - inherit (nixpkgsFor."${system}") lurker node_modules dockerImage; 90 + inherit (nixpkgsFor."${system}") lurker node_modules; 112 91 }); 113 92 114 93 defaultPackage = forAllSystems (system: nixpkgsFor."${system}".lurker);
+23 -7
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 ··· 60 60 # pull the latest image from gh container registry 61 61 $ docker pull ghcr.io/oppiliappan/lurker:latest 62 62 63 - # the image will be marked as created on 1970, this is a 64 - # quirk of using nix, it should not affect usage 65 - $ docker image ls 66 63 REPOSITORY TAG IMAGE ID CREATED SIZE 67 - ghcr.io/oppiliappan/lurker latest ba3733164889 54 years ago 186MB 64 + ghcr.io/oppiliappan/lurker latest ba3733164889 ??? 227MB 68 65 69 66 # start lurker in a container 70 67 # 71 68 # lurker stores data in /data, 72 69 # so create a volume on the host accordingly: 73 - $ docker run -v /your/host/lurker-data:/data ghcr.io/oppiliappan/lurker:latest 70 + $ docker run -v /your/host/lurker-data:/data -p 3000 ghcr.io/oppiliappan/lurker:latest 71 + ``` 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" 74 85 ``` 75 86 76 87 or with just [bun](https://bun.sh/): 77 88 78 89 ```bash 79 - bun run src/index.js 90 + bun run src/index.js 80 91 ``` 81 92 82 93 ### usage ··· 87 98 username at the top-right to view the dashboard and to 88 99 invite other users to your instance. copy the link and send 89 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`. 90 106 91 107 ### technical 92 108
+64 -21
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) ··· 241 269 options.q = query; 242 270 options.type = "link"; 243 271 244 - const params = { 245 - limit: 25, 246 - include_over_18: true, 247 - }; 248 - 249 272 return await fetch( 250 - `${this.host}/search.json?${new URLSearchParams(Object.assign(params, options))}`, 273 + `${this.host}/search.json?${new URLSearchParams(options)}`, 274 + { headers: this.headers }, 251 275 ) 252 276 .then((res) => res.json()) 253 277 .then((json) => json.data) ··· 268 292 269 293 return await fetch( 270 294 `${this.host}/subreddits/search.json?${new URLSearchParams(Object.assign(params, options))}`, 295 + { headers: this.headers }, 271 296 ) 272 297 .then((res) => res.json()) 273 298 .then((json) => json.data) ··· 288 313 289 314 return await fetch( 290 315 `${this.host}/users/search.json?${new URLSearchParams(Object.assign(params, options))}`, 316 + { headers: this.headers }, 291 317 ) 292 318 .then((res) => res.json()) 293 319 .then((json) => json.data) ··· 312 338 `${ 313 339 this.host + subredditStr 314 340 }/search.json?${new URLSearchParams(Object.assign(params, options))}`, 341 + { headers: this.headers }, 315 342 ) 316 343 .then((res) => res.json()) 317 344 .then((json) => ··· 329 356 } 330 357 331 358 async getSubmission(id) { 332 - return await fetch(`${this.host}/by_id/${id}.json`) 359 + return await fetch(`${this.host}/by_id/${id}.json`, { 360 + headers: this.headers, 361 + }) 333 362 .then((res) => res.json()) 334 363 .then((json) => json.data.children[0].data) 335 364 .catch((err) => null); ··· 338 367 async getSubmissionComments(id, options = this.parameters) { 339 368 return await fetch( 340 369 `${this.host}/comments/${id}.json?${new URLSearchParams(options)}`, 370 + { headers: this.headers }, 341 371 ) 342 372 .then((res) => res.json()) 343 373 .then((json) => ({ ··· 350 380 async getSingleCommentThread(parent_id, child_id, options = this.parameters) { 351 381 return await fetch( 352 382 `${this.host}/comments/${parent_id}/comment/${child_id}.json?${new URLSearchParams(options)}`, 383 + { headers: this.headers }, 353 384 ) 354 385 .then((res) => res.json()) 355 386 .then((json) => ({ ··· 362 393 async getSubredditComments(subreddit, options = this.parameters) { 363 394 return await fetch( 364 395 `${this.host}/r/${subreddit}/comments.json?${new URLSearchParams(options)}`, 396 + { headers: this.headers }, 365 397 ) 366 398 .then((res) => res.json()) 367 399 .then((json) => json.data.children) ··· 369 401 } 370 402 371 403 async getUser(username) { 372 - return await fetch(`${this.host}/user/${username}/about.json`) 404 + return await fetch(`${this.host}/user/${username}/about.json`, { 405 + headers: this.headers, 406 + }) 373 407 .then((res) => res.json()) 374 408 .then((json) => json.data) 375 409 .catch((err) => null); ··· 378 412 async getUserOverview(username, options = this.parameters) { 379 413 return await fetch( 380 414 `${this.host}/user/${username}/overview.json?${new URLSearchParams(options)}`, 415 + { headers: this.headers }, 381 416 ) 382 417 .then((res) => res.json()) 383 418 .then((json) => json.data) ··· 391 426 async getUserComments(username, options = this.parameters) { 392 427 return await fetch( 393 428 `${this.host}/user/${username}/comments.json?${new URLSearchParams(options)}`, 429 + { headers: this.headers }, 394 430 ) 395 431 .then((res) => res.json()) 396 432 .then((json) => json.data) ··· 404 440 async getUserSubmissions(username, options = this.parameters) { 405 441 return await fetch( 406 442 `${this.host}/user/${username}/submitted.json?${new URLSearchParams(options)}`, 443 + { headers: this.headers }, 407 444 ) 408 445 .then((res) => res.json()) 409 446 .then((json) => json.data) ··· 415 452 } 416 453 417 454 async getLiveThread(id) { 418 - return await fetch(`${this.host}/live/${id}/about.json`) 455 + return await fetch(`${this.host}/live/${id}/about.json`, { 456 + headers: this.headers, 457 + }) 419 458 .then((res) => res.json()) 420 459 .then((json) => json.data) 421 460 .catch((err) => null); ··· 424 463 async getLiveThreadUpdates(id, options = this.parameters) { 425 464 return await fetch( 426 465 `${this.host}/live/${id}.json?${new URLSearchParams(options)}`, 466 + { headers: this.headers }, 427 467 ) 428 468 .then((res) => res.json()) 429 469 .then((json) => json.data.children) ··· 433 473 async getLiveThreadContributors(id, options = this.parameters) { 434 474 return await fetch( 435 475 `${this.host}/live/${id}/contributors.json?${new URLSearchParams(options)}`, 476 + { headers: this.headers }, 436 477 ) 437 478 .then((res) => res.json()) 438 479 .then((json) => json.data.children) ··· 442 483 async getLiveThreadDiscussions(id, options = this.parameters) { 443 484 return await fetch( 444 485 `${this.host}/live/${id}/discussions.json?${new URLSearchParams(options)}`, 486 + { headers: this.headers }, 445 487 ) 446 488 .then((res) => res.json()) 447 489 .then((json) => json.data.children) ··· 451 493 async getLiveThreadsNow(options = this.parameters) { 452 494 return await fetch( 453 495 `${this.host}/live/happening_now.json?${new URLSearchParams(options)}`, 496 + { headers: this.headers }, 454 497 ) 455 498 .then((res) => res.json()) 456 499 .then((json) => json.data.children)
+2 -3
src/index.js
··· 1 1 const express = require("express"); 2 2 const rateLimit = require("express-rate-limit"); 3 3 const path = require("node:path"); 4 - const geddit = require("./geddit.js"); 5 4 const cookieParser = require("cookie-parser"); 6 5 const app = express(); 7 6 const hasher = new Bun.CryptoHasher("sha256", "secret-key"); ··· 30 29 app.use("/", routes); 31 30 32 31 const port = process.env.LURKER_PORT; 33 - const server = app.listen(port ? port : 3000, () => { 34 - console.log(`started on ${server.address().port}`); 32 + const server = app.listen(port ? port : 3000, "0.0.0.0", () => { 33 + console.log("started on", server.address()); 35 34 });
+16 -4
src/mixins/comment.pug
··· 1 1 include ../utils 2 + include postUtils 2 3 3 4 mixin infoContainer(data, next_id, prev_id) 5 + - var hats = (data.is_submitter?['op']:[]).concat(data.distinguished=="moderator"?['mod']:[]) 4 6 div.comment-info-container 5 7 p 6 8 | #{fmtnum(data.ups)} โ†‘ ··· 11 13 if prev_id 12 14 a(href=`#${prev_id}` title="scroll to previous comment").nav-link prev 13 15 | &nbsp;ยท&nbsp; 16 + if data.gilded > 0 17 + span.gilded 18 + | #{data.gilded} โ˜† 19 + | &nbsp;ยท&nbsp; 14 20 span(class=`${data.is_submitter ? 'op' : ''}`) 15 - | u/#{data.author} #{data.is_submitter ? '(op)' : ''} 21 + | u/#{data.author} #{hats.length==0?'':`(${hats.join('|')})`} 16 22 | &nbsp;ยท&nbsp; 17 - if data.collapsed_reason_code == "DELETED" 23 + if data.collapsed_reason_code == "DELETED" || data.author == "[deleted]" 18 24 a(href=`https://undelete.pullpush.io${data.permalink}`) search on undelete 19 25 | &nbsp;ยท&nbsp; 20 26 | #{timeDifference(Date.now(), data.created * 1000)} 21 27 | &nbsp;ยท&nbsp; 28 + if data.edited !== false 29 + | edited #{timeDifference(Date.now(), data.edited * 1000)} ago 30 + | &nbsp;ยท&nbsp; 31 + if data.stickied 32 + | stickied 33 + | &nbsp;ยท&nbsp; 22 34 a(href=`https://reddit.com${data.permalink}` title="view on reddit").nav-link open โ†— 23 35 24 36 - ··· 35 47 a(href=`/comments/${parent_id}/comment/${data.id}`) 36 48 | #{data.count} more #{fmttxt(data.count, 'comment')} 37 49 else 38 - div(class=`comment ${isfirst ? 'first' : ''}`) 50 + div(class=`comment ${isfirst ? 'first' : ''} ${data.stickied ? 'sticky' : ''}`) 39 51 details(id=`${data.id}` open="") 40 52 summary.expand-comments 41 53 +infoContainer(data, next_id, prev_id) 42 54 div.comment-body 43 - != data.body_html 55 + != convertInlineImageLinks(data.body_html) 44 56 if hasReplyData 45 57 div.replies 46 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 -
+6 -5
src/mixins/header.pug
··· 1 1 mixin header(user) 2 + - var viewQuery = 'view=' + (query && query.view ? query.view : 'compact') 2 3 div.header 3 4 div.header-item 4 - a(href=`/`) home 5 + a(href=`/?${viewQuery}`) home 5 6 div.header-item 6 - a(href=`/r/all`) all 7 + a(href=`/r/all?${viewQuery}`) all 7 8 div.header-item 8 - a(href=`/search`) search 9 + a(href=`/search?${viewQuery}`) search 9 10 div.header-item 10 - a(href=`/subs`) subs 11 + a(href=`/subs?${viewQuery}`) subs 11 12 if user 12 13 div.header-item 13 - a(href='/dashboard') #{user.username} 14 + a(href=`/dashboard?${viewQuery}`) #{user.username} 14 15 |&nbsp; 15 16 a(href='/logout') (logout) 16 17 else
+77 -62
src/mixins/post.pug
··· 1 1 include ../utils 2 2 include postUtils 3 - mixin post(p) 4 - article.post 5 - div.post-container 6 - div.post-text 7 - div.title-container 8 - a(href=`/comments/${p.id}`) 9 - != p.title 10 - span.domain (#{p.domain}) 11 - div.info-container 12 - p 13 - | #{fmtnum(p.ups)} โ†‘ 14 - span.post-author 3 + mixin post(p, currentUrl) 4 + - var from = encodeURIComponent(currentUrl) 5 + - var viewQuery = query && query.view ? query.view : 'compact' 6 + - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 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 + | &nbsp;ยท&nbsp; 20 + span.gilded 21 + | #{p.gilded} โ˜† 22 + span.post-author 23 + | &nbsp;ยท&nbsp; 24 + | u/#{p.author} 15 25 | &nbsp;ยท&nbsp; 16 - | u/#{p.author} 17 - | &nbsp;ยท&nbsp; 18 - | #{timeDifference(Date.now(), p.created * 1000)} 19 - | &nbsp;ยท&nbsp; 20 - a(href=`/r/${p.subreddit}`) r/#{p.subreddit} 21 - | &nbsp;ยท&nbsp; 22 - a(href=`/comments/${p.id}`) #{fmtnum (p.num_comments)} โ†ฉ 23 - div.media-preview 24 - if isPostGallery(p) 25 - - var item = postGalleryItems(p)[0] 26 - img(src=item.url onclick=`toggleDetails('${p.id}')`) 27 - else if isPostImage(p) 28 - - var url = postThumbnail(p) 29 - img(src=url onclick=`toggleDetails('${p.id}')`) 30 - else if isPostVideo(p) 31 - - var url = p.secure_media.reddit_video.scrubber_media_url 32 - video(src=url data-dashjs-player width='100px' height='100px' onclick=`toggleDetails('${p.id}')`) 33 - else if isPostLink(p) 34 - a(href=p.url) 35 - | โ†— 26 + | #{timeDifference(Date.now(), p.created * 1000)} 27 + | &nbsp;ยท&nbsp; 28 + a(href=`/r/${p.subreddit}?view=${viewQuery}`) r/#{p.subreddit} 29 + | &nbsp;ยท&nbsp; 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 + | โ†— 36 54 37 - if isPostGallery(p) 38 - details(id=`${p.id}`) 39 - summary.expand-post expand gallery 40 - div.gallery 41 - each item in postGalleryItems(p) 42 - div.gallery-item 43 - div.gallery-item-idx 44 - | #{`${item.idx}/${item.total}`} 45 - a(href=`/media/${item.url}`) 46 - img(src=item.url loading="lazy") 47 - button(onclick=`toggleDetails('${p.id}')`) close 48 - else if isPostImage(p) 49 - details(id=`${p.id}`) 50 - summary.expand-post expand image 51 - a(href=`/media/${p.url}`) 52 - img(src=p.url loading="lazy").post-media 53 - button(onclick=`toggleDetails('${p.id}')`) close 54 - else if isPostVideo(p) 55 - details(id=`${p.id}`) 56 - summary.expand-post expand video 57 - - var url = p.secure_media.reddit_video.dash_url 58 - video(src=url controls data-dashjs-player loading="lazy").post-media 59 - button(onclick=`toggleDetails('${p.id}')`) close 60 - else if isPostLink(p) 61 - details(id=`${p.id}`) 62 - summary.expand-post expand link 63 - a(href=`${p.url}`) 64 - | #{p.url} 65 - br 66 - button(onclick=`toggleDetails('${p.id}')`) 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
+41 -6
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); ··· 10 14 } 11 15 - 12 16 function postThumbnail(p) { 13 - if (p.thumbnail == "image" || p.thumbnail == "") { 14 - return p.url; 15 - } else if (p.over_18) { 16 - return "/nsfw.svg"; 17 + if (p.over_18) { 18 + return "/nsfw.svg"; 17 19 } else if (p.thumbnail == "spoiler") { 18 - return "/spoiler.svg"; 20 + return "/spoiler.svg"; 21 + } else if (p.thumbnail == "image" || p.thumbnail == "") { 22 + return p.url; 19 23 } else { 20 - return p.thumbnail; 24 + return p.thumbnail; 21 25 } 22 26 } 23 27 - ··· 51 55 return null; 52 56 } 53 57 } 58 + - 59 + function convertInlineImageLinks(html) { 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)); 64 + var result = html; 65 + matches.forEach((match) => { 66 + // Replace each occurrence with an actual img tag 67 + result = result.replace(match[0], '<a href="' + match[1] + '"><img class="inline" src="' + match[1] + '"></a>'); 68 + }) 69 + 70 + return result; 71 + } 72 + - 73 + function decodePostVideoUrls(p) { 74 + // Video URLs have querystring separators that are HTML-encoded, so replace them. 75 + const expression = /&amp;/g; 76 + 77 + var hls_url = p.secure_media && p.secure_media.reddit_video && p.secure_media.reddit_video.hls_url ? p.secure_media.reddit_video.hls_url.replace(expression, '&') : ''; 78 + 79 + var dash_url = p.secure_media && p.secure_media.reddit_video && p.secure_media.reddit_video.dash_url ? p.secure_media.reddit_video.dash_url.replace(expression, '&') : ''; 80 + 81 + var fallback_url = p.secure_media && p.secure_media.reddit_video && p.secure_media.reddit_video.fallback_url ? p.secure_media.reddit_video.fallback_url.replace(expression, '&') : ''; 82 + 83 + var scrubber_url = p.secure_media && p.secure_media.reddit_video && p.secure_media.reddit_video.scrubber_media_url ? p.secure_media.reddit_video.scrubber_media_url.replace(expression, '&') : ''; 84 + 85 + var poster_url = p.preview && p.preview.images ? p.preview.images[0].source.url.replace(expression, '&') : ''; 86 + 87 + return [hls_url, dash_url, fallback_url, scrubber_url, poster_url]; 88 + }
+250 -25
src/public/styles.css
··· 5 5 --text-color: black; 6 6 --text-color-muted: #999; 7 7 --blockquote-color: green; 8 + --sticky-color: #dcfeda; 9 + --gilded: darkorange; 8 10 --link-color: #29BC9B; 9 11 --link-visited-color: #999; 10 12 --accent: var(--link-color); 11 13 --error-text-color: red; 14 + --border-radius-card: 0.5vmin; 15 + --border-radius-media: 0.5vmin; 16 + --border-radius-preview: 0.3vmin; 12 17 13 18 font-family: Inter, sans-serif; 14 19 font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'dlig' 1, 'ss01' 1, 'ss07' 1, 'ss08' 1; ··· 22 27 --text-color: white; 23 28 --text-color-muted: #999; 24 29 --blockquote-color: lightgreen; 30 + --sticky-color: #014413; 31 + --gilded: gold; 25 32 --link-color: #79ffe1; 26 33 --link-visited-color: #999; 27 34 --accent: var(--link-color); 28 - --error-text-color: lightcoral; 35 + --error-text-color: lightcoral; 29 36 } 30 37 } 31 38 ··· 43 50 color: var(--text-color); 44 51 } 45 52 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; 71 + } 72 + 46 73 main { 47 74 display: flex; 48 75 flex-direction: column; ··· 54 81 .info-container a, 55 82 .comment-info-container a, 56 83 .sort-opts a, 84 + .view-opts a, 57 85 .more a 58 86 { 59 87 text-decoration: none; ··· 88 116 align-items: center; 89 117 } 90 118 91 - .sorting { 119 + .sorting, 120 + .viewing { 92 121 margin-top: 20px; 93 122 } 94 123 95 - .sort-opts { 96 - display: flex; 97 - flex-direction: row; 98 - flex-wrap: wrap; 99 - justify-content: space-between; 124 + .sort-opts, 125 + .view-opts { 126 + display: grid; 127 + margin: 10px; 100 128 } 101 129 102 - .sort-opts a { 103 - margin: 10px; 130 + .sort-opts, 131 + .view-opts { 132 + grid-template-columns: repeat(2, 1fr); 133 + grid-template-rows: repeat(5, 1fr); 134 + grid-auto-flow: column; 135 + } 136 + 137 + .view-opts { 138 + grid-template-rows: repeat(2, 1fr); 104 139 } 105 140 106 141 .footer { ··· 115 150 nav { 116 151 display: flex; 117 152 align-items: stretch; 153 + } 154 + 155 + .post { 156 + margin-bottom: 5px; 118 157 } 119 158 120 159 .post, .comments-container, .hero, .header, .footer { ··· 127 166 font-size: 0.9rem; 128 167 } 129 168 169 + .post-container.card { 170 + border: 1px solid var(--bg-color-muted); 171 + border-radius: var(--border-radius-card); 172 + display: block; 173 + } 174 + 175 + .post-text.card { 176 + padding: 0.9rem; 177 + padding-top: 0.5rem; 178 + padding-bottom: 0.5rem; 179 + overflow-wrap: break-word; 180 + max-width: 95%; 181 + } 182 + 183 + .self-text-overflow.card { 184 + /* For spoiler positioning */ 185 + position: relative; 186 + padding-top: 0.3rem; 187 + max-height: 10vh; 188 + overflow: hidden; 189 + overflow-wrap: break-word; 190 + display: block; 191 + max-width: 98%; 192 + } 193 + 194 + .self-text.card { 195 + overflow: hidden; 196 + display: -webkit-box; 197 + /* Safari on iOS <= 17 */ 198 + -webkit-box-orient: vertical; 199 + -webkit-line-clamp: 3; 200 + line-clamp: 3; 201 + text-overflow: ellipsis; 202 + } 203 + 204 + .image-viewer { 205 + position: relative; 206 + } 207 + 208 + .image-viewer > img { 209 + cursor: pointer; 210 + } 211 + 212 + .spoiler { 213 + background-color: rbga(var(--bg-color-muted), 0.2); 214 + /* Safari on iOS <= 17 */ 215 + -webkit-backdrop-filter: blur(3rem); 216 + backdrop-filter: blur(3rem); 217 + border-radius: var(--border-radius-preview); 218 + 219 + position: absolute; 220 + top: 0; 221 + left: 0; 222 + 223 + box-sizing: border-box; 224 + display: flex; 225 + height: 100%; 226 + width: 100%; 227 + 228 + justify-content: center; 229 + align-items: center; 230 + 231 + cursor: pointer; 232 + 233 + z-index: 10; 234 + } 235 + 236 + .gallery-item-idx, 237 + .spoiler > h2 { 238 + text-shadow: 0.1rem 0.1rem 1rem var(--bg-color-muted); 239 + } 240 + 130 241 .comments-container { 131 242 font-size: 0.9rem; 132 243 } ··· 169 280 object-fit: cover; 170 281 width: 4rem; 171 282 height: 4rem; 283 + border-radius: var(--border-radius-preview); 172 284 } 173 285 174 - .media-preview a { 175 - font-size: 2rem; 176 - text-decoration: none; 177 - padding: 1rem; 286 + .image-viewer img, 287 + .image-viewer video { 288 + border-radius: var(--border-radius-media); 289 + 290 + max-height: 50vh; 291 + max-width: 95%; 292 + 293 + display: block; 294 + width: unset; 295 + height: unset; 296 + margin-left: auto; 297 + margin-right: auto; 298 + margin-bottom: 0.5rem; 299 + 300 + object-fit: fill; 178 301 } 179 302 180 - .media-maximized-container { 181 - display: flex; 182 - justify-content: center; 183 - align-items: center; 184 - width: 100vw; 185 - height: 100vh; 186 - overflow: hidden; 303 + .image-viewer.main-content img, 304 + .image-viewer.main-content video { 305 + max-height: 70vh; 306 + } 307 + 308 + .image-viewer a:has(img) { 309 + font-size: 0rem; 310 + padding: unset; 311 + margin: unset; 187 312 } 188 313 189 314 .media-maximized { ··· 196 321 object-fit: contain; 197 322 } 198 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; 331 + } 332 + 199 333 .post-author { 200 334 display: none 201 335 } ··· 215 349 } 216 350 217 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 + } 218 357 .post, .comments-container, .hero, .header, .footer { 219 358 flex: 1 1 90%; 220 359 width: 90%; ··· 224 363 { 225 364 width: 5rem; 226 365 height: 5rem; 366 + } 367 + .image-viewer img, 368 + .image-viewer video 369 + { 370 + max-height: 50vh; 371 + } 372 + .post-text.card { 373 + max-width: 100%; 374 + } 375 + .self-text-overflow.card { 376 + max-width: 100%; 227 377 } 228 378 .post-author { 229 379 display: inline ··· 234 384 form { 235 385 width: 40%; 236 386 } 387 + .sort-opts, 388 + .view-opts { 389 + grid-template-columns: repeat(9, 1fr); 390 + grid-template-rows: repeat(1, 1fr); 391 + grid-auto-flow: row; 392 + } 237 393 } 238 394 239 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 + } 240 401 .post, .comments-container, .hero, .header, .footer { 241 402 flex: 1 1 60%; 242 403 width: 60%; ··· 247 408 width: 5rem; 248 409 height: 5rem; 249 410 } 411 + .image-viewer img, 412 + .image-viewer video 413 + { 414 + max-height: 45vh; 415 + } 250 416 .media-preview a { 251 417 font-size: 2rem; 252 418 padding: 2rem; 253 419 } 420 + .self-text.card { 421 + -webkit-line-clamp: 4; 422 + line-clamp: 4; 423 + } 254 424 .post-author { 255 425 display: inline 256 426 } ··· 259 429 } 260 430 form { 261 431 width: 20%; 432 + } 433 + .sort-opts, 434 + .view-opts { 435 + grid-template-columns: repeat(9, 1fr); 436 + grid-template-rows: repeat(1, 1fr); 437 + grid-auto-flow: row; 262 438 } 263 439 } 264 440 ··· 267 443 flex: 1 1 40%; 268 444 width: 40%; 269 445 } 446 + .image-viewer img, 447 + .image-viewer video 448 + { 449 + max-height: 30vh; 450 + } 451 + .sort-opts, 452 + .view-opts { 453 + grid-template-columns: repeat(9, 1fr); 454 + grid-template-rows: repeat(1, 1fr); 455 + grid-auto-flow: row; 456 + } 270 457 } 271 458 272 459 .comment, .more { ··· 286 473 margin-top: 10px; 287 474 } 288 475 289 - .post-container { 476 + .post-info { 290 477 display: flex; 291 478 flex-direction: row; 292 479 align-items: center; 293 480 } 294 481 482 + .post-container:target { 483 + outline: 4px solid var(--bg-color-muted); 484 + background: var(--bg-color-muted); 485 + border-radius: 2px; 486 + padding: 5px; 487 + } 488 + 295 489 .post-text { 296 490 display: flex; 297 491 flex-direction: column; ··· 321 515 .title-container > a { 322 516 color: var(--text-color); 323 517 text-decoration: none; 518 + } 519 + 520 + .title-container.card > a { 521 + font-size: 1.125rem; 522 + font-weight: bold; 324 523 } 325 524 326 525 .title-container > a:hover { ··· 333 532 334 533 .header a, 335 534 .sort-opts a, 535 + .view-opts a, 336 536 .sub-title a { 337 537 color: var(--text-color); 338 538 } ··· 427 627 overflow-x: auto; 428 628 position: relative; 429 629 padding: 5px; 630 + align-items: center; 631 + scroll-snap-type: both mandatory; 430 632 } 431 633 432 634 .gallery-item { 433 635 flex: 0 0 auto; 434 636 margin-right: 10px; 637 + max-width: 100%; 638 + width: 100%; 639 + scroll-snap-align: center; 435 640 } 436 641 437 - .gallery img { 438 - width: auto; 439 - max-height: 500px; 642 + .gallery-item-idx { 643 + text-align: center; 440 644 } 441 645 442 646 .post-title { ··· 447 651 color: var(--accent); 448 652 } 449 653 654 + .gilded { 655 + color: var(--gilded); 656 + } 657 + 450 658 button { 451 659 border: 0px solid; 452 - border-radius: 4px; 660 + border-radius: 2px; 453 661 background-color: var(--bg-color-muted); 454 662 color: var(--text-color); 455 663 padding: 5px; ··· 583 791 .comment-info-container > p { 584 792 margin-top: 0px; 585 793 } 794 + 795 + select { 796 + -webkit-appearance: none; 797 + -moz-appearance: none; 798 + text-indent: 1px; 799 + text-overflow: ''; 800 + } 801 + 802 + .sticky { 803 + background-color: var(--sticky-color); 804 + border-radius: var(--border-radius-card); 805 + border: 4px solid var(--sticky-color); 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 + */
+79 -37
src/routes/index.js
··· 10 10 const router = express.Router(); 11 11 const G = new geddit.Geddit(); 12 12 13 + const commonRenderOptions = { 14 + theme: process.env.LURKER_THEME, 15 + }; 16 + 13 17 // GET / 14 18 router.get("/", authenticateToken, async (req, res) => { 15 19 const subs = db 16 20 .query("SELECT * FROM subscriptions WHERE user_id = $id") 17 21 .all({ id: req.user.id }); 22 + 23 + const qs = req.query ? "?" + new URLSearchParams(req.query).toString() : ""; 24 + 18 25 if (subs.length === 0) { 19 - res.redirect("/r/all"); 26 + res.redirect(`/r/all${qs}`); 20 27 } else { 21 28 const p = subs.map((s) => s.subreddit).join("+"); 22 - res.redirect(`/r/${p}`); 29 + res.redirect(`/r/${p}${qs}`); 23 30 } 24 31 }); 25 32 ··· 31 38 if (!query.sort) { 32 39 query.sort = "hot"; 33 40 } 41 + if (!query.view) { 42 + query.view = "compact"; 43 + } 34 44 35 45 let isSubbed = false; 36 46 if (!isMulti) { ··· 46 56 47 57 const [posts, about] = await Promise.all([postsReq, aboutReq]); 48 58 59 + if (query.view == "card" && posts && posts.posts) { 60 + posts.posts.forEach(unescape_selftext); 61 + } 62 + 49 63 res.render("index", { 50 64 subreddit, 51 65 posts, ··· 54 68 isMulti, 55 69 user: req.user, 56 70 isSubbed, 71 + currentUrl: req.url, 72 + ...commonRenderOptions, 57 73 }); 58 74 }); 59 75 ··· 65 81 limit: 50, 66 82 }; 67 83 response = await G.getSubmissionComments(id, params); 68 - 69 84 res.render("comments", { 70 85 data: unescape_submission(response), 71 86 user: req.user, 87 + from: req.query.from, 88 + query: req.query, 89 + ...commonRenderOptions, 72 90 }); 73 91 }); 74 92 ··· 90 108 comments, 91 109 parent_id, 92 110 user: req.user, 111 + ...commonRenderOptions, 93 112 }); 94 113 }, 95 114 ); ··· 97 116 // GET /subs 98 117 router.get("/subs", authenticateToken, async (req, res) => { 99 118 const subs = db 100 - .query("SELECT * FROM subscriptions WHERE user_id = $id") 119 + .query( 120 + "SELECT * FROM subscriptions WHERE user_id = $id ORDER by LOWER(subreddit)", 121 + ) 101 122 .all({ id: req.user.id }); 102 - res.render("subs", { subs, user: req.user }); 123 + 124 + res.render("subs", { 125 + subs, 126 + user: req.user, 127 + query: req.query, 128 + ...commonRenderOptions, 129 + }); 103 130 }); 104 131 105 132 // GET /search 106 133 router.get("/search", authenticateToken, async (req, res) => { 107 - res.render("search", { user: req.user }); 134 + res.render("search", { 135 + user: req.user, 136 + query: req.query, 137 + ...commonRenderOptions, 138 + }); 108 139 }); 109 140 110 141 // GET /sub-search 111 142 router.get("/sub-search", authenticateToken, async (req, res) => { 112 143 if (!req.query || !req.query.q) { 113 - res.render("sub-search", { user: req.user }); 144 + res.render("sub-search", { user: req.user, ...commonRenderOptions }); 114 145 } else { 115 - const { q, options } = parseQuery(req.query.q); 116 - const { items, after } = await G.searchSubreddits(q, { 117 - include_over_18: (options.nsfw ?? "no") === "yes", 118 - }); 146 + const { items, after } = await G.searchSubreddits(req.query.q); 119 147 const subs = db 120 148 .query("SELECT subreddit FROM subscriptions WHERE user_id = $id") 121 149 .all({ id: req.user.id }) ··· 131 159 message, 132 160 user: req.user, 133 161 original_query: req.query.q, 162 + query: req.query, 163 + ...commonRenderOptions, 134 164 }); 135 165 } 136 166 }); ··· 138 168 // GET /post-search 139 169 router.get("/post-search", authenticateToken, async (req, res) => { 140 170 if (!req.query || !req.query.q) { 141 - res.render("post-search", { user: req.user }); 171 + res.render("post-search", { user: req.user, ...commonRenderOptions }); 142 172 } else { 143 - const { q, options } = parseQuery(req.query.q); 144 - const { items, after } = await G.searchSubmissions(q, { 145 - include_over_18: (options.nsfw ?? "no") === "yes", 146 - }); 173 + const { items, after } = await G.searchSubmissions(req.query.q); 147 174 const message = 148 175 items.length === 0 149 176 ? "no results found" 150 177 : `showing ${items.length} results`; 178 + 179 + if (req.query.view == "card" && items) { 180 + items.forEach(unescape_selftext); 181 + } 182 + 151 183 res.render("post-search", { 152 184 items, 153 185 after, 154 186 message, 155 187 user: req.user, 156 188 original_query: req.query.q, 189 + currentUrl: req.url, 190 + query: req.query, 191 + ...commonRenderOptions, 157 192 }); 158 193 } 159 194 }); 160 195 161 - function parseQuery(q) { 162 - return q.split(/\s+/).reduce( 163 - (acc, word) => { 164 - if (word.includes(":")) { 165 - const [key, val] = word.split(":"); 166 - acc.options[key] = val; 167 - } else { 168 - acc.q += `${word} `; 169 - } 170 - return acc; 171 - }, 172 - { options: [], q: "" }, 173 - ); 174 - } 175 - 176 196 // GET /dashboard 177 197 router.get("/dashboard", authenticateToken, async (req, res) => { 178 198 let invites = null; ··· 191 211 usedAt: Date.parse(inv.usedAt), 192 212 })); 193 213 } 194 - res.render("dashboard", { invites, isAdmin, user: req.user }); 214 + res.render("dashboard", { 215 + invites, 216 + isAdmin, 217 + user: req.user, 218 + query: req.query, 219 + ...commonRenderOptions, 220 + }); 195 221 }); 196 222 197 223 router.get("/create-invite", authenticateAdmin, async (req, res) => { ··· 230 256 const kind = ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) 231 257 ? "img" 232 258 : "video"; 233 - res.render("media", { kind, url }); 259 + res.render("media", { kind, url, ...commonRenderOptions }); 234 260 }); 235 261 236 262 router.get("/register", validateInviteToken, async (req, res) => { 237 - res.render("register", { isDisabled: false, token: req.query.token }); 263 + res.render("register", { 264 + isDisabled: false, 265 + token: req.query.token, 266 + ...commonRenderOptions, 267 + }); 238 268 }); 239 269 240 270 router.post("/register", validateInviteToken, async (req, res) => { ··· 250 280 if (user) { 251 281 return res.render("register", { 252 282 message: `user by the name "${username}" exists, choose a different username`, 283 + ...commonRenderOptions, 253 284 }); 254 285 } 255 286 256 287 if (password !== confirm_password) { 257 288 return res.render("register", { 258 289 message: "passwords do not match, try again", 290 + ...commonRenderOptions, 259 291 }); 260 292 } 261 293 ··· 291 323 } catch (err) { 292 324 return res.render("register", { 293 325 message: "error registering user, try again later", 326 + ...commonRenderOptions, 294 327 }); 295 328 } 296 329 }); ··· 374 407 const post = response.submission.data; 375 408 const comments = response.comments; 376 409 377 - if (post.selftext_html) { 378 - post.selftext_html = he.decode(post.selftext_html); 379 - } 410 + unescape_selftext(post); 380 411 comments.forEach(unescape_comment); 381 412 382 413 return { post, comments }; 414 + } 415 + 416 + function unescape_selftext(post) { 417 + // If called after getSubmissions 418 + if (post.data && post.data.selftext_html) { 419 + post.data.selftext_html = he.decode(post.data.selftext_html); 420 + } 421 + // If called after getSubmissionComments 422 + if (post.selftext_html) { 423 + post.selftext_html = he.decode(post.selftext_html); 424 + } 383 425 } 384 426 385 427 function unescape_comment(comment) {
+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 -
+27 -19
src/views/comments.pug
··· 6 6 7 7 - var post = data.post 8 8 - var comments = data.comments 9 + - var viewQuery = 'view=' + (query && query.view ? query.view : 'compact') 10 + - var sortQuery = 'sort=' + (query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot') 9 11 doctype html 10 12 html 11 13 +head(post.title) ··· 22 24 +header(user) 23 25 div.hero 24 26 h3.sub-title 25 - a(href=`/r/${post.subreddit}`) โ† r/#{post.subreddit} 27 + if from 28 + a(href=`${from}#${post.id}`) <- back 29 + | &nbsp;&nbsp; 30 + | ยท 31 + | &nbsp;&nbsp; 32 + a(href=`/r/${post.subreddit}?${sortQuery}&${viewQuery}`) r/#{post.subreddit} 26 33 27 34 div.info-container 28 35 - var domain = (new URL(post.url)).hostname ··· 40 47 h2.post-title 41 48 != post.title 42 49 43 - if isPostGallery(post) 44 - div.gallery 45 - each item in postGalleryItems(post) 46 - div.gallery-item 47 - div.gallery-item-idx 48 - | #{`${item.idx}/${item.total}`} 49 - a(href=`/media/${item.url}`) 50 - img(src=item.url loading="lazy") 51 - else if isPostImage(post) 52 - a(href=`/media/${post.url}`) 53 - img(src=post.url).post-media 54 - else if isPostVideo(post) 55 - - var url = post.secure_media.reddit_video.dash_url 56 - video(controls data-dashjs-player src=`${url}`).post-media 57 - else if isPostLink(post) 58 - a(href=post.url) 59 - | #{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} 60 68 61 69 if post.selftext_html 62 70 div.self-text 63 - != post.selftext_html 71 + != convertInlineImageLinks(post.selftext_html) 64 72 65 73 hr 66 74
+38 -14
src/views/index.pug
··· 2 2 include ../mixins/header 3 3 include ../mixins/head 4 4 include ../utils 5 + - var viewQuery = query && query.view ? query.view : 'compact' 6 + - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 5 7 doctype html 6 8 html 7 9 +head("home") ··· 14 16 div.sub-title 15 17 h1 16 18 if isMulti 17 - a(href=`/`) lurker 19 + a(href=`/?sort=${sortQuery}&view=${viewQuery}`) lurker 18 20 else 19 - a(href=`/r/${subreddit}`) 21 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=${viewQuery}`) 20 22 | r/#{subreddit} 21 23 if !isMulti 22 24 div#button-container ··· 31 33 | consider donating to&nbsp; 32 34 a(href="https://donate.stripe.com/dR62bTaZH1295Da4gg") oppiliappan 33 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 34 42 hr 35 - details 43 + details.sort-details 36 44 summary.sorting sorting by #{query.sort + (query.t?' '+query.t:'')} 37 45 div.sort-opts 38 - a(href=`/r/${subreddit}?sort=hot`) hot 39 - a(href=`/r/${subreddit}?sort=new`) new 40 - a(href=`/r/${subreddit}?sort=rising`) rising 41 - a(href=`/r/${subreddit}?sort=top`) top 42 - a(href=`/r/${subreddit}?sort=top&t=day`) top day 43 - a(href=`/r/${subreddit}?sort=top&t=week`) top week 44 - a(href=`/r/${subreddit}?sort=top&t=month`) top month 45 - a(href=`/r/${subreddit}?sort=top&t=year`) top year 46 - a(href=`/r/${subreddit}?sort=top&t=all`) top all 46 + div 47 + a(href=`/r/${subreddit}?sort=hot&view=${viewQuery}`) hot 48 + div 49 + a(href=`/r/${subreddit}?sort=new&view=${viewQuery}`) new 50 + div 51 + a(href=`/r/${subreddit}?sort=rising&view=${viewQuery}`) rising 52 + div 53 + a(href=`/r/${subreddit}?sort=top&view=${viewQuery}`) top 54 + div 55 + a(href=`/r/${subreddit}?sort=top&t=day&view=${viewQuery}`) top day 56 + div 57 + a(href=`/r/${subreddit}?sort=top&t=week&view=${viewQuery}`) top week 58 + div 59 + a(href=`/r/${subreddit}?sort=top&t=month&view=${viewQuery}`) top month 60 + div 61 + a(href=`/r/${subreddit}?sort=top&t=year&view=${viewQuery}`) top year 62 + div 63 + a(href=`/r/${subreddit}?sort=top&t=all&view=${viewQuery}`) top all 64 + details.view-details 65 + summary.viewing viewing as #{viewQuery} 66 + div.view-opts 67 + div 68 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=compact`) compact 69 + div 70 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=card`) card 47 71 48 72 if posts 49 73 each child in posts.posts 50 - +post(child.data) 51 - 74 + +post(child.data, currentUrl) 75 + 52 76 if posts.after 53 77 div.footer 54 78 div.footer-item
+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
+5 -1
src/views/post-search.pug
··· 2 2 include ../mixins/header 3 3 include ../mixins/head 4 4 5 + - var viewQuery = query && query.view ? query.view : 'compact' 6 + - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 5 7 doctype html 6 8 html 7 9 +head("search posts") ··· 14 16 form(action="/post-search" method="get").search-bar 15 17 - var prefill = original_query ?? ""; 16 18 input(type="text" name="q" placeholder="type in a search term..." value=prefill required).search-input 19 + input(type="hidden" name="sort" value=sortQuery) 20 + input(type="hidden" name="view" value=viewQuery) 17 21 button(type="submit").search-button go 18 22 if message 19 23 div.search-message 20 24 i #{message} 21 25 if items 22 26 each item in items 23 - +post(item.data) 27 + +post(item.data, currentUrl)
+6
src/views/search.pug
··· 1 1 include ../mixins/header 2 2 include ../mixins/head 3 3 4 + - var viewQuery = query && query.view ? query.view : 'compact' 5 + - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 4 6 doctype html 5 7 html 6 8 +head("search subreddits") ··· 14 16 form(action="/sub-search" method="get").search-bar 15 17 - var prefill = original_query ?? ""; 16 18 input(type="text" name="q" placeholder="type in a search term..." value=prefill required).search-input 19 + input(type="hidden" name="sort" value=sortQuery) 20 + input(type="hidden" name="view" value=viewQuery) 17 21 button(type="submit").search-button go 18 22 19 23 hr ··· 23 27 form(action="/post-search" method="get").search-bar 24 28 - var prefill = original_query ?? ""; 25 29 input(type="text" name="q" placeholder="type in a search term..." value=prefill required).search-input 30 + input(type="hidden" name="sort" value=sortQuery) 31 + input(type="hidden" name="view" value=viewQuery) 26 32 button(type="submit").search-button go 27 33 p 28 34 | you can narrow search results using filters:
+5 -1
src/views/sub-search.pug
··· 1 1 include ../mixins/header 2 2 include ../mixins/head 3 3 4 + - var viewQuery = (query && query.view) ? query.view : 'compact' 5 + - var sortQuery = (query && query.sort) ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 4 6 doctype html 5 7 html 6 8 +head("search subreddits") ··· 13 15 form(action="/sub-search" method="get").search-bar 14 16 - var prefill = original_query ?? ""; 15 17 input(type="text" name="q" placeholder="type in a search term..." value=prefill required).search-input 18 + input(type="hidden" name="sort" value=sortQuery) 19 + input(type="hidden" name="view" value=viewQuery) 16 20 button(type="submit").search-button go 17 21 if message 18 22 div.search-message ··· 25 29 - var isSubbed = subs.includes(subreddit) 26 30 div.sub-title 27 31 h3 28 - a(href=`/r/${subreddit}`) 32 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=${viewQuery}`) 29 33 | r/#{subreddit} 30 34 div#button-container 31 35 if isSubbed
+4 -1
src/views/subs.pug
··· 1 1 include ../mixins/header 2 2 include ../mixins/head 3 3 4 + - var viewQuery = query && query.view ? query.view : 'compact' 5 + - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 4 6 doctype html 5 7 html 6 8 +head("subscriptions") ··· 13 15 p 14 16 each s in subs 15 17 - var subreddit = s.subreddit 18 + - var isSubbed = true 16 19 div.sub-title 17 20 h4 18 - a(href=`/r/${subreddit}`) 21 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=${viewQuery}`) 19 22 | r/#{subreddit} 20 23 div#button-container 21 24 if isSubbed