diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d7be86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +mlmym +VERSION +*.toml +*.txt diff --git a/Dockerfile b/Dockerfile index 1bd6c58..9b9ab73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /app COPY go.* ./ RUN go mod download COPY . ./ +RUN git describe --tag > VERSION RUN go build -v -o mlmym FROM debian:bullseye-slim @@ -14,4 +15,5 @@ RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install - COPY --from=builder /app/mlmym /app/mlmym COPY --from=builder /app/templates /app/templates COPY --from=builder /app/public /app/public +COPY --from=builder /app/VERSION /app/VERSION CMD ["./mlmym", "--addr", "0.0.0.0:8080"] diff --git a/Makefile b/Makefile index ca918f2..1b88d82 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,18 @@ -.PHONY: dev reload serve style +.PHONY: dev reload serve VERSION -all: - $(MAKE) -j3 --no-print-directory dev +all: mlmym -dev: reload serve style +mlmym: VERSION + go build -v -o mlmym + +dev: + $(MAKE) -j2 --no-print-directory reload serve reload: - #websocketd --port=8080 watchexec -w public echo reload &>/dev/null - websocketd --loglevel=fatal --port=8009 watchexec --no-vcs-ignore -e html,css,js -d 500 -w public 'echo "$$WATCHEXEC_WRITTEN_PATH"' + websocketd --loglevel=fatal --port=8009 watchexec --no-vcs-ignore -e html,css,js 'echo "$$WATCHEXEC_WRITTEN_PATH"' -serve: - #python -m http.server --directory ./public 8081 &>/dev/null - watchexec -e go -r "go run . --addr 0.0.0.0:8008 -w" +VERSION: + git describe --tag > $@ -style: - npm run watchcss > /dev/null 2>&1 +serve: VERSION + DEBUG=true watchexec --no-vcs-ignore -e go -r "go run . --addr 0.0.0.0:8008 -w" diff --git a/README.md b/README.md index 4c17ef0..c2bde67 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,25 @@ # mlmym a familiar desktop experience for [lemmy](https://join-lemmy.org). -![screenshot](https://raw.githubusercontent.com/rystaf/mlmym/main/screenshot.png?raw=true) +![screenshot](https://raw.githubusercontent.com/rystaf/mlmym/main/screenshot1.png?raw=true) -### deployment +## deployment ```bash docker run -it -p "8080:8080" ghcr.io/rystaf/mlmym:latest ``` -### config +## config Set the environment variable `LEMMY_DOMAIN` to run in single instance mode ```bash docker run -it -e LEMMY_DOMAIN='lemmydomain.com' -p "8080:8080" ghcr.io/rystaf/mlmym:latest ``` +#### default user settings +| environment variable | default | +| -------------------- | ------- | +| DARK | false | +| HIDE_THUMBNAILS | false | +| LISTING | All | +| SORT | Hot | +| COMMENT_SORT | Hot | + diff --git a/go.mod b/go.mod index 88f3104..062f0de 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,16 @@ module mlmym go 1.19 require ( - github.com/cenkalti/backoff/v4 v4.2.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/go-querystring v1.1.0 // indirect - github.com/gorilla/securecookie v1.1.1 // indirect - github.com/gorilla/sessions v1.2.1 // indirect - github.com/gorilla/websocket v1.4.2 // indirect - github.com/julienschmidt/httprouter v1.3.0 // indirect - github.com/rystaf/go-lemmy v0.0.0-20230623191350-f39e3c8bdcb5 // indirect - github.com/yuin/goldmark v1.5.4 // indirect - go.elara.ws/go-lemmy v0.17.3 // indirect - golang.org/x/text v0.10.0 // indirect + github.com/dustin/go-humanize v1.0.1 + github.com/julienschmidt/httprouter v1.3.0 + github.com/k3a/html2text v1.2.1 + github.com/rystaf/go-lemmy v0.0.0-20230720221045-c6d79b98e968 + github.com/yuin/goldmark v1.5.4 + golang.org/x/text v0.10.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.2.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect ) diff --git a/go.sum b/go.sum index 66acff2..984409b 100644 --- a/go.sum +++ b/go.sum @@ -2,37 +2,33 @@ github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+M github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= -github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/rystaf/go-lemmy v0.0.0-20230622213726-c394de37235c h1:VxOcsDMWaqoBKbhoiSBxPl1zZ62YZ/VAW2nxlBRJiow= -github.com/rystaf/go-lemmy v0.0.0-20230622213726-c394de37235c/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= -github.com/rystaf/go-lemmy v0.0.0-20230622214853-5f2ab0756865 h1:xitFpcTOSP8RlZWR569yY75B2/7WX08rQQVG+0Mi4SA= -github.com/rystaf/go-lemmy v0.0.0-20230622214853-5f2ab0756865/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= -github.com/rystaf/go-lemmy v0.0.0-20230622215253-d38b61ec174f h1:EueAC5v+8oX9xK9bT36Tpgbz+c66wUZx5zmyxePurbw= -github.com/rystaf/go-lemmy v0.0.0-20230622215253-d38b61ec174f/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= -github.com/rystaf/go-lemmy v0.0.0-20230622222647-983d49e1d285 h1:tihBOF3ejTXzYVftaflwqRAXnaY4W9q3iNiE3YMF+D8= -github.com/rystaf/go-lemmy v0.0.0-20230622222647-983d49e1d285/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= -github.com/rystaf/go-lemmy v0.0.0-20230622230518-ee2cfdf288a4 h1:++T5SoZzghtfNJprWlXiRSpPPdnMSSZgIWWAnPoGx/w= -github.com/rystaf/go-lemmy v0.0.0-20230622230518-ee2cfdf288a4/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= -github.com/rystaf/go-lemmy v0.0.0-20230623185656-962f9bf8359d h1:ORS2KIBuT+wBn4wJncF1SoLDCVCAUPHASHpQ+Y3TnRI= -github.com/rystaf/go-lemmy v0.0.0-20230623185656-962f9bf8359d/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= -github.com/rystaf/go-lemmy v0.0.0-20230623191111-7ff8c74b1935 h1:zmzUz6PGRB8yQTT6BRaZNTgNlrk6L7e72dzTnWJTw+I= -github.com/rystaf/go-lemmy v0.0.0-20230623191111-7ff8c74b1935/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= -github.com/rystaf/go-lemmy v0.0.0-20230623191350-f39e3c8bdcb5 h1:MoI87uid2KqpLdUMZGK2HBOuxJMnPOJaar/4Og2PshM= -github.com/rystaf/go-lemmy v0.0.0-20230623191350-f39e3c8bdcb5/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY= +github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA= +github.com/rystaf/go-lemmy v0.0.0-20230720221045-c6d79b98e968 h1:wfyB6wQzYMH2U8xQvdamExbyCyPhe4o8HP47FMZM5Jk= +github.com/rystaf/go-lemmy v0.0.0-20230720221045-c6d79b98e968/go.mod h1:nRSkTD+ARAHXtqlSPdf5q3hjHLP1ALsS1m5D3o86o+4= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.elara.ws/go-lemmy v0.17.3 h1:644k23BS2xqKJHJ9cHd8eyt1INpb5myqsBQQL2chBiA= -go.elara.ws/go-lemmy v0.17.3/go.mod h1:rurQND/HT3yWfX/T4w+hb6vEwRAeAlV+9bSGFkkx5rA= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index cdd4cdc..95e0f3d 100644 --- a/main.go +++ b/main.go @@ -7,12 +7,14 @@ import ( "log" "net" "net/http" + "os" "github.com/julienschmidt/httprouter" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" ) +var version string var watch = flag.Bool("w", false, "watch for file changes") var addr = flag.String("addr", ":80", "http service address") var md goldmark.Markdown @@ -48,10 +50,13 @@ func NewAddHeaderTransport(remoteAddr string) *AddHeaderTransport { } func init() { - md = goldmark.New(goldmark.WithExtensions(extension.Linkify)) + md = goldmark.New(goldmark.WithExtensions( + extension.Linkify, + extension.Table, + )) templates = make(map[string]*template.Template) if !*watch { - for _, name := range []string{"index.html", "login.html", "frontpage.html", "root.html", "settings.html"} { + for _, name := range []string{"index.html", "login.html", "frontpage.html", "root.html", "settings.html", "xhr.html", "create_comment.html"} { t := template.New(name).Funcs(funcMap) glob, err := t.ParseGlob("templates/*") if err != nil { @@ -61,6 +66,47 @@ func init() { templates[name] = glob } } + if os.Getenv("DEBUG") != "" { + test() + } + if data, err := os.ReadFile("VERSION"); err == nil { + version = string(data) + } +} +func test() { + links := [][]string{ + []string{"https://lemmy.local/u/dude", "/lemmy.local/u/dude", "/u/dude"}, + []string{"https://lemmy.local/u/dude@lemmy.local", "/lemmy.local/u/dude", "/u/dude"}, + []string{"/u/dude", "/lemmy.local/u/dude", "/u/dude"}, + []string{"/u/dude@lemmy.world", "/lemmy.local/u/dude@lemmy.world", "/u/dude@lemmy.world"}, + []string{"/u/dude@lemmy.local", "/lemmy.local/u/dude", "/u/dude"}, + []string{"https://lemmy.world/c/dude", "/lemmy.local/c/dude@lemmy.world", "/c/dude@lemmy.world"}, + []string{"https://lemmy.world/u/dude", "/lemmy.local/u/dude@lemmy.world", "/u/dude@lemmy.world"}, + []string{"https://lemmy.world/u/dude@lemmy.world", "/lemmy.local/u/dude@lemmy.world", "/u/dude@lemmy.world"}, + []string{"https://lemmy.world/post/123", "/lemmy.local/post/123@lemmy.world", "/post/123@lemmy.world"}, + []string{"https://lemmy.world/post/123#123", "https://lemmy.world/post/123#123", "https://lemmy.world/post/123#123"}, + []string{"/post/123", "/lemmy.local/post/123", "/post/123"}, + []string{"/comment/123", "/lemmy.local/comment/123", "/comment/123"}, + []string{"https://lemmy.local/comment/123", "/lemmy.local/comment/123", "/comment/123"}, + } + for _, url := range links { + output := LemmyLinkRewrite(`href="`+url[0]+`"`, "lemmy.local", "") + success := (output == (`href="` + url[1] + `"`)) + if !success { + fmt.Println("\n!!!! multi instance link rewrite failure !!!!") + fmt.Println(url) + fmt.Println(output) + fmt.Println("") + } + output = LemmyLinkRewrite(`href="`+url[0]+`"`, ".", "lemmy.local") + success = (output == (`href="` + url[2] + `"`)) + if !success { + fmt.Println("\n!!!! single instance link rewrite failure !!!!") + fmt.Println(success, url) + fmt.Println(output) + fmt.Println("") + } + } } func middleware(n httprouter.Handle) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { diff --git a/public/noscript.css b/public/noscript.css new file mode 100644 index 0000000..8b24e2a --- /dev/null +++ b/public/noscript.css @@ -0,0 +1,17 @@ +.scripting, +.expando-button, +.minimize, +#showimages, +#lmc, +.hidechildren { + display: none !important; +} +.post .expando .image img { + visibility: visible; +} +div.pager { + display: block; +} +.savecomment input[type=file] { + display: inline-block; +} diff --git a/public/style.css b/public/style.css index 2537f8b..7522f61 100644 --- a/public/style.css +++ b/public/style.css @@ -7,6 +7,12 @@ body.dark { background-color: #262626; color: #ddd; } +code { + word-wrap: break-word; +} +summary { + cursor: pointer; +} .dark a { color: #8cb3d9; } @@ -21,24 +27,38 @@ body.dark { .clearleft { clear: left; } +.img-blur { + filter: blur(10px); + -webkit-filter: blur(10px); + -moz-filter: blur(10px); + -o-filter: blur(10px); + -ms-filter: blur(10px); + transform: scale(1.03); +} .post { - margin: 6px; + margin: 6px 0px; } .post .thumb { height: 52px; width: 70px; margin: 0px 4px; + position: relative; + overflow: hidden; + --background-color: #e7e7e7; +} +.post .thumb div { + height: 52px; + width: 70px; background-repeat: no-repeat; background-size: cover; background-position: center; - --background-color: #e7e7e7; } .rank { color: #c6c6c6; - font-size: medium; + font-size: 14px; margin-top: 17px; text-align: right; - min-width: 19px; + min-width: 21px; } .dark .post .rank { color: #646464; @@ -54,52 +74,67 @@ body.dark { color: #646464; } .comment .score { - margin-right: 2px; + clear:left; + margin-right: 4px; overflow:hidden; } .comment .content { + line-height: 20px; overflow:hidden; + max-width: 840px; +} +.comment.hidden { + padding-bottom:5px; } .comment.hidden .score { - display: none; + visibility: hidden; } .score form.link-btn input { - display: block; + display: inline-block; color: #b7b7b7; font-size: 20px; - line-height: 16px; + line-height: 15px; } .dark .score form.link-btn input { color: #646464; } -.comment form.link-btn input { +.comment .score form.link-btn input { line-height: 17px; } .score form.like.link-btn input:first-child, .score .like div { - color: orangered; + color: #3880ff; } .score form.dislike.link-btn input:last-child, .score .dislike div { - color: #8080FF; + color: #eb445a; } -.score form div { - position: relative; - top: 2px; -} .title a { - color: #0000ff; font-size: medium; text-decoration: none; } +.title a p { + display: inline; +} +.post.distinguished .title a, .post.announcement .title a, +.dark .post.distinguished .title a:visited, .post.announcement .title a:visited { + color: #228822; + font-weight: bold; +} .dark .title a { color: #dedede; } +.dark .title a:visited { + color: #a6a6a6 +} .post.deleted .title a { text-decoration: line-through; } [disabled] { cursor: not-allowed; } +#loadmore [disabled], .link-btn [disabled] { + cursor: wait; +} .post .title { color: #888; font-size: 10px; @@ -128,8 +163,8 @@ body.dark { .message b { color: #000; } -.meta { - color: #888; +.dark .message b { + color: #ddd; } .dark .meta { color: #b4b4b4; @@ -137,10 +172,10 @@ body.dark { .comment { font-size: 14px; - margin: 0px 0px 5px 15px; + margin: 0px 0px 5px 0px; border: 1px solid #e6e6e6; border-radius: 3px; - padding: 5px 10px 5px 5px; + padding: 10px 10px 0px 7px; } .dark .comment { border-color: #333; @@ -152,7 +187,6 @@ body.dark { max-height: 300px; } .comment .comment { - margin-left: 15px; } .comment .comment, .comment .comment .comment .comment, @@ -183,9 +217,30 @@ body.dark { } .comment .meta { font-size: 10px; - margin-bottom: 3px; + margin-bottom: 6px; } -.meta a { +.preview h3 { + background-color: #f0f3fc; + border: 0px solid #e6e6e6; + border-bottom-width: 1px; + color: black; + margin: 0px; + padding: 10px; + font-size: 14px; +} +.dark .preview h3 { + background-color: #333; + border-color: #333; + color: #ccc; +} +.preview .comment { + margin-top: 5px; + padding: 0px; +} +.preview .comment .content { + padding: 5px 10px; +} +.meta a, .activity .meta a { color: #369; text-decoration: none; font-size: 10px; @@ -194,6 +249,13 @@ body.dark { .dark .meta a { color: #6a98af; } +.comment .meta a.distinguished.admin, .post.distinguished a.admin, .post.announcement a.admin { + background-color: #ff0011; + color: white; + font-weight: bold; + border-radius: 3px; + padding: 0px 2px; +} .meta a:hover { text-decoration: underline; } @@ -204,29 +266,65 @@ body.dark { border-radius: 3px; padding: 0px 2px; } +.comment .meta a.distinguished { + background-color: #228822; + color: white; + font-weight: bold; + border-radius: 3px; + padding: 0px 2px; +} .commentmenu { font-size: 16px; - margin: 0px 0px 10px 10px; + margin: 0px 0px 10px 0px; } .commentmenu div { border-top: 1px dotted gray; font-size: 12px; } -.savecomment { - margin: 10px; +form.savecomment { + margin: 0px 0px 10px 0px; + width: 500px; +} +.comment > .children > form.savecomment { + margin: 0px 0px 10px 20px; +} +.comment .children { + margin: 5px 0px 10px 15px; } .savecomment textarea { - width: 500px; + margin: 5px 0px; + width: 100%; height: 100px; } + +.savecomment .upload label div { + display: inline-block; + border: 1px solid #ccc; + height: 20px; + line-height: 20px; + width: 32px; + position: relative; + background-color: #999; + color: #000; + text-align: center; + cursor: pointer; +} +.savecomment .upload input { + display: none; +} +.savecomment .right { + float:right; +} +.savecomment .right a { + line-height: 28px; + font-size: 10px; +} + .comment .meta a.minimize { color: #369; font-size: 10px; margin-right: 2px; } -.comment.hidden { - margin-bottom: 10px; -} .comment.hidden .meta a { color: gray; font-weight: 400; @@ -235,11 +333,15 @@ body.dark { font-style: italic; font-weight: 700; } -.comment.hidden .content, .comment.hidden .children { +.comment.hidden .content, .comment.hidden .children, .comment.hidden .morecomments { display: none; } +.children .morecomments { +} .morecomments { - margin: 10px 0px; + height: 20px; + clear: left; + margin: 0px 0px 10px 0px; font-size: 10px; } .morecomments a { @@ -250,11 +352,11 @@ body.dark { .morecomments a:hover { text-decoration: underline; } -.member { +.member, .block { display: inline-block; margin-bottom:3px; } -.member input { +.member input, .block input { font-size: 10px; font-weight: bold; display: inline-block; @@ -266,10 +368,10 @@ body.dark { bottom: 1px; cursor: pointer; } -.join input { +.join input, .block input { background-color: green; } -.leave input { +.leave input, .block.unblock input { background-color: #cf6165; } .pending input { @@ -278,9 +380,37 @@ body.dark { .left { float: left; } +span.nsfw { + color: #d10023; + font-size: 10px; + line-height: 14px; + border-radius:3px; + border: 1px solid #d10023; + padding: 0 4px; + display: inline-block; + font-weight: 400; +} +form.nsfw { + text-align: center; + margin: 50px auto; + width: 650px; + font-size: 18px; +} +form.nsfw div { + font-size: 40px; + background-color: #ff575b; + display: inline-block; + padding: 20px 10px; + border-radius: 50%; + font-weight: bold; + color: white; +} .gray { color: #808080; } +.loading { + color: red !important; +} .error { color: red; font-size: 13px; @@ -303,7 +433,7 @@ body.dark { top: 1px; } .pager { - margin: 10px; + margin: 20px 0px; } .pager a { padding: 1px 4px; @@ -314,6 +444,21 @@ body.dark { text-decoration: none; color: #369; } +.pager.hidden { + display: none; +} +#loadmore { + display: none; +} +#loadmore, #end { + margin: 10px 0px; +} +#loadmore.show { + display: block; +} +#end { + visibility: hidden; +} .buttons li { display: inline; } @@ -322,8 +467,11 @@ body.dark { font-size: 10px; padding: 0; } +.comment .textarea { + width: 100%; +} .comment .buttons { - margin: 8px 0px 3px 0px; + margin: 3px 0px 0px 0px; } .comment.hidden .buttons { display: none; @@ -333,12 +481,13 @@ body.dark { border-left: 2px solid #c5c1ad; padding: 0 8px; } -.buttons a, .buttons form input { +.buttons a, .buttons form input, .comment .buttons form input { text-decoration: none; color: #888; - padding-right: 4px; + display: inline-block; + margin-right: 5px !important; } -.buttons a:hover, .title a:hover, .buttons input:hover { +.buttons a:hover, .title a:hover, .buttons form input:hover, .comment .buttons form input:hover { text-decoration: underline; } .entry { @@ -378,29 +527,116 @@ body.dark { .expando-button:hover{ background-color: #466599; } +.expando-button.hidden, .children.hidden .comment, .children.hidden .morecomments { + display: none; +} +.hidechildren .show { + display: none; +} +.hidechildren.hidden .show { + display: inline; +} +.hidechildren.hidden .hide { + display: none; +} +.hidechildren span { + pointer-events: none; +} .expando { display: none; - max-width: 587px; + max-width: 870px; + margin-top: 5px; + position: relative; color: #000; } +#mycommunities, #settingspopup { + background-color: white; + border: 1px solid #888; + display: none; + position: absolute; + z-index: 100; +} +#mycommunities { + top: 17px; + padding: 5px 0px; + border-width: 0px 1px 1px 0px; +} +#mycommunities div { + margin: 0px 5px; +} +#mycommunities a:first-child { + text-align: right; +} +#mycommunities a { + text-decoration: none; + color: #369; + text-transform: uppercase; + font-size: 9px; + display: block; + padding: 0px 3px; +} +.dark #mycommunities a { + color: #8cb3d9; +} +.dark #mycommunities a:hover { + background-color: #3e3e3e; +} +#mycommunities a:hover { + background-color: #c7def7; +} +#settingspopup { + right: 10px; + top: 45px; +} +#settingspopup form { + margin: 0px; +} +.dark #settingspopup, .dark #mycommunities { + background-color: #262626; +} +#settingspopup.open, #mycommunities.open { + display: inline-block; +} .expando.open{ display: block; } -.expando img { +.expando .embed { + text-align: center; +} +.expando .image { + display: block; + overflow: hidden; + resize: both; + max-width: 578px; + margin: 0 auto; + background-repeat: no-repeat; + background-size: contain; + background-position: top left; +} +.expando .image img { + visibility: hidden; max-width: 100%; } -.expando > div { +.expando .md { background-color: #fafafa; border: 1px solid #369; border-radius: 7px; padding: 5px 10px; - margin: 5px 0px; + margin: 5px auto; + max-width: 578px; font-size: 14px; overflow: auto; } -.dark .expando > div { +.expando .md img { + max-width: 100%; +} +.expando.open.showimage .md { + display: none; +} +.dark .expando .md { background-color: #262626; color: #ddd; + border-color: #666; } .expando p, .comment p, .message p { margin-top: 0; @@ -436,24 +672,30 @@ body.dark { text-align: right; } .side { + display: none; margin: 0 auto; font-size: 12px; width: 300px; padding-right: 5px; margin-bottom: 10px; } +.side img, .md img{ + max-width: 100%; +} main { position: relative; + margin: 0px 10px; } @media (min-width: 900px) { .side { + display: block; position: absolute; top: 0; right: 0; } main { - padding-right: 310px; - margin-left: 10px; + padding-right: 316px; + margin-right: 0px; } } .side form { @@ -538,7 +780,7 @@ main { color: white; } .dark .create a:hover{ - color: #1496dc; + color: #0cbe30; } .dark .create input[type=submit], .dark .search .query input, @@ -598,8 +840,8 @@ h1, h2 { margin-left: 36px; } nav { - border-bottom: 1px solid #5f99cf; - background-color: #cee3f8; + border-bottom: 1px solid #00a846; + background-color: #9ad59b; z-index: 99; margin-bottom: 5px; position: relative; @@ -634,16 +876,20 @@ nav .communities a.more { font-weight: bold; position: absolute; right: 0; + margin: 0; +} +.dark nav .communities a.more { + background-color: #cccccc; } .orangered, .orangered b { color: orangered !important; } -nav a { +nav .communities a { text-decoration: none; color: black; } -nav > a:hover { +nav .title a:hover { text-decoration: underline; } @@ -656,7 +902,7 @@ nav .title, nav > span { font-variant: small-caps; } .dark nav .title, .dark nav > span { - color: #8cb3d9;; + color: #ececec; } nav a.title { margin-left: 70px; @@ -687,32 +933,37 @@ nav .icon img { height:100%; } +nav .tabs { + overflow-x: auto; +} + nav ul { - white-space: nowrap; list-style: none; margin: 5px 2.5px 0px 2.5px; padding: 0; - display: inline; + display: inline-block; vertical-align: bottom; } nav li { + white-space: nowrap; margin: 0px 1px; padding: 0px; - display: inline; + display: inline-block; + margin-top: 5px; font-size: 12px; font-weight: 700; } nav ul a { + color: #369; background-color: #eff7ff; text-decoration: none; - color: #369; padding: 2px 6px 0 6px; } .dark nav ul a { background-color: #262626; - color: #6a98af; + color: #ddd; } .selected { @@ -722,20 +973,23 @@ nav ul a { nav .selected a { color: orangered; background-color: white; - border: 1px solid #5f99cf; + border: 1px solid #00a846; border-bottom: 1px solid white; } .dark nav .selected a { + color: #d25a32; border-bottom: 1px solid #262626; } nav .right { position: absolute; right: 0px; - background-color: #EFF7FF; + background-color: #EFFFEF; padding: 4px 6px; line-height: 12px; border-bottom-left-radius: 7px; + border-bottom-right-radius: 7px; + margin-right: 10px; color: gray; font-size: 10px; z-index: 101; @@ -749,12 +1003,12 @@ nav .right a.mailbox { top: 4px; color: gray; } -nav .right a, .right input[type=submit] { +nav .right a, nav .right input[type=submit] { color: #369; text-decoration: none; } -.dark nav .right a, .dark .right input[type=submit]{ - color: #8cb3d9; +.dark nav .right a, .dark nav .right input[type=submit]{ + color: #dadada; } nav .right form, .comment form, form.link-btn { display: inline-block; @@ -799,10 +1053,16 @@ nav .right form input, .comment .buttons input, form.link-btn input { padding: 5px 10px; margin: 5px 10px; } +.dark .warning { + background-color: #544400; +} .highlight { background-color: #ffc; padding-left: 5px; } +.dark .highlight{ + background-color: #4c4c4c; +} form.create { width: 520px; @@ -837,13 +1097,21 @@ form.create input[type=file], form.create select { content: "*"; color: red; } +.preferences { + margin: 20px; +} .preferences div { font-size: 13px; margin: 10px; } +.preferences div:last-child label { + text-align: left; + font-size: 10px; +} .preferences label{ display: inline-block; - width: 100px; + width: 150px; + margin-right: 5px; text-align: right; } diff --git a/public/utils.js b/public/utils.js index 4bdffbc..df8c22a 100644 --- a/public/utils.js +++ b/public/utils.js @@ -1,50 +1,69 @@ -function request(url, params, callback) { +function request(url, params, callback, errorcallback = function(){}) { var xmlHttp = new XMLHttpRequest(); xmlHttp.onreadystatechange = function() { - if (xmlHttp.readyState == 4 && xmlHttp.status == 200) - callback(xmlHttp.responseText); + if (xmlHttp.readyState != 4 ) { return } + if (xmlHttp.status == 200) { + return callback(xmlHttp.responseText); + } + errorcallback(xmlHttp.responseText); } var method = "GET" if (params) method = "POST" xmlHttp.open(method, url, true); - if (method = "POST") - xmlHttp.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); xmlHttp.send(params); } function postClick(e) { - console.log(e) e = e || window.event; if (e.target.className.indexOf("expando-button") == -1) { return } var targ = e.currentTarget || e.srcElement || e; if (targ.nodeType == 3) targ = targ.parentNode; var bdy = targ.getElementsByClassName("expando")[0] var btn = targ.getElementsByClassName("expando-button")[0] - console.log(bdy.style.display) - console.log(bdy.style.display.indexOf("block")) if (bdy.className.indexOf("open")>-1) { bdy.className = 'expando'; btn.className = "expando-button" + targ.getElementsByClassName("embed")[0].innerHTML = "" } else { bdy.className = 'expando open'; btn.className = "expando-button open" + var url = targ.getElementsByClassName("url")[0].href + if (id = parseYoutube(url)) { + targ.getElementsByClassName("embed")[0].innerHTML = youtubeIframe(id) + } } } +function uptil (el, f) { + if (el) return f(el) ? el : uptil(el.parentNode, f) +} function commentClick(e) { e = e || window.event; var targ = e.currentTarget || e.srcElement || e; if (targ.nodeType == 3) targ = targ.parentNode; - if (e.target.name=="vote") { + if (e.target.name=="submit") { e.preventDefault() - var form = e.target.parentNode + var form = uptil(e.target, function(el){ return el.tagName == "FORM" }) if (form) { data = new FormData(form) - if (("c"+data.get("commentid")) != targ.id) { return } - params = new URLSearchParams(data).toString() - params += "&" + e.target.name + "=" + e.target.value - params += "&xhr=1" - request(targ.target, params, function(res){ - targ.outerHTML = res - }) + data.set(e.target.name, e.target.value) + data.set("xhr", 1) + if (("c"+data.get("commentid")) == targ.id) { + targ.action = form.action + if (e.target.value == "preview") { + targ = form + } + console.log("ok") + } else if (("c"+data.get("parentid")) == targ.id) { + targ = form + } else { return } + e.target.disabled = "disabled" + request(targ.action || "", data, + function(res){ + targ.outerHTML = res + setup() + }, + function(res){ + e.target.disabled = "" + }) } return false } @@ -62,8 +81,21 @@ function commentClick(e) { } return false } - if ((e.target.className.indexOf("loadmore") != -1) || - (e.target.className.indexOf("edit") != -1) || + if (e.target.className.indexOf("hidechildren") != -1) { + if (e.target.getAttribute("for") != targ.id) { return } + e.preventDefault() + var btn = targ.getElementsByClassName("hidechildren")[0] + var children = targ.getElementsByClassName("children")[0] + if (children.className.indexOf("hidden") == -1) { + children.className = "children hidden" + btn.className = "hidechildren hidden" + } else { + children.className = "children" + btn.className = "hidechildren" + } + return false + } + if ((e.target.className.indexOf("edit") != -1) || (e.target.className.indexOf("source") != -1) || (e.target.className.indexOf("reply") != -1)) { var id = targ.id @@ -71,21 +103,341 @@ function commentClick(e) { e.preventDefault() request(e.target.href+"&xhr",false, function(res){ targ.outerHTML = res + setup() + }) + return false + } + if (e.target.className.indexOf("loadmore") != -1) { + var id = targ.id + if (e.target.getAttribute("for") != id) { return } + e.preventDefault() + var comments = targ.getElementsByClassName("comment") + var skip = [] + for (var i = 0; i < comments.length; i++) { + skip.push(comments[i].id) + } + request(e.target.href+"&xhr",false, function(res){ + var parent = e.target.parentNode + parent.innerHTML = res + parent.innerHTML = parent.getElementsByClassName("children")[0].innerHTML + var comments = parent.getElementsByClassName("comment") + for (var i = 0; i < skip.length; i++) { + for (var c = 0; c < comments.length; c++) { + if (skip[i] == comments[c].id) { + comments[c].remove() + } + } + } + parent.outerHTML = parent.innerHTML + setup() }) return false } } + +function loadMoreComments(e) { + e.preventDefault() + page = e.target.getAttribute("data-page") + var urlParams = new URLSearchParams(window.location.search); + urlParams.set("xhr", "1") + urlParams.set("page", page) + e.target.innerHTML = "loading" + e.target.className = "loading" + request(window.location.origin+window.location.pathname+"?"+urlParams.toString(), "", + function(res){ + if (res.trim()) { + e.target.parentNode.outerHTML = res + '
load more comments
' + setup() + } else { + e.target.parentNode.innerHTML = "" + } + }, function() { + e.target.innerHTML = "loading failed" + }) + return false; +} +function loadMore(e) { + e.preventDefault() + page = e.target.getAttribute("data-page") + e.target.disabled="disabled" + e.target.value="loading" + var urlParams = new URLSearchParams(window.location.search); + urlParams.set("xhr", "1") + urlParams.set("page", page) + request(window.location.origin+window.location.pathname+"?"+urlParams.toString(), "", + function(res){ + if (res.trim()) { + e.target.outerHTML = res + '' + if (showimages = document.getElementById("showimages")) { + if (showimages.className == "selected") { + toggleImages(true) + } + } + var loadmore = document.getElementById("loadmore") + loadmore.className = "show" + loadmore.addEventListener("click", loadMore) + setup() + } + else { + e.target.outerHTML = '' + } + }, + function(res) { + e.target.outerHTML = '' + var loadmore = document.getElementById("loadmore") + loadmore.className = "show" + loadmore.addEventListener("click", loadMore) + } + ) + return false; +} +function hideAllChildComments(e) { + e.preventDefault() + var comments = document.getElementsByClassName("comment") + if (e.target.innerHTML == "hide all child comments") { + e.target.innerHTML = "show all child comments" + } else { + e.target.innerHTML = "hide all child comments" + } + for (var i = 0; i < comments.length; i++) { + var comment = comments[i] + var btn = comment.getElementsByClassName("hidechildren") + if (!btn.length) { continue } + btn = btn[0] + if (btn.getAttribute("for") != comment.id) { continue } + var children = comment.getElementsByClassName("children")[0] + if (e.target.innerHTML == "show all child comments") { + children.className = "children hidden" + btn.className = "hidechildren hidden" + } else { + children.className = "children" + btn.className = "hidechildren" + } + } + return false +} function formSubmit(e) { e = e || window.event; var targ = e.currentTarget || e.srcElement || e; - console.log(e) e.preventDefault() var data = new FormData(targ) - params = new URLSearchParams(data).toString() - params += "&" + e.submitter.name + "=" + e.submitter.value - params += "&xhr=1" - request(targ.target, params, function(res){ - targ.outerHTML = res + data.set(e.submitter.name, e.submitter.value) + data.set("xhr", "1") + e.submitter.disabled = "disabled" + request(targ.target, data, + function(res){ + if (data.get("op") == "read_post") { + document.getElementById("p"+data.get("postid")).remove() + return + } + targ.outerHTML = res + setup() + }, + function(res){ + e.submitter.disabled = "" + } + ) + return false +} + +function toggleMyCommunities(e) { + e.preventDefault() + var mycommunities = document.getElementById("mycommunities") + if (mycommunities.className.indexOf("open") > -1) { + mycommunities.className = "" + return false + } + mycommunities.className = "open" + if (mycommunities.innerHTML == "") { + mycommunities.innerHTML = "
loading
" + request(e.target.href + "&xhr=1", "", function(res) { + mycommunities.innerHTML = '
view all »' + mycommunities.innerHTML += res + }, function() { + mycommunities.className = "" + }) + } + return false +} + +function openSettings(e) { + e.preventDefault() + var settings = document.getElementById("settingspopup") + if (settings.className == "open") { + settings.className = "" + return false + } + settings.className = "open" + request(e.target.href + "?xhr=1", "", function(res) { + settings.innerHTML = res + var options = document.getElementsByClassName("scripting") + for (var i = 0; i < options.length; i++) { + var input = options[i].getElementsByTagName('input') + if (!input.length) { continue } + if (localStorage.getItem(input[0].name) == "true") { + input[0].checked = "checked" + } + } + document.getElementById("settings").addEventListener("submit", saveSettings) + document.getElementById("closesettings").addEventListener("click", closeSettings) }) return false } + +function closeSettings(e) { + e.preventDefault() + var settings = document.getElementById("settingspopup") + settings.className = "" + return false +} + +function saveSettings(e) { + e = e || window.event; + var targ = e.currentTarget || e.srcElement || e; + var data = new FormData(targ) + e.preventDefault() + request(targ.target, data, function(res) { + ["endlessScrolling", "autoLoad"].map(function(x) { + localStorage.setItem(x, data.get(x)=="on") + }) + window.location.reload() + }) + return false; +} + +function parseYoutube(url){ + if (url.indexOf("youtu") == -1) return false + var regExp = /^.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/|shorts\/)|(?:(?:watch)?\?v(?:i)?=|\&v(?:i)?=))([^#\&\?]*).*/; + var match = url.match(regExp); + if (match && match.length > 1) { + return match[1] + } + return false +} +function youtubeIframe(id) { + return '' +} + +function showImages(e) { + e = e || window.event; + e.preventDefault() + var targ = e.currentTarget || e.srcElement || e; + var parent = targ.parentNode + if (parent.className == "") { + parent.className = "selected" + toggleImages(true) + } else { + parent.className = "" + toggleImages(false) + } + return false +} + +function toggleImages(open) { + var posts = document.getElementsByClassName("post") + for (var i = 0; i < posts.length; i++) { + var btn = posts[i].getElementsByClassName("expando-button")[0] + if (btn.className.indexOf("hidden") != -1) { continue } + var img = posts[i].getElementsByClassName("image") + if (!img.length) { continue } + var bdy = posts[i].getElementsByClassName("expando")[0] + if (open) { + bdy.className = 'expando open showimage'; + btn.className = "expando-button open" + } else { + bdy.className = 'expando'; + btn.className = "expando-button" + } + } +} + +function insertImg(e) { + e = e || window.event; + var form = uptil(e.target, function(el){ return el.tagName == "FORM" }) + form.querySelector("input[value=preview]").click() + var inputs = form.getElementsByTagName("input") + for (var i = 0; i < inputs.length; i++) { + inputs[i].disabled = "disabled" + } +} + +function setup() { + if (showimages = document.getElementById("se")) { + showimages.addEventListener("click", showImages) + } + if (settings = document.getElementById("opensettings")) { + settings.addEventListener("click", openSettings) + } + if (settings = document.getElementById("openmycommunities")) { + settings.addEventListener("click", toggleMyCommunities) + } + if (hidechildren = document.getElementById("hidechildren")){ + hidechildren.addEventListener("click", hideAllChildComments) + } + if (lmc = document.getElementById("lmc")){ + var pager = document.getElementsByClassName("pager") + if (pager.length) { + pager[0].style.display = "none"; + } + lmc.addEventListener("click", loadMoreComments) + } + var imgUpload = document.getElementsByClassName("imgupload") + for (var i = 0; i < imgUpload.length; i++) { + imgUpload[i].addEventListener("change", insertImg) + } + var posts = document.getElementsByClassName("post") + for (var i = 0; i < posts.length; i++) { + posts[i].addEventListener("click", postClick) + var forms = posts[i].getElementsByClassName("link-btn") + for (var f = 0; f < forms.length; f++) { + forms[f].addEventListener("submit", formSubmit) + } + var url = posts[i].getElementsByClassName("url")[0].href + if (id = parseYoutube(url)) { + var btn = posts[i].getElementsByClassName("expando-button")[0] + if (btn.className.indexOf("open") > -1) { + posts[i].getElementsByClassName("embed")[0].innerHTML = youtubeIframe(id) + } else { + btn.className = "expando-button" + } + } + } + var comments = document.getElementsByClassName("comment") + for (var i = 0; i < comments.length; i++) { + comments[i].addEventListener("click", commentClick) + } +} +setup() + +if (localStorage.getItem("endlessScrolling") == "true") { + var pager = document.getElementsByClassName("pager") + if (pager.length) pager[0].className = "pager hidden" + var loadmore = document.getElementById("loadmore") + if (loadmore) { + loadmore.className = "show" + loadmore.addEventListener("click", loadMore) + } +} +if (localStorage.getItem("autoLoad") == "true") { + window.onscroll = function(e) { + if ((window.innerHeight + Math.round(window.scrollY)) >= document.body.offsetHeight) { + if (localStorage.getItem("endlessScrolling") == "true") { + if (loadmore = document.getElementById("loadmore")) { + loadmore.click() + } + } + if (lmc = document.getElementById("lmc")) { + lmc.click() + } + } + }; +} + +// delete cookies without HTTPOnly +var cookies = document.cookie.split(";"); +for (var i = 0; i < cookies.length; i++) { + var cookie = cookies[i]; + var eqPos = cookie.indexOf("="); + var name = eqPos > -1 ? cookie.substr(0, eqPos) : cookie; + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;SameSite=None;Secure"; +} diff --git a/public/ws.js b/public/ws.js new file mode 100644 index 0000000..08ac7c3 --- /dev/null +++ b/public/ws.js @@ -0,0 +1,33 @@ +var ok = false +var t = 800 +var ws +var prot = location.protocol == 'https:' ? "wss://":"ws://" +let port = ":8009" + +function start(){ + let url = prot + document.location.hostname + port + document.location.pathname + document.location.search; + console.log('connecting to ', url); + ws = new WebSocket(url); + ws.onopen = function(){ + console.log("open"); + t = 800 + } + ws.onmessage = function(msg){ + console.log("reload:", msg.data) + if (msg.data == "") { + return + } + window.location.reload() + } + ws.onclose = function(){ + console.log("close"); + setTimeout(function(){ + //start() + if (t < 10 * 1000) t += 200 + }, t); + }; +} +console.log("ws"); +if (typeof WebSocket != 'undefined') { + start() +} diff --git a/routes.go b/routes.go index 94253a8..d72d653 100644 --- a/routes.go +++ b/routes.go @@ -18,6 +18,7 @@ import ( "github.com/dustin/go-humanize" "github.com/julienschmidt/httprouter" + "github.com/k3a/html2text" "github.com/rystaf/go-lemmy" "github.com/rystaf/go-lemmy/types" "golang.org/x/text/language" @@ -31,12 +32,12 @@ var funcMap = template.FuncMap{ } return host }, - "proxy": func(s string) string { + "localize": func(s string) string { u, err := url.Parse(s) if err != nil { return s } - return "/" + u.Host + u.Path + return "." + u.Path + "@" + u.Host }, "printer": func(n any) string { p := message.NewPrinter(language.English) @@ -110,31 +111,120 @@ var funcMap = template.FuncMap{ } return false }, + "thumbnail": func(p types.Post) string { + if p.ThumbnailURL.IsValid() { + return p.ThumbnailURL.String() + "?format=jpg&thumbnail=96" + } + re := regexp.MustCompile(`\/pictrs\/image\/([a-z0-9\-]+)\.([a-z]+)$`) + if re.MatchString(p.URL.String()) { + return p.URL.String() + "?format=jpg&thumbnail=96" + } + re = regexp.MustCompile(`^https:\/\/(i\.)?imgur.com\/([a-zA-Z0-9]{5,})(\.[a-zA-Z0-9]+)?`) + if re.MatchString(p.URL.String()) { + return re.ReplaceAllString(p.URL.String(), "https://i.imgur.com/${2}s.jpg") + } + if p.URL.IsValid() { + return "/_/static/link.png" + } + return "/_/static/text.png" + }, "humanize": humanize.Time, "markdown": func(host string, body string) template.HTML { var buf bytes.Buffer + re := regexp.MustCompile(`\s---\s`) + body = re.ReplaceAllString(body, "\n***\n") + // community bangs + body = RegReplace(body, `([^\[])!([a-zA-Z0-9_]+)@([a-zA-Z0-9\-]+(\.[a-zA-Z0-9\-]+)+)`, `$1[!$2@$3](/c/$2@$3)`) if err := md.Convert([]byte(body), &buf); err != nil { - panic(err) + fmt.Println(err) + return template.HTML(body) } - converted := buf.String() - converted = strings.Replace(converted, `$1$2") + return template.HTML(body) + }, + "rmmarkdown": func(body string) string { + var buf bytes.Buffer + if err := md.Convert([]byte(body), &buf); err != nil { + fmt.Println(err) + return body } - return template.HTML(converted) + text := html2text.HTML2TextWithOptions(buf.String(), html2text.WithLinksInnerText()) + re := regexp.MustCompile(`\<(https?:\/\/|mailto)(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)\>`) + return re.ReplaceAllString(text, "") }, "contains": strings.Contains, "sub": func(a int32, b int) int { return int(a) - b }, + "add": func(a int32, b int) int { + return int(a) + b + }, +} + +func LemmyLinkRewrite(input string, host string, lemmy_domain string) (body string) { + body = input + // localize community and user links + body = RegReplace(body, `href="https:\/\/([a-zA-Z0-9\.\-]+)\/((c|u|comment|post)\/[^#\?]*?)"`, `href="/$2@$1"`) + // remove extra instance tag + body = RegReplace(body, `href="(https:\/)?(\/[a-zA-Z0-9\.\-]+)?\/((c|u)\/[a-zA-Z0-9]+@[a-zA-Z0-9\.\-]+)@([a-zA-Z0-9\.\-]+)"`, `href="/$3"`) + if lemmy_domain == "" { + // add domain to relative links + body = RegReplace(body, `href="\/((c|u|post|comment)\/(.*?)")`, `href="/`+host+`/$1`) + // convert links to relative + body = RegReplace(body, `href="https:\/\/([a-zA-Z0-9\.\-]+\/((c|u|post|comment)\/[a-zA-Z0-9]+"))`, `href="/$1`) + } else { + // convert local links to relative + body = RegReplace(body, `href="https:\/\/`+lemmy_domain+`\/(c\/[a-zA-Z0-9]+"|(c|u|post|comment)\/(.*?)")`, `href="/$1`) + body = RegReplace(body, `href="(.*)@`+lemmy_domain+`"`, `href="$1"`) + } + + re := regexp.MustCompile(`href="\/?([a-zA-Z0-9\.\-]*)\/(c|u|post|comment)\/(.*?)@(.*?)"`) + // assume "old." subdomain is mlmym and remove + matches := re.FindAllStringSubmatch(body, -1) + for _, match := range matches { + if match[4][0:4] == "old." { + s := 1 + if match[1] == "" { + s += 1 + } + body = strings.Replace(body, match[0], `href="/`+strings.Join(match[s:4], "/")+"@"+match[4][4:]+`"`, -1) + } + } + // remove redundant instance tag + matches = re.FindAllStringSubmatch(body, -1) + for _, match := range matches { + if match[1] == match[4] { + body = strings.Replace(body, match[0], `href="/`+strings.Join(match[1:4], "/")+`"`, -1) + } + } + return body +} + +func RegReplace(input string, match string, replace string) string { + re := regexp.MustCompile(match) + return re.ReplaceAllString(input, replace) +} + +func getenv(key, fallback string) string { + value := os.Getenv(key) + if len(value) == 0 { + return fallback + } + return value } func Initialize(Host string, r *http.Request) (State, error) { state := State{ - Host: Host, - Page: 1, - Status: http.StatusOK, + Host: Host, + Page: 1, + Status: http.StatusOK, + Version: version, + } + if watch != nil { + state.Watch = *watch } lemmyDomain := os.Getenv("LEMMY_DOMAIN") if lemmyDomain != "" { @@ -157,7 +247,7 @@ func Initialize(Host string, r *http.Request) (State, error) { token := getCookie(r, "jwt") user := getCookie(r, "user") parts := strings.Split(user, ":") - if len(parts) == 2 { + if len(parts) == 2 && token != "" { if id, err := strconv.Atoi(parts[1]); err == nil { state.Client.Token = token sess := Session{ @@ -169,13 +259,28 @@ func Initialize(Host string, r *http.Request) (State, error) { } state.Listing = getCookie(r, "DefaultListingType") state.Sort = getCookie(r, "DefaultSortType") - state.Dark = getCookie(r, "Dark") != "" + state.CommentSort = getCookie(r, "DefaultCommentSortType") + if dark := getCookie(r, "Dark"); dark != "" { + state.Dark = dark != "0" + } else { + state.Dark = os.Getenv("DARK") != "" + } + state.ShowNSFW = getCookie(r, "ShowNSFW") != "" + state.HideInstanceNames = getCookie(r, "HideInstanceNames") != "" + if hide := getCookie(r, "HideThumbnails"); hide != "" { + state.HideThumbnails = hide != "0" + } else { + state.HideThumbnails = os.Getenv("HIDE_THUMBNAILS") != "" + } state.ParseQuery(r.URL.RawQuery) if state.Sort == "" { - state.Sort = "Hot" + state.Sort = getenv("SORT", "Hot") + } + if state.CommentSort == "" { + state.CommentSort = getenv("COMMENT_SORT", "Hot") } if state.Listing == "" || state.Session == nil && state.Listing == "Subscribed" { - state.Listing = "All" + state.Listing = getenv("LISTING", "All") } return state, nil } @@ -210,6 +315,8 @@ func Render(w http.ResponseWriter, templateName string, state State) { if state.Status != http.StatusOK { w.WriteHeader(state.Status) } + header := w.Header() + header.Set("Content-Security-Policy", "script-src 'self'") err = tmpl.Execute(w, state) if err != nil { fmt.Println("execute fail", err) @@ -219,6 +326,7 @@ func Render(w http.ResponseWriter, templateName string, state State) { } func GetRoot(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { data := make(map[string]any) + data["Title"] = r.Host tmpl, err := GetTemplate("root.html") if err != nil { fmt.Println("execute fail", err) @@ -334,7 +442,49 @@ func GetFrontpage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) if state.Op == "" { state.GetPosts() } - Render(w, "frontpage.html", state) + if state.XHR { + Render(w, "xhr.html", state) + } else { + Render(w, "frontpage.html", state) + } +} + +func GetCommunities(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + r.URL.Path = "/search" + if ps.ByName("host") != "" { + r.URL.Path = "/" + ps.ByName("host") + "/search" + } + r.URL.RawQuery = "searchtype=Communities&sort=TopMonth" + http.Redirect(w, r, r.URL.String(), 301) +} + +func ResolveId(r *http.Request, class string, id string, host string) string { + remoteAddr := r.RemoteAddr + if r.Header.Get("CF-Connecting-IP") != "" { + remoteAddr = r.Header.Get("CF-Connecting-IP") + } + client := http.Client{Transport: NewAddHeaderTransport(remoteAddr)} + c, err := lemmy.NewWithClient("https://"+host, &client) + if err != nil { + return "" + } + idn, _ := strconv.Atoi(id) + if class == "post" { + resp, err := c.Post(context.Background(), types.GetPost{ + ID: types.NewOptional(idn), + }) + if err != nil { + return "" + } + return resp.PostView.Post.ApID + } + resp, err := c.Comment(context.Background(), types.GetComment{ + ID: idn, + }) + if err != nil { + return "" + } + return resp.CommentView.Comment.ApID } func GetPost(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -343,11 +493,40 @@ func GetPost(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { Render(w, "index.html", state) return } + if path := strings.Split(ps.ByName("postid"), "@"); len(path) > 1 { + apid := ResolveId(r, "post", path[0], path[1]) + if apid != "" { + resp, err := state.Client.ResolveObject(context.Background(), types.ResolveObject{ + Q: apid, + }) + if err != nil { + dest := apid + if os.Getenv("LEMMY_DOMAIN") == "" { + dest = RegReplace(dest, `https:\/\/([a-zA-Z0-9\.\-]+\/post\/\d+)`, `/$1`) + } + http.Redirect(w, r, dest, 302) + return + } + post, _ := resp.Post.Value() + if post.Post.ID > 0 { + dest := RegReplace(r.URL.String(), `(([a-zA-Z0-9\.\-]+)?/post/)([a-zA-Z0-9\-\.@]+)`, `$1`) + dest += strconv.Itoa(post.Post.ID) + http.Redirect(w, r, dest, 302) + return + } else { + http.Redirect(w, r, apid, 302) + return + } + } + } m, _ := url.ParseQuery(r.URL.RawQuery) if len(m["edit"]) > 0 { state.Op = "edit_post" state.GetSite() } + if len(m["content"]) > 0 { + state.Content = m["content"][0] + } postid, _ := strconv.Atoi(ps.ByName("postid")) state.GetPost(postid) state.GetComments() @@ -359,6 +538,32 @@ func GetComment(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { Render(w, "index.html", state) return } + if path := strings.Split(ps.ByName("commentid"), "@"); len(path) > 1 { + apid := ResolveId(r, "comment", path[0], path[1]) + if apid != "" { + resp, err := state.Client.ResolveObject(context.Background(), types.ResolveObject{ + Q: apid, + }) + if err != nil { + dest := apid + if os.Getenv("LEMMY_DOMAIN") == "" { + dest = RegReplace(dest, `https:\/\/([a-zA-Z0-9\.\-]+\/comment\/\d+)`, `/$1`) + } + http.Redirect(w, r, dest, 302) + return + } + comment, _ := resp.Comment.Value() + if comment.Comment.ID > 0 { + dest := RegReplace(r.URL.String(), `(([a-zA-Z0-9\.\-]+)?/comment/)([a-zA-Z0-9\-\.@]+)`, `$1`) + dest += strconv.Itoa(comment.Comment.ID) + http.Redirect(w, r, dest, 302) + return + } else { + http.Redirect(w, r, apid, 302) + return + } + } + } m, _ := url.ParseQuery(r.URL.RawQuery) if len(m["reply"]) > 0 { state.Op = "reply" @@ -366,11 +571,22 @@ func GetComment(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { if len(m["edit"]) > 0 { state.Op = "edit" } + if r.Method == "POST" && len(m["content"]) > 0 { + state.Content = m["content"][0] + } if len(m["source"]) > 0 { state.Op = "source" } + if len(m["context"]) > 0 { + ctx, _ := strconv.Atoi(m["context"][0]) + state.Context = ctx + } commentid, _ := strconv.Atoi(ps.ByName("commentid")) state.GetComment(commentid) + if state.XHR && len(m["content"]) > 0 { + Render(w, "create_comment.html", state) + return + } state.GetPost(state.PostID) Render(w, "index.html", state) } @@ -461,10 +677,13 @@ func setCookie(w http.ResponseWriter, host string, name string, value string) { host = "" } cookie := http.Cookie{ - Name: name, - Value: value, - MaxAge: 86400 * 30, - Path: "/" + host, + Name: name, + Value: value, + MaxAge: 86400 * 30, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + Secure: true, + Path: "/" + host, } http.SetCookie(w, &cookie) } @@ -485,23 +704,49 @@ func Settings(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { Render(w, "index.html", state) return } + state.GetSite() switch r.Method { case "POST": - for _, name := range []string{"DefaultSortType", "DefaultListingType"} { - setCookie(w, state.Host, name, r.FormValue(name)) + for _, name := range []string{"DefaultSortType", "DefaultListingType", "DefaultCommentSortType"} { + deleteCookie(w, state.Host, name) + setCookie(w, "", name, r.FormValue(name)) } if r.FormValue("darkmode") != "" { - setCookie(w, state.Host, "Dark", "1") + setCookie(w, "", "Dark", "1") state.Dark = true } else { - deleteCookie(w, state.Host, "Dark") + setCookie(w, "", "Dark", "0") state.Dark = false } + if r.FormValue("shownsfw") != "" { + setCookie(w, "", "ShowNSFW", "1") + state.ShowNSFW = true + } else { + deleteCookie(w, state.Host, "ShowNSFW") + deleteCookie(w, "", "ShowNSFW") + state.ShowNSFW = false + } + if r.FormValue("hideInstanceNames") != "" { + setCookie(w, "", "HideInstanceNames", "1") + state.HideInstanceNames = true + } else { + deleteCookie(w, "", "HideInstanceNames") + state.HideInstanceNames = false + } + if r.FormValue("hideThumbnails") != "" { + setCookie(w, "", "HideThumbnails", "1") + state.HideInstanceNames = true + } else { + setCookie(w, "", "HideThumbnails", "0") + state.HideInstanceNames = false + } state.Listing = r.FormValue("DefaultListingType") state.Sort = r.FormValue("DefaultSortType") + state.CommentSort = r.FormValue("DefaultCommentSortType") + // TODO save user settings case "GET": if state.Session != nil { - // TODO fetch server settings + // TODO fetch user settings } } Render(w, "settings.html", state) @@ -510,6 +755,7 @@ func Settings(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { func SignUpOrLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { state, err := Initialize(ps.ByName("host"), r) if err != nil { + fmt.Println(err) Render(w, "index.html", state) return } @@ -517,11 +763,19 @@ func SignUpOrLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) var username string switch r.FormValue("submit") { case "log in": - resp, err := state.Client.Login(context.Background(), types.Login{ + login := types.Login{ UsernameOrEmail: r.FormValue("username"), Password: r.FormValue("password"), - }) + } + if r.FormValue("totp") != "" { + login.Totp2faToken = types.NewOptional(r.FormValue("totp")) + } + resp, err := state.Client.Login(context.Background(), login) if err != nil { + if strings.Contains(fmt.Sprintf("%v", err), "missing_totp_token") { + state.Op = "2fa" + } + fmt.Println(err) state.Error = err state.GetSite() state.GetCaptcha() @@ -531,6 +785,7 @@ func SignUpOrLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) if resp.JWT.IsValid() { token = resp.JWT.String() username = r.FormValue("username") + deleteCookie(w, state.Host, "ShowNSFW") } case "sign up": register := types.Register{ @@ -574,17 +829,14 @@ func SignUpOrLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) q.Add("alert", alert) r.URL.RawQuery = q.Encode() http.Redirect(w, r, r.URL.String(), 301) + return } } if token != "" { - if err != nil { - state.Error = err - state.GetSite() - state.GetCaptcha() - Render(w, "login.html", state) + state.GetUser(username) + if state.User == nil { return } - state.GetUser(username) setCookie(w, state.Host, "jwt", token) userid := strconv.Itoa(state.User.PersonView.Person.ID) setCookie(w, state.Host, "user", state.User.PersonView.Person.Name+":"+userid) @@ -601,7 +853,7 @@ func GetLogin(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { return } state.GetSite() - if state.Site.SiteView.LocalSite.CaptchaEnabled { + if state.Site != nil && state.Site.SiteView.LocalSite.CaptchaEnabled { state.GetCaptcha() } m, _ := url.ParseQuery(r.URL.RawQuery) @@ -667,22 +919,49 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { CommunityID: communityid, Follow: true, }) + case "block": + communityid, _ := strconv.Atoi(r.FormValue("communityid")) + state.Client.BlockCommunity(context.Background(), types.BlockCommunity{ + CommunityID: communityid, + Block: true, + }) + case "unblock": + communityid, _ := strconv.Atoi(r.FormValue("communityid")) + state.Client.BlockCommunity(context.Background(), types.BlockCommunity{ + CommunityID: communityid, + Block: false, + }) case "logout": deleteCookie(w, state.Host, "jwt") deleteCookie(w, state.Host, "user") case "login": - resp, err := state.Client.Login(context.Background(), types.Login{ - UsernameOrEmail: r.FormValue("user"), - Password: r.FormValue("pass"), - }) - if err != nil { - state.Status = http.StatusUnauthorized + login := types.Login{ + UsernameOrEmail: r.FormValue("username"), + Password: r.FormValue("password"), } - if resp.JWT.IsValid() { - state.GetUser(r.FormValue("user")) - setCookie(w, state.Host, "jwt", resp.JWT.String()) - userid := strconv.Itoa(state.User.PersonView.Person.ID) - setCookie(w, state.Host, "user", state.User.PersonView.Person.Name+":"+userid) + if r.FormValue("totp") != "" { + login.Totp2faToken = types.NewOptional(r.FormValue("totp")) + } + resp, err := state.Client.Login(context.Background(), login) + if err != nil { + if strings.Contains(fmt.Sprintf("%v", err), "missing_totp_token") { + state.Op = "2fa" + } + state.GetSite() + if state.Site != nil && state.Site.SiteView.LocalSite.CaptchaEnabled { + state.GetCaptcha() + } + state.Status = http.StatusUnauthorized + state.Error = err + Render(w, "login.html", state) + return + } else if resp.JWT.IsValid() { + state.GetUser(r.FormValue("username")) + if state.User != nil { + setCookie(w, state.Host, "jwt", resp.JWT.String()) + userid := strconv.Itoa(state.User.PersonView.Person.ID) + setCookie(w, state.Host, "user", state.User.PersonView.Person.Name+":"+userid) + } } case "create_community": state.GetSite() @@ -759,6 +1038,9 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { fmt.Println(err) } case "create_post": + if state.CommunityName == "" { + state.CommunityName = r.FormValue("communityname") + } state.GetCommunity(state.CommunityName) state.GetSite() if state.Community == nil { @@ -805,6 +1087,7 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { post := types.EditPost{ PostID: postid, Body: types.NewOptional(r.FormValue("body")), + Name: types.NewOptional(r.FormValue("name")), URL: types.NewOptional(r.FormValue("url")), } if r.FormValue("url") == "" { @@ -835,6 +1118,38 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { state.Error = err fmt.Println(err) } + case "save_post": + postid, _ := strconv.Atoi(r.FormValue("postid")) + _, err := state.Client.SavePost(context.Background(), types.SavePost{ + PostID: postid, + Save: r.FormValue("submit") == "save", + }) + if err != nil { + fmt.Println(err) + } + if r.FormValue("xhr") != "" { + state.GetPost(postid) + state.PostID = 0 + state.Op = "save_post" + state.XHR = true + Render(w, "index.html", state) + return + } + case "save_comment": + commentid, _ := strconv.Atoi(r.FormValue("commentid")) + _, err := state.Client.SaveComment(context.Background(), types.SaveComment{ + CommentID: commentid, + Save: r.FormValue("submit") == "save", + }) + if err != nil { + fmt.Println(err) + } + if r.FormValue("xhr") != "" { + state.XHR = true + state.GetComment(commentid) + Render(w, "index.html", state) + return + } case "delete_post": postid, _ := strconv.Atoi(r.FormValue("postid")) post := types.DeletePost{ @@ -851,6 +1166,22 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { r.URL.Path = "/" + state.Host + "/c/" + resp.PostView.Community.Name r.URL.RawQuery = "" } + case "read_post": + postid, _ := strconv.Atoi(r.FormValue("postid")) + post := types.MarkPostAsRead{ + PostID: postid, + Read: true, + } + if r.FormValue("submit") == "mark unread" { + post.Read = false + } + _, err := state.Client.MarkPostAsRead(context.Background(), post) + if err != nil { + fmt.Println(err) + } else if r.FormValue("xhr") != "" { + w.Write([]byte{}) + return + } case "vote_post": var score int16 score = 1 @@ -868,6 +1199,8 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { state.Client.CreatePostLike(context.Background(), post) if r.FormValue("xhr") != "" { state.GetPost(postid) + state.PostID = 0 + state.Op = "vote_post" state.XHR = true Render(w, "index.html", state) return @@ -875,7 +1208,7 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { case "vote_comment": var score int16 score = 1 - if r.FormValue("vote") != "▲" { + if r.FormValue("submit") != "▲" { score = -1 } if r.FormValue("undo") == strconv.Itoa(int(score)) { @@ -902,33 +1235,122 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { parentid, _ := strconv.Atoi(r.FormValue("parentid")) state.GetComment(parentid) } - createComment := types.CreateComment{ - Content: r.FormValue("content"), - PostID: state.PostID, - } - if state.CommentID > 0 { - createComment.ParentID = types.NewOptional(state.CommentID) - } - resp, err := state.Client.CreateComment(context.Background(), createComment) + content := r.FormValue("content") + file, handler, err := r.FormFile("file") if err == nil { - postid := strconv.Itoa(state.PostID) - commentid := strconv.Itoa(resp.CommentView.Comment.ID) - r.URL.Path = "/" + state.Host + "/post/" + postid - r.URL.Fragment = "c" + commentid - } else { - fmt.Println(err) + pres, err := state.UploadImage(file, handler) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + content += ("![](https://" + state.Host + "/pictrs/image/" + pres.Files[0].Filename + ")") + } + if r.FormValue("submit") == "save" { + createComment := types.CreateComment{ + Content: content, + PostID: state.PostID, + } + if state.CommentID > 0 { + createComment.ParentID = types.NewOptional(state.CommentID) + } + resp, err := state.Client.CreateComment(context.Background(), createComment) + if err == nil { + if r.FormValue("xhr") != "" { + state.XHR = true + state.Comments = nil + state.GetComment(resp.CommentView.Comment.ID) + Render(w, "index.html", state) + return + } + postid := strconv.Itoa(state.PostID) + commentid := strconv.Itoa(resp.CommentView.Comment.ID) + r.URL.Path = "/" + state.Host + "/post/" + postid + r.URL.Fragment = "c" + commentid + } else { + fmt.Println(err) + } + } else if r.FormValue("submit") == "preview" { + q := r.URL.Query() + q.Set("content", content) + q.Set("reply", "") + if r.FormValue("xhr") != "" { + q.Set("xhr", "1") + } + r.URL.RawQuery = q.Encode() + if ps.ByName("postid") != "" { + GetPost(w, r, ps) + return + } + if ps.ByName("commentid") != "" { + GetComment(w, r, ps) + return + } + } else if r.FormValue("xhr") != "" { + w.Write([]byte{}) + return + } + if r.FormValue("submit") == "cancel" { + r.URL.RawQuery = "" } case "edit_comment": commentid, _ := strconv.Atoi(r.FormValue("commentid")) - resp, err := state.Client.EditComment(context.Background(), types.EditComment{ - CommentID: commentid, - Content: types.NewOptional(r.FormValue("content")), - }) - if err != nil { - fmt.Println(err) - } else { - commentid := strconv.Itoa(resp.CommentView.Comment.ID) - r.URL.Fragment = "c" + commentid + q := r.URL.Query() + content := r.FormValue("content") + file, handler, err := r.FormFile("file") + if err == nil { + pres, err := state.UploadImage(file, handler) + if err != nil { + state.Error = err + Render(w, "index.html", state) + return + } + content += ("![](https://" + state.Host + "/pictrs/image/" + pres.Files[0].Filename + ")") + } + + if r.FormValue("submit") == "save" { + resp, err := state.Client.EditComment(context.Background(), types.EditComment{ + CommentID: commentid, + Content: types.NewOptional(content), + }) + if err != nil { + fmt.Println(err) + } else { + commentid := strconv.Itoa(resp.CommentView.Comment.ID) + r.URL.Fragment = "c" + commentid + r.URL.RawQuery = "" + } + } else if r.FormValue("submit") == "preview" { + q.Set("content", content) + q.Set("edit", "") + if r.FormValue("xhr") != "" { + q.Set("xhr", "1") + } + r.URL.RawQuery = q.Encode() + if ps.ByName("commentid") != "" { + GetComment(w, r, ps) + return + } + } else if r.FormValue("submit") == "cancel" { + if ps.ByName("commentid") != "" { + if r.FormValue("xhr") != "" { + q.Set("xhr", "1") + } + r.URL.RawQuery = q.Encode() + GetComment(w, r, ps) + return + } + } else if r.FormValue("xhr") != "" { + w.Write([]byte{}) + return + } + if r.FormValue("xhr") != "" { + state.XHR = true + state.GetComment(commentid) + Render(w, "index.html", state) + return + } + if r.FormValue("submit") == "cancel" { r.URL.RawQuery = "" } case "delete_comment": @@ -944,6 +1366,12 @@ func UserOp(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { r.URL.Fragment = "c" + commentid r.URL.RawQuery = "" } + case "shownsfw": + if r.FormValue("submit") == "continue" { + setCookie(w, "", "ShowNSFW", "1") + } else { + r.URL.Path = "/" + state.Host + } } http.Redirect(w, r, r.URL.String(), 301) } @@ -958,6 +1386,7 @@ func GetRouter() *httprouter.Router { router.GET("/:host/search", middleware(Search)) router.POST("/:host/search", middleware(UserOp)) router.GET("/:host/inbox", middleware(Inbox)) + router.POST("/:host/inbox", middleware(UserOp)) router.GET("/:host/login", middleware(GetLogin)) router.POST("/:host/login", middleware(SignUpOrLogin)) router.GET("/:host/settings", middleware(Settings)) @@ -982,12 +1411,14 @@ func GetRouter() *httprouter.Router { router.POST("/:host/create_post", middleware(UserOp)) router.GET("/:host/create_community", middleware(GetCreateCommunity)) router.POST("/:host/create_community", middleware(UserOp)) + router.GET("/:host/communities", middleware(GetCommunities)) } else { router.ServeFiles("/_/static/*filepath", http.Dir("public")) router.GET("/", middleware(GetFrontpage)) router.GET("/search", middleware(Search)) router.POST("/search", middleware(UserOp)) router.GET("/inbox", middleware(Inbox)) + router.POST("/inbox", middleware(UserOp)) router.GET("/login", middleware(GetLogin)) router.POST("/login", middleware(SignUpOrLogin)) router.GET("/settings", middleware(Settings)) @@ -1012,6 +1443,7 @@ func GetRouter() *httprouter.Router { router.POST("/create_post", middleware(UserOp)) router.GET("/create_community", middleware(GetCreateCommunity)) router.POST("/create_community", middleware(UserOp)) + router.GET("/communities", middleware(GetCommunities)) } return router } diff --git a/screenshot.png b/screenshot.png deleted file mode 100644 index ca97abe..0000000 Binary files a/screenshot.png and /dev/null differ diff --git a/screenshot1.png b/screenshot1.png new file mode 100644 index 0000000..d05c42c Binary files /dev/null and b/screenshot1.png differ diff --git a/state.go b/state.go index c13966c..1e2085a 100644 --- a/state.go +++ b/state.go @@ -11,6 +11,8 @@ import ( "mime/multipart" "net/http" "net/url" + "os" + "regexp" "sort" "strconv" "strings" @@ -33,6 +35,12 @@ func (c *Comment) Submitter() bool { return c.P.Comment.CreatorID == c.P.Post.CreatorID } +func (c *Comment) ParentID() int { + path := strings.Split(c.P.Comment.Path, ".") + id, _ := strconv.Atoi(path[len(path)-2]) + return id +} + type Person struct { types.PersonViewSafe } @@ -51,45 +59,85 @@ type Post struct { } type Session struct { - UserName string - UserID int + UserName string + UserID int + Communities []types.CommunityView } type State struct { - Client *lemmy.Client - HTTPClient *http.Client - Session *Session - Status int - Error error - Alert string - Host string - CommunityName string - Community *types.GetCommunityResponse - TopCommunities []types.CommunityView - Communities []types.CommunityView - UnreadCount int64 - Sort string - Listing string - Page int - Parts []string - Posts []Post - Comments []Comment - Activities []Activity - CommentCount int - PostID int - CommentID int - UserName string - User *types.GetPersonDetailsResponse - Now int64 - XHR bool - Op string - Site *types.GetSiteResponse - Query string - SearchType string - Captcha *types.CaptchaResponse - Dark bool + Watch bool + Version string + Client *lemmy.Client + HTTPClient *http.Client + Session *Session + Status int + Error error + Alert string + Host string + CommunityName string + Community *types.GetCommunityResponse + TopCommunities []types.CommunityView + Communities []types.CommunityView + UnreadCount int64 + Sort string + CommentSort string + Listing string + Page int + Parts []string + Posts []Post + Comments []Comment + Activities []Activity + CommentCount int + PostID int + CommentID int + Context int + UserName string + User *types.GetPersonDetailsResponse + Now int64 + XHR bool + Op string + Site *types.GetSiteResponse + Query string + Content string + SearchType string + Captcha *types.CaptchaResponse + Dark bool + ShowNSFW bool + HideInstanceNames bool + HideThumbnails bool } +func (s State) Unknown() string { + fmt.Println(fmt.Sprintf("%v", s.Error)) + re := regexp.MustCompile(`(.*?)@(.*?)@`) + if strings.Contains(fmt.Sprintf("%v", s.Error), "couldnt_find_community") { + matches := re.FindAllStringSubmatch(s.CommunityName+"@", -1) + if len(matches) < 1 || len(matches[0]) < 3 { + return "" + } + if matches[0][2] != s.Host { + remote := "/" + matches[0][2] + "/c/" + matches[0][1] + if os.Getenv("LEMMY_DOMAIN") != "" { + remote = "https:/" + remote + } + return remote + } + } + if strings.Contains(fmt.Sprintf("%v", s.Error), "couldnt_find_that_username_or_email") { + matches := re.FindAllStringSubmatch(s.UserName+"@", -1) + if len(matches) < 1 || len(matches[0]) < 3 { + return "" + } + if matches[0][2] != s.Host { + remote := "/" + matches[0][2] + "/u/" + matches[0][1] + if os.Getenv("LEMMY_DOMAIN") != "" { + remote = "https:/" + remote + } + return remote + } + } + return "" +} func (p State) SortBy(v string) string { var q string if p.Query != "" || p.SearchType == "Communities" { @@ -155,6 +203,7 @@ func (state *State) ParseQuery(RawQuery string) { } if len(m["sort"]) > 0 { state.Sort = m["sort"][0] + state.CommentSort = m["sort"][0] } if len(m["communityname"]) > 0 { state.CommunityName = m["communityname"][0] @@ -168,6 +217,11 @@ func (state *State) ParseQuery(RawQuery string) { if len(m["xhr"]) > 0 { state.XHR = true } + if len(m["view"]) > 0 { + if m["view"][0] == "Saved" { + state.Op = "Saved" + } + } //if len(m["op"]) > 0 { // state.Op = m["op"][0] //} @@ -208,16 +262,27 @@ func (state *State) GetCaptcha() { } } func (state *State) GetSite() { - token := state.Client.Token - state.Client.Token = "" resp, err := state.Client.Site(context.Background(), types.GetSite{}) if err != nil { + fmt.Println(err) state.Status = http.StatusInternalServerError - state.Host = "" + state.Host = "." + state.Error = errors.New("unable to retrieve site") return } - state.Client.Token = token state.Site = resp + if !state.Site.MyUser.IsValid() { + return + } + for _, c := range state.Site.MyUser.MustValue().Follows { + state.Session.Communities = append(state.Session.Communities, types.CommunityView{ + Community: c.Community, + Subscribed: "Subscribed", + }) + } + sort.Slice(state.Session.Communities, func(a, b int) bool { + return state.Session.Communities[a].Community.Name < state.Session.Communities[b].Community.Name + }) } func (state *State) GetComment(commentid int) { @@ -227,9 +292,9 @@ func (state *State) GetComment(commentid int) { state.CommentID = commentid cresp, err := state.Client.Comments(context.Background(), types.GetComments{ ParentID: types.NewOptional(state.CommentID), - Sort: types.NewOptional(types.CommentSortType(state.Sort)), + Sort: types.NewOptional(types.CommentSortType(state.CommentSort)), Type: types.NewOptional(types.ListingType("All")), - Limit: types.NewOptional(int64(200)), + Limit: types.NewOptional(int64(50)), }) if err != nil { fmt.Println(err) @@ -251,6 +316,33 @@ func (state *State) GetComment(commentid int) { state.Comments = append(state.Comments, comment) } } + if len(state.Comments) == 0 { + return + } + ctx, err := state.GetContext(state.Context, state.Comments[0]) + if err != nil { + fmt.Println(err) + } else { + state.Comments = []Comment{ctx} + } +} +func (state *State) GetContext(depth int, comment Comment) (ctx Comment, err error) { + if depth < 1 || comment.ParentID() == 0 { + return comment, nil + } + cresp, err := state.Client.Comment(context.Background(), types.GetComment{ + ID: comment.ParentID(), + }) + if err != nil { + return + } + ctx, err = state.GetContext(depth-1, Comment{ + P: cresp.CommentView, + State: state, + C: []Comment{comment}, + ChildCount: comment.ChildCount + 1, + }) + return } func (state *State) GetComments() { if state.Sort != "Hot" && state.Sort != "Top" && state.Sort != "Old" && state.Sort != "New" { @@ -258,9 +350,9 @@ func (state *State) GetComments() { } cresp, err := state.Client.Comments(context.Background(), types.GetComments{ PostID: types.NewOptional(state.PostID), - Sort: types.NewOptional(types.CommentSortType(state.Sort)), + Sort: types.NewOptional(types.CommentSortType(state.CommentSort)), Type: types.NewOptional(types.ListingType("All")), - Limit: types.NewOptional(int64(200)), + Limit: types.NewOptional(int64(50)), Page: types.NewOptional(int64(state.Page)), }) if err != nil { @@ -343,6 +435,7 @@ func (state *State) GetMessages() { Post: m.Post, Creator: m.Creator, Community: m.Community, + Counts: m.Counts, }, Op: unread, State: state, @@ -362,12 +455,14 @@ func (state *State) GetUser(username string) { limit = 1 } resp, err := state.Client.PersonDetails(context.Background(), types.GetPersonDetails{ - Username: types.NewOptional(state.UserName), - Page: types.NewOptional(int64(state.Page)), - Limit: types.NewOptional(int64(limit)), + Username: types.NewOptional(state.UserName), + Page: types.NewOptional(int64(state.Page)), + Limit: types.NewOptional(int64(limit)), + SavedOnly: types.NewOptional(state.Op == "Saved"), }) if err != nil { fmt.Println(err) + state.Error = err state.Status = http.StatusInternalServerError return } @@ -425,13 +520,16 @@ func (state *State) MarkAllAsRead() { } func (state *State) GetPosts() { - resp, err := state.Client.Posts(context.Background(), types.GetPosts{ - Sort: types.NewOptional(types.SortType(state.Sort)), - Type: types.NewOptional(types.ListingType(state.Listing)), - CommunityName: types.NewOptional(state.CommunityName), - Limit: types.NewOptional(int64(25)), - Page: types.NewOptional(int64(state.Page)), - }) + posts := types.GetPosts{ + Sort: types.NewOptional(types.SortType(state.Sort)), + Type: types.NewOptional(types.ListingType(state.Listing)), + Limit: types.NewOptional(int64(25)), + Page: types.NewOptional(int64(state.Page)), + } + if state.CommunityName != "" { + posts.CommunityName = types.NewOptional(state.CommunityName) + } + resp, err := state.Client.Posts(context.Background(), posts) if err != nil { fmt.Println(err) state.Status = http.StatusInternalServerError @@ -449,7 +547,18 @@ func (state *State) GetPosts() { func (state *State) Search(searchtype string) { if state.Query == "" && searchtype == "Communities" { + if state.Listing == "Subscribed" { + if state.Page > 1 { + return + } + if state.Site == nil { + state.GetSite() + } + state.Communities = state.Session.Communities + return + } resp, err := state.Client.Communities(context.Background(), types.ListCommunities{ + Type: types.NewOptional(types.ListingType(state.Listing)), Sort: types.NewOptional(types.SortType(state.Sort)), Limit: types.NewOptional(int64(25)), Page: types.NewOptional(int64(state.Page)), @@ -464,7 +573,7 @@ func (state *State) Search(searchtype string) { search := types.Search{ Q: state.Query, Sort: types.NewOptional(types.SortType(state.Sort)), - ListingType: types.NewOptional(types.ListingType("All")), + ListingType: types.NewOptional(types.ListingType(state.Listing)), Type: types.NewOptional(types.SearchType(searchtype)), Limit: types.NewOptional(int64(25)), Page: types.NewOptional(int64(state.Page)), @@ -492,9 +601,13 @@ func (state *State) Search(searchtype string) { }) } for _, c := range resp.Comments { - state.Comments = append(state.Comments, Comment{ + comment := Comment{ P: c, State: state, + } + state.Activities = append(state.Activities, Activity{ + Timestamp: c.Comment.Published.Time, + Comment: &comment, }) } state.Communities = resp.Communities @@ -512,22 +625,22 @@ func (state *State) GetPost(postid int) { }) if err != nil { state.Status = http.StatusInternalServerError + state.Error = err return - } else { - state.Posts = []Post{Post{ - PostView: resp.PostView, - State: state, - }} - if state.CommentID > 0 && len(state.Posts) > 0 { - state.Posts[0].Rank = -1 - } - state.CommunityName = resp.PostView.Community.Name - cresp := types.GetCommunityResponse{ - CommunityView: resp.CommunityView, - Moderators: resp.Moderators, - } - state.Community = &cresp } + state.Posts = []Post{Post{ + PostView: resp.PostView, + State: state, + }} + if state.CommentID > 0 && len(state.Posts) > 0 { + state.Posts[0].Rank = -1 + } + state.CommunityName = resp.PostView.Community.Name + cresp := types.GetCommunityResponse{ + CommunityView: resp.CommunityView, + Moderators: resp.Moderators, + } + state.Community = &cresp } func (state *State) GetCommunity(communityName string) { @@ -580,19 +693,19 @@ func (state *State) UploadImage(file multipart.File, header *multipart.FileHeade func getChildren(parent *Comment, pool []types.CommentView, postCreatorID int) { var children []Comment - total := -1 + total := int32(0) for _, c := range pool { levels := strings.Split(c.Comment.Path, ".") for i, l := range levels { id, _ := strconv.Atoi(l) if id == parent.P.Comment.ID { - total = total + 1 if i == (len(levels) - 2) { children = append(children, Comment{ P: c, C: children, State: parent.State, }) + total += c.Counts.ChildCount } } @@ -600,7 +713,8 @@ func getChildren(parent *Comment, pool []types.CommentView, postCreatorID int) { } for i, _ := range children { getChildren(&children[i], pool, postCreatorID) + parent.ChildCount += 1 } parent.C = children - parent.ChildCount = total + parent.P.Counts.ChildCount -= total } diff --git a/templates/activities.html b/templates/activities.html index f05c5a4..110419b 100644 --- a/templates/activities.html +++ b/templates/activities.html @@ -3,16 +3,23 @@
{{ if $activity.Comment }}
- {{ if not $state.User }} + {{ if and (not $state.User) (not $state.Query) }} comment on {{ end }} - {{ $activity.Comment.P.Post.Name}} + {{ $activity.Comment.P.Post.Name}} + {{ if $state.User}} by {{$state.User.PersonView.Person.Name }} {{ end }} in - /c/{{ $activity.Comment.P.Community.Name }} + + c/{{ if $state.HideInstanceNames -}} + {{ $activity.Comment.P.Community.Name }} + {{ else -}} + {{ fullcname $activity.Comment.P.Community }} + {{ end }} +
{{ template "comment.html" $activity.Comment }} {{ else if $activity.Post }} @@ -23,10 +30,22 @@ message {{ if eq $activity.Message.Creator.ID $state.Session.UserID }} to - {{ $activity.Message.Recipient.Name }} + + {{- if $state.HideInstanceNames -}} + {{ $activity.Message.Recipient.Name }} + {{- else -}} + {{ fullname $activity.Message.Recipient }} + {{- end -}} + {{ else }} from - {{ $activity.Message.Creator.Name }} + + {{- if $state.HideInstanceNames -}} + {{ $activity.Message.Creator.Name }} + {{- else -}} + {{ fullname $activity.Message.Creator }} + {{- end -}} + {{end}} sent {{ humanize $activity.Message.PrivateMessage.Published.Time }} diff --git a/templates/comment.html b/templates/comment.html index a720562..c33d77a 100644 --- a/templates/comment.html +++ b/templates/comment.html @@ -1,88 +1,117 @@ -
-
+
{{ if .State.Session }}
{{ end }} +
-{{if or (lt .P.Counts.Score -5) .P.Comment.Deleted }} +{{- if or (lt .P.Counts.Score -5) .P.Comment.Deleted -}} [+] -{{ else }} +{{- else -}} [-] -{{ end }} +{{- end -}} - {{fullname .P.Creator}} - {{.P.Counts.Score}} points {{ humanize .P.Comment.Published.Time }} + + {{- if .State.HideInstanceNames -}} + {{ .P.Creator.Name }} + {{- else -}} + {{ fullname .P.Creator }} + {{- end -}} + + {{.P.Counts.Score}} points {{ humanize .P.Comment.Published.Time }} {{- if gt .P.Comment.Updated.Time.Unix .P.Comment.Published.Time.Unix -}} * (last edited {{ humanize .P.Comment.Updated.Time }}) {{ end }}
+
{{ if eq .Op "edit" }} -
-
- -
- - - -
+ {{ template "create_comment.html" .State }} {{ else }} -
{{if .P.Comment.Deleted}}[removed]{{else}}{{ markdown .State.Host .P.Comment.Content }}{{end}}
+ {{if .P.Comment.Deleted}} + [deleted] + {{else if .P.Comment.Removed }} + [removed by mod] + {{else}} +
+ {{ markdown .State.Host .P.Comment.Content }} +
+ {{end}} {{ if eq .Op "source" }} -
+
{{end}} {{ end }} -
    -
  • permalink
  • +
      +
    • permalink
    • +
    • fedilink
    • {{ if ne .Op "source"}} -
    • source
    • +
    • source
    • {{ else }} -
    • hide source
    • +
    • hide source
    • {{ end }} - {{ if .State.Session }} - {{ if and (eq .P.Comment.CreatorID .State.Session.UserID) (ne .Op "edit")}} -
    • edit
    • -
    • -
      - - - -
      -
    • - {{ end }} - {{ if ne .Op "reply"}} -
    • reply
    • + {{ if and (eq .P.Comment.CreatorID .State.Session.UserID) (ne .Op "edit") }} +
    • edit
    • +
    • +
      + + + +
      +
    • {{ end }} +
    • + +
    • +
    • + + reply + +
    • {{ end }} -
    +{{ if and .ParentID .State.CommentID (not .State.XHR) }} +
  • + parent +
  • +{{ end }} +{{ if and .ParentID (or .State.Activities .State.Query) }} +
  • + context +
  • +{{ end }} +{{ if and .State.PostID (gt (add .P.Counts.ChildCount .ChildCount) 0) }} +
  • hideshow {{add .P.Counts.ChildCount .ChildCount }} child comments
  • +{{ end }} +
+
{{ if and (eq .State.Op "reply") (eq .State.CommentID .P.Comment.ID)}} -
-
- -
- - - -
+ {{ template "create_comment.html" .State }} {{ end}} {{ range $ci, $child := .C }}{{ template "comment.html" $child }}{{end}} -
-{{ if ne .P.Counts.ChildCount .ChildCount}} -
- load more comments - ({{ sub .P.Counts.ChildCount .ChildCount}} replies) -
+{{ if and (ne .P.Counts.ChildCount .ChildCount) (not .State.Activities) (not .State.Query) }} +
+ load more comments + ({{ sub .P.Counts.ChildCount .ChildCount}} replies) +
{{end}} -
+
+
diff --git a/templates/community.html b/templates/community.html index f1af3c6..287e868 100644 --- a/templates/community.html +++ b/templates/community.html @@ -1,17 +1,17 @@
- +
- c/{{fullcname .Community}}: {{.Community.Title}} + c/{{fullcname .Community}}: {{.Community.Title}}
{{ if .Community.Description.IsValid }}
- {{markdown "poop" .Community.Description.String}} + {{ markdown "" .Community.Description.String }}
{{ end }}
- {{printer .Counts.Subscribers}} subscribers, + {{ if .Counts.Subscribers }}{{ printer .Counts.Subscribers }} subscribers,{{end}} a community founded {{ humanize .Community.Published.Time }}
diff --git a/templates/create_comment.html b/templates/create_comment.html new file mode 100644 index 0000000..081d8eb --- /dev/null +++ b/templates/create_comment.html @@ -0,0 +1,45 @@ +
+
+ +
+
+ +
+ {{ if eq .Op "edit" }} + + + {{ else }} + + {{ end }} + + + {{ if or .Op .Content }} + + {{ end }} + + {{ if .Content }} +
+
+

Preview

+
+ {{ markdown .Host .Content }} +
+
+
+ {{ end }} +
diff --git a/templates/frontpage.html b/templates/frontpage.html index ed38c8a..bce44e6 100644 --- a/templates/frontpage.html +++ b/templates/frontpage.html @@ -2,48 +2,49 @@ {{ if and .Community (ne .Community.CommunityView.Community.Title "")}}{{.Community.CommunityView.Community.Title}}{{else if ne .CommunityName ""}}/c/{{.CommunityName}}{{ else if .User}}overview for {{.User.PersonView.Person.Name}}{{else}}{{ host .Host }}{{end}} - + {{ template "nav.html" . -}} +{{ if and (not .ShowNSFW) .Community .Community.CommunityView.Community.NSFW }} + {{ template "nsfw.html" }} +{{ else }}
-{{ if or (contains .Sort "Top") (and (not .PostID) (not .User) (not .Community) (not .Activities) (eq .Op ""))}} {{ template "menu.html" . }} -{{ end}} -{{ if .Error }} -
{{.Error}}
-{{ end }} + {{ if .Error }} +
+ {{.Error}}. + {{ if .Unknown }} + try remote instance: {{ .Unknown }} + {{ end }} +
+ {{ end }} -{{ range .Posts }} - {{ template "post.html" . }} -{{ end }} + {{ range .Posts }} + {{ template "post.html" . }} + {{ end }} -{{ if or (and (not .Op) (not .Activities) (not .Comments) (not .Posts) (not .Communities)) (and (not .Comments) .PostID) (and (not .Activities) (not .Query) .User) }} -
there doesn't seem to be anything here
-{{ end }} + {{ if or (and (not .Op) (not .Activities) (not .Comments) (not .Posts) (not .Communities)) (and (not .Comments) .PostID) (and (not .Activities) (not .Query) .User) }} +
there doesn't seem to be anything here
+ {{ end }} -{{ if or .Query (eq .SearchType "Communities") (eq (len .Posts) 25) (and .Comments (and (eq .CommentCount 200) (gt (index .Posts 0).Counts.Comments .CommentCount))) (and .User (or (gt .User.PersonView.Counts.CommentCount 10) (gt .User.PersonView.Counts.PostCount 10))) }} -
- view more: {{if gt .Page 1 }}‹ prev{{ end }} next › -
-{{ end }} + {{ if or .Query (eq .SearchType "Communities") (eq (len .Posts) 25) (and .Comments (and (eq .CommentCount 200) (gt (index .Posts 0).Counts.Comments .CommentCount))) (and .User (or (gt .User.PersonView.Counts.CommentCount 10) (gt .User.PersonView.Counts.PostCount 10))) }} +
+ view more: {{if gt .Page 1 }}‹ prev{{ end }} next › +
+ + {{ end }} - {{ template "sidebar.html" . }} + {{ template "sidebar.html" . }}
- +{{ end }} + diff --git a/templates/login.html b/templates/login.html index 78cc14a..2ecb7bd 100644 --- a/templates/login.html +++ b/templates/login.html @@ -2,7 +2,7 @@ {{ host .Host }}: sign up or log in - + @@ -35,9 +35,10 @@
{{.Error}}
{{ end }}