selfhostable, read-only reddit client

Compare changes

Choose any two refs to compare.

+14 -7
.github/workflows/publish-docker.yml
··· 13 - name: checkout repository 14 uses: actions/checkout@v4 15 16 - - name: build docker image 17 - run: docker build -t lurker:latest . 18 - 19 - name: log in to github container registry 20 uses: docker/login-action@v3 21 with: ··· 23 username: ${{ github.actor }} 24 password: ${{ secrets.GITHUB_TOKEN }} 25 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
··· 13 - name: checkout repository 14 uses: actions/checkout@v4 15 16 - name: log in to github container registry 17 uses: docker/login-action@v3 18 with: ··· 20 username: ${{ github.actor }} 21 password: ${{ secrets.GITHUB_TOKEN }} 22 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 - no account necessary for over-18 content 10 11 i host a version for myself and a few friends. reach out to 12 - me if you would like an invite. 13 14 ### features 15 ··· 70 $ docker run -v /your/host/lurker-data:/data -p 3000 ghcr.io/oppiliappan/lurker:latest 71 ``` 72 73 or with just [bun](https://bun.sh/): 74 75 ```bash 76 - bun run src/index.js 77 ``` 78 79 ### usage ··· 84 username at the top-right to view the dashboard and to 85 invite other users to your instance. copy the link and send 86 it to your friends! 87 88 ### technical 89
··· 9 - no account necessary for over-18 content 10 11 i host a version for myself and a few friends. reach out to 12 + me if you would like an invite. 13 14 ### features 15 ··· 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" 85 + ``` 86 + 87 or with just [bun](https://bun.sh/): 88 89 ```bash 90 + bun run src/index.js 91 ``` 92 93 ### usage ··· 98 username at the top-right to view the dashboard and to 99 invite other users to your instance. copy the link and send 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`. 106 107 ### technical 108
+63 -15
src/geddit.js
··· 10 include_over_18: true, 11 type: "sr,link,user", 12 }; 13 } 14 15 async getSubmissions(sort = "hot", subreddit = null, options = {}) { ··· 24 `${ 25 this.host + subredditStr 26 }/${sort}.json?${new URLSearchParams(Object.assign(params, options))}`, 27 ) 28 .then((res) => res.json()) 29 .then((json) => json.data) ··· 37 async getDomainHot(domain, options = this.parameters) { 38 return await fetch( 39 `${this.host}/domain/${domain}/hot.json?${new URLSearchParams(options)}`, 40 ) 41 .then((res) => res.json()) 42 .then((json) => json.data) ··· 50 async getDomainBest(domain, options = this.parameters) { 51 return await fetch( 52 `${this.host}/domain/${domain}/best.json?${new URLSearchParams(options)}`, 53 ) 54 .then((res) => res.json()) 55 .then((json) => json.data) ··· 63 async getDomainTop(domain, options = this.parameters) { 64 return await fetch( 65 `${this.host}/domain/${domain}/top.json?${new URLSearchParams(options)}`, 66 ) 67 .then((res) => res.json()) 68 .then((json) => json.data) ··· 70 after: data.after, 71 posts: data.children, 72 })) 73 - .catch((err) => null); 74 } 75 76 async getDomainNew(domain, options = this.parameters) { 77 return await fetch( 78 `${this.host}/domain/${domain}/new.json?${new URLSearchParams(options)}`, 79 ) 80 .then((res) => res.json()) 81 .then((json) => json.data) ··· 89 async getDomainRising(domain, options = this.parameters) { 90 return await fetch( 91 `${this.host}/domain/${domain}/rising.json?${new URLSearchParams(options)}`, 92 ) 93 .then((res) => res.json()) 94 .then((json) => json.data) ··· 102 async getDomainControversial(domain, options = this.parameters) { 103 return await fetch( 104 `${this.host}/domain/${domain}/controversial.json?${new URLSearchParams(options)}`, 105 ) 106 .then((res) => res.json()) 107 .then((json) => json.data) ··· 113 } 114 115 async getSubreddit(subreddit) { 116 - return await fetch(`${this.host}/r/${subreddit}/about.json`) 117 .then((res) => res.json()) 118 .then((json) => json.data) 119 .catch((err) => null); 120 } 121 122 async getSubredditRules(subreddit) { 123 - return await fetch(`${this.host}/r/${subreddit}/about/rules.json`) 124 .then((res) => res.json()) 125 .then((json) => json.data) 126 .catch((err) => null); 127 } 128 129 async getSubredditModerators(subreddit) { 130 - return await fetch(`${this.host}/r/${subreddit}/about/moderators.json`) 131 .then((res) => res.json()) 132 .then((json) => json.data) 133 - .then({ 134 - data: { 135 - users: data.children, 136 - }, 137 - }) 138 .catch((err) => null); 139 } 140 141 async getSubredditWikiPages(subreddit) { 142 - return await fetch(`${this.host}/r/${subreddit}/wiki/pages.json`) 143 .then((res) => res.json()) 144 .then((json) => json.data) 145 .catch((err) => null); 146 } 147 148 async getSubredditWikiPage(subreddit, page) { 149 - return await fetch(`${this.host}/r/${subreddit}/wiki/${page}.json`) 150 .then((res) => res.json()) 151 .then((json) => json.data) 152 .catch((err) => null); 153 } 154 155 async getSubredditWikiPageRevisions(subreddit, page) { 156 - return await fetch(`${this.host}/r/${subreddit}/wiki/revisions${page}.json`) 157 .then((res) => res.json()) 158 .then((json) => json.data.children) 159 .catch((err) => null); ··· 162 async getPopularSubreddits(options = this.parameters) { 163 return await fetch( 164 `${this.host}/subreddits/popular.json?${new URLSearchParams(options)}`, 165 ) 166 .then((res) => res.json()) 167 .then((json) => json.data) ··· 175 async getNewSubreddits(options = this.parameters) { 176 return await fetch( 177 `${this.host}/subreddits/new.json?${new URLSearchParams(options)}`, 178 ) 179 .then((res) => res.json()) 180 .then((json) => json.data) ··· 188 async getPremiumSubreddits(options = this.parameters) { 189 return await fetch( 190 `${this.host}/subreddits/premium.json?${new URLSearchParams(options)}`, 191 ) 192 .then((res) => res.json()) 193 .then((json) => json.data) ··· 201 async getDefaultSubreddits(options = this.parameters) { 202 return await fetch( 203 `${this.host}/subreddits/default.json?${new URLSearchParams(options)}`, 204 ) 205 .then((res) => res.json()) 206 .then((json) => json.data) ··· 214 async getPopularUsers(options = this.parameters) { 215 return await fetch( 216 `${this.host}/users/popular.json?${new URLSearchParams(options)}`, 217 ) 218 .then((res) => res.json()) 219 .then((json) => json.data) ··· 227 async getNewUsers(options = this.parameters) { 228 return await fetch( 229 `${this.host}/users/new.json?${new URLSearchParams(options)}`, 230 ) 231 .then((res) => res.json()) 232 .then((json) => json.data) ··· 243 244 return await fetch( 245 `${this.host}/search.json?${new URLSearchParams(options)}`, 246 ) 247 .then((res) => res.json()) 248 .then((json) => json.data) ··· 263 264 return await fetch( 265 `${this.host}/subreddits/search.json?${new URLSearchParams(Object.assign(params, options))}`, 266 ) 267 .then((res) => res.json()) 268 .then((json) => json.data) ··· 283 284 return await fetch( 285 `${this.host}/users/search.json?${new URLSearchParams(Object.assign(params, options))}`, 286 ) 287 .then((res) => res.json()) 288 .then((json) => json.data) ··· 307 `${ 308 this.host + subredditStr 309 }/search.json?${new URLSearchParams(Object.assign(params, options))}`, 310 ) 311 .then((res) => res.json()) 312 .then((json) => ··· 324 } 325 326 async getSubmission(id) { 327 - return await fetch(`${this.host}/by_id/${id}.json`) 328 .then((res) => res.json()) 329 .then((json) => json.data.children[0].data) 330 .catch((err) => null); ··· 333 async getSubmissionComments(id, options = this.parameters) { 334 return await fetch( 335 `${this.host}/comments/${id}.json?${new URLSearchParams(options)}`, 336 ) 337 .then((res) => res.json()) 338 .then((json) => ({ ··· 345 async getSingleCommentThread(parent_id, child_id, options = this.parameters) { 346 return await fetch( 347 `${this.host}/comments/${parent_id}/comment/${child_id}.json?${new URLSearchParams(options)}`, 348 ) 349 .then((res) => res.json()) 350 .then((json) => ({ ··· 357 async getSubredditComments(subreddit, options = this.parameters) { 358 return await fetch( 359 `${this.host}/r/${subreddit}/comments.json?${new URLSearchParams(options)}`, 360 ) 361 .then((res) => res.json()) 362 .then((json) => json.data.children) ··· 364 } 365 366 async getUser(username) { 367 - return await fetch(`${this.host}/user/${username}/about.json`) 368 .then((res) => res.json()) 369 .then((json) => json.data) 370 .catch((err) => null); ··· 373 async getUserOverview(username, options = this.parameters) { 374 return await fetch( 375 `${this.host}/user/${username}/overview.json?${new URLSearchParams(options)}`, 376 ) 377 .then((res) => res.json()) 378 .then((json) => json.data) ··· 386 async getUserComments(username, options = this.parameters) { 387 return await fetch( 388 `${this.host}/user/${username}/comments.json?${new URLSearchParams(options)}`, 389 ) 390 .then((res) => res.json()) 391 .then((json) => json.data) ··· 399 async getUserSubmissions(username, options = this.parameters) { 400 return await fetch( 401 `${this.host}/user/${username}/submitted.json?${new URLSearchParams(options)}`, 402 ) 403 .then((res) => res.json()) 404 .then((json) => json.data) ··· 410 } 411 412 async getLiveThread(id) { 413 - return await fetch(`${this.host}/live/${id}/about.json`) 414 .then((res) => res.json()) 415 .then((json) => json.data) 416 .catch((err) => null); ··· 419 async getLiveThreadUpdates(id, options = this.parameters) { 420 return await fetch( 421 `${this.host}/live/${id}.json?${new URLSearchParams(options)}`, 422 ) 423 .then((res) => res.json()) 424 .then((json) => json.data.children) ··· 428 async getLiveThreadContributors(id, options = this.parameters) { 429 return await fetch( 430 `${this.host}/live/${id}/contributors.json?${new URLSearchParams(options)}`, 431 ) 432 .then((res) => res.json()) 433 .then((json) => json.data.children) ··· 437 async getLiveThreadDiscussions(id, options = this.parameters) { 438 return await fetch( 439 `${this.host}/live/${id}/discussions.json?${new URLSearchParams(options)}`, 440 ) 441 .then((res) => res.json()) 442 .then((json) => json.data.children) ··· 446 async getLiveThreadsNow(options = this.parameters) { 447 return await fetch( 448 `${this.host}/live/happening_now.json?${new URLSearchParams(options)}`, 449 ) 450 .then((res) => res.json()) 451 .then((json) => json.data.children)
··· 10 include_over_18: true, 11 type: "sr,link,user", 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 + }; 17 } 18 19 async getSubmissions(sort = "hot", subreddit = null, options = {}) { ··· 28 `${ 29 this.host + subredditStr 30 }/${sort}.json?${new URLSearchParams(Object.assign(params, options))}`, 31 + { headers: this.headers }, 32 ) 33 .then((res) => res.json()) 34 .then((json) => json.data) ··· 42 async getDomainHot(domain, options = this.parameters) { 43 return await fetch( 44 `${this.host}/domain/${domain}/hot.json?${new URLSearchParams(options)}`, 45 + { headers: this.headers }, 46 ) 47 .then((res) => res.json()) 48 .then((json) => json.data) ··· 56 async getDomainBest(domain, options = this.parameters) { 57 return await fetch( 58 `${this.host}/domain/${domain}/best.json?${new URLSearchParams(options)}`, 59 + { headers: this.headers }, 60 ) 61 .then((res) => res.json()) 62 .then((json) => json.data) ··· 70 async getDomainTop(domain, options = this.parameters) { 71 return await fetch( 72 `${this.host}/domain/${domain}/top.json?${new URLSearchParams(options)}`, 73 + { headers: this.headers }, 74 ) 75 .then((res) => res.json()) 76 .then((json) => json.data) ··· 78 after: data.after, 79 posts: data.children, 80 })) 81 + .catch((_) => null); 82 } 83 84 async getDomainNew(domain, options = this.parameters) { 85 return await fetch( 86 `${this.host}/domain/${domain}/new.json?${new URLSearchParams(options)}`, 87 + { headers: this.headers }, 88 ) 89 .then((res) => res.json()) 90 .then((json) => json.data) ··· 98 async getDomainRising(domain, options = this.parameters) { 99 return await fetch( 100 `${this.host}/domain/${domain}/rising.json?${new URLSearchParams(options)}`, 101 + { headers: this.headers }, 102 ) 103 .then((res) => res.json()) 104 .then((json) => json.data) ··· 112 async getDomainControversial(domain, options = this.parameters) { 113 return await fetch( 114 `${this.host}/domain/${domain}/controversial.json?${new URLSearchParams(options)}`, 115 + { headers: this.headers }, 116 ) 117 .then((res) => res.json()) 118 .then((json) => json.data) ··· 124 } 125 126 async getSubreddit(subreddit) { 127 + return await fetch(`${this.host}/r/${subreddit}/about.json`, { 128 + headers: this.headers, 129 + }) 130 .then((res) => res.json()) 131 .then((json) => json.data) 132 .catch((err) => null); 133 } 134 135 async getSubredditRules(subreddit) { 136 + return await fetch(`${this.host}/r/${subreddit}/about/rules.json`, { 137 + headers: this.headers, 138 + }) 139 .then((res) => res.json()) 140 .then((json) => json.data) 141 .catch((err) => null); 142 } 143 144 async getSubredditModerators(subreddit) { 145 + return await fetch(`${this.host}/r/${subreddit}/about/moderators.json`, { 146 + headers: this.headers, 147 + }) 148 .then((res) => res.json()) 149 .then((json) => json.data) 150 + .then((data) => ({ 151 + users: data.children, 152 + })) 153 .catch((err) => null); 154 } 155 156 async getSubredditWikiPages(subreddit) { 157 + return await fetch(`${this.host}/r/${subreddit}/wiki/pages.json`, { 158 + headers: this.headers, 159 + }) 160 .then((res) => res.json()) 161 .then((json) => json.data) 162 .catch((err) => null); 163 } 164 165 async getSubredditWikiPage(subreddit, page) { 166 + return await fetch(`${this.host}/r/${subreddit}/wiki/${page}.json`, { 167 + headers: this.headers, 168 + }) 169 .then((res) => res.json()) 170 .then((json) => json.data) 171 .catch((err) => null); 172 } 173 174 async getSubredditWikiPageRevisions(subreddit, page) { 175 + return await fetch( 176 + `${this.host}/r/${subreddit}/wiki/revisions${page}.json`, 177 + { headers: this.headers }, 178 + ) 179 .then((res) => res.json()) 180 .then((json) => json.data.children) 181 .catch((err) => null); ··· 184 async getPopularSubreddits(options = this.parameters) { 185 return await fetch( 186 `${this.host}/subreddits/popular.json?${new URLSearchParams(options)}`, 187 + { headers: this.headers }, 188 ) 189 .then((res) => res.json()) 190 .then((json) => json.data) ··· 198 async getNewSubreddits(options = this.parameters) { 199 return await fetch( 200 `${this.host}/subreddits/new.json?${new URLSearchParams(options)}`, 201 + { headers: this.headers }, 202 ) 203 .then((res) => res.json()) 204 .then((json) => json.data) ··· 212 async getPremiumSubreddits(options = this.parameters) { 213 return await fetch( 214 `${this.host}/subreddits/premium.json?${new URLSearchParams(options)}`, 215 + { headers: this.headers }, 216 ) 217 .then((res) => res.json()) 218 .then((json) => json.data) ··· 226 async getDefaultSubreddits(options = this.parameters) { 227 return await fetch( 228 `${this.host}/subreddits/default.json?${new URLSearchParams(options)}`, 229 + { headers: this.headers }, 230 ) 231 .then((res) => res.json()) 232 .then((json) => json.data) ··· 240 async getPopularUsers(options = this.parameters) { 241 return await fetch( 242 `${this.host}/users/popular.json?${new URLSearchParams(options)}`, 243 + { headers: this.headers }, 244 ) 245 .then((res) => res.json()) 246 .then((json) => json.data) ··· 254 async getNewUsers(options = this.parameters) { 255 return await fetch( 256 `${this.host}/users/new.json?${new URLSearchParams(options)}`, 257 + { headers: this.headers }, 258 ) 259 .then((res) => res.json()) 260 .then((json) => json.data) ··· 271 272 return await fetch( 273 `${this.host}/search.json?${new URLSearchParams(options)}`, 274 + { headers: this.headers }, 275 ) 276 .then((res) => res.json()) 277 .then((json) => json.data) ··· 292 293 return await fetch( 294 `${this.host}/subreddits/search.json?${new URLSearchParams(Object.assign(params, options))}`, 295 + { headers: this.headers }, 296 ) 297 .then((res) => res.json()) 298 .then((json) => json.data) ··· 313 314 return await fetch( 315 `${this.host}/users/search.json?${new URLSearchParams(Object.assign(params, options))}`, 316 + { headers: this.headers }, 317 ) 318 .then((res) => res.json()) 319 .then((json) => json.data) ··· 338 `${ 339 this.host + subredditStr 340 }/search.json?${new URLSearchParams(Object.assign(params, options))}`, 341 + { headers: this.headers }, 342 ) 343 .then((res) => res.json()) 344 .then((json) => ··· 356 } 357 358 async getSubmission(id) { 359 + return await fetch(`${this.host}/by_id/${id}.json`, { 360 + headers: this.headers, 361 + }) 362 .then((res) => res.json()) 363 .then((json) => json.data.children[0].data) 364 .catch((err) => null); ··· 367 async getSubmissionComments(id, options = this.parameters) { 368 return await fetch( 369 `${this.host}/comments/${id}.json?${new URLSearchParams(options)}`, 370 + { headers: this.headers }, 371 ) 372 .then((res) => res.json()) 373 .then((json) => ({ ··· 380 async getSingleCommentThread(parent_id, child_id, options = this.parameters) { 381 return await fetch( 382 `${this.host}/comments/${parent_id}/comment/${child_id}.json?${new URLSearchParams(options)}`, 383 + { headers: this.headers }, 384 ) 385 .then((res) => res.json()) 386 .then((json) => ({ ··· 393 async getSubredditComments(subreddit, options = this.parameters) { 394 return await fetch( 395 `${this.host}/r/${subreddit}/comments.json?${new URLSearchParams(options)}`, 396 + { headers: this.headers }, 397 ) 398 .then((res) => res.json()) 399 .then((json) => json.data.children) ··· 401 } 402 403 async getUser(username) { 404 + return await fetch(`${this.host}/user/${username}/about.json`, { 405 + headers: this.headers, 406 + }) 407 .then((res) => res.json()) 408 .then((json) => json.data) 409 .catch((err) => null); ··· 412 async getUserOverview(username, options = this.parameters) { 413 return await fetch( 414 `${this.host}/user/${username}/overview.json?${new URLSearchParams(options)}`, 415 + { headers: this.headers }, 416 ) 417 .then((res) => res.json()) 418 .then((json) => json.data) ··· 426 async getUserComments(username, options = this.parameters) { 427 return await fetch( 428 `${this.host}/user/${username}/comments.json?${new URLSearchParams(options)}`, 429 + { headers: this.headers }, 430 ) 431 .then((res) => res.json()) 432 .then((json) => json.data) ··· 440 async getUserSubmissions(username, options = this.parameters) { 441 return await fetch( 442 `${this.host}/user/${username}/submitted.json?${new URLSearchParams(options)}`, 443 + { headers: this.headers }, 444 ) 445 .then((res) => res.json()) 446 .then((json) => json.data) ··· 452 } 453 454 async getLiveThread(id) { 455 + return await fetch(`${this.host}/live/${id}/about.json`, { 456 + headers: this.headers, 457 + }) 458 .then((res) => res.json()) 459 .then((json) => json.data) 460 .catch((err) => null); ··· 463 async getLiveThreadUpdates(id, options = this.parameters) { 464 return await fetch( 465 `${this.host}/live/${id}.json?${new URLSearchParams(options)}`, 466 + { headers: this.headers }, 467 ) 468 .then((res) => res.json()) 469 .then((json) => json.data.children) ··· 473 async getLiveThreadContributors(id, options = this.parameters) { 474 return await fetch( 475 `${this.host}/live/${id}/contributors.json?${new URLSearchParams(options)}`, 476 + { headers: this.headers }, 477 ) 478 .then((res) => res.json()) 479 .then((json) => json.data.children) ··· 483 async getLiveThreadDiscussions(id, options = this.parameters) { 484 return await fetch( 485 `${this.host}/live/${id}/discussions.json?${new URLSearchParams(options)}`, 486 + { headers: this.headers }, 487 ) 488 .then((res) => res.json()) 489 .then((json) => json.data.children) ··· 493 async getLiveThreadsNow(options = this.parameters) { 494 return await fetch( 495 `${this.host}/live/happening_now.json?${new URLSearchParams(options)}`, 496 + { headers: this.headers }, 497 ) 498 .then((res) => res.json()) 499 .then((json) => json.data.children)
+2 -2
src/index.js
··· 29 app.use("/", routes); 30 31 const port = process.env.LURKER_PORT; 32 - const server = app.listen(port ? port : 3000, () => { 33 - console.log(`started on ${server.address().port}`); 34 });
··· 29 app.use("/", routes); 30 31 const port = process.env.LURKER_PORT; 32 + const server = app.listen(port ? port : 3000, "0.0.0.0", () => { 33 + console.log("started on", server.address()); 34 });
+12 -3
src/mixins/comment.pug
··· 1 include ../utils 2 3 mixin infoContainer(data, next_id, prev_id) 4 div.comment-info-container 5 p 6 | #{fmtnum(data.ups)} โ†‘ ··· 11 if prev_id 12 a(href=`#${prev_id}` title="scroll to previous comment").nav-link prev 13 |  ยท  14 span(class=`${data.is_submitter ? 'op' : ''}`) 15 - | u/#{data.author} #{data.is_submitter ? '(op)' : ''} 16 |  ยท  17 if data.collapsed_reason_code == "DELETED" || data.author == "[deleted]" 18 a(href=`https://undelete.pullpush.io${data.permalink}`) search on undelete ··· 22 if data.edited !== false 23 | edited #{timeDifference(Date.now(), data.edited * 1000)} ago 24 |  ยท  25 a(href=`https://reddit.com${data.permalink}` title="view on reddit").nav-link open โ†— 26 27 - ··· 38 a(href=`/comments/${parent_id}/comment/${data.id}`) 39 | #{data.count} more #{fmttxt(data.count, 'comment')} 40 else 41 - div(class=`comment ${isfirst ? 'first' : ''}`) 42 details(id=`${data.id}` open="") 43 summary.expand-comments 44 +infoContainer(data, next_id, prev_id) 45 div.comment-body 46 - != data.body_html 47 if hasReplyData 48 div.replies 49 - var total = data.replies.data.children.length
··· 1 include ../utils 2 + include postUtils 3 4 mixin infoContainer(data, next_id, prev_id) 5 + - var hats = (data.is_submitter?['op']:[]).concat(data.distinguished=="moderator"?['mod']:[]) 6 div.comment-info-container 7 p 8 | #{fmtnum(data.ups)} โ†‘ ··· 13 if prev_id 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} โ˜† 19 + |  ยท  20 span(class=`${data.is_submitter ? 'op' : ''}`) 21 + | u/#{data.author} #{hats.length==0?'':`(${hats.join('|')})`} 22 |  ยท  23 if data.collapsed_reason_code == "DELETED" || data.author == "[deleted]" 24 a(href=`https://undelete.pullpush.io${data.permalink}`) search on undelete ··· 28 if data.edited !== false 29 | edited #{timeDifference(Date.now(), data.edited * 1000)} ago 30 |  ยท  31 + if data.stickied 32 + | stickied 33 + |  ยท  34 a(href=`https://reddit.com${data.permalink}` title="view on reddit").nav-link open โ†— 35 36 - ··· 47 a(href=`/comments/${parent_id}/comment/${data.id}`) 48 | #{data.count} more #{fmttxt(data.count, 'comment')} 49 else 50 + div(class=`comment ${isfirst ? 'first' : ''} ${data.stickied ? 'sticky' : ''}`) 51 details(id=`${data.id}` open="") 52 summary.expand-comments 53 +infoContainer(data, next_id, prev_id) 54 div.comment-body 55 + != convertInlineImageLinks(data.body_html) 56 if hasReplyData 57 div.replies 58 - var total = data.replies.data.children.length
+2 -1
src/mixins/head.pug
··· 4 meta(charset='UTF-8') 5 title #{`${title} ยท lurker `} 6 link(rel="stylesheet", href="/styles.css") 7 link(rel="preconnect" href="https://rsms.me/") 8 link(rel="stylesheet" href="https://rsms.me/inter/inter.css") 9 script(src="https://cdn.dashjs.org/latest/dash.all.min.js") 10 -
··· 4 meta(charset='UTF-8') 5 title #{`${title} ยท lurker `} 6 link(rel="stylesheet", href="/styles.css") 7 + if theme 8 + link(rel="stylesheet", href=`/${theme}.css`) 9 link(rel="preconnect" href="https://rsms.me/") 10 link(rel="stylesheet" href="https://rsms.me/inter/inter.css") 11 script(src="https://cdn.dashjs.org/latest/dash.all.min.js")
+6 -5
src/mixins/header.pug
··· 1 mixin header(user) 2 div.header 3 div.header-item 4 - a(href=`/`) home 5 div.header-item 6 - a(href=`/r/all`) all 7 div.header-item 8 - a(href=`/search`) search 9 div.header-item 10 - a(href=`/subs`) subs 11 if user 12 div.header-item 13 - a(href='/dashboard') #{user.username} 14 |  15 a(href='/logout') (logout) 16 else
··· 1 mixin header(user) 2 + - var viewQuery = 'view=' + (query && query.view ? query.view : 'compact') 3 div.header 4 div.header-item 5 + a(href=`/?${viewQuery}`) home 6 div.header-item 7 + a(href=`/r/all?${viewQuery}`) all 8 div.header-item 9 + a(href=`/search?${viewQuery}`) search 10 div.header-item 11 + a(href=`/subs?${viewQuery}`) subs 12 if user 13 div.header-item 14 + a(href=`/dashboard?${viewQuery}`) #{user.username} 15 |  16 a(href='/logout') (logout) 17 else
+75 -61
src/mixins/post.pug
··· 2 include postUtils 3 mixin post(p, currentUrl) 4 - var from = encodeURIComponent(currentUrl) 5 - article.post 6 - div.post-container 7 - div.post-text 8 - div.title-container 9 - a(href=`/comments/${p.id}?from=${from}`) 10 - != p.title 11 - span.domain (#{p.domain}) 12 - div.info-container 13 - p 14 - | #{fmtnum(p.ups)} โ†‘ 15 - span.post-author 16 |  ยท  17 - | u/#{p.author} 18 - |  ยท  19 - | #{timeDifference(Date.now(), p.created * 1000)} 20 - |  ยท  21 - a(href=`/r/${p.subreddit}`) r/#{p.subreddit} 22 - |  ยท  23 - a(href=`/comments/${p.id}?from=${from}`) #{fmtnum (p.num_comments)} โ†ฉ 24 - div.media-preview 25 - if isPostGallery(p) 26 - - var item = postGalleryItems(p)[0] 27 - img(src=item.url onclick=`toggleDetails('${p.id}')`) 28 - else if isPostImage(p) 29 - - var url = postThumbnail(p) 30 - img(src=url onclick=`toggleDetails('${p.id}')`) 31 - else if isPostVideo(p) 32 - - var url = p.secure_media.reddit_video.scrubber_media_url 33 - video(src=url data-dashjs-player width='100px' height='100px' onclick=`toggleDetails('${p.id}')`) 34 - else if isPostLink(p) 35 - a(href=p.url) 36 - | โ†— 37 38 - if isPostGallery(p) 39 - details(id=`${p.id}`) 40 - summary.expand-post expand gallery 41 - div.gallery 42 - each item in postGalleryItems(p) 43 - div.gallery-item 44 - div.gallery-item-idx 45 - | #{`${item.idx}/${item.total}`} 46 - a(href=`/media/${item.url}`) 47 - img(src=item.url loading="lazy") 48 - button(onclick=`toggleDetails('${p.id}')`) close 49 - else if isPostImage(p) 50 - details(id=`${p.id}`) 51 - summary.expand-post expand image 52 - a(href=`/media/${p.url}`) 53 - img(src=p.url loading="lazy").post-media 54 - button(onclick=`toggleDetails('${p.id}')`) close 55 - else if isPostVideo(p) 56 - details(id=`${p.id}`) 57 - summary.expand-post expand video 58 - - var url = p.secure_media.reddit_video.dash_url 59 - video(src=url controls data-dashjs-player loading="lazy").post-media 60 - button(onclick=`toggleDetails('${p.id}')`) close 61 - else if isPostLink(p) 62 - details(id=`${p.id}`) 63 - summary.expand-post expand link 64 - a(href=`${p.url}`) 65 - | #{p.url} 66 - br 67 - button(onclick=`toggleDetails('${p.id}')`) close
··· 2 include postUtils 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 + |  ยท  20 + span.gilded 21 + | #{p.gilded} โ˜† 22 + span.post-author 23 + |  ยท  24 + | u/#{p.author} 25 |  ยท  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 + | โ†— 54 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 isPostGallery(p) { 3 return (p.is_gallery && p.is_gallery == true); ··· 10 } 11 - 12 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 } else if (p.thumbnail == "spoiler") { 18 - return "/spoiler.svg"; 19 } else { 20 - return p.thumbnail; 21 } 22 } 23 - ··· 51 return null; 52 } 53 }
··· 1 + - 2 + function isPostMedia(p) { 3 + return isPostImage(p) || isPostGallery(p) || isPostVideo(p); 4 + } 5 - 6 function isPostGallery(p) { 7 return (p.is_gallery && p.is_gallery == true); ··· 14 } 15 - 16 function postThumbnail(p) { 17 + if (p.over_18) { 18 + return "/nsfw.svg"; 19 } else if (p.thumbnail == "spoiler") { 20 + return "/spoiler.svg"; 21 + } else if (p.thumbnail == "image" || p.thumbnail == "") { 22 + return p.url; 23 } else { 24 + return p.thumbnail; 25 } 26 } 27 - ··· 55 return null; 56 } 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 + }
+217 -21
src/public/styles.css
··· 5 --text-color: black; 6 --text-color-muted: #999; 7 --blockquote-color: green; 8 --link-color: #29BC9B; 9 --link-visited-color: #999; 10 --accent: var(--link-color); 11 --error-text-color: red; 12 13 font-family: Inter, sans-serif; 14 font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'dlig' 1, 'ss01' 1, 'ss07' 1, 'ss08' 1; ··· 22 --text-color: white; 23 --text-color-muted: #999; 24 --blockquote-color: lightgreen; 25 --link-color: #79ffe1; 26 --link-visited-color: #999; 27 --accent: var(--link-color); ··· 43 color: var(--text-color); 44 } 45 46 main { 47 display: flex; 48 flex-direction: column; ··· 54 .info-container a, 55 .comment-info-container a, 56 .sort-opts a, 57 .more a 58 { 59 text-decoration: none; ··· 88 align-items: center; 89 } 90 91 - .sorting { 92 margin-top: 20px; 93 } 94 95 - .sort-opts { 96 display: grid; 97 margin: 10px; 98 } 99 100 - .sort-opts { 101 grid-template-columns: repeat(2, 1fr); 102 grid-template-rows: repeat(5, 1fr); 103 grid-auto-flow: column; 104 } 105 106 .footer { 107 display: flex; 108 flex-direction: row; ··· 115 nav { 116 display: flex; 117 align-items: stretch; 118 } 119 120 .post, .comments-container, .hero, .header, .footer { ··· 127 font-size: 0.9rem; 128 } 129 130 .comments-container { 131 font-size: 0.9rem; 132 } ··· 169 object-fit: cover; 170 width: 4rem; 171 height: 4rem; 172 } 173 174 - .media-preview a { 175 - font-size: 2rem; 176 - text-decoration: none; 177 - padding: 1rem; 178 } 179 180 - .media-maximized-container { 181 - display: flex; 182 - justify-content: center; 183 - align-items: center; 184 - width: 100vw; 185 - height: 100vh; 186 - overflow: hidden; 187 } 188 189 .media-maximized { ··· 196 object-fit: contain; 197 } 198 199 .post-author { 200 display: none 201 } ··· 215 } 216 217 @media (min-width: 768px) { 218 .post, .comments-container, .hero, .header, .footer { 219 flex: 1 1 90%; 220 width: 90%; ··· 225 width: 5rem; 226 height: 5rem; 227 } 228 .post-author { 229 display: inline 230 } ··· 234 form { 235 width: 40%; 236 } 237 - .sort-opts { 238 grid-template-columns: repeat(9, 1fr); 239 grid-template-rows: repeat(1, 1fr); 240 grid-auto-flow: row; ··· 242 } 243 244 @media (min-width: 1080px) { 245 .post, .comments-container, .hero, .header, .footer { 246 flex: 1 1 60%; 247 width: 60%; ··· 252 width: 5rem; 253 height: 5rem; 254 } 255 .media-preview a { 256 font-size: 2rem; 257 padding: 2rem; 258 } 259 .post-author { 260 display: inline 261 } ··· 265 form { 266 width: 20%; 267 } 268 - .sort-opts { 269 grid-template-columns: repeat(9, 1fr); 270 grid-template-rows: repeat(1, 1fr); 271 grid-auto-flow: row; ··· 277 flex: 1 1 40%; 278 width: 40%; 279 } 280 - .sort-opts { 281 grid-template-columns: repeat(9, 1fr); 282 grid-template-rows: repeat(1, 1fr); 283 grid-auto-flow: row; ··· 301 margin-top: 10px; 302 } 303 304 - .post-container { 305 display: flex; 306 flex-direction: row; 307 align-items: center; ··· 345 text-decoration: none; 346 } 347 348 .title-container > a:hover { 349 text-decoration: underline; 350 } ··· 355 356 .header a, 357 .sort-opts a, 358 .sub-title a { 359 color: var(--text-color); 360 } ··· 449 overflow-x: auto; 450 position: relative; 451 padding: 5px; 452 } 453 454 .gallery-item { 455 flex: 0 0 auto; 456 margin-right: 10px; 457 } 458 459 - .gallery img { 460 - width: auto; 461 - max-height: 500px; 462 } 463 464 .post-title { ··· 469 color: var(--accent); 470 } 471 472 button { 473 border: 0px solid; 474 border-radius: 2px; ··· 612 text-indent: 1px; 613 text-overflow: ''; 614 }
··· 5 --text-color: black; 6 --text-color-muted: #999; 7 --blockquote-color: green; 8 + --sticky-color: #dcfeda; 9 + --gilded: darkorange; 10 --link-color: #29BC9B; 11 --link-visited-color: #999; 12 --accent: var(--link-color); 13 --error-text-color: red; 14 + --border-radius-card: 0.5vmin; 15 + --border-radius-media: 0.5vmin; 16 + --border-radius-preview: 0.3vmin; 17 18 font-family: Inter, sans-serif; 19 font-feature-settings: 'ss01' 1, 'kern' 1, 'liga' 1, 'cv05' 1, 'dlig' 1, 'ss01' 1, 'ss07' 1, 'ss08' 1; ··· 27 --text-color: white; 28 --text-color-muted: #999; 29 --blockquote-color: lightgreen; 30 + --sticky-color: #014413; 31 + --gilded: gold; 32 --link-color: #79ffe1; 33 --link-visited-color: #999; 34 --accent: var(--link-color); ··· 50 color: var(--text-color); 51 } 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 + 73 main { 74 display: flex; 75 flex-direction: column; ··· 81 .info-container a, 82 .comment-info-container a, 83 .sort-opts a, 84 + .view-opts a, 85 .more a 86 { 87 text-decoration: none; ··· 116 align-items: center; 117 } 118 119 + .sorting, 120 + .viewing { 121 margin-top: 20px; 122 } 123 124 + .sort-opts, 125 + .view-opts { 126 display: grid; 127 margin: 10px; 128 } 129 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); 139 + } 140 + 141 .footer { 142 display: flex; 143 flex-direction: row; ··· 150 nav { 151 display: flex; 152 align-items: stretch; 153 + } 154 + 155 + .post { 156 + margin-bottom: 5px; 157 } 158 159 .post, .comments-container, .hero, .header, .footer { ··· 166 font-size: 0.9rem; 167 } 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 + 241 .comments-container { 242 font-size: 0.9rem; 243 } ··· 280 object-fit: cover; 281 width: 4rem; 282 height: 4rem; 283 + border-radius: var(--border-radius-preview); 284 } 285 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; 301 } 302 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; 312 } 313 314 .media-maximized { ··· 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; 331 + } 332 + 333 .post-author { 334 display: none 335 } ··· 349 } 350 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 + } 357 .post, .comments-container, .hero, .header, .footer { 358 flex: 1 1 90%; 359 width: 90%; ··· 364 width: 5rem; 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%; 377 + } 378 .post-author { 379 display: inline 380 } ··· 384 form { 385 width: 40%; 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; ··· 393 } 394 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 + } 401 .post, .comments-container, .hero, .header, .footer { 402 flex: 1 1 60%; 403 width: 60%; ··· 408 width: 5rem; 409 height: 5rem; 410 } 411 + .image-viewer img, 412 + .image-viewer video 413 + { 414 + max-height: 45vh; 415 + } 416 .media-preview a { 417 font-size: 2rem; 418 padding: 2rem; 419 } 420 + .self-text.card { 421 + -webkit-line-clamp: 4; 422 + line-clamp: 4; 423 + } 424 .post-author { 425 display: inline 426 } ··· 430 form { 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; ··· 443 flex: 1 1 40%; 444 width: 40%; 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; ··· 473 margin-top: 10px; 474 } 475 476 + .post-info { 477 display: flex; 478 flex-direction: row; 479 align-items: center; ··· 517 text-decoration: none; 518 } 519 520 + .title-container.card > a { 521 + font-size: 1.125rem; 522 + font-weight: bold; 523 + } 524 + 525 .title-container > a:hover { 526 text-decoration: underline; 527 } ··· 532 533 .header a, 534 .sort-opts a, 535 + .view-opts a, 536 .sub-title a { 537 color: var(--text-color); 538 } ··· 627 overflow-x: auto; 628 position: relative; 629 padding: 5px; 630 + align-items: center; 631 + scroll-snap-type: both mandatory; 632 } 633 634 .gallery-item { 635 flex: 0 0 auto; 636 margin-right: 10px; 637 + max-width: 100%; 638 + width: 100%; 639 + scroll-snap-align: center; 640 } 641 642 + .gallery-item-idx { 643 + text-align: center; 644 } 645 646 .post-title { ··· 651 color: var(--accent); 652 } 653 654 + .gilded { 655 + color: var(--gilded); 656 + } 657 + 658 button { 659 border: 0px solid; 660 border-radius: 2px; ··· 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 + */
+71 -14
src/routes/index.js
··· 6 const { db } = require("../db"); 7 const { authenticateToken, authenticateAdmin } = require("../auth"); 8 const { validateInviteToken } = require("../invite"); 9 - const url = require("url"); 10 11 const router = express.Router(); 12 const G = new geddit.Geddit(); 13 14 // GET / 15 router.get("/", authenticateToken, async (req, res) => { 16 const subs = db 17 .query("SELECT * FROM subscriptions WHERE user_id = $id") 18 .all({ id: req.user.id }); 19 if (subs.length === 0) { 20 - res.redirect("/r/all"); 21 } else { 22 const p = subs.map((s) => s.subreddit).join("+"); 23 - res.redirect(`/r/${p}`); 24 } 25 }); 26 ··· 31 const query = req.query ? req.query : {}; 32 if (!query.sort) { 33 query.sort = "hot"; 34 } 35 36 let isSubbed = false; ··· 47 48 const [posts, about] = await Promise.all([postsReq, aboutReq]); 49 50 res.render("index", { 51 subreddit, 52 posts, ··· 56 user: req.user, 57 isSubbed, 58 currentUrl: req.url, 59 }); 60 }); 61 ··· 71 data: unescape_submission(response), 72 user: req.user, 73 from: req.query.from, 74 }); 75 }); 76 ··· 92 comments, 93 parent_id, 94 user: req.user, 95 }); 96 }, 97 ); ··· 104 ) 105 .all({ id: req.user.id }); 106 107 - res.render("subs", { subs, user: req.user }); 108 }); 109 110 // GET /search 111 router.get("/search", authenticateToken, async (req, res) => { 112 - res.render("search", { user: req.user }); 113 }); 114 115 // GET /sub-search 116 router.get("/sub-search", authenticateToken, async (req, res) => { 117 if (!req.query || !req.query.q) { 118 - res.render("sub-search", { user: req.user }); 119 } else { 120 - const { items, after } = await G.searchSubreddits(q); 121 const subs = db 122 .query("SELECT subreddit FROM subscriptions WHERE user_id = $id") 123 .all({ id: req.user.id }) ··· 133 message, 134 user: req.user, 135 original_query: req.query.q, 136 }); 137 } 138 }); ··· 140 // GET /post-search 141 router.get("/post-search", authenticateToken, async (req, res) => { 142 if (!req.query || !req.query.q) { 143 - res.render("post-search", { user: req.user }); 144 } else { 145 const { items, after } = await G.searchSubmissions(req.query.q); 146 const message = 147 items.length === 0 148 ? "no results found" 149 : `showing ${items.length} results`; 150 res.render("post-search", { 151 items, 152 after, ··· 154 user: req.user, 155 original_query: req.query.q, 156 currentUrl: req.url, 157 }); 158 } 159 }); ··· 176 usedAt: Date.parse(inv.usedAt), 177 })); 178 } 179 - res.render("dashboard", { invites, isAdmin, user: req.user }); 180 }); 181 182 router.get("/create-invite", authenticateAdmin, async (req, res) => { ··· 215 const kind = ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) 216 ? "img" 217 : "video"; 218 - res.render("media", { kind, url }); 219 }); 220 221 router.get("/register", validateInviteToken, async (req, res) => { 222 - res.render("register", { isDisabled: false, token: req.query.token }); 223 }); 224 225 router.post("/register", validateInviteToken, async (req, res) => { ··· 235 if (user) { 236 return res.render("register", { 237 message: `user by the name "${username}" exists, choose a different username`, 238 }); 239 } 240 241 if (password !== confirm_password) { 242 return res.render("register", { 243 message: "passwords do not match, try again", 244 }); 245 } 246 ··· 276 } catch (err) { 277 return res.render("register", { 278 message: "error registering user, try again later", 279 }); 280 } 281 }); ··· 359 const post = response.submission.data; 360 const comments = response.comments; 361 362 if (post.selftext_html) { 363 post.selftext_html = he.decode(post.selftext_html); 364 } 365 - comments.forEach(unescape_comment); 366 - 367 - return { post, comments }; 368 } 369 370 function unescape_comment(comment) {
··· 6 const { db } = require("../db"); 7 const { authenticateToken, authenticateAdmin } = require("../auth"); 8 const { validateInviteToken } = require("../invite"); 9 10 const router = express.Router(); 11 const G = new geddit.Geddit(); 12 13 + const commonRenderOptions = { 14 + theme: process.env.LURKER_THEME, 15 + }; 16 + 17 // GET / 18 router.get("/", authenticateToken, async (req, res) => { 19 const subs = db 20 .query("SELECT * FROM subscriptions WHERE user_id = $id") 21 .all({ id: req.user.id }); 22 + 23 + const qs = req.query ? "?" + new URLSearchParams(req.query).toString() : ""; 24 + 25 if (subs.length === 0) { 26 + res.redirect(`/r/all${qs}`); 27 } else { 28 const p = subs.map((s) => s.subreddit).join("+"); 29 + res.redirect(`/r/${p}${qs}`); 30 } 31 }); 32 ··· 37 const query = req.query ? req.query : {}; 38 if (!query.sort) { 39 query.sort = "hot"; 40 + } 41 + if (!query.view) { 42 + query.view = "compact"; 43 } 44 45 let isSubbed = false; ··· 56 57 const [posts, about] = await Promise.all([postsReq, aboutReq]); 58 59 + if (query.view == "card" && posts && posts.posts) { 60 + posts.posts.forEach(unescape_selftext); 61 + } 62 + 63 res.render("index", { 64 subreddit, 65 posts, ··· 69 user: req.user, 70 isSubbed, 71 currentUrl: req.url, 72 + ...commonRenderOptions, 73 }); 74 }); 75 ··· 85 data: unescape_submission(response), 86 user: req.user, 87 from: req.query.from, 88 + query: req.query, 89 + ...commonRenderOptions, 90 }); 91 }); 92 ··· 108 comments, 109 parent_id, 110 user: req.user, 111 + ...commonRenderOptions, 112 }); 113 }, 114 ); ··· 121 ) 122 .all({ id: req.user.id }); 123 124 + res.render("subs", { 125 + subs, 126 + user: req.user, 127 + query: req.query, 128 + ...commonRenderOptions, 129 + }); 130 }); 131 132 // GET /search 133 router.get("/search", authenticateToken, async (req, res) => { 134 + res.render("search", { 135 + user: req.user, 136 + query: req.query, 137 + ...commonRenderOptions, 138 + }); 139 }); 140 141 // GET /sub-search 142 router.get("/sub-search", authenticateToken, async (req, res) => { 143 if (!req.query || !req.query.q) { 144 + res.render("sub-search", { user: req.user, ...commonRenderOptions }); 145 } else { 146 + const { items, after } = await G.searchSubreddits(req.query.q); 147 const subs = db 148 .query("SELECT subreddit FROM subscriptions WHERE user_id = $id") 149 .all({ id: req.user.id }) ··· 159 message, 160 user: req.user, 161 original_query: req.query.q, 162 + query: req.query, 163 + ...commonRenderOptions, 164 }); 165 } 166 }); ··· 168 // GET /post-search 169 router.get("/post-search", authenticateToken, async (req, res) => { 170 if (!req.query || !req.query.q) { 171 + res.render("post-search", { user: req.user, ...commonRenderOptions }); 172 } else { 173 const { items, after } = await G.searchSubmissions(req.query.q); 174 const message = 175 items.length === 0 176 ? "no results found" 177 : `showing ${items.length} results`; 178 + 179 + if (req.query.view == "card" && items) { 180 + items.forEach(unescape_selftext); 181 + } 182 + 183 res.render("post-search", { 184 items, 185 after, ··· 187 user: req.user, 188 original_query: req.query.q, 189 currentUrl: req.url, 190 + query: req.query, 191 + ...commonRenderOptions, 192 }); 193 } 194 }); ··· 211 usedAt: Date.parse(inv.usedAt), 212 })); 213 } 214 + res.render("dashboard", { 215 + invites, 216 + isAdmin, 217 + user: req.user, 218 + query: req.query, 219 + ...commonRenderOptions, 220 + }); 221 }); 222 223 router.get("/create-invite", authenticateAdmin, async (req, res) => { ··· 256 const kind = ["jpg", "jpeg", "png", "gif", "webp"].includes(ext) 257 ? "img" 258 : "video"; 259 + res.render("media", { kind, url, ...commonRenderOptions }); 260 }); 261 262 router.get("/register", validateInviteToken, async (req, res) => { 263 + res.render("register", { 264 + isDisabled: false, 265 + token: req.query.token, 266 + ...commonRenderOptions, 267 + }); 268 }); 269 270 router.post("/register", validateInviteToken, async (req, res) => { ··· 280 if (user) { 281 return res.render("register", { 282 message: `user by the name "${username}" exists, choose a different username`, 283 + ...commonRenderOptions, 284 }); 285 } 286 287 if (password !== confirm_password) { 288 return res.render("register", { 289 message: "passwords do not match, try again", 290 + ...commonRenderOptions, 291 }); 292 } 293 ··· 323 } catch (err) { 324 return res.render("register", { 325 message: "error registering user, try again later", 326 + ...commonRenderOptions, 327 }); 328 } 329 }); ··· 407 const post = response.submission.data; 408 const comments = response.comments; 409 410 + unescape_selftext(post); 411 + comments.forEach(unescape_comment); 412 + 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 } 425 } 426 427 function unescape_comment(comment) {
+6 -1
src/utils.pug
··· 1 - - var fmtnum = (n)=>n>=1000?(n/1000).toFixed(1)+'k':n; 2 - var fmttxt = (n,t)=>`${t}${n==1?'':'s'}` 3 - var stripPrefix = (s, p) => s.startsWith(p) ? s.slice(p.length) : s; 4 -
··· 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 + } 7 - var fmttxt = (n,t)=>`${t}${n==1?'':'s'}` 8 - var stripPrefix = (s, p) => s.startsWith(p) ? s.slice(p.length) : s; 9 -
+22 -19
src/views/comments.pug
··· 6 7 - var post = data.post 8 - var comments = data.comments 9 doctype html 10 html 11 +head(post.title) ··· 27 | &nbsp;&nbsp; 28 | ยท 29 | &nbsp;&nbsp; 30 - a(href=`/r/${post.subreddit}`) r/#{post.subreddit} 31 32 div.info-container 33 - var domain = (new URL(post.url)).hostname ··· 45 h2.post-title 46 != post.title 47 48 - if isPostGallery(post) 49 - div.gallery 50 - each item in postGalleryItems(post) 51 - div.gallery-item 52 - div.gallery-item-idx 53 - | #{`${item.idx}/${item.total}`} 54 - a(href=`/media/${item.url}`) 55 - img(src=item.url loading="lazy") 56 - else if isPostImage(post) 57 - a(href=`/media/${post.url}`) 58 - img(src=post.url).post-media 59 - else if isPostVideo(post) 60 - - var url = post.secure_media.reddit_video.dash_url 61 - video(controls data-dashjs-player src=`${url}`).post-media 62 - else if isPostLink(post) 63 - a(href=post.url) 64 - | #{post.url} 65 66 if post.selftext_html 67 div.self-text 68 - != post.selftext_html 69 70 hr 71
··· 6 7 - var post = data.post 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') 11 doctype html 12 html 13 +head(post.title) ··· 29 | &nbsp;&nbsp; 30 | ยท 31 | &nbsp;&nbsp; 32 + a(href=`/r/${post.subreddit}?${sortQuery}&${viewQuery}`) r/#{post.subreddit} 33 34 div.info-container 35 - var domain = (new URL(post.url)).hostname ··· 47 h2.post-title 48 != post.title 49 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} 68 69 if post.selftext_html 70 div.self-text 71 + != convertInlineImageLinks(post.selftext_html) 72 73 hr 74
+27 -12
src/views/index.pug
··· 2 include ../mixins/header 3 include ../mixins/head 4 include ../utils 5 doctype html 6 html 7 +head("home") ··· 14 div.sub-title 15 h1 16 if isMulti 17 - a(href=`/`) lurker 18 else 19 - a(href=`/r/${subreddit}`) 20 | r/#{subreddit} 21 if !isMulti 22 div#button-container ··· 31 | consider donating to&nbsp; 32 a(href="https://donate.stripe.com/dR62bTaZH1295Da4gg") oppiliappan 33 |, author of lurker 34 hr 35 - details 36 summary.sorting sorting by #{query.sort + (query.t?' '+query.t:'')} 37 div.sort-opts 38 div 39 - a(href=`/r/${subreddit}?sort=hot`) hot 40 div 41 - a(href=`/r/${subreddit}?sort=new`) new 42 div 43 - a(href=`/r/${subreddit}?sort=rising`) rising 44 div 45 - a(href=`/r/${subreddit}?sort=top`) top 46 div 47 - a(href=`/r/${subreddit}?sort=top&t=day`) top day 48 div 49 - a(href=`/r/${subreddit}?sort=top&t=week`) top week 50 div 51 - a(href=`/r/${subreddit}?sort=top&t=month`) top month 52 div 53 - a(href=`/r/${subreddit}?sort=top&t=year`) top year 54 div 55 - a(href=`/r/${subreddit}?sort=top&t=all`) top all 56 57 if posts 58 each child in posts.posts
··· 2 include ../mixins/header 3 include ../mixins/head 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' 7 doctype html 8 html 9 +head("home") ··· 16 div.sub-title 17 h1 18 if isMulti 19 + a(href=`/?sort=${sortQuery}&view=${viewQuery}`) lurker 20 else 21 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=${viewQuery}`) 22 | r/#{subreddit} 23 if !isMulti 24 div#button-container ··· 33 | consider donating to&nbsp; 34 a(href="https://donate.stripe.com/dR62bTaZH1295Da4gg") oppiliappan 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 42 hr 43 + details.sort-details 44 summary.sorting sorting by #{query.sort + (query.t?' '+query.t:'')} 45 div.sort-opts 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 71 72 if posts 73 each child in posts.posts
+2 -2
src/views/media.pug
··· 2 doctype html 3 html 4 +head("home") 5 - body 6 - div.media-maximized-container 7 if kind == 'img' 8 img(src=url).media-maximized 9 else
··· 2 doctype html 3 html 4 +head("home") 5 + body.media-maximized 6 + div.media-maximized.container 7 if kind == 'img' 8 img(src=url).media-maximized 9 else
+4
src/views/post-search.pug
··· 2 include ../mixins/header 3 include ../mixins/head 4 5 doctype html 6 html 7 +head("search posts") ··· 14 form(action="/post-search" method="get").search-bar 15 - var prefill = original_query ?? ""; 16 input(type="text" name="q" placeholder="type in a search term..." value=prefill required).search-input 17 button(type="submit").search-button go 18 if message 19 div.search-message
··· 2 include ../mixins/header 3 include ../mixins/head 4 5 + - var viewQuery = query && query.view ? query.view : 'compact' 6 + - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 7 doctype html 8 html 9 +head("search posts") ··· 16 form(action="/post-search" method="get").search-bar 17 - var prefill = original_query ?? ""; 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) 21 button(type="submit").search-button go 22 if message 23 div.search-message
+6
src/views/search.pug
··· 1 include ../mixins/header 2 include ../mixins/head 3 4 doctype html 5 html 6 +head("search subreddits") ··· 14 form(action="/sub-search" method="get").search-bar 15 - var prefill = original_query ?? ""; 16 input(type="text" name="q" placeholder="type in a search term..." value=prefill required).search-input 17 button(type="submit").search-button go 18 19 hr ··· 23 form(action="/post-search" method="get").search-bar 24 - var prefill = original_query ?? ""; 25 input(type="text" name="q" placeholder="type in a search term..." value=prefill required).search-input 26 button(type="submit").search-button go 27 p 28 | you can narrow search results using filters:
··· 1 include ../mixins/header 2 include ../mixins/head 3 4 + - var viewQuery = query && query.view ? query.view : 'compact' 5 + - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 6 doctype html 7 html 8 +head("search subreddits") ··· 16 form(action="/sub-search" method="get").search-bar 17 - var prefill = original_query ?? ""; 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) 21 button(type="submit").search-button go 22 23 hr ··· 27 form(action="/post-search" method="get").search-bar 28 - var prefill = original_query ?? ""; 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) 32 button(type="submit").search-button go 33 p 34 | you can narrow search results using filters:
+5 -1
src/views/sub-search.pug
··· 1 include ../mixins/header 2 include ../mixins/head 3 4 doctype html 5 html 6 +head("search subreddits") ··· 13 form(action="/sub-search" method="get").search-bar 14 - var prefill = original_query ?? ""; 15 input(type="text" name="q" placeholder="type in a search term..." value=prefill required).search-input 16 button(type="submit").search-button go 17 if message 18 div.search-message ··· 25 - var isSubbed = subs.includes(subreddit) 26 div.sub-title 27 h3 28 - a(href=`/r/${subreddit}`) 29 | r/#{subreddit} 30 div#button-container 31 if isSubbed
··· 1 include ../mixins/header 2 include ../mixins/head 3 4 + - var viewQuery = (query && query.view) ? query.view : 'compact' 5 + - var sortQuery = (query && query.sort) ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 6 doctype html 7 html 8 +head("search subreddits") ··· 15 form(action="/sub-search" method="get").search-bar 16 - var prefill = original_query ?? ""; 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) 20 button(type="submit").search-button go 21 if message 22 div.search-message ··· 29 - var isSubbed = subs.includes(subreddit) 30 div.sub-title 31 h3 32 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=${viewQuery}`) 33 | r/#{subreddit} 34 div#button-container 35 if isSubbed
+3 -1
src/views/subs.pug
··· 1 include ../mixins/header 2 include ../mixins/head 3 4 doctype html 5 html 6 +head("subscriptions") ··· 16 - var isSubbed = true 17 div.sub-title 18 h4 19 - a(href=`/r/${subreddit}`) 20 | r/#{subreddit} 21 div#button-container 22 if isSubbed
··· 1 include ../mixins/header 2 include ../mixins/head 3 4 + - var viewQuery = query && query.view ? query.view : 'compact' 5 + - var sortQuery = query && query.sort ? query.sort + (query.t ? '&t=' + query.t : '') : 'hot' 6 doctype html 7 html 8 +head("subscriptions") ··· 18 - var isSubbed = true 19 div.sub-title 20 h4 21 + a(href=`/r/${subreddit}?sort=${sortQuery}&view=${viewQuery}`) 22 | r/#{subreddit} 23 div#button-container 24 if isSubbed