18 Commits

Author SHA1 Message Date
video-prize-ranch
157407c013 Break words when needed (#150)
Closes #149

Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/150
Reviewed-by: orangix <orangix@noreply.codeberg.org>
Co-authored-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
Co-committed-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
2023-10-17 22:25:47 +00:00
video-prize-ranch
8fe3f73568 Update README (#148)
Closes #119

Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/148
Co-authored-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
Co-committed-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
2023-10-07 20:17:46 +00:00
video-prize-ranch
5cce60c3ad Legal clarity (#147)
Closes #56

Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/147
Co-authored-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
Co-committed-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
2023-10-07 19:33:03 +00:00
video-prize-ranch
1ec92f4809 Merge branch 'move-instances' 2023-09-23 19:24:03 -04:00
video-prize-ranch
7a0c008bba Move instances 2023-09-23 19:19:17 -04:00
lostskunk_Donetsk
d354d6314a New instance (#136)
Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/136
Reviewed-by: orangix <orangix@noreply.codeberg.org>
Co-authored-by: lostskunk_Donetsk <lostskunk_donetsk@noreply.codeberg.org>
Co-committed-by: lostskunk_Donetsk <lostskunk_donetsk@noreply.codeberg.org>
2023-09-17 01:20:00 +00:00
video-prize-ranch
121ad6b157 Add rimgo.quantenzitrone.eu (#137) (#139)
Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/139
Co-authored-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
Co-committed-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
2023-09-16 14:36:35 +00:00
video-prize-ranch
fc88bfbca5 Cleanup previous/next page buttons (#134)
Use noteq to simplify code, add current page to previous/next page bar

Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/134
Reviewed-by: orangix <orangix@noreply.codeberg.org>
Co-authored-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
Co-committed-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
2023-08-30 17:24:54 +00:00
orangix
0fea1e46a3 Add user favorites (#133)
Closes #10

Co-authored-by: video-prize-ranch <cb.8a3w5@simplelogin.co>
Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/133
Reviewed-by: video-prize-ranch <video-prize-ranch@noreply.codeberg.org>
Co-authored-by: orangix <uleo8b8g@anonaddy.me>
Co-committed-by: orangix <uleo8b8g@anonaddy.me>
2023-08-29 22:59:59 +00:00
video-prize-ranch
877ee7faa9 Change max age on cache-control in production 2023-08-16 11:46:41 -04:00
video-prize-ranch
d490581c44 Fix overflowing text on mobile 2023-08-16 11:07:32 -04:00
orangix
730aead3a1 fix header on user submissions 2023-08-16 10:26:35 -04:00
video-prize-ranch
2024f7f6cf Add projectsegfau.lt instances to instances.json 2023-08-15 17:05:07 -04:00
orangix
34ecc2a32b User comments support (#131)
#10

Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/131
Co-authored-by: orangix <uleo8b8g@anonaddy.me>
Co-committed-by: orangix <uleo8b8g@anonaddy.me>
2023-08-15 20:38:14 +00:00
Arya K
e5e1c38058 Update location of Project Segfault instance in README (#130)
Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/130
Co-authored-by: Arya K <arya@projectsegfau.lt>
Co-committed-by: Arya K <arya@projectsegfau.lt>
2023-08-15 20:30:58 +00:00
video-prize-ranch
493a17385a Remove rimgo.fascinated.cc (closes #125) 2023-08-14 18:59:35 -04:00
orangix
a8abb43f3a Trending page (#128)
Fixes #46

Reviewed-on: https://codeberg.org/rimgo/rimgo/pulls/128
Co-authored-by: orangix <orangix@noreply.codeberg.org>
Co-committed-by: orangix <orangix@noreply.codeberg.org>
2023-08-14 22:47:59 +00:00
video-prize-ranch
d69d8dba0e Switch image to ko 2023-08-14 14:13:19 -04:00
22 changed files with 666 additions and 136 deletions

View File

@@ -25,7 +25,7 @@ archives:
checksum: checksum:
name_template: 'checksums.txt' name_template: 'checksums.txt'
kos: kos:
- repository: codeberg.org/rimgo/ko-test - repository: codeberg.org/rimgo/rimgo
tags: tags:
- '{{.Version}}' - '{{.Version}}'
- latest - latest
@@ -34,6 +34,7 @@ kos:
platforms: platforms:
- linux/amd64 - linux/amd64
- linux/arm64 - linux/arm64
sbom: none
gitea_urls: gitea_urls:
api: https://codeberg.org/api/v1 api: https://codeberg.org/api/v1
download: https://codeberg.org download: https://codeberg.org

View File

@@ -65,59 +65,8 @@ Stack Overflow: `https://i.stack.imgur.com/KnO3v.jpg?s=64&g=1` -> `https://rimgo
To automatically redirect Imgur links, see [Redirection](https://rimgo.codeberg.page/docs/usage/redirection/). To automatically redirect Imgur links, see [Redirection](https://rimgo.codeberg.page/docs/usage/redirection/).
## Instances ## Instances
Open an issue to have your instance listed here! See the rules for the instance list [here](https://rimgo.codeberg.page/docs/usage/instance-list-rules/).
> For more details on instance privacy, see https://rimgo.codeberg.page/docs/usage/instance-privacy/ Available at https://rimgo.codeberg.page/ or https://codeberg.org/rimgo/instances
### Clearnet
| URL | Country | Provider | Privacy | Notes |
| :------------------------------------------------------------ | :----------- | :----------------------- | :-------------------- | :---- |
| [rimgo.pussthecat.org](https://rimgo.pussthecat.org) | 🇩🇪 DE | Hetzner | ⚠️ Data collected | |
| [rimgo.totaldarkness.net](https://rimgo.totaldarkness.net) | 🇨🇦 CA | Vultr | ✅ Data not collected | |
| [rimgo.bus-hit.me](https://rimgo.bus-hit.me) | 🇨🇦 CA | Oracle | ⚠️ Data collected | |
| [imgur.artemislena.eu](https://imgur.artemislena.eu) | 🇩🇪 DE | Vodafone Deutschland | ✅ Data not collected | Self-hosted, provider is ISP |
| [rimgo.vern.cc](https://rimgo.vern.cc) | 🇺🇸 US | OVHCloud | ✅ Data not collected | [Edited theme](https://git.vern.cc/root/modifications/src/branch/master/rimgo) |
| [rim.odyssey346.dev](https://rim.odyssey346.dev/) | 🇫🇷️ FR | Trolling Solutions (OVH) | ✅ Data not collected | |
| [i.habedieeh.re](https://i.habedieeh.re/) | 🇨🇦️ CA | Oracle Cloud | ✅ Data not collected | |
| [rimgo.hostux.net](https://rimgo.hostux.net/) | 🇫🇷️ FR | Gandi | ⚠️ Data collected | |
| [ri.zzls.xyz](https://ri.zzls.xyz/) | 🇨🇱 CL | TELEFÓNICA CHILE | ✅ Data not collected | Self-hosted, provider is ISP |
| [rimgo.lunar.icu](https://rimgo.marcopisco.com/) | 🇩🇪 DE | Cloudflare | ✅ Data not collected | |
| [imgur.010032.xyz](https://imgur.010032.xyz/) | 🇰🇷 KR | Oracle Cloud | ✅ Data not collected | |
| [rimgo.kling.gg](https://rimgo.kling.gg/) | 🇳🇱 NL | RamNode | ✅ Data not collected | |
| [i.01r.xyz](https://i.01r.xyz/) | 🇺🇸 US | Cloudflare | ✅ Data not collected | |
| [rimgo.projectsegfau.lt](https://rimgo.projectsegfau.lt/) | 🇱🇺 LU, 🇺🇸 US, 🇮🇳 IN | See below | ✅ Data not collected | |
| [rimgo.eu.projectsegfau.lt](https://rimgo.projectsegfau.lt/) | 🇱🇺 LU | FranTech Solutions | ✅ Data not collected | |
| [rimgo.us.projectsegfau.lt](https://rimgo.projectsegfau.lt/) | 🇺🇸 US | DigitalOcean | ✅ Data not collected | |
| [rimgo.in.projectsegfau.lt](https://rimgo.projectsegfau.lt/) | 🇮🇳 IN | Airtel | ✅ Data not collected | |
| [rimgo.fascinated.cc](https://rimgo.fascinated.cc/) | 🇺🇸 US | Cloudflare | ✅ Data not collected | |
| [rimgo.whateveritworks.org](https://rimgo.whateveritworks.org/) | 🇩🇪 DE | Cloudflare | ✅ Data not collected | |
| [rimgo.nohost.network](https://rimgo.nohost.network/) | 🇲🇽 MX | Telmex | ✅ Data not collected | |
| [rimgo.catsarch.com](https://rimgo.catsarch.com/) | 🇺🇸 US | Comcast | ✅ Data not collected | Self-hosted, provider is ISP |
| [rimgo.frontendfriendly.xyz](https://rimgo.frontendfriendly.xyz/) | 🇩🇪 DE | Hetzner | ⚠️ Data collected | |
### Tor
| URL | Privacy | Notes |
| :-- | :------ | :----------------------- |
| [rimgo.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://rimgo.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion) | ✅ Data not collected | Onion of rimgo.vern.cc |
| [imgur.lpoaj7z2zkajuhgnlltpeqh3zyq7wk2iyeggqaduhgxhyajtdt2j7wad.onion](http://imgur.lpoaj7z2zkajuhgnlltpeqh3zyq7wk2iyeggqaduhgxhyajtdt2j7wad.onion) | ✅ Data not collected | Onion of imgur.artemislena.eu |
| [rim.odysfvr23q5wgt7i456o5t3trw2cw5dgn56vbjfbq2m7xsc5vqbqpcyd.onion](http://rim.odysfvr23q5wgt7i456o5t3trw2cw5dgn56vbjfbq2m7xsc5vqbqpcyd.onion) | ⚠️ Data collected | |
| [tdp6uqjtmok723suum5ms3jbquht6d7dssug4cgcxhfniatb25gcipad.onion](http://tdp6uqjtmok723suum5ms3jbquht6d7dssug4cgcxhfniatb25gcipad.onion) | ✅ Data not collected | Onion of rimgo.privacytools.io |
| [i.habeehrhadazsw3izbrbilqajalfyqqln54mrja3iwpqxgcuxnus7eid.onion](http://i.habeehrhadazsw3izbrbilqajalfyqqln54mrja3iwpqxgcuxnus7eid.onion/) | ✅ Data not collected | Onion of i.habedieeh.re |
| [rimgo.zzlsghu6mvvwyy75mvga6gaf4znbp3erk5xwfzedb4gg6qqh2j6rlvid.onion](http://rimgo.zzlsghu6mvvwyy75mvga6gaf4znbp3erk5xwfzedb4gg6qqh2j6rlvid.onion/) | ✅ Data not collected | Onion of ri.zzls.xyz |
| [tdn7zoxctmsopey77mp4eg2gazaudyhgbuyytf4zpk5u7lknlxlgbnid.onion/](http://tdn7zoxctmsopey77mp4eg2gazaudyhgbuyytf4zpk5u7lknlxlgbnid.onion/) | ✅ Data not collected | Onion of rimgo.kling.gg |
| [rimgo.pjsfkvpxlinjamtawaksbnnaqs2fc2mtvmozrzckxh7f3kis6yea25ad.onion](http://rimgo.pjsfkvpxlinjamtawaksbnnaqs2fc2mtvmozrzckxh7f3kis6yea25ad.onion/) | ✅ Data not collected | Onion of rimgo.eu.projectsegfau.lt |
### I2P
| URL | Privacy | Notes |
| :-- | :------ | :----------------------- |
| [rimgo.i2p](http://rimgo.i2p) | ✅ Data not collected | i.habedieeh.re on I2P |
| [rimgov7l2tqyrm5txrtvhtnfyrzkc5d7ipafofavchbnnyog4r3q.b32.i2p](http://rimgov7l2tqyrm5txrtvhtnfyrzkc5d7ipafofavchbnnyog4r3q.b32.i2p) | ✅ Data not collected | Same as rimgo.i2p |
| [rimgo.zzls.i2p](http://rimgo.zzls.i2p) | ✅ Data not collected | ri.zzls.xyz on I2P |
| [p57356k2xwhxrg2lxrjajcftkrptv4zejeeblzfgkcvpzuetkz2a.b32.i2p](http://p57356k2xwhxrg2lxrjajcftkrptv4zejeeblzfgkcvpzuetkz2a.b32.i2p) | ✅ Data not collected | Same as rimgo.zzls.i2p |
| [ovzamsts5czfx3jasbbhbccyyl2z7qmdngtlqxdh4oi7abhdz3ia.b32.i2p](http://ovzamsts5czfx3jasbbhbccyyl2z7qmdngtlqxdh4oi7abhdz3ia.b32.i2p) | ✅ Data not collected | rimgo.kling.gg on I2P |
## Install ## Install
@@ -128,7 +77,10 @@ See [Install](https://rimgo.codeberg.page/docs/getting-started/install/).
See [Configuration](https://rimgo.codeberg.page/docs/usage/configuration/). See [Configuration](https://rimgo.codeberg.page/docs/usage/configuration/).
## Contributing ## Contributing
Pull requests are welcome! If you have any questions or bug reports, open an [issue](https://codeberg.org/rimgo/rimgo/issues/new). Pull requests are welcome! If you have any questions or bug reports, open an [issue](https://codeberg.org/rimgo/rimgo/issues/new). Please remember to follow our [Code of Conduct](https://rimgo.codeberg.page/docs/code-of-conduct/)!
## License ## License
This software is released under the AGPL-3.0 license. If you make any modifications to the code and distribute it (including use on a network server), you must publicly distribute your changes and release them under the AGPL-3.0. This software is released under the AGPL-3.0 license. If you make any modifications to the code and distribute it (including use on a network server), you must publicly distribute your changes and release them under the AGPL-3.0.
## Legal notice
rimgo does not allow uploads or host any content, media. All content on any rimgo instances is from Imgur™. Imgur is a trademark of Imgur, Inc. Any issues with content on rimgo should be be reported to Imgur.

View File

@@ -16,14 +16,15 @@ import (
type Comment struct { type Comment struct {
Comments []Comment Comments []Comment
User User User User
Post Submission
Id string Id string
Comment string Comment string
Upvotes int64 Upvotes int64
Downvotes int64 Downvotes int64
Platform string Platform string
CreatedAt string CreatedAt string
RelTime string RelTime string
UpdatedAt string UpdatedAt string
DeletedAt string DeletedAt string
} }
@@ -55,7 +56,7 @@ func (client *Client) FetchComments(galleryID string) ([]Comment, error) {
) )
wg.Wait() wg.Wait()
client.Cache.Set(galleryID + "-comments", comments, cache.DefaultExpiration) client.Cache.Set(galleryID+"-comments", comments, cache.DefaultExpiration)
return comments, nil return comments, nil
} }
@@ -130,13 +131,14 @@ func parseComment(data gjson.Result) Comment {
Username: data.Get("account.username").String(), Username: data.Get("account.username").String(),
Avatar: userAvatar, Avatar: userAvatar,
}, },
Post: parseSubmission(data.Get("post")),
Id: data.Get("id").String(), Id: data.Get("id").String(),
Comment: comment, Comment: comment,
Upvotes: data.Get("upvote_count").Int(), Upvotes: data.Get("upvote_count").Int(),
Downvotes: data.Get("downvote_count").Int(), Downvotes: data.Get("downvote_count").Int(),
Platform: data.Get("platform").String(), Platform: data.Get("platform").String(),
CreatedAt: createdAt, CreatedAt: createdAt,
RelTime: humanize.Time(createdTime), RelTime: humanize.Time(createdTime),
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
DeletedAt: deletedAt, DeletedAt: deletedAt,
} }

107
api/trending.go Normal file
View File

@@ -0,0 +1,107 @@
package api
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"codeberg.org/rimgo/rimgo/utils"
"github.com/patrickmn/go-cache"
"github.com/tidwall/gjson"
)
func (client *Client) FetchTrending(section, sort, page string) ([]Submission, error) {
cacheData, found := client.Cache.Get(fmt.Sprintf("trending-%s-%s-%s", section, sort, page))
if found {
return cacheData.([]Submission), nil
}
req, err := http.NewRequest("GET", "https://api.imgur.com/post/v1/posts", nil)
if err != nil {
return []Submission{}, err
}
utils.SetReqHeaders(req)
q := req.URL.Query()
q.Add("client_id", client.ClientID)
q.Add("include", "cover")
q.Add("page", page)
switch sort {
case "newest":
q.Add("filter[window]", "week")
q.Add("sort", "-time")
case "best":
q.Add("filter[window]", "all")
q.Add("sort", "-top")
case "popular":
fallthrough
default:
q.Add("filter[window]", "week")
q.Add("sort", "-viral")
sort = "popular"
}
switch section {
case "hot":
q.Add("filter[section]", "eq:hot")
case "new":
q.Add("filter[section]", "eq:new")
case "top":
q.Add("filter[section]", "eq:top")
q.Add("filter[window]", "day")
default:
q.Add("filter[section]", "eq:hot")
section = "hot"
}
req.URL.RawQuery = q.Encode()
res, err := http.DefaultClient.Do(req)
if err != nil {
return []Submission{}, err
}
body, err := io.ReadAll(res.Body)
if err != nil {
return []Submission{}, err
}
data := gjson.Parse(string(body))
wg := sync.WaitGroup{}
posts := make([]Submission, 0)
data.ForEach(
func(key, value gjson.Result) bool {
wg.Add(1)
go func() {
defer wg.Done()
posts = append(posts, Submission{
Id: value.Get("id").String(),
Title: value.Get("title").String(),
Link: strings.ReplaceAll(value.Get("url").String(), "https://imgur.com", ""),
Cover: Media{
Id: value.Get("cover_id").String(),
Type: value.Get("cover.type").String(),
Url: strings.ReplaceAll(value.Get("cover.url").String(), "https://i.imgur.com", ""),
},
Points: value.Get("point_count").Int(),
Upvotes: value.Get("upvote_count").Int(),
Downvotes: value.Get("downvote_count").Int(),
Comments: value.Get("comment_count").Int(),
Views: value.Get("view_count").Int(),
IsAlbum: value.Get("is_album").Bool(),
})
}()
return true
},
)
wg.Wait()
client.Cache.Set(fmt.Sprintf("trending-%s-%s-%s", section, sort, page), posts, cache.DefaultExpiration)
return posts, nil
}

View File

@@ -8,6 +8,7 @@ import (
"time" "time"
"codeberg.org/rimgo/rimgo/utils" "codeberg.org/rimgo/rimgo/utils"
"github.com/patrickmn/go-cache"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
@@ -64,12 +65,12 @@ func (client *Client) FetchUser(username string) (User, error) {
CreatedAt: createdTime.Format("January 2, 2006"), CreatedAt: createdTime.Format("January 2, 2006"),
} }
client.Cache.Set(username + "-user", user, 1*time.Hour) client.Cache.Set(username+"-user", user, 1*time.Hour)
return user, nil return user, nil
} }
func (client *Client) FetchSubmissions(username string, sort string, page string) ([]Submission, error) { func (client *Client) FetchSubmissions(username string, sort string, page string) ([]Submission, error) {
cacheData, found := client.Cache.Get(username + "-submissions") cacheData, found := client.Cache.Get(username + "-submissions-" + sort + page)
if found { if found {
return cacheData.([]Submission), nil return cacheData.([]Submission), nil
} }
@@ -89,41 +90,7 @@ func (client *Client) FetchSubmissions(username string, sort string, page string
go func() { go func() {
defer wg.Done() defer wg.Done()
coverData := value.Get("images.#(id==\"" + value.Get("cover").String() + "\")") submissions = append(submissions, parseSubmission(value))
cover := Media{
Id: value.Get("id").String(),
Description: value.Get("description").String(),
Type: strings.Split(value.Get("type").String(), "/")[0],
Url: strings.ReplaceAll(value.Get("link").String(), "https://i.imgur.com", ""),
}
if coverData.Exists() {
cover = Media{
Id: coverData.Get("id").String(),
Description: coverData.Get("description").String(),
Type: strings.Split(coverData.Get("type").String(), "/")[0],
Url: strings.ReplaceAll(coverData.Get("link").String(), "https://i.imgur.com", ""),
}
}
id := value.Get("id").String()
link := "/a/" + id
if value.Get("in_gallery").Bool() {
link = "/gallery/" + id
}
submissions = append(submissions, Submission{
Id: id,
Link: link,
Title: value.Get("title").String(),
Cover: cover,
Points: value.Get("points").Int(),
Upvotes: value.Get("ups").Int(),
Downvotes: value.Get("downs").Int(),
Comments: value.Get("comment_count").Int(),
Views: value.Get("views").Int(),
IsAlbum: value.Get("is_album").Bool(),
})
}() }()
return true return true
@@ -131,6 +98,151 @@ func (client *Client) FetchSubmissions(username string, sort string, page string
) )
wg.Wait() wg.Wait()
client.Cache.Set(username + "-submissions", submissions, 15*time.Minute) client.Cache.Set(username+"-submissions-"+sort+page, submissions, 15*time.Minute)
return submissions, nil return submissions, nil
} }
func (client *Client) FetchUserFavorites(username string, sort string, page string) ([]Submission, error) {
cacheData, found := client.Cache.Get(username + "-favorites-" + sort + page)
if found {
return cacheData.([]Submission), nil
}
req, err := http.NewRequest("GET", "https://api.imgur.com/3/account/"+username+"/gallery_favorites/"+page+"/"+sort, nil)
if err != nil {
return []Submission{}, err
}
utils.SetReqHeaders(req)
q := req.URL.Query()
q.Add("client_id", client.ClientID)
req.URL.RawQuery = q.Encode()
res, err := http.DefaultClient.Do(req)
if err != nil {
return []Submission{}, err
}
body, err := io.ReadAll(res.Body)
if err != nil {
return []Submission{}, err
}
data := gjson.Parse(string(body))
submissions := []Submission{}
wg := sync.WaitGroup{}
data.Get("data").ForEach(
func(key, value gjson.Result) bool {
wg.Add(1)
go func() {
defer wg.Done()
submissions = append(submissions, parseSubmission(value))
}()
return true
},
)
wg.Wait()
client.Cache.Set(username+"-favorites-"+sort+page, submissions, 15*time.Minute)
return submissions, nil
}
func (client *Client) FetchUserComments(username string) ([]Comment, error) {
cacheData, found := client.Cache.Get(username + "-usercomments")
if found {
return cacheData.([]Comment), nil
}
req, err := http.NewRequest("GET", "https://api.imgur.com/comment/v1/comments", nil)
if err != nil {
return []Comment{}, err
}
utils.SetReqHeaders(req)
q := req.URL.Query()
q.Add("client_id", client.ClientID)
q.Add("filter[account]", "eq:"+username)
q.Add("include", "account,post")
q.Add("sort", "new")
req.URL.RawQuery = q.Encode()
res, err := http.DefaultClient.Do(req)
if err != nil {
return []Comment{}, err
}
body, err := io.ReadAll(res.Body)
if err != nil {
return []Comment{}, err
}
data := gjson.Parse(string(body))
comments := make([]Comment, 0)
data.Get("data").ForEach(
func(key, value gjson.Result) bool {
comments = append(comments, parseComment(value))
return true
},
)
client.Cache.Set(username+"-usercomments", comments, cache.DefaultExpiration)
return comments, nil
}
func parseSubmission(value gjson.Result) Submission {
var cover Media
c := value.Get("cover")
coverData := value.Get("images.#(id==\"" + c.String() + "\")")
switch {
case c.Type == gjson.String && coverData.Exists():
cover = Media{
Id: coverData.Get("id").String(),
Description: coverData.Get("description").String(),
Type: strings.Split(coverData.Get("type").String(), "/")[0],
Url: strings.ReplaceAll(coverData.Get("link").String(), "https://i.imgur.com", ""),
}
// This case is when fetching comments
case c.Type != gjson.Null:
cover = Media{
Id: c.Get("id").String(),
Url: strings.ReplaceAll(c.Get("url").String(), "https://i.imgur.com", ""),
}
// Replace with thumbnails here because it's easier.
if strings.HasSuffix(cover.Url, ".mp4") {
cover.Url = cover.Url[:len(cover.Url)-3] + "webp"
}
default:
cover = Media{
Id: value.Get("id").String(),
Description: value.Get("description").String(),
Type: strings.Split(value.Get("type").String(), "/")[0],
Url: strings.ReplaceAll(value.Get("link").String(), "https://i.imgur.com", ""),
}
}
id := value.Get("id").String()
link := "/a/" + id
if value.Get("in_gallery").Bool() {
link = "/gallery/" + id
}
return Submission{
Id: id,
Link: link,
Title: value.Get("title").String(),
Cover: cover,
Points: value.Get("points").Int(),
Upvotes: value.Get("ups").Int(),
Downvotes: value.Get("downs").Int(),
Comments: value.Get("comment_count").Int(),
Views: value.Get("views").Int(),
IsAlbum: value.Get("is_album").Bool(),
}
}

View File

@@ -105,11 +105,25 @@
"cloudflare": true "cloudflare": true
}, },
{ {
"url": "https://rimgo.fascinated.cc", "url": "https://rimgo.eu.projectsegfau.lt",
"countries": [
"fr"
],
"cloudflare": false
},
{
"url": "https://rimgo.us.projectsegfau.lt",
"countries": [ "countries": [
"us" "us"
], ],
"cloudflare": true "cloudflare": false
},
{
"url": "https://rimgo.in.projectsegfau.lt",
"countries": [
"in"
],
"cloudflare": false
}, },
{ {
"url": "https://rimgo.whateveritworks.org", "url": "https://rimgo.whateveritworks.org",
@@ -138,5 +152,12 @@
"de" "de"
], ],
"cloudflare": false "cloudflare": false
},
{
"url": "https://rimgo.quantenzitrone.eu",
"countries": [
"cz"
],
"cloudflare": false
} }
] ]

23
main.go
View File

@@ -24,7 +24,7 @@ func main() {
envPath := flag.String("c", ".env", "Path to env file") envPath := flag.String("c", ".env", "Path to env file")
godotenv.Load(*envPath) godotenv.Load(*envPath)
utils.LoadConfig() utils.LoadConfig()
pages.InitializeApiClient() pages.InitializeApiClient()
views := http.FS(views.GetFiles()) views := http.FS(views.GetFiles())
@@ -32,7 +32,7 @@ func main() {
views = http.Dir("./views") views = http.Dir("./views")
} }
engine := handlebars.NewFileSystem(views, ".hbs") engine := handlebars.NewFileSystem(views, ".hbs")
engine.AddFunc("noteq", func(a interface{}, b interface{}, options *raymond.Options) interface{} { engine.AddFunc("noteq", func(a interface{}, b interface{}, options *raymond.Options) interface{} {
if raymond.Str(a) != raymond.Str(b) { if raymond.Str(a) != raymond.Str(b) {
return options.Fn() return options.Fn()
@@ -69,10 +69,9 @@ func main() {
fmt.Println(e) fmt.Println(e)
}, },
})) }))
if os.Getenv("ENV") == "dev" { if os.Getenv("ENV") == "dev" {
app.Use("/static", filesystem.New(filesystem.Config{ app.Use("/static", filesystem.New(filesystem.Config{
MaxAge: 2592000,
Root: http.Dir("./static"), Root: http.Dir("./static"),
})) }))
app.Get("/errors/429", func(c *fiber.Ctx) error { app.Get("/errors/429", func(c *fiber.Ctx) error {
@@ -88,14 +87,15 @@ func main() {
}) })
} else { } else {
app.Use("/static", filesystem.New(filesystem.Config{ app.Use("/static", filesystem.New(filesystem.Config{
Root: http.FS(static.GetFiles()), MaxAge: 2592000,
Root: http.FS(static.GetFiles()),
})) }))
app.Use(cache.New(cache.Config{ app.Use(cache.New(cache.Config{
Expiration: 30 * time.Minute, Expiration: 30 * time.Minute,
MaxBytes: 25000000, MaxBytes: 25000000,
KeyGenerator: func(c *fiber.Ctx) string { KeyGenerator: func(c *fiber.Ctx) string {
return c.OriginalURL() return c.OriginalURL()
}, },
CacheControl: true, CacheControl: true,
StoreResponseHeaders: true, StoreResponseHeaders: true,
})) }))
@@ -116,12 +116,15 @@ func main() {
app.Get("/about", pages.HandleAbout) app.Get("/about", pages.HandleAbout)
app.Get("/privacy", pages.HandlePrivacy) app.Get("/privacy", pages.HandlePrivacy)
app.Get("/search", pages.HandleSearch) app.Get("/search", pages.HandleSearch)
app.Get("/trending", pages.HandleTrending)
app.Get("/a/:postID", pages.HandlePost) app.Get("/a/:postID", pages.HandlePost)
app.Get("/a/:postID/embed", pages.HandleEmbed) app.Get("/a/:postID/embed", pages.HandleEmbed)
app.Get("/t/:tag", pages.HandleTag) app.Get("/t/:tag", pages.HandleTag)
app.Get("/t/:tag/:postID", pages.HandlePost) app.Get("/t/:tag/:postID", pages.HandlePost)
app.Get("/user/:userID", pages.HandleUser)
app.Get("/r/:sub/:postID", pages.HandlePost) app.Get("/r/:sub/:postID", pages.HandlePost)
app.Get("/user/:userID", pages.HandleUser)
app.Get("/user/:userID/favorites", pages.HandleUserFavorites)
app.Get("/user/:userID/comments", pages.HandleUserComments)
app.Get("/user/:userID/cover", pages.HandleUserCover) app.Get("/user/:userID/cover", pages.HandleUserCover)
app.Get("/user/:userID/avatar", pages.HandleUserAvatar) app.Get("/user/:userID/avatar", pages.HandleUserAvatar)
app.Get("/gallery/:postID", pages.HandlePost) app.Get("/gallery/:postID", pages.HandlePost)

View File

@@ -29,11 +29,6 @@ func HandleSearch(c *fiber.Ctx) error {
pageNumber = 0 pageNumber = 0
} }
displayPrevPage := true
if page == "0" {
displayPrevPage = false
}
results, err := ApiClient.Search(query, page) results, err := ApiClient.Search(query, page)
if err != nil { if err != nil {
return err return err
@@ -42,8 +37,7 @@ func HandleSearch(c *fiber.Ctx) error {
return c.Render("search", fiber.Map{ return c.Render("search", fiber.Map{
"query": query, "query": query,
"results": results, "results": results,
"page": pageNumber + 1, "page": pageNumber,
"displayPrev": displayPrevPage,
"nextPage": pageNumber + 1, "nextPage": pageNumber + 1,
"prevPage": pageNumber - 1, "prevPage": pageNumber - 1,
}) })

View File

@@ -23,11 +23,6 @@ func HandleTag(c *fiber.Ctx) error {
pageNumber = 0 pageNumber = 0
} }
displayPrevPage := true
if page == "1" {
displayPrevPage = false
}
tag, err := ApiClient.FetchTag(c.Params("tag"), c.Query("sort"), page) tag, err := ApiClient.FetchTag(c.Params("tag"), c.Query("sort"), page)
if err != nil && err.Error() == "ratelimited by imgur" { if err != nil && err.Error() == "ratelimited by imgur" {
return c.Status(429).Render("errors/429", fiber.Map{ return c.Status(429).Render("errors/429", fiber.Map{
@@ -44,7 +39,6 @@ func HandleTag(c *fiber.Ctx) error {
return c.Render("tag", fiber.Map{ return c.Render("tag", fiber.Map{
"tag": tag, "tag": tag,
"page": page, "page": page,
"displayPrev": displayPrevPage,
"nextPage": pageNumber + 1, "nextPage": pageNumber + 1,
"prevPage": pageNumber - 1, "prevPage": pageNumber - 1,
}) })

52
pages/trending.go Normal file
View File

@@ -0,0 +1,52 @@
package pages
import (
"strconv"
"codeberg.org/rimgo/rimgo/utils"
"github.com/gofiber/fiber/v2"
)
func HandleTrending(c *fiber.Ctx) error {
utils.SetHeaders(c)
c.Set("X-Frame-Options", "DENY")
c.Set("Cache-Control", "public,max-age=604800")
c.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; style-src 'unsafe-inline' 'self'; media-src 'self'; img-src 'self'; manifest-src 'self'; block-all-mixed-content")
page := "1"
if c.Query("page") != "" {
page = c.Query("page")
}
pageNumber, err := strconv.Atoi(c.Query("page"))
if err != nil {
pageNumber = 1
}
section := c.Query("section")
switch section {
case "hot", "new", "top":
default:
section = "hot"
}
sort := c.Query("sort")
switch sort {
case "newest", "best", "popular":
default:
sort = "popular"
}
results, err := ApiClient.FetchTrending(section, sort, page)
if err != nil {
return err
}
return c.Render("trending", fiber.Map{
"results": results,
"section": section,
"sort": sort,
"page": pageNumber,
"nextPage": pageNumber + 1,
"prevPage": pageNumber - 1,
})
}

View File

@@ -51,7 +51,92 @@ func HandleUser(c *fiber.Ctx) error {
"user": user, "user": user,
"submissions": submissions, "submissions": submissions,
"page": page, "page": page,
"nextPage": pageNumber + 1, "nextPage": pageNumber + 1,
"prevPage": pageNumber - 1, "prevPage": pageNumber - 1,
})
}
func HandleUserComments(c *fiber.Ctx) error {
utils.SetHeaders(c)
c.Set("X-Frame-Options", "DENY")
c.Set("Cache-Control", "public,max-age=604800")
c.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; media-src 'self'; style-src 'unsafe-inline' 'self'; img-src 'self'; manifest-src 'self'; block-all-mixed-content")
user, err := ApiClient.FetchUser(c.Params("userID"))
if err != nil && err.Error() == "ratelimited by imgur" {
return c.Status(429).Render("errors/429", fiber.Map{
"path": c.Path(),
})
}
if err != nil {
return err
}
if user.Username == "" {
return c.Status(404).Render("errors/404", nil)
}
comments, err := ApiClient.FetchUserComments(c.Params("userID"))
if err != nil && err.Error() == "ratelimited by imgur" {
c.Status(429)
return c.Render("errors/429", fiber.Map{
"path": c.Path(),
})
}
if err != nil {
return err
}
return c.Render("userComments", fiber.Map{
"user": user,
"comments": comments,
})
}
func HandleUserFavorites(c *fiber.Ctx) error {
utils.SetHeaders(c)
c.Set("X-Frame-Options", "DENY")
c.Set("Cache-Control", "public,max-age=604800")
c.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; media-src 'self'; style-src 'unsafe-inline' 'self'; img-src 'self'; manifest-src 'self'; block-all-mixed-content")
page := "0"
if c.Query("page") != "" {
page = c.Query("page")
}
pageNumber, err := strconv.Atoi(c.Query("page"))
if err != nil {
pageNumber = 0
}
user, err := ApiClient.FetchUser(c.Params("userID"))
if err != nil && err.Error() == "ratelimited by imgur" {
return c.Status(429).Render("errors/429", fiber.Map{
"path": c.Path(),
})
}
if err != nil {
return err
}
if user.Username == "" {
return c.Status(404).Render("errors/404", nil)
}
favorites, err := ApiClient.FetchUserFavorites(c.Params("userID"), "newest", page)
if err != nil && err.Error() == "ratelimited by imgur" {
c.Status(429)
return c.Render("errors/429", fiber.Map{
"path": c.Path(),
})
}
if err != nil {
return err
}
return c.Render("userFavorites", fiber.Map{
"user": user,
"favorites": favorites,
"page": page,
"nextPage": pageNumber + 1,
"prevPage": pageNumber - 1,
}) })
} }

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><path fill="currentColor" d="M200 64v104a8 8 0 0 1-16 0V83.31L69.66 197.66a8 8 0 0 1-11.32-11.32L172.69 72H88a8 8 0 0 1 0-16h104a8 8 0 0 1 8 8Z"/></svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@@ -7,7 +7,7 @@ body {
} }
p a { p a {
text-decoration: underline @apply break-words underline
} }
.posts { .posts {

View File

@@ -13,6 +13,7 @@
<header class="my-8 p-8 rounded-xl flex flex-col gap-4 items-center justify-center bg-gradient-to-r from-blue-400 to-emerald-400"> <header class="my-8 p-8 rounded-xl flex flex-col gap-4 items-center justify-center bg-gradient-to-r from-blue-400 to-emerald-400">
<h2 class="font-bold text-white text-2xl">The fast, private image viewer for Imgur.</h2> <h2 class="font-bold text-white text-2xl">The fast, private image viewer for Imgur.</h2>
{{> partials/searchBar }} {{> partials/searchBar }}
<a class="flex gap-1 items-center" href="/trending">Or see what's trending <img class="invert" src="/static/icons/PhArrowUpRight.svg" alt="" height="18" width="18" /></a>
</header> </header>
<main class="my-8"> <main class="my-8">
@@ -24,8 +25,8 @@
rimgo is not affiliated with Imgur, all content is proxied from Imgur. rimgo is not affiliated with Imgur, all content is proxied from Imgur.
</p> </p>
<br/> <br/>
<h3 class="font-bold text-xl">Notice</h3> <h3 class="font-bold text-xl">Legal notice</h3>
<p>All images and media are from Imgur. rimgo does not allow uploads or comments. Any issues with content should be reported to Imgur.</p> <p>rimgo does not allow uploads or host any content, media. All content on any rimgo instances is from Imgur. Imgur is a trademark of Imgur, Inc. Any issues with content on rimgo should be be reported to Imgur.</p>
</main> </main>
<h2 class="font-bold text-2xl">This instance</h2> <h2 class="font-bold text-2xl">This instance</h2>

View File

@@ -0,0 +1,22 @@
<a href="{{Post.Link}}">
<div class="sm:grid gap-4 w-full" style="grid-template-columns: 120px 1fr;">
<img class="object-cover block w-full h-[300px] sm:w-[120px] sm:h-[140px] rounded-lg rounded-b-none sm:rounded-b-lg" src="{{this.Post.Cover.Url}}" alt="">
<div class="flex flex-col gap-2 bg-slate-600 p-4 rounded-lg rounded-t-none sm:rounded-t-lg w-full">
<div class="flex flex-col h-full">
<p class="md-container">{{{this.Comment}}}</p>
<div class="flex-grow"></div>
<div class="flex gap-2">
<span title="{{this.CreatedAt}}">{{this.RelTime}}</span>
{{#if this.DeletedAt}}
<span class="text-md">(deleted {{this.DeletedAt}})</span>
{{/if}}
|
<img class="invert icon" src="/static/icons/PhArrowFatUp.svg" alt="Likes" width="24px" height="24px">
{{this.Upvotes}}
<img class="invert icon" src="/static/icons/PhArrowFatDown.svg" alt="Dislikes" width="24px" height="24px">
{{this.Downvotes}}
</div>
</div>
</div>
</div>
</a>

View File

@@ -25,7 +25,7 @@
</header> </header>
<main> <main>
<div class="flex gap-4 items-center my-4"> <div class="flex flex-col gap-2 md:flex-row md:gap-4 md:items-center my-4">
{{#if post.User.Username}} {{#if post.User.Username}}
<a href="/user/{{post.User.Username}}" class="flex gap-2 items-center"> <a href="/user/{{post.User.Username}}" class="flex gap-2 items-center">
<img src="{{post.User.Avatar}}" class="rounded-full" width="36" height="36" /> <img src="{{post.User.Avatar}}" class="rounded-full" width="36" height="36" />
@@ -52,7 +52,7 @@
</div> </div>
</div> </div>
<div class="flex flex-center flex-col"> <div class="flex flex-center flex-col break-words">
{{#each post.Media}} {{#each post.Media}}
{{#if this.Title}} {{#if this.Title}}
<h4 class="font-bold">{{this.Title}}</h4> <h4 class="font-bold">{{this.Title}}</h4>

View File

@@ -22,10 +22,10 @@
</div> </div>
<div class="flex justify-between mt-4 font-bold"> <div class="flex justify-between mt-4 font-bold">
{{#if displayPrev}} {{#noteq page "0"}}
<a href="/search?q={{query}}&page={{prevPage}}">Previous page</a> <a href="/search?q={{query}}&page={{prevPage}}">Previous page</a>
{{/if}} {{/noteq}}
<p>Page {{page}}</p> <p>Page {{nextPage}}</p>
<a href="/search?q={{query}}&page={{nextPage}}">Next page</a> <a href="/search?q={{query}}&page={{nextPage}}">Next page</a>
</div> </div>
</main> </main>

View File

@@ -45,10 +45,11 @@
{{/each}} {{/each}}
</div> </div>
<div class="mt-4 font-bold"> <div class="flex justify-between mt-4 font-bold">
{{#if displayPrev}} {{#noteq page "1"}}
<a href="{{channel.RelUrl}}?page={{prevPage}}">Previous page</a> <a href="{{channel.RelUrl}}?page={{prevPage}}">Previous page</a>
{{/if}} {{/noteq}}
<p>Page {{nextPage}}</p>
<a href="{{channel.RelUrl}}?page={{nextPage}}">Next page</a> <a href="{{channel.RelUrl}}?page={{nextPage}}">Next page</a>
</div> </div>
</main> </main>

79
views/trending.hbs Normal file
View File

@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Trending - rimgo</title>
{{> partials/head }}
</head>
<body class="font-sans text-lg bg-slate-800 text-white">
{{> partials/nav }}
<section class="my-4 w-full flex flex-col items-center">
{{> partials/searchBar }}
</section>
<header class="p-4 rounded-xl text-white mb-4 bg-gradient-to-r from-blue-400 to-emerald-400">
<div class="flex flex-col items-center justify-center text-center">
<h2 class="text-2xl font-extrabold">Trending</h2>
</div>
<div class="flex flex-col sm:flex-row sm:justify-between">
<div class="flex flex-col">
{{#equal section "hot"}}
<a href="?section=hot&sort={{sort}}"><b>Hot</b></a>
<a href="?section=new&sort={{sort}}">New</a>
<a href="?section=top&sort={{sort}}">Top</a>
{{/equal}}
{{#equal section "new"}}
<a href="?section=hot&sort={{sort}}">Hot</a>
<a href="?section=new&sort={{sort}}"><b>New</b></a>
<a href="?section=top&sort={{sort}}">Top</a>
{{/equal}}
{{#equal section "top"}}
<a href="?section=hot&sort={{sort}}">Hot</a>
<a href="?section=new&sort={{sort}}">New</a>
<a href="?section=top&sort={{sort}}"><b>Top</b></a>
{{/equal}}
</div>
<hr class="sm:hidden my-2" />
<div class="flex flex-col sm:items-end">
{{#equal sort "popular"}}
<a href="?section={{section}}&sort=popular"><b>Popular</b></a>
<a href="?section={{section}}&sort=newest">Newest</a>
<a href="?section={{section}}&sort=best">Best</a>
{{/equal}}
{{#equal sort "newest"}}
<a href="?section={{section}}&sort=popular">Popular</a>
<a href="?section={{section}}&sort=newest"><b>Newest</b></a>
<a href="?section={{section}}&sort=best">Best</a>
{{/equal}}
{{#equal sort "best"}}
<a href="?section={{section}}&sort=popular">Popular</a>
<a href="?section={{section}}&sort=newest">Newest</a>
<a href="?section={{section}}&sort=best"><b>Best</b></a>
{{/equal}}
</div>
</div>
</header>
<main>
<div class="posts">
{{#each results}}
{{> partials/post }}
{{/each}}
</div>
<div class="flex justify-between mt-4 font-bold">
{{#noteq page "1"}}
<a href="/trending?section={{section}}&sort={{sort}}&page={{prevPage}}">Previous page</a>
{{/noteq}}
<p>Page {{page}}</p>
<a href="/trending?section={{section}}&sort={{sort}}&page={{nextPage}}">Next page</a>
</div>
</main>
{{> partials/footer }}
</body>
</html>

View File

@@ -15,12 +15,18 @@
</section> </section>
<header class="p-4 rounded-xl text-white mb-4" style="background-image: url('{{user.Cover}}');"> <header class="p-4 rounded-xl text-white mb-4" style="background-image: url('{{user.Cover}}');">
<div class="flex items-center gap-2"> <div class="flex flex-col sm:flex-row items-center gap-2">
<img class="rounded-full" src="{{user.Avatar}}" width="72" height="72"> <img class="rounded-full" src="{{user.Avatar}}" width="72" height="72">
<div> <div class="items-center sm:items-start text-center sm:text-left">
<h2 class="font-bold text-2xl">{{user.Username}}</h2> <h2 class="font-bold text-2xl">{{user.Username}}</h2>
<p>{{user.Points}} pts · {{user.CreatedAt}}</p> <p>{{user.Points}} pts · {{user.CreatedAt}}</p>
</div> </div>
<hr class="sm:border-0 flex-grow">
<div class="flex flex-col items-center sm:items-end">
<a href="/user/{{user.Username}}"><b>Submissions</b></a>
<a href="/user/{{user.Username}}/favorites">Favorites</a>
<a href="/user/{{user.Username}}/comments">Comments</a>
</div>
</div> </div>
<p class="mt-2">{{user.Bio}}</p> <p class="mt-2">{{user.Bio}}</p>
</header> </header>

45
views/userComments.hbs Normal file
View File

@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{user.Username}}'s comments - rimgo</title>
{{> partials/head }}
</head>
<body class="font-sans text-lg bg-slate-800 text-white">
{{> partials/nav }}
<section class="my-4 w-full flex flex-col items-center">
{{> partials/searchBar }}
</section>
<header class="p-4 rounded-xl text-white mb-4" style="background-image: url('{{user.Cover}}');">
<div class="flex flex-col sm:flex-row items-center gap-2">
<img class="rounded-full" src="{{user.Avatar}}" width="72" height="72">
<div class="items-center sm:items-start text-center sm:text-left">
<h2 class="font-bold text-2xl">{{user.Username}}</h2>
<p>{{user.Points}} pts · {{user.CreatedAt}}</p>
</div>
<hr class="sm:border-0 flex-grow">
<div class="flex flex-col items-center sm:items-end">
<a href="/user/{{user.Username}}">Submissions</a>
<a href="/user/{{user.Username}}/favorites">Favorites</a>
<a href="/user/{{user.Username}}/comments"><b>Comments</b></a>
</div>
</div>
<p class="mt-2">{{user.Bio}}</p>
</header>
<main>
<div class="comments flex flex-col gap-4">
{{#each comments}}
{{> partials/contextComment }}
{{/each}}
</div>
</main>
{{> partials/footer }}
</body>
</html>

52
views/userFavorites.hbs Normal file
View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{user.Username}}'s favorites - rimgo</title>
{{> partials/head }}
</head>
<body class="font-sans text-lg bg-slate-800 text-white">
{{> partials/nav }}
<section class="my-4 w-full flex flex-col items-center">
{{> partials/searchBar }}
</section>
<header class="p-4 rounded-xl text-white mb-4" style="background-image: url('{{user.Cover}}');">
<div class="flex flex-col sm:flex-row items-center gap-2">
<img class="rounded-full" src="{{user.Avatar}}" width="72" height="72">
<div class="items-center sm:items-start text-center sm:text-left">
<h2 class="font-bold text-2xl">{{user.Username}}</h2>
<p>{{user.Points}} pts · {{user.CreatedAt}}</p>
</div>
<hr class="sm:border-0 flex-grow">
<div class="flex flex-col items-center sm:items-end">
<a href="/user/{{user.Username}}">Submissions</a>
<a href="/user/{{user.Username}}/favorites"><b>Favorites</b></a>
<a href="/user/{{user.Username}}/comments">Comments</a>
</div>
</div>
<p class="mt-2">{{user.Bio}}</p>
</header>
<main>
<div class="posts">
{{#each favorites}}
{{> partials/post }}
{{/each}}
</div>
<div class="flex mt-4 font-bold justify-between">
{{#noteq page "0" }}
<a href="{{channel.RelUrl}}?page={{prevPage}}">Previous page</a>
{{/noteq}}
<a href="{{channel.RelUrl}}?page={{nextPage}}">Next page</a>
</div>
</main>
{{> partials/footer }}
</body>
</html>