Compare commits

...

34 Commits

Author SHA1 Message Date
httpjamesm 487a62e7aa chore: version 1.8 2023-02-20 14:23:42 -05:00
httpjamesm abfd0cfb6f feat: SO/SE converter
refactor: rework some author logic
2023-02-20 14:17:24 -05:00
httpjamesm 63a30ab1cf feat: don't count static toward ratelimit 2023-02-20 13:56:44 -05:00
httpjamesm f1370c29b6 fix: pass down domain to comment for author URLs 2023-02-20 13:54:09 -05:00
httpjamesm d935ce72f6 fix: pass domain to question template 2023-02-20 13:49:54 -05:00
httpjamesm 45644a0503 feat: get stackexchange posts
fix: comment author distinguishing
2023-02-16 10:25:24 -05:00
httpjamesm f81b726612 chore: version 1.7.1 2023-02-14 20:25:26 -05:00
httpjamesm c55b8b6ed5 fix: redeclare correct answer author vars 2023-02-14 20:25:16 -05:00
httpjamesm ba55578143 chore: version 1.7 2023-02-14 20:17:28 -05:00
httpjamesm 95e270d9f3 style: center align answer meta 2023-02-14 20:16:55 -05:00
httpjamesm 0968932eff fix: defer close raw response body 2023-02-14 20:16:30 -05:00
httpjamesm 64302c9334 refactor: simplify author parsing logic 2023-02-14 20:13:24 -05:00
httpjamesm 9d28ee483c Merge branch 'answer-linking' 2023-02-14 20:12:56 -05:00
httpjamesm 4fa89879cf feat: permalink generator 2023-02-14 20:12:49 -05:00
httpjamesm 203e8aa23c Merge branch 'answer-linking' 2023-02-14 20:10:06 -05:00
httpjamesm cc9e549aaf feat: get answer ID and fragment scrolling 2023-02-14 19:55:17 -05:00
httpjamesm fae2717a35 chore: version 1.6.1 2023-02-14 19:41:51 -05:00
httpjamesm cf8892b4cb Merge pull request #16 from r-flash/patch-1
fix: Remove inline CSS backgrounds in a code block
2023-02-13 12:39:17 -05:00
Adam P c8a394e0ce Remove inline CSS backgrounds in a code block
The lines in a `<pre>` code block may slightly overlap. Lower parts of a line can thus be obcured by the next one. This PR removes the background of `<code>` when it's inside a `<pre>` which already has the same  background color set.
2023-02-13 18:33:01 +01:00
httpjamesm 6bfed5a9e3 Merge branch 'main' of https://github.com/httpjamesm/AnonymousOverflow 2023-02-12 12:28:48 -05:00
httpjamesm a0df61a92d docs: add ao.foss.wtf and overflow.hostux.net 2023-02-12 12:28:19 -05:00
httpjamesm bbf098e7f4 Merge pull request #13 from privacytime101/main
docs: add adminforge.de
2023-02-05 14:27:24 -05:00
Whatever Social 2c4ded0800 Add adminforge.de 2023-02-05 11:01:37 -08:00
httpjamesm f0e42287cf chore: version 1.6 2023-02-04 13:42:57 -05:00
httpjamesm 9ebb6c90d9 fix: sort names 2023-02-04 13:42:24 -05:00
httpjamesm 69d96d646d feat: question tags 2023-02-04 13:40:57 -05:00
httpjamesm c87ce26da8 style: use border-bottom and disable border on last-child 2023-02-04 13:18:40 -05:00
httpjamesm 3dea27b09f Merge branch 'sorting' 2023-02-04 13:16:33 -05:00
httpjamesm 4d8632e26c fix: add no cache to static 2023-02-04 13:14:52 -05:00
httpjamesm eee8ec42c5 feat: hashmap with SO sort values, ssr selection 2023-02-04 13:05:10 -05:00
httpjamesm d7bca93af8 feat: sort select with button 2023-02-04 12:58:50 -05:00
httpjamesm 2b608b4725 feat: redirect questions without title in URL 2023-02-04 12:49:28 -05:00
httpjamesm bd48b309ac docs: update screenshot CDN links 2023-02-03 17:53:59 -05:00
httpjamesm 15b843d46b fix: remove ?url for image proxy 2023-02-02 19:15:55 -05:00
18 changed files with 269 additions and 76 deletions
+6 -3
View File
@@ -6,11 +6,11 @@ This project is super lightweight by design. The UI is simple and the frontend i
## Screenshots
![Home](https://cdn.horizon.pics/7HbXKZ3JBJbBDMtt51DVJgo5THFMYX.png)
![Home](https://files.horizon.pics/e2b9275c-1409-4978-801b-de981a8d3ae9?a=1&mime1=image&mime2=png)
![Question](https://cdn.horizon.pics/lqEuJMzYpgeB8au4QASsZ0S0BQHknJ.png)
![Question](https://files.horizon.pics/0f6b0036-87f0-4acd-9a0f-936b5c397a73?a=1&mime1=image&mime2=png)
![Answer](https://cdn.horizon.pics/0RwQtrxH3COTC9sfo0FsQ56I3Opl0m.png)
![Answer](https://files.horizon.pics/861ec510-644b-43f2-9439-0a2cae841422?a=1&mime1=image&mime2=png)
## Clearnet Instances
@@ -22,6 +22,9 @@ This project is super lightweight by design. The UI is simple and the frontend i
| [overflow.smnz.de](https://overflow.smnz.de) | Germany | Operated by [smnz.de](https://smnz.de) |
| [anonymousoverflow.esmailelbob.xyz](https://anonymousoverflow.esmailelbob.xyz/) | Canada | Operated by [Esmail EL BoB](https://esmailelbob.xyz) |
| [overflow.lunar.icu](https://overflow.lunar.icu) | Germany | Operated by [lunar.icu](https://lunar.icu/) |
| [overflow.adminforge.de](https://overflow.adminforge.de/) | Germany | Operated by [adminForge](https://adminforge.de/) |
| [ao.foss.wtf](https://ao.foss.wtf) | Germany | Operated by [foss.wtf](https://foss.wtf) |
| [overflow.hostux.net](https://overflow.hostux.net/) | France | Operated by [Hostux](https://hostux.net/) |
## Other Instances
+1 -1
View File
@@ -1,3 +1,3 @@
package config
var Version = "1.5"
var Version = "1.8"
+19 -2
View File
@@ -32,7 +32,6 @@ func main() {
r := gin.Default()
r.LoadHTMLGlob("templates/*")
r.Static("/static", "./public")
r.Use(gin.Recovery())
r.Use(middleware.XssPreventionHeaders())
@@ -40,6 +39,8 @@ func main() {
r.Use(middleware.OptionsMiddleware())
r.Use(middleware.Ratelimit())
r.GET("/static/*filepath", routes.StaticContent)
r.GET("/robots.txt", func(c *gin.Context) {
c.String(200, "User-agent: *\nDisallow: /")
})
@@ -47,10 +48,26 @@ func main() {
r.GET("/options/:name", routes.ChangeOptions)
r.GET("/", routes.GetHome)
r.POST("/", routes.PostHome)
r.GET("/questions/:id", func(c *gin.Context) {
// redirect user to the question with the title
c.Redirect(302, fmt.Sprintf("/questions/%s/placeholder", c.Param("id")))
})
r.GET("/questions/:id/:title", routes.ViewQuestion)
r.GET("/questions/:id/:title/:answerId", func(c *gin.Context) {
// redirect user to the answer with the title
c.Redirect(302, fmt.Sprintf("/questions/%s/%s#%s", c.Param("id"), c.Param("title"), c.Param("answerId")))
})
r.GET("/exchange/:sub/questions/:id/:title", routes.ViewQuestion)
r.GET("/exchange/:sub/questions/:id", func(c *gin.Context) {
// redirect user to the question with the title
c.Redirect(302, fmt.Sprintf("/exchange/%s/questions/%s/placeholder", c.Param("sub"), c.Param("id")))
})
r.GET("/exchange/:sub/questions/:id/:title/:answerId", func(c *gin.Context) {
// redirect user to the answer with the title
c.Redirect(302, fmt.Sprintf("/exchange/%s/questions/%s/%s#%s", c.Param("sub"), c.Param("id"), c.Param("title"), c.Param("answerId")))
})
r.GET("/proxy", routes.GetImage)
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>

After

Width:  |  Height:  |  Size: 363 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>

After

Width:  |  Height:  |  Size: 491 B

+73 -8
View File
@@ -29,6 +29,20 @@ body {
justify-content: space-between;
}
.card-tags {
display: flex;
gap: .5rem;
}
.card-tags .tag {
background-color: var(--meta-bg);
color: var(--tag-text-color);
padding: 0.5rem;
border-radius: 5px;
font-size: 0.8rem;
text-decoration: none;
}
code {
background-color: var(--code-bg);
padding: 0.15rem;
@@ -45,27 +59,34 @@ pre {
line-height: 1.35;
}
pre code {
background: none;
}
.timestamp {
color: var(--muted-text-color);
font-size: 0.8rem;
}
.post-divider,
.answer-divider {
.post-divider {
margin-top: 3rem;
margin-bottom: 3rem;
}
.answer-divider {
border: 0;
height: 1px;
background-color: var(--divider-color);
}
img {
max-width: 100%;
}
.answer {
padding-top: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--muted-text-color);
}
.answer:last-child {
border-bottom: 0;
}
.answer-meta {
border-radius: 5px;
padding: 1rem;
@@ -73,6 +94,14 @@ img {
margin-bottom: 1rem;
background-color: var(--meta-bg);
color: var(--text-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.answer-meta p {
margin: 0;
}
.answer-meta.accepted {
@@ -111,6 +140,42 @@ img {
margin-bottom: 1rem;
}
.answers-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.answers-header .sorting form {
display: flex;
align-items: center;
gap: 0.5rem;
}
.answers-header select {
border: 0;
border-radius: 5px;
padding: 0.5rem;
background-color: var(--meta-bg);
color: var(--text-color);
height: 2rem;
}
.answers-header button {
width: 2rem;
border: 0;
height: 2rem;
border-radius: 5px;
background-color: var(--meta-bg);
color: var(--text-color);
font-size: 1.5rem;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
@media only screen and (max-width: 800px) {
body {
padding-left: 1rem;
+7
View File
@@ -2,6 +2,7 @@ package middleware
import (
"anonymousoverflow/config"
"strings"
"sync"
"time"
@@ -12,6 +13,12 @@ var ipMap = sync.Map{}
func Ratelimit() gin.HandlerFunc {
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/static") {
c.Next()
return
}
ip := c.ClientIP()
// log request count as the value, ip as key
+16 -4
View File
@@ -3,6 +3,7 @@ package routes
import (
"anonymousoverflow/config"
"fmt"
"regexp"
"strings"
"github.com/gin-gonic/gin"
@@ -19,6 +20,8 @@ type urlConversionRequest struct {
URL string `form:"url" binding:"required"`
}
var stackExchangeRegex = regexp.MustCompile(`https://(.+).stackexchange.com/questions/`)
func PostHome(c *gin.Context) {
body := urlConversionRequest{}
@@ -33,15 +36,24 @@ func PostHome(c *gin.Context) {
soLink := body.URL
// validate URL
if !strings.HasPrefix(soLink, "https://stackoverflow.com/questions/") {
isStackOverflow := strings.HasPrefix(soLink, "https://stackoverflow.com/questions/")
isStackExchange := stackExchangeRegex.MatchString(soLink)
if !isStackExchange && !isStackOverflow {
c.HTML(400, "home.html", gin.H{
"errorMessage": "Invalid stack overflow URL",
"errorMessage": "Invalid stack overflow/exchange URL",
"theme": c.MustGet("theme").(string),
})
return
}
// redirect to the proxied thread
c.Redirect(302, fmt.Sprintf("/questions/%s", strings.TrimPrefix(soLink, "https://stackoverflow.com/questions/")))
// if stack overflow, trim https://stackoverflow.com
if isStackOverflow {
c.Redirect(302, strings.TrimPrefix(soLink, "https://stackoverflow.com"))
return
}
// if stack exchange, extract the subdomain
sub := stackExchangeRegex.FindStringSubmatch(soLink)[1]
c.Redirect(302, fmt.Sprintf("/exchange/%s/%s", sub, strings.TrimPrefix(soLink, fmt.Sprintf("https://%s.stackexchange.com", sub))))
}
+1 -12
View File
@@ -19,12 +19,6 @@ func GetImage(c *gin.Context) {
return
}
url := c.Query("url")
if url == "" {
c.String(400, "Missing url")
return
}
// validate the auth token
token, err := jwt.ParseWithClaims(authorization, &types.ImageProxyClaims{}, func(token *jwt.Token) (interface{}, error) {
@@ -51,11 +45,6 @@ func GetImage(c *gin.Context) {
return
}
if claims.ImageURL != url {
c.String(400, "Request & token mismatch")
return
}
if claims.Exp < time.Now().Unix() {
c.String(400, "Token expired")
return
@@ -63,7 +52,7 @@ func GetImage(c *gin.Context) {
// download the image
client := resty.New()
resp, err := client.R().Get(url)
resp, err := client.R().Get(claims.ImageURL)
if err != nil {
c.AbortWithStatus(500)
return
+65 -29
View File
@@ -21,6 +21,13 @@ import (
var codeBlockRegex = regexp.MustCompile(`(?s)<pre><code>(.+?)<\/code><\/pre>`)
var questionCodeBlockRegex = regexp.MustCompile(`(?s)<pre class=".+"><code( class=".+")?>(.+?)</code></pre>`)
var soSortValues = map[string]string{
"votes": "scoredesc",
"trending": "trending",
"newest": "modifieddesc",
"oldest": "createdasc",
}
func ViewQuestion(c *gin.Context) {
client := resty.New()
@@ -36,7 +43,25 @@ func ViewQuestion(c *gin.Context) {
questionTitle := c.Param("title")
soLink := fmt.Sprintf("https://stackoverflow.com/questions/%s/%s", questionId, questionTitle)
sortValue := c.Query("sort_by")
if sortValue == "" {
sortValue = "votes"
}
soSortValue, ok := soSortValues[sortValue]
if !ok {
soSortValue = soSortValues["votes"]
}
sub := c.Param("sub")
domain := "stackoverflow.com"
if sub != "" {
domain = fmt.Sprintf("%s.stackexchange.com", sub)
}
soLink := fmt.Sprintf("https://%s/questions/%s/%s?answertab=%s", domain, questionId, questionTitle, soSortValue)
resp, err := client.R().Get(soLink)
if err != nil {
@@ -47,6 +72,7 @@ func ViewQuestion(c *gin.Context) {
})
return
}
defer resp.RawResponse.Body.Close()
if resp.StatusCode() != 200 {
c.HTML(500, "home.html", gin.H{
@@ -81,6 +107,9 @@ func ViewQuestion(c *gin.Context) {
questionPostLayout := doc.Find("div.post-layout").First()
questionTags := utils.GetPostTags(questionPostLayout)
newFilteredQuestion.Tags = questionTags
questionBodyParent := doc.Find("div.s-prose")
questionBodyParentHTML, err := questionBodyParent.Html()
@@ -108,7 +137,7 @@ func ViewQuestion(c *gin.Context) {
newFilteredQuestion.ShortenedBody = shortenedBody
comments := utils.FindAndReturnComments(questionBodyParentHTML, questionPostLayout)
comments := utils.FindAndReturnComments(questionBodyParentHTML, domain, questionPostLayout)
newFilteredQuestion.Comments = comments
// parse any code blocks and highlight them
@@ -149,24 +178,33 @@ func ViewQuestion(c *gin.Context) {
userDetails := questionMetadata.Find("div.user-details")
questionAuthor := ""
questionAuthorURL := ""
questionAuthorURL := fmt.Sprintf("https://%s", domain)
// check if the question has been edited
isQuestionEdited := false
questionMetadata.Find("a.js-gps-track").Each(func(i int, s *goquery.Selection) {
if strings.Contains(s.Text(), "edited") {
isQuestionEdited = true
return
}
})
userDetails.Find("a").Each(func(i int, s *goquery.Selection) {
// get the second
if i == 0 {
if s.Text() != "" {
// if it's not been edited, it means it's the first
// if question has been edited, the author is the second element.
if isQuestionEdited {
if i == 1 {
questionAuthor = s.Text()
questionAuthorURL, _ = s.Attr("href")
questionAuthorURL += s.AttrOr("href", "")
return
}
} else {
// otherwise it's the first element
if i == 0 {
questionAuthor = s.Text()
questionAuthorURL += s.AttrOr("href", "")
return
}
}
// otherwise it's the second element
if i == 1 {
questionAuthor = s.Text()
questionAuthorURL, _ = s.Attr("href")
return
}
})
@@ -176,6 +214,7 @@ func ViewQuestion(c *gin.Context) {
answers := []types.FilteredAnswer{}
doc.Find("div.answer").Each(func(i int, s *goquery.Selection) {
newFilteredAnswer := types.FilteredAnswer{}
postLayout := s.Find("div.post-layout")
@@ -191,30 +230,25 @@ func ViewQuestion(c *gin.Context) {
answerFooter := s.Find("div.mt24")
answerAuthorURL := ""
answerAuthorURL := fmt.Sprintf("https://%s", domain)
answerAuthorName := ""
answerTimestamp := ""
answerFooter.Find("div.post-signature").Each(func(i int, s *goquery.Selection) {
answerAuthorDetails := s.Find("div.user-details")
if answerAuthorDetails.Length() == 0 {
return
if answerAuthorDetails.Length() > 0 {
questionAuthor := answerAuthorDetails.Find("a").First()
answerAuthorName = html.EscapeString(questionAuthor.Text())
answerAuthorURL += html.EscapeString(questionAuthor.AttrOr("href", ""))
}
if answerAuthorDetails.Length() > 1 {
if i == 0 {
return
}
}
answerAuthor := answerAuthorDetails.Find("a").First()
answerAuthorURL = html.EscapeString(answerAuthor.AttrOr("href", ""))
answerAuthorName = html.EscapeString(answerAuthor.Text())
answerTimestamp = html.EscapeString(s.Find("span.relativetime").Text())
})
answerId, _ := s.Attr("data-answerid")
newFilteredAnswer.ID = answerId
newFilteredAnswer.AuthorName = answerAuthorName
newFilteredAnswer.AuthorURL = answerAuthorURL
newFilteredAnswer.Timestamp = answerTimestamp
@@ -231,7 +265,7 @@ func ViewQuestion(c *gin.Context) {
answerBodyHTML = strings.Replace(answerBodyHTML, codeBlock, highlightedCodeBlock, 1)
}
comments = utils.FindAndReturnComments(answerBodyHTML, postLayout)
comments = utils.FindAndReturnComments(answerBodyHTML, domain, postLayout)
newFilteredAnswer.Comments = comments
newFilteredAnswer.Body = template.HTML(utils.ReplaceImgTags(answerBodyHTML))
@@ -251,6 +285,8 @@ func ViewQuestion(c *gin.Context) {
"imagePolicy": imagePolicy,
"theme": c.MustGet("theme").(string),
"currentUrl": fmt.Sprintf("%s/questions/%s/%s", os.Getenv("APP_URL"), questionId, questionTitle),
"sortValue": sortValue,
"domain": domain,
})
}
+14
View File
@@ -0,0 +1,14 @@
package routes
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
func StaticContent(c *gin.Context) {
cleanFilePath := strings.ReplaceAll(c.Param("filepath"), "..", "")
c.File(fmt.Sprintf("./public/%s", cleanFilePath))
}
+2
View File
@@ -3,6 +3,8 @@ package types
import "html/template"
type FilteredAnswer struct {
ID string
Upvotes string
IsAccepted bool
+1
View File
@@ -10,4 +10,5 @@ type FilteredQuestion struct {
AuthorURL string
ShortenedBody string
Comments []FilteredComment
Tags []string
}
+4 -3
View File
@@ -2,12 +2,13 @@ package utils
import (
"anonymousoverflow/src/types"
"fmt"
"html/template"
"github.com/PuerkitoBio/goquery"
)
func FindAndReturnComments(inHtml string, postLayout *goquery.Selection) (comments []types.FilteredComment) {
func FindAndReturnComments(inHtml, domain string, postLayout *goquery.Selection) (comments []types.FilteredComment) {
commentsComponent := postLayout.Find("div.js-post-comments-component")
@@ -34,7 +35,7 @@ func FindAndReturnComments(inHtml string, postLayout *goquery.Selection) (commen
return
}
commentAuthorURL := ""
commentAuthorURL := fmt.Sprintf("https://%s", domain)
commentAuthor := commentBody.Find("span.comment-user")
if commentAuthor.Length() == 0 {
@@ -43,7 +44,7 @@ func FindAndReturnComments(inHtml string, postLayout *goquery.Selection) (commen
return
}
commentAuthorURL = commentAuthor.AttrOr("href", "")
commentAuthorURL += commentAuthor.AttrOr("href", "")
}
commentTimestamp := commentBody.Find("span.relativetime-clean").Text()
+1 -1
View File
@@ -25,7 +25,7 @@ func ReplaceImgTags(inHtml string) string {
authToken, _ := generateImageProxyAuth(src)
// replace the img tag with a proxied url
inHtml = strings.Replace(inHtml, imgTag, fmt.Sprintf(`<img src="%s/proxy?url=%s&auth=%s">`, os.Getenv("APP_URL"), src, authToken), 1)
inHtml = strings.Replace(inHtml, imgTag, fmt.Sprintf(`<img src="%s/proxy?auth=%s">`, os.Getenv("APP_URL"), authToken), 1)
}
return inHtml
+13
View File
@@ -0,0 +1,13 @@
package utils
import (
"github.com/PuerkitoBio/goquery"
)
func GetPostTags(postLayout *goquery.Selection) []string {
var tags []string
postLayout.Find("a.post-tag").Each(func(i int, s *goquery.Selection) {
tags = append(tags, s.Text())
})
return tags
}
+1 -1
View File
@@ -19,7 +19,7 @@
<div class="comment-author">
Commented {{ $comment.Timestamp }} by
<a
href="https://stackoverflow.com{{ $comment.AuthorURL }}"
href="{{ $comment.AuthorURL }}"
target="_blank"
rel="noopener noreferrer"
>{{ $comment.AuthorName }}</a
+39 -12
View File
@@ -30,7 +30,7 @@
<p class="timestamp">
Asked {{ .question.Timestamp }} by
<a
href="https://stackoverflow.com{{ .question.AuthorURL }}"
href="{{ .question.AuthorURL }}"
target="_blank"
rel="noopener noreferrer"
>{{ .question.AuthorName }}</a
@@ -38,38 +38,65 @@
</p>
</div>
<div class="card-body">{{ .question.Body }}</div>
{{ if .question.Comments }}
{{ template "comments.html" .question }}
{{end}}
<div class="card-tags">
{{ range .question.Tags }}
<div class="tag">{{ . }}</div>
{{ end }}
</div>
{{ if .question.Comments }} {{ template "comments.html"
.question }} {{end}}
</div>
<hr class="post-divider" />
<h2>Answers</h2>
<div class="answers-header">
<h2>Answers</h2>
<div class="sorting">
<form>
<select name="sort_by">
<option disabled value="">Sort answers by...</option>
<option value="votes"{{ if eq .sortValue "votes" }} selected{{ end }}>Votes</option>
<option value="trending"{{ if eq .sortValue "trending" }} selected{{ end }}>Trending</option>
<option value="newest"{{ if eq .sortValue "newest" }} selected{{ end }}>Date modified (newest first)</option>
<option value="oldest"{{ if eq .sortValue "oldest" }} selected{{ end }}>Date created (oldest first)</option>
</select>
<button type="submit">
<img
src="/static/icons/sort.svg"
alt="Sieve icon"
/>
</button>
</form>
</div>
</div>
{{ range $answer := .answers }}
<div class="answer">
<div class="answer" id="{{ $answer.ID }}">
<div
class="answer-meta{{ if $answer.IsAccepted }} accepted{{end}}"
>
<p>
{{ if $answer.IsAccepted }} Accepted - {{ end }}
{{$answer.Upvotes}} Votes
</div>
</p>
<a href="#{{ $answer.ID }}" class="answer-link">
<div class="icon">
<img src="/static/icons/link.svg" alt="Paperclip icon" />
</div>
</a>
</div>
{{ $answer.Body }}
<div class="answer-author-parent">
<div class="answer-author">
Answered {{ $answer.Timestamp }} by
<a
href="https://stackoverflow.com{{ $answer.AuthorURL }}"
href="{{ $answer.AuthorURL }}"
target="_blank"
rel="noopener noreferrer"
>{{ $answer.AuthorName }}</a
>
</div>
</div>
{{ if $answer.Comments }}
{{ template "comments.html" $answer }}
{{ if $answer.Comments }} {{ template "comments.html" $answer }}
{{end}}
</div>
<hr class="answer-divider" />
{{ end }}
</div>
</body>