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