+14
-7
.github/workflows/publish-docker.yml
+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
+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
+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
+2
-2
src/index.js
+12
-3
src/mixins/comment.pug
+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
+2
-1
src/mixins/head.pug
···
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
+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
+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
+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 = /&/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
+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
+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
+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
+6
-1
src/utils.pug
+22
-19
src/views/comments.pug
+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
|
28
| ยท
29
|
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
|
30
| ยท
31
|
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
+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
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
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
+
| ยท
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
-2
src/views/media.pug
+4
src/views/post-search.pug
+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
+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
+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
+3
-1
src/views/subs.pug
···
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