Merge branch 'master' of gitssh.handmade.network:hmn/hmn
This commit is contained in:
commit
b41a556fcd
|
@ -0,0 +1,33 @@
|
||||||
|
importScripts('../go_wasm_exec.js');
|
||||||
|
|
||||||
|
/*
|
||||||
|
NOTE(ben): The structure here is a little funny but allows for some debouncing. Any postMessages
|
||||||
|
that got queued up can run all at once, then it can process the latest one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let ready = false;
|
||||||
|
let inputData = null;
|
||||||
|
|
||||||
|
onmessage = ({ data }) => {
|
||||||
|
inputData = data;
|
||||||
|
setTimeout(doPreview, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const go = new Go();
|
||||||
|
WebAssembly.instantiateStreaming(fetch('../parsing.wasm'), go.importObject)
|
||||||
|
.then(result => {
|
||||||
|
go.run(result.instance); // don't await this; we want it to be continuously running
|
||||||
|
ready = true;
|
||||||
|
setTimeout(doPreview, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const doPreview = () => {
|
||||||
|
if (!ready || inputData === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseMarkdown(inputData);
|
||||||
|
inputData = null;
|
||||||
|
|
||||||
|
postMessage(result);
|
||||||
|
}
|
Binary file not shown.
|
@ -7312,6 +7312,17 @@ article code {
|
||||||
.flex-grow-1 {
|
.flex-grow-1 {
|
||||||
flex-grow: 1; }
|
flex-grow: 1; }
|
||||||
|
|
||||||
|
.flex-fair {
|
||||||
|
flex-basis: 1px;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1; }
|
||||||
|
|
||||||
|
@media screen and (min-width: 30em) {
|
||||||
|
.flex-fair-ns {
|
||||||
|
flex-basis: 1px;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1; } }
|
||||||
|
|
||||||
.b--theme {
|
.b--theme {
|
||||||
border-color: #666;
|
border-color: #666;
|
||||||
border-color: var(--theme-color); }
|
border-color: var(--theme-color); }
|
||||||
|
|
27
src/db/db.go
27
src/db/db.go
|
@ -54,6 +54,11 @@ func typeIsQueryable(t reflect.Type) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This interface should match both a direct pgx connection or a pgx transaction.
|
||||||
|
type ConnOrTx interface {
|
||||||
|
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
||||||
|
}
|
||||||
|
|
||||||
var connInfo = pgtype.NewConnInfo()
|
var connInfo = pgtype.NewConnInfo()
|
||||||
|
|
||||||
func NewConn() *pgx.Conn {
|
func NewConn() *pgx.Conn {
|
||||||
|
@ -211,7 +216,7 @@ func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.V
|
||||||
return val, field
|
return val, field
|
||||||
}
|
}
|
||||||
|
|
||||||
func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
|
func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
|
||||||
destType := reflect.TypeOf(destExample)
|
destType := reflect.TypeOf(destExample)
|
||||||
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "")
|
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -279,7 +284,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
|
||||||
|
|
||||||
var ErrNoMatchingRows = errors.New("no matching rows")
|
var ErrNoMatchingRows = errors.New("no matching rows")
|
||||||
|
|
||||||
func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
|
func QueryOne(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
|
||||||
rows, err := Query(ctx, conn, destExample, query, args...)
|
rows, err := Query(ctx, conn, destExample, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -294,7 +299,7 @@ func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{},
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func QueryScalar(ctx context.Context, conn *pgxpool.Pool, query string, args ...interface{}) (interface{}, error) {
|
func QueryScalar(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (interface{}, error) {
|
||||||
rows, err := conn.Query(ctx, query, args...)
|
rows, err := conn.Query(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -317,7 +322,7 @@ func QueryScalar(ctx context.Context, conn *pgxpool.Pool, query string, args ...
|
||||||
return nil, ErrNoMatchingRows
|
return nil, ErrNoMatchingRows
|
||||||
}
|
}
|
||||||
|
|
||||||
func QueryInt(ctx context.Context, conn *pgxpool.Pool, query string, args ...interface{}) (int, error) {
|
func QueryInt(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (int, error) {
|
||||||
result, err := QueryScalar(ctx, conn, query, args...)
|
result, err := QueryScalar(ctx, conn, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
@ -334,3 +339,17 @@ func QueryInt(ctx context.Context, conn *pgxpool.Pool, query string, args ...int
|
||||||
return 0, oops.New(nil, "QueryInt got a non-int result: %v", result)
|
return 0, oops.New(nil, "QueryInt got a non-int result: %v", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func QueryBool(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (bool, error) {
|
||||||
|
result, err := QueryScalar(ctx, conn, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r := result.(type) {
|
||||||
|
case bool:
|
||||||
|
return r, nil
|
||||||
|
default:
|
||||||
|
return false, oops.New(nil, "QueryBool got a non-bool result: %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -392,15 +392,6 @@ func BuildForumPostReply(projectSlug string, subforums []string, threadId int, p
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumPostQuote = regexp.MustCompile(`^/forums(/(?P<cats>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/quote$`)
|
|
||||||
|
|
||||||
func BuildForumPostQuote(projectSlug string, subforums []string, threadId int, postId int) string {
|
|
||||||
defer CatchPanic()
|
|
||||||
builder := buildForumPostPath(subforums, threadId, postId)
|
|
||||||
builder.WriteString("/quote")
|
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Blog
|
* Blog
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(AddPostReplyId{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddPostReplyId struct{}
|
||||||
|
|
||||||
|
func (m AddPostReplyId) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2021, 7, 20, 2, 40, 51, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddPostReplyId) Name() string {
|
||||||
|
return "AddPostReplyId"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddPostReplyId) Description() string {
|
||||||
|
return "Add a reply id to posts"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddPostReplyId) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
ALTER TABLE handmade_post
|
||||||
|
ADD reply_id INT REFERENCES handmade_post (id) ON DELETE SET NULL;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to add columns")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddPostReplyId) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
panic("Implement me")
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(DropSuperuserColumn{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type DropSuperuserColumn struct{}
|
||||||
|
|
||||||
|
func (m DropSuperuserColumn) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2021, 7, 22, 1, 59, 29, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DropSuperuserColumn) Name() string {
|
||||||
|
return "DropSuperuserColumn"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DropSuperuserColumn) Description() string {
|
||||||
|
return "Drop the is_superuser column on users, in favor of is_staff"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DropSuperuserColumn) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
ALTER TABLE auth_user
|
||||||
|
DROP is_superuser;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to drop superuser column")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DropSuperuserColumn) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
panic("Implement me")
|
||||||
|
}
|
|
@ -17,9 +17,8 @@ type User struct {
|
||||||
DateJoined time.Time `db:"date_joined"`
|
DateJoined time.Time `db:"date_joined"`
|
||||||
LastLogin *time.Time `db:"last_login"`
|
LastLogin *time.Time `db:"last_login"`
|
||||||
|
|
||||||
IsSuperuser bool `db:"is_superuser"`
|
IsStaff bool `db:"is_staff"`
|
||||||
IsStaff bool `db:"is_staff"`
|
IsActive bool `db:"is_active"`
|
||||||
IsActive bool `db:"is_active"`
|
|
||||||
|
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
Bio string `db:"bio"`
|
Bio string `db:"bio"`
|
||||||
|
|
|
@ -23,7 +23,8 @@ import (
|
||||||
|
|
||||||
var BBCodePriority = 1 // TODO: This is maybe too high a priority?
|
var BBCodePriority = 1 // TODO: This is maybe too high a priority?
|
||||||
|
|
||||||
var reTag = regexp.MustCompile(`(?P<open>\[\s*(?P<opentagname>[a-zA-Z0-9]+))|(?P<close>\[\s*\/\s*(?P<closetagname>[a-zA-Z0-9]+)\s*\])`)
|
var reOpenTag = regexp.MustCompile(`^\[\s*(?P<name>[a-zA-Z0-9]+)`)
|
||||||
|
var reTag = regexp.MustCompile(`\[\s*(?P<opentagname>[a-zA-Z0-9]+)|\[\s*\/\s*(?P<closetagname>[a-zA-Z0-9]+)\s*\]`)
|
||||||
|
|
||||||
var previewBBCodeCompiler = bbcode.NewCompiler(false, false)
|
var previewBBCodeCompiler = bbcode.NewCompiler(false, false)
|
||||||
var realBBCodeCompiler = bbcode.NewCompiler(false, false)
|
var realBBCodeCompiler = bbcode.NewCompiler(false, false)
|
||||||
|
@ -245,38 +246,42 @@ func (s bbcodeParser) Parse(parent gast.Node, block text.Reader, pc parser.Conte
|
||||||
_, pos := block.Position()
|
_, pos := block.Position()
|
||||||
restOfSource := block.Source()[pos.Start:]
|
restOfSource := block.Source()[pos.Start:]
|
||||||
|
|
||||||
matches := reTag.FindAllSubmatchIndex(restOfSource, -1)
|
openMatch := reOpenTag.FindSubmatch(restOfSource)
|
||||||
if matches == nil {
|
if openMatch == nil {
|
||||||
// No tags anywhere
|
// not a bbcode tag
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
otIndex := reTag.SubexpIndex("opentagname")
|
otIndex := reTag.SubexpIndex("opentagname")
|
||||||
ctIndex := reTag.SubexpIndex("closetagname")
|
ctIndex := reTag.SubexpIndex("closetagname")
|
||||||
|
|
||||||
tagName := extractStringBySubmatchIndices(restOfSource, matches[0], otIndex)
|
tagName := string(openMatch[reOpenTag.SubexpIndex("name")])
|
||||||
if tagName == "" {
|
|
||||||
// Not an opening tag
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
depth := 0
|
depth := 0
|
||||||
endIndex := -1
|
endIndex := -1
|
||||||
for _, m := range matches {
|
|
||||||
if openName := extractStringBySubmatchIndices(restOfSource, m, otIndex); openName != "" {
|
searchStartIndex := 0
|
||||||
if openName == tagName {
|
|
||||||
depth++
|
for {
|
||||||
}
|
searchText := restOfSource[searchStartIndex:]
|
||||||
} else if closeName := extractStringBySubmatchIndices(restOfSource, m, ctIndex); closeName != "" {
|
|
||||||
if closeName == tagName {
|
match := reTag.FindSubmatchIndex(searchText)
|
||||||
depth--
|
if match == nil {
|
||||||
if depth == 0 {
|
// no more tags
|
||||||
// We have balanced out!
|
break
|
||||||
endIndex = m[1] // the end index of this closing tag (exclusive)
|
}
|
||||||
break
|
|
||||||
}
|
if openName := extractStringBySubmatchIndices(searchText, match, otIndex); openName == tagName {
|
||||||
|
depth++
|
||||||
|
} else if closeName := extractStringBySubmatchIndices(searchText, match, ctIndex); closeName == tagName {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
// We have balanced out!
|
||||||
|
endIndex = searchStartIndex + match[1] // the end index of this closing tag (exclusive)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
searchStartIndex = searchStartIndex + match[1]
|
||||||
}
|
}
|
||||||
if endIndex < 0 {
|
if endIndex < 0 {
|
||||||
// Unbalanced, too many opening tags
|
// Unbalanced, too many opening tags
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -63,58 +63,28 @@ func main() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const allBBCode = `
|
func TestSharlock(t *testing.T) {
|
||||||
[b]bold[/b]
|
t.Skipf("This doesn't pass right now because parts of Sharlock's original source read as indented code blocks, or depend on different line break behavior.")
|
||||||
|
t.Run("sanity check", func(t *testing.T) {
|
||||||
|
result := ParsePostInput(sharlock, RealMarkdown)
|
||||||
|
|
||||||
[i]italic[/i]
|
for _, line := range strings.Split(result, "\n") {
|
||||||
|
assert.NotContains(t, line, "[b]")
|
||||||
[u]underline[/u]
|
assert.NotContains(t, line, "[/b]")
|
||||||
|
assert.NotContains(t, line, "[ul]")
|
||||||
[h1]heading 1[/h1]
|
assert.NotContains(t, line, "[/ul]")
|
||||||
|
assert.NotContains(t, line, "[li]")
|
||||||
[h2]heading 2[/h2]
|
assert.NotContains(t, line, "[/li]")
|
||||||
|
assert.NotContains(t, line, "[img]")
|
||||||
[h3]heading 3[/h3]
|
assert.NotContains(t, line, "[/img]")
|
||||||
|
assert.NotContains(t, line, "[code")
|
||||||
[m]monospace[/m]
|
assert.NotContains(t, line, "[/code]")
|
||||||
|
}
|
||||||
[ol]
|
})
|
||||||
[li]ordered lists[/li]
|
|
||||||
[/ol]
|
|
||||||
|
|
||||||
[ul]
|
|
||||||
[li]unordered list[/li]
|
|
||||||
[/ul]
|
|
||||||
|
|
||||||
[url]https://handmade.network/[/url]
|
|
||||||
[url=https://handmade.network/]Handmade Network[/url]
|
|
||||||
|
|
||||||
[img=https://handmade.network/static/media/members/avatars/delix.jpeg]Ryan[/img]
|
|
||||||
|
|
||||||
[quote]quotes[/quote]
|
|
||||||
[quote=delix]Some quote[/quote]
|
|
||||||
|
|
||||||
[code]
|
|
||||||
Code
|
|
||||||
[/code]
|
|
||||||
|
|
||||||
[code language=go]
|
|
||||||
func main() {
|
|
||||||
fmt.Println("Hello, world!")
|
|
||||||
}
|
}
|
||||||
[/code]
|
|
||||||
|
|
||||||
[spoiler]spoilers[/spoiler]
|
func BenchmarkSharlock(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
[table]
|
ParsePostInput(sharlock, RealMarkdown)
|
||||||
[tr]
|
}
|
||||||
[th]Heading 1[/th] [th]Heading 2[/th]
|
}
|
||||||
[/tr]
|
|
||||||
[tr]
|
|
||||||
[td]Body 1[/td] [td]Body 2[/td]
|
|
||||||
[/tr]
|
|
||||||
[/table]
|
|
||||||
|
|
||||||
[youtube]https://www.youtube.com/watch?v=0J8G9qNT7gQ[/youtube]
|
|
||||||
[youtube]https://youtu.be/0J8G9qNT7gQ[/youtube]
|
|
||||||
`
|
|
||||||
|
|
|
@ -180,6 +180,20 @@ article code {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-fair {
|
||||||
|
flex-basis: 1px;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media #{$breakpoint-not-small} {
|
||||||
|
.flex-fair-ns {
|
||||||
|
flex-basis: 1px;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.b--theme {
|
.b--theme {
|
||||||
@include usevar(border-color, theme-color);
|
@include usevar(border-color, theme-color);
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,12 +12,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func PostToTemplate(p *models.Post, author *models.User, currentTheme string) Post {
|
func PostToTemplate(p *models.Post, author *models.User, currentTheme string) Post {
|
||||||
var authorUser *User
|
|
||||||
if author != nil {
|
|
||||||
authorTmpl := UserToTemplate(author, currentTheme)
|
|
||||||
authorUser = &authorTmpl
|
|
||||||
}
|
|
||||||
|
|
||||||
return Post{
|
return Post{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
|
|
||||||
|
@ -26,7 +20,7 @@ func PostToTemplate(p *models.Post, author *models.User, currentTheme string) Po
|
||||||
Preview: p.Preview,
|
Preview: p.Preview,
|
||||||
ReadOnly: p.ReadOnly,
|
ReadOnly: p.ReadOnly,
|
||||||
|
|
||||||
Author: authorUser,
|
Author: UserToTemplate(author, currentTheme),
|
||||||
// No content. A lot of the time we don't have this handy and don't need it. See AddContentVersion.
|
// No content. A lot of the time we don't have this handy and don't need it. See AddContentVersion.
|
||||||
PostDate: p.PostDate,
|
PostDate: p.PostDate,
|
||||||
}
|
}
|
||||||
|
@ -49,7 +43,6 @@ func (p *Post) AddUrls(projectSlug string, subforums []string, threadId int, pos
|
||||||
p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId)
|
p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId)
|
||||||
p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId)
|
p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId)
|
||||||
p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId)
|
p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId)
|
||||||
p.QuoteUrl = hmnurl.BuildForumPostQuote(projectSlug, subforums, threadId, postId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var LifecycleBadgeClasses = map[models.ProjectLifecycle]string{
|
var LifecycleBadgeClasses = map[models.ProjectLifecycle]string{
|
||||||
|
@ -132,7 +125,7 @@ func UserAvatarUrl(u *models.User, currentTheme string) string {
|
||||||
currentTheme = "light"
|
currentTheme = "light"
|
||||||
}
|
}
|
||||||
avatar := ""
|
avatar := ""
|
||||||
if u.Avatar != nil && len(*u.Avatar) > 0 {
|
if u != nil && u.Avatar != nil && len(*u.Avatar) > 0 {
|
||||||
avatar = hmnurl.BuildUserFile(*u.Avatar)
|
avatar = hmnurl.BuildUserFile(*u.Avatar)
|
||||||
} else {
|
} else {
|
||||||
avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true)
|
avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true)
|
||||||
|
@ -149,7 +142,12 @@ func UserDisplayName(u *models.User) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserToTemplate(u *models.User, currentTheme string) User {
|
func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
// TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function.
|
if u == nil {
|
||||||
|
return User{
|
||||||
|
Name: "Deleted user",
|
||||||
|
AvatarUrl: UserAvatarUrl(u, currentTheme),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
email := ""
|
email := ""
|
||||||
if u.ShowEmail {
|
if u.ShowEmail {
|
||||||
|
@ -158,11 +156,10 @@ func UserToTemplate(u *models.User, currentTheme string) User {
|
||||||
}
|
}
|
||||||
|
|
||||||
return User{
|
return User{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
Email: email,
|
Email: email,
|
||||||
IsSuperuser: u.IsSuperuser,
|
IsStaff: u.IsStaff,
|
||||||
IsStaff: u.IsStaff,
|
|
||||||
|
|
||||||
Name: UserDisplayName(u),
|
Name: UserDisplayName(u),
|
||||||
Blurb: u.Blurb,
|
Blurb: u.Blurb,
|
||||||
|
@ -267,7 +264,7 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
||||||
builder.WriteString(`",`)
|
builder.WriteString(`",`)
|
||||||
|
|
||||||
builder.WriteString(`"owner_name":"`)
|
builder.WriteString(`"owner_name":"`)
|
||||||
builder.WriteString(item.OwnerName)
|
builder.WriteString(item.OwnerName) // TODO: Do we need to do escaping on these other string fields too? Feels like someone could use this for XSS.
|
||||||
builder.WriteString(`",`)
|
builder.WriteString(`",`)
|
||||||
|
|
||||||
builder.WriteString(`"owner_avatar":"`)
|
builder.WriteString(`"owner_avatar":"`)
|
||||||
|
|
|
@ -7,115 +7,177 @@
|
||||||
|
|
||||||
<script src="{{ static "go_wasm_exec.js" }}"></script>
|
<script src="{{ static "go_wasm_exec.js" }}"></script>
|
||||||
<script>
|
<script>
|
||||||
const go = new Go();
|
const previewWorker = new Worker('{{ static "js/editorpreviews.js" }}');
|
||||||
const goLoaded = WebAssembly.instantiateStreaming(fetch("{{ static "parsing.wasm" }}"), go.importObject)
|
|
||||||
.then(result => {
|
|
||||||
go.run(result.instance);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#editor {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="content-block">
|
<div class="content-block ph3 ph0-ns">
|
||||||
<form action="{{ .SubmitUrl }}" method="post">
|
{{ if .Title }}
|
||||||
{{ csrftoken .Session }}
|
<h2>{{ .Title }}</h2>
|
||||||
|
{{ end }}
|
||||||
|
<div class="flex flex-column flex-row-ns">
|
||||||
|
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns">
|
||||||
|
{{ csrftoken .Session }}
|
||||||
|
|
||||||
<input class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .PostTitle }}"/>
|
{{ if not (or .PostReplyingTo .IsEditing) }}
|
||||||
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
|
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..."/>
|
||||||
{{/*
|
{{ end }}
|
||||||
<div class="toolbar" id="toolbar">
|
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
|
||||||
<input type="button" id="bold" value="B" />
|
{{/*
|
||||||
<input type="button" id="italic" value="I" />
|
<div class="toolbar" id="toolbar">
|
||||||
<input type="button" id="underline" value="U" />
|
<input type="button" id="bold" value="B" />
|
||||||
<input type="button" id="monospace" value="monospace" />
|
<input type="button" id="italic" value="I" />
|
||||||
<input type="button" id="url" value="url" />
|
<input type="button" id="underline" value="U" />
|
||||||
<input type="button" id="img" value="img" />
|
<input type="button" id="monospace" value="monospace" />
|
||||||
<input type="button" id="code" value="code" />
|
<input type="button" id="url" value="url" />
|
||||||
<input type="button" id="quote_simple" value="quote (anon)" />
|
<input type="button" id="img" value="img" />
|
||||||
<input type="button" id="quote_member" value="quote (member)" />
|
<input type="button" id="code" value="code" />
|
||||||
<input type="button" id="spoiler" value="spoiler" />
|
<input type="button" id="quote_simple" value="quote (anon)" />
|
||||||
<input type="button" id="lalign" value="Left" />
|
<input type="button" id="quote_member" value="quote (member)" />
|
||||||
<input type="button" id="calign" value="Center" />
|
<input type="button" id="spoiler" value="spoiler" />
|
||||||
<input type="button" id="ralign" value="Right" />
|
<input type="button" id="lalign" value="Left" />
|
||||||
<input type="button" id="ulist" value="ul" />
|
<input type="button" id="calign" value="Center" />
|
||||||
<input type="button" id="olist" value="ol" />
|
<input type="button" id="ralign" value="Right" />
|
||||||
<input type="button" id="litem" value="li" />
|
<input type="button" id="ulist" value="ul" />
|
||||||
<input type="button" id="youtube" value="youtube" />
|
<input type="button" id="olist" value="ol" />
|
||||||
</div>
|
<input type="button" id="litem" value="li" />
|
||||||
*/}}
|
<input type="button" id="youtube" value="youtube" />
|
||||||
<textarea id="editor" class="w-100 minw-100 mw-100 h5 minh-5" name="body">{{ .PostBody }}</textarea>
|
</div>
|
||||||
|
*/}}
|
||||||
|
<textarea id="editor" class="w-100 h5 minh-5" name="body">{{ if .IsEditing }}{{ .EditInitialContents }}{{ end }}</textarea>
|
||||||
|
|
||||||
<div class="flex flex-row-reverse justify-start mt2">
|
<div class="flex flex-row-reverse justify-start mt2">
|
||||||
<input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" />
|
<input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="post post-preview mv3 mathjax">
|
{{ if .IsEditing }}
|
||||||
|
<span class="editreason">
|
||||||
|
<label for="editreason">Edit reason:</label>
|
||||||
|
<input name="editreason" maxlength="255" type="text" id="editreason" />
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{/* TODO: Sticky threads
|
||||||
|
{% if user.is_staff and post and post.depth == 0 %}
|
||||||
|
<div class="checkbox sticky">
|
||||||
|
<input type="checkbox" name="sticky" id="sticky" {% if thread.sticky %}checked{% endif%} />
|
||||||
|
<label for="sticky">Sticky thread</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{ with .PostReplyingTo }}
|
||||||
|
<h4 class="mt3">The post you're replying to:</h4>
|
||||||
|
{{ template "forum_post_standalone.html" . }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
|
||||||
|
{% if context_newer %}
|
||||||
|
<h4>Replies since then:</h4>
|
||||||
|
<div class="recent-posts">
|
||||||
|
{% for post in posts_newer %}
|
||||||
|
{% include "forum_thread_single_post.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if context_older %}
|
||||||
|
<h4>Replies before then:</h4>
|
||||||
|
<div class="recent-posts">
|
||||||
|
{% for post in posts_older %}
|
||||||
|
{% include "forum_thread_single_post.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
*/}}
|
||||||
|
</form>
|
||||||
|
<div class="post post-preview mv3 mathjax flex-fair-ns mv0-ns ml3-ns">
|
||||||
<div id="preview" class="body contents"></div>
|
<div id="preview" class="body contents"></div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{{ if .IsEditing }}
|
|
||||||
<span class="editreason">
|
|
||||||
<label for="editreason">Edit reason:</label>
|
|
||||||
<input name="editreason" maxlength="255" type="text" id="editreason" />
|
|
||||||
</span>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{/* TODO: Sticky threads
|
|
||||||
{% if user.is_staff and post and post.depth == 0 %}
|
|
||||||
<div class="checkbox sticky">
|
|
||||||
<input type="checkbox" name="sticky" id="sticky" {% if thread.sticky %}checked{% endif%} />
|
|
||||||
<label for="sticky">Sticky thread</label>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
*/}}
|
|
||||||
|
|
||||||
{{/*
|
|
||||||
|
|
||||||
{% if context_reply_to %}
|
|
||||||
<h4>The post you're replying to:</h4>
|
|
||||||
<div class="recent-posts">
|
|
||||||
{% with post=post_reply_to %}
|
|
||||||
{% include "forum_thread_single_post.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if context_newer %}
|
|
||||||
<h4>Replies since then:</h4>
|
|
||||||
<div class="recent-posts">
|
|
||||||
{% for post in posts_newer %}
|
|
||||||
{% include "forum_thread_single_post.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if context_older %}
|
|
||||||
<h4>Replies before then:</h4>
|
|
||||||
<div class="recent-posts">
|
|
||||||
{% for post in posts_older %}
|
|
||||||
{% include "forum_thread_single_post.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
*/}}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const tf = document.querySelector('#editor');
|
const form = document.querySelector('#form');
|
||||||
|
const titleField = document.querySelector('#title'); // may be undefined, be careful!
|
||||||
|
const textField = document.querySelector('#editor');
|
||||||
const preview = document.querySelector('#preview');
|
const preview = document.querySelector('#preview');
|
||||||
|
|
||||||
function updatePreview() {
|
const storagePrefix = 'post-contents';
|
||||||
const previewHtml = parseMarkdown(tf.value);
|
|
||||||
|
// Delete old irrelevant local post contents
|
||||||
|
const aWeekAgo = new Date().getTime() - (7 * 24 * 60 * 60 * 1000);
|
||||||
|
for (const key in window.localStorage) {
|
||||||
|
if (!window.localStorage.hasOwnProperty(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith(storagePrefix)) {
|
||||||
|
try {
|
||||||
|
const { when } = JSON.parse(window.localStorage.getItem(key));
|
||||||
|
if (when <= aWeekAgo) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load any stored content from localStorage
|
||||||
|
const storageKey = `${storagePrefix}/${window.location.host}${window.location.pathname}`;
|
||||||
|
const storedContents = window.localStorage.getItem(storageKey);
|
||||||
|
if (storedContents && !textField.value) {
|
||||||
|
try {
|
||||||
|
const { title, contents } = JSON.parse(storedContents);
|
||||||
|
if (titleField) {
|
||||||
|
titleField.value = title;
|
||||||
|
}
|
||||||
|
textField.value = contents;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview(previewHtml) {
|
||||||
preview.innerHTML = previewHtml;
|
preview.innerHTML = previewHtml;
|
||||||
MathJax.typeset();
|
MathJax.typeset();
|
||||||
}
|
}
|
||||||
|
|
||||||
goLoaded.then(() => {
|
previewWorker.onmessage = ({ data }) => {
|
||||||
updatePreview();
|
updatePreview(data);
|
||||||
});
|
};
|
||||||
tf.addEventListener('input', () => {
|
|
||||||
updatePreview();
|
function doMarkdown() {
|
||||||
|
const md = textField.value;
|
||||||
|
previewWorker.postMessage(md);
|
||||||
|
updateContentCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContentCache() {
|
||||||
|
window.localStorage.setItem(storageKey, JSON.stringify({
|
||||||
|
when: new Date().getTime(),
|
||||||
|
title: titleField ? titleField.value : '',
|
||||||
|
contents: textField.value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
doMarkdown();
|
||||||
|
textField.addEventListener('input', () => doMarkdown());
|
||||||
|
if (titleField) {
|
||||||
|
titleField.addEventListener('input', () => updateContentCache());
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', e => {
|
||||||
|
window.localStorage.removeItem(storageKey);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="mw7 margin-center">
|
||||||
|
<h3 class="mb3">Are you sure you want to delete this post?</h3>
|
||||||
|
{{ template "forum_post_standalone.html" .Post }}
|
||||||
|
<form action="{{ .SubmitUrl }}" method="POST" class="pv3 flex justify-end">
|
||||||
|
<input type="submit" value="Delete Post">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -7,55 +7,50 @@
|
||||||
{{ template "pagination.html" .Pagination }}
|
{{ template "pagination.html" .Pagination }}
|
||||||
</div>
|
</div>
|
||||||
{{ range .Posts }}
|
{{ range .Posts }}
|
||||||
<div class="post background-even pa3 bbcode"> {{/* TODO: Dynamically switch between bbcode and markdown */}}
|
<div class="post background-even pa3">
|
||||||
<div class="fl w-100 w-25-l pt3 pa3-l flex tc-l">
|
<div class="fl w-100 w-25-l pt3 pa3-l flex tc-l">
|
||||||
{{ if .Author }}
|
<div class="fl w-20 mw3 dn-l w3">
|
||||||
<div class="fl w-20 mw3 dn-l w3">
|
<!-- Mobile avatar -->
|
||||||
<!-- Mobile avatar -->
|
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
||||||
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
</div>
|
||||||
</div>
|
<div class="w-100-l pl3 pl0-l flex flex-column items-center-l">
|
||||||
<div class="w-100-l pl3 pl0-l flex flex-column items-center-l">
|
<div>
|
||||||
<div>
|
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
|
||||||
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
|
<!-- Mobile badges -->
|
||||||
<!-- Mobile badges -->
|
<div class="di dn-l ph1">
|
||||||
<div class="di dn-l ph1">
|
|
||||||
{{ if .Author.IsStaff }}
|
|
||||||
<div class="badge staff"></div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
|
|
||||||
<div class="c--dim f7"> {{ .Author.Name }} </div>
|
|
||||||
{{ end }}
|
|
||||||
<!-- Large avatar -->
|
|
||||||
<div class="dn db-l w-60 pv2">
|
|
||||||
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
|
||||||
</div>
|
|
||||||
{{/* TODO: Aggregate user data
|
|
||||||
<div class="c--dim f7">
|
|
||||||
{{ post.author.posts }} posts
|
|
||||||
{% if post.author.public_projects.values|length > 0 %}
|
|
||||||
/ {{ post.author.public_projects.values|length }} project{%if post.author.public_projects.values|length > 1 %}s{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div> */}}
|
|
||||||
<!-- Large badges -->
|
|
||||||
<div class="dn db-l pv2">
|
|
||||||
{{ if .Author.IsStaff }}
|
{{ if .Author.IsStaff }}
|
||||||
<div class="badge staff"></div>
|
<div class="badge staff"></div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="i c--dim f7">
|
|
||||||
{{ if .Author.Blurb }}
|
|
||||||
{{ .Author.Blurb }} {{/* TODO: Linebreaks? */}}
|
|
||||||
{{ else if .Author.Bio }}
|
|
||||||
{{ .Author.Bio }}
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{ else }}
|
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
|
||||||
<div class="username">Deleted member</div>
|
<div class="c--dim f7"> {{ .Author.Name }} </div>
|
||||||
<div class="avatar" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
{{ end }}
|
||||||
{{ end }}
|
<!-- Large avatar -->
|
||||||
|
<div class="dn db-l w-60 pv2">
|
||||||
|
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
||||||
|
</div>
|
||||||
|
{{/* TODO: Aggregate user data
|
||||||
|
<div class="c--dim f7">
|
||||||
|
{{ post.author.posts }} posts
|
||||||
|
{% if post.author.public_projects.values|length > 0 %}
|
||||||
|
/ {{ post.author.public_projects.values|length }} project{%if post.author.public_projects.values|length > 1 %}s{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div> */}}
|
||||||
|
<!-- Large badges -->
|
||||||
|
<div class="dn db-l pv2">
|
||||||
|
{{ if .Author.IsStaff }}
|
||||||
|
<div class="badge staff"></div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="i c--dim f7">
|
||||||
|
{{ if .Author.Blurb }}
|
||||||
|
{{ .Author.Blurb }} {{/* TODO: Linebreaks? */}}
|
||||||
|
{{ else if .Author.Bio }}
|
||||||
|
{{ .Author.Bio }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fl w-100 w-75-l pv3 pa3-l">
|
<div class="fl w-100 w-75-l pv3 pa3-l">
|
||||||
<div class="w-100 flex-l flex-row-reverse-l">
|
<div class="w-100 flex-l flex-row-reverse-l">
|
||||||
|
@ -74,7 +69,6 @@
|
||||||
WARNING: locked thread - use power responsibly!
|
WARNING: locked thread - use power responsibly!
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<a class="reply action button" href="{{ .ReplyUrl }}" title="Reply">↪</a>
|
<a class="reply action button" href="{{ .ReplyUrl }}" title="Reply">↪</a>
|
||||||
<a class="quote action button" href="{{ .QuoteUrl }}" title="Quote">❝</a>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -101,6 +95,11 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{{ if .ReplyPost }}
|
||||||
|
<div class="i c--dim f7 pb2">
|
||||||
|
Replying to {{ if .Author }}{{ .Author.Username }}{{ else }}deleted user{{ end }} (<a href="{{ .ReplyPost.Url }}">#{{ .ReplyPost.ID }}</a>)
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
<div class="contents overflow-x-auto">
|
<div class="contents overflow-x-auto">
|
||||||
{{ .Content }}
|
{{ .Content }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
<div class="bg--dim pa3 br3 tl">
|
||||||
|
<div class="w-100 flex items-center">
|
||||||
|
<div class="w-20 mw3 w3">
|
||||||
|
<!-- Mobile avatar -->
|
||||||
|
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pl3 flex flex-column">
|
||||||
|
<div>
|
||||||
|
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
|
||||||
|
<!-- Mobile badges -->
|
||||||
|
<div class="di ph1">
|
||||||
|
{{ if .Author.IsStaff }}
|
||||||
|
<div class="badge staff"></div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
|
||||||
|
<div class="c--dim f7"> {{ .Author.Name }} </div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="c--dim f7">
|
||||||
|
{{ timehtml (relativedate .PostDate) .PostDate }}
|
||||||
|
{{ if .Editor }}
|
||||||
|
<span class="pl3">
|
||||||
|
Edited by
|
||||||
|
<a class="name" href="{{ .Editor.ProfileUrl }}" target="_blank">{{ coalesce .Editor.Name .Editor.Username }}</a>
|
||||||
|
on {{ timehtml (absolutedate .EditDate) .EditDate }}
|
||||||
|
{{ with .EditReason }}
|
||||||
|
Reason: {{ . }}
|
||||||
|
{{ end }}
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-100 pt3">
|
||||||
|
<div class="contents overflow-x-auto">
|
||||||
|
{{ .Content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,7 +1,7 @@
|
||||||
<header class="mb3">
|
<header class="mb3">
|
||||||
<div class="user-options flex justify-center justify-end-ns">
|
<div class="user-options flex justify-center justify-end-ns">
|
||||||
{{ if .User }}
|
{{ if .User }}
|
||||||
{{ if .User.IsSuperuser }}
|
{{ if .User.IsStaff }}
|
||||||
<a class="admin-panel" href="{{ .Header.AdminUrl }}"><span class="icon-settings"> Admin</span></a>
|
<a class="admin-panel" href="{{ .Header.AdminUrl }}"><span class="icon-settings"> Admin</span></a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<a class="username settings" href="{{ .Header.UserSettingsUrl }}"><span class="icon-settings"></span> {{ .User.Username }}</a>
|
<a class="username settings" href="{{ .Header.UserSettingsUrl }}"><span class="icon-settings"></span> {{ .User.Username }}</a>
|
||||||
|
|
|
@ -21,8 +21,8 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</style>
|
</style>
|
||||||
<script type="text/javascript" src="{{ static "js/templates.js" }}"></script>
|
<script src="{{ static "js/templates.js" }}"></script>
|
||||||
<script type="text/javascript" src="{{ static "js/showcase.js" }}"></script>
|
<script src="{{ static "js/showcase.js" }}"></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
|
|
@ -73,12 +73,11 @@ type Post struct {
|
||||||
DeleteUrl string
|
DeleteUrl string
|
||||||
EditUrl string
|
EditUrl string
|
||||||
ReplyUrl string
|
ReplyUrl string
|
||||||
QuoteUrl string
|
|
||||||
|
|
||||||
Preview string
|
Preview string
|
||||||
ReadOnly bool
|
ReadOnly bool
|
||||||
|
|
||||||
Author *User
|
Author User
|
||||||
Content template.HTML
|
Content template.HTML
|
||||||
PostDate time.Time
|
PostDate time.Time
|
||||||
|
|
||||||
|
@ -87,6 +86,8 @@ type Post struct {
|
||||||
EditReason string
|
EditReason string
|
||||||
|
|
||||||
IP string
|
IP string
|
||||||
|
|
||||||
|
ReplyPost *Post
|
||||||
}
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
|
@ -116,11 +117,10 @@ type Project struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int
|
ID int
|
||||||
Username string
|
Username string
|
||||||
Email string
|
Email string
|
||||||
IsSuperuser bool
|
IsStaff bool
|
||||||
IsStaff bool
|
|
||||||
|
|
||||||
Name string
|
Name string
|
||||||
Blurb string
|
Blurb string
|
||||||
|
|
|
@ -94,7 +94,7 @@ func Feed(c *RequestContext) ResponseData {
|
||||||
baseData.BodyClasses = append(baseData.BodyClasses, "feed")
|
baseData.BodyClasses = append(baseData.BodyClasses, "feed")
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("feed.html", FeedData{
|
res.MustWriteTemplate("feed.html", FeedData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
|
|
||||||
AtomFeedUrl: hmnurl.BuildAtomFeed(),
|
AtomFeedUrl: hmnurl.BuildAtomFeed(),
|
||||||
|
@ -102,10 +102,6 @@ func Feed(c *RequestContext) ResponseData {
|
||||||
Posts: posts,
|
Posts: posts,
|
||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -303,11 +299,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("atom.xml", feedData, c.Perf)
|
res.MustWriteTemplate("atom.xml", feedData, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
@ -16,6 +18,8 @@ import (
|
||||||
"git.handmade.network/hmn/hmn/src/parsing"
|
"git.handmade.network/hmn/hmn/src/parsing"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
"git.handmade.network/hmn/hmn/src/utils"
|
"git.handmade.network/hmn/hmn/src/utils"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
|
"github.com/jackc/pgx/v4/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
type forumCategoryData struct {
|
type forumCategoryData struct {
|
||||||
|
@ -35,6 +39,18 @@ type forumSubcategoryData struct {
|
||||||
TotalThreads int
|
TotalThreads int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type editorData struct {
|
||||||
|
templates.BaseData
|
||||||
|
SubmitUrl string
|
||||||
|
Title string
|
||||||
|
SubmitLabel string
|
||||||
|
|
||||||
|
IsEditing bool // false if new post, true if updating existing one
|
||||||
|
EditInitialContents string
|
||||||
|
|
||||||
|
PostReplyingTo *templates.Post
|
||||||
|
}
|
||||||
|
|
||||||
func ForumCategory(c *RequestContext) ResponseData {
|
func ForumCategory(c *RequestContext) ResponseData {
|
||||||
const threadsPerPage = 25
|
const threadsPerPage = 25
|
||||||
|
|
||||||
|
@ -260,7 +276,7 @@ func ForumCategory(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("forum_category.html", forumCategoryData{
|
res.MustWriteTemplate("forum_category.html", forumCategoryData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
|
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
|
||||||
MarkReadUrl: hmnurl.BuildMarkRead(currentCatId),
|
MarkReadUrl: hmnurl.BuildMarkRead(currentCatId),
|
||||||
|
@ -276,10 +292,6 @@ func ForumCategory(c *RequestContext) ResponseData {
|
||||||
},
|
},
|
||||||
Subcategories: subcats,
|
Subcategories: subcats,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -376,6 +388,9 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
Ver models.PostVersion `db:"ver"`
|
Ver models.PostVersion `db:"ver"`
|
||||||
Author *models.User `db:"author"`
|
Author *models.User `db:"author"`
|
||||||
Editor *models.User `db:"editor"`
|
Editor *models.User `db:"editor"`
|
||||||
|
|
||||||
|
ReplyPost *models.Post `db:"reply"`
|
||||||
|
ReplyAuthor *models.User `db:"reply_author"`
|
||||||
}
|
}
|
||||||
itPosts, err := db.Query(c.Context(), c.Conn, postsQueryResult{},
|
itPosts, err := db.Query(c.Context(), c.Conn, postsQueryResult{},
|
||||||
`
|
`
|
||||||
|
@ -385,10 +400,12 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
JOIN handmade_postversion AS ver ON post.current_id = ver.id
|
JOIN handmade_postversion AS ver ON post.current_id = ver.id
|
||||||
LEFT JOIN auth_user AS author ON post.author_id = author.id
|
LEFT JOIN auth_user AS author ON post.author_id = author.id
|
||||||
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
|
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
|
||||||
|
LEFT JOIN handmade_post AS reply ON post.reply_id = reply.id
|
||||||
|
LEFT JOIN auth_user AS reply_author ON reply.author_id = reply_author.id
|
||||||
WHERE
|
WHERE
|
||||||
post.thread_id = $1
|
post.thread_id = $1
|
||||||
AND NOT post.deleted
|
AND NOT post.deleted
|
||||||
ORDER BY postdate
|
ORDER BY post.postdate
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
`,
|
`,
|
||||||
thread.ID,
|
thread.ID,
|
||||||
|
@ -409,6 +426,12 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
post.AddContentVersion(row.Ver, row.Editor)
|
post.AddContentVersion(row.Ver, row.Editor)
|
||||||
post.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
|
post.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
|
||||||
|
|
||||||
|
if row.ReplyPost != nil {
|
||||||
|
reply := templates.PostToTemplate(row.ReplyPost, row.ReplyAuthor, c.Theme)
|
||||||
|
reply.AddUrls(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
|
||||||
|
post.ReplyPost = &reply
|
||||||
|
}
|
||||||
|
|
||||||
posts = append(posts, post)
|
posts = append(posts, post)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -417,7 +440,7 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
// TODO(asaf): Set breadcrumbs
|
// TODO(asaf): Set breadcrumbs
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("forum_thread.html", forumThreadData{
|
res.MustWriteTemplate("forum_thread.html", forumThreadData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
Thread: templates.ThreadToTemplate(&thread),
|
Thread: templates.ThreadToTemplate(&thread),
|
||||||
Posts: posts,
|
Posts: posts,
|
||||||
|
@ -425,14 +448,13 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
|
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, *thread.FirstID),
|
||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func ForumPostRedirect(c *RequestContext) ResponseData {
|
func ForumPostRedirect(c *RequestContext) ResponseData {
|
||||||
|
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
|
||||||
|
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
|
||||||
|
// Consider compressing this later.
|
||||||
c.Perf.StartBlock("SQL", "Fetch category tree")
|
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||||
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||||
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||||
|
@ -517,15 +539,6 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
|
||||||
), http.StatusSeeOther)
|
), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
type editorData struct {
|
|
||||||
templates.BaseData
|
|
||||||
SubmitUrl string
|
|
||||||
PostTitle string
|
|
||||||
PostBody string
|
|
||||||
SubmitLabel string
|
|
||||||
IsEditing bool // false if new post, true if updating existing one
|
|
||||||
}
|
|
||||||
|
|
||||||
func ForumNewThread(c *RequestContext) ResponseData {
|
func ForumNewThread(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(c)
|
baseData := getBaseData(c)
|
||||||
baseData.Title = "Create New Thread"
|
baseData.Title = "Create New Thread"
|
||||||
|
@ -543,15 +556,11 @@ func ForumNewThread(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("editor.html", editorData{
|
res.MustWriteTemplate("editor.html", editorData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), true),
|
SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), true),
|
||||||
SubmitLabel: "Post New Thread",
|
SubmitLabel: "Post New Thread",
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -581,17 +590,6 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
||||||
sticky = true
|
sticky = true
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed := parsing.ParsePostInput(unparsed, parsing.RealMarkdown)
|
|
||||||
now := time.Now()
|
|
||||||
ip := net.ParseIP(c.Req.RemoteAddr)
|
|
||||||
|
|
||||||
const previewMaxLength = 100
|
|
||||||
parsedPlaintext := parsing.ParsePostInput(unparsed, parsing.PlaintextMarkdown)
|
|
||||||
preview := parsedPlaintext
|
|
||||||
if len(preview) > previewMaxLength-1 {
|
|
||||||
preview = preview[:previewMaxLength-1] + "…"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create thread
|
// Create thread
|
||||||
var threadId int
|
var threadId int
|
||||||
err = tx.QueryRow(c.Context(),
|
err = tx.QueryRow(c.Context(),
|
||||||
|
@ -610,58 +608,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
||||||
panic(oops.New(err, "failed to create thread"))
|
panic(oops.New(err, "failed to create thread"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create post
|
postId, _ := createNewForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil)
|
||||||
var postId int
|
|
||||||
err = tx.QueryRow(c.Context(),
|
|
||||||
`
|
|
||||||
INSERT INTO handmade_post (postdate, category_id, thread_id, preview, current_id, author_id, category_kind, project_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
RETURNING id
|
|
||||||
`,
|
|
||||||
now,
|
|
||||||
currentCatId,
|
|
||||||
threadId,
|
|
||||||
preview,
|
|
||||||
-1,
|
|
||||||
c.CurrentUser.ID,
|
|
||||||
models.CatKindForum,
|
|
||||||
c.CurrentProject.ID,
|
|
||||||
).Scan(&postId)
|
|
||||||
if err != nil {
|
|
||||||
panic(oops.New(err, "failed to create post"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create post version
|
|
||||||
var versionId int
|
|
||||||
err = tx.QueryRow(c.Context(),
|
|
||||||
`
|
|
||||||
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
RETURNING id
|
|
||||||
`,
|
|
||||||
postId,
|
|
||||||
unparsed,
|
|
||||||
parsed,
|
|
||||||
ip,
|
|
||||||
now,
|
|
||||||
).Scan(&versionId)
|
|
||||||
if err != nil {
|
|
||||||
panic(oops.New(err, "failed to create post version"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update post with version id
|
|
||||||
_, err = tx.Exec(c.Context(),
|
|
||||||
`
|
|
||||||
UPDATE handmade_post
|
|
||||||
SET current_id = $1
|
|
||||||
WHERE id = $2
|
|
||||||
`,
|
|
||||||
versionId,
|
|
||||||
postId,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
panic(oops.New(err, "failed to set current post version"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update thread with post id
|
// Update thread with post id
|
||||||
_, err = tx.Exec(c.Context(),
|
_, err = tx.Exec(c.Context(),
|
||||||
|
@ -688,6 +635,550 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
||||||
return c.Redirect(newThreadUrl, http.StatusSeeOther)
|
return c.Redirect(newThreadUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ForumPostReply(c *RequestContext) ResponseData {
|
||||||
|
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
|
||||||
|
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
|
||||||
|
// Consider compressing this later.
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||||
|
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||||
|
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
|
||||||
|
if !valid {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch post to reply to")
|
||||||
|
// TODO: Scope this down to just what you need
|
||||||
|
type postQuery struct {
|
||||||
|
Thread models.Thread `db:"thread"`
|
||||||
|
Post models.Post `db:"post"`
|
||||||
|
CurrentVersion models.PostVersion `db:"ver"`
|
||||||
|
Author *models.User `db:"author"`
|
||||||
|
Editor *models.User `db:"editor"`
|
||||||
|
}
|
||||||
|
postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_thread AS thread
|
||||||
|
JOIN handmade_post AS post ON post.thread_id = thread.id
|
||||||
|
JOIN handmade_postversion AS ver ON post.current_id = ver.id
|
||||||
|
LEFT JOIN auth_user AS author ON post.author_id = author.id
|
||||||
|
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
|
||||||
|
WHERE
|
||||||
|
post.category_id = $1
|
||||||
|
AND post.thread_id = $2
|
||||||
|
AND post.id = $3
|
||||||
|
AND NOT post.deleted
|
||||||
|
ORDER BY postdate
|
||||||
|
`,
|
||||||
|
currentCatId,
|
||||||
|
requestedThreadId,
|
||||||
|
requestedPostId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
|
return FourOhFour(c)
|
||||||
|
} else {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch reply post"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := postQueryResult.(*postQuery)
|
||||||
|
|
||||||
|
baseData := getBaseData(c)
|
||||||
|
baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name)
|
||||||
|
baseData.MathjaxEnabled = true
|
||||||
|
// TODO(ben): Set breadcrumbs
|
||||||
|
|
||||||
|
templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme)
|
||||||
|
templatePost.AddContentVersion(result.CurrentVersion, result.Editor)
|
||||||
|
|
||||||
|
var res ResponseData
|
||||||
|
res.MustWriteTemplate("editor.html", editorData{
|
||||||
|
BaseData: baseData,
|
||||||
|
SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId),
|
||||||
|
SubmitLabel: "Submit Reply",
|
||||||
|
|
||||||
|
Title: "Replying to post",
|
||||||
|
PostReplyingTo: &templatePost,
|
||||||
|
}, c.Perf)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForumPostReplySubmit(c *RequestContext) ResponseData {
|
||||||
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(c.Context())
|
||||||
|
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||||
|
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||||
|
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
|
||||||
|
if !valid {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
threadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
postId, err := strconv.Atoi(c.PathParams["postid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Req.ParseForm()
|
||||||
|
|
||||||
|
unparsed := c.Req.Form.Get("body")
|
||||||
|
|
||||||
|
newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &postId)
|
||||||
|
|
||||||
|
err = tx.Commit(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
|
||||||
|
}
|
||||||
|
|
||||||
|
newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, newPostId)
|
||||||
|
return c.Redirect(newPostUrl, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForumPostEdit(c *RequestContext) ResponseData {
|
||||||
|
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
|
||||||
|
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
|
||||||
|
// Consider compressing this later.
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||||
|
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||||
|
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
|
||||||
|
if !valid {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch post to edit")
|
||||||
|
// TODO: Scope this down to just what you need
|
||||||
|
type postQuery struct {
|
||||||
|
Thread models.Thread `db:"thread"`
|
||||||
|
Post models.Post `db:"post"`
|
||||||
|
CurrentVersion models.PostVersion `db:"ver"`
|
||||||
|
Author *models.User `db:"author"`
|
||||||
|
Editor *models.User `db:"editor"`
|
||||||
|
}
|
||||||
|
postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_thread AS thread
|
||||||
|
JOIN handmade_post AS post ON post.thread_id = thread.id
|
||||||
|
JOIN handmade_postversion AS ver ON post.current_id = ver.id
|
||||||
|
LEFT JOIN auth_user AS author ON post.author_id = author.id
|
||||||
|
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
|
||||||
|
WHERE
|
||||||
|
post.category_id = $1
|
||||||
|
AND post.thread_id = $2
|
||||||
|
AND post.id = $3
|
||||||
|
AND NOT post.deleted
|
||||||
|
ORDER BY postdate
|
||||||
|
`,
|
||||||
|
currentCatId,
|
||||||
|
requestedThreadId,
|
||||||
|
requestedPostId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
|
return FourOhFour(c)
|
||||||
|
} else {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch reply post"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := postQueryResult.(*postQuery)
|
||||||
|
|
||||||
|
// Ensure that the user is permitted to edit the post
|
||||||
|
canEdit, err := canEditPost(c.Context(), c.Conn, requestedPostId, *c.CurrentUser)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||||||
|
} else if !canEdit {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseData := getBaseData(c)
|
||||||
|
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name)
|
||||||
|
baseData.MathjaxEnabled = true
|
||||||
|
// TODO(ben): Set breadcrumbs
|
||||||
|
|
||||||
|
templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme)
|
||||||
|
templatePost.AddContentVersion(result.CurrentVersion, result.Editor)
|
||||||
|
|
||||||
|
var res ResponseData
|
||||||
|
res.MustWriteTemplate("editor.html", editorData{
|
||||||
|
BaseData: baseData,
|
||||||
|
SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId),
|
||||||
|
Title: result.Thread.Title,
|
||||||
|
SubmitLabel: "Submit Edited Post",
|
||||||
|
|
||||||
|
IsEditing: true,
|
||||||
|
EditInitialContents: result.CurrentVersion.TextRaw,
|
||||||
|
}, c.Perf)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForumPostEditSubmit(c *RequestContext) ResponseData {
|
||||||
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(c.Context())
|
||||||
|
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||||
|
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||||
|
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
|
||||||
|
if !valid {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
threadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
postId, err := strconv.Atoi(c.PathParams["postid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that the user is permitted to edit the post
|
||||||
|
canEdit, err := canEditPost(c.Context(), c.Conn, postId, *c.CurrentUser)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||||||
|
} else if !canEdit {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Req.ParseForm()
|
||||||
|
unparsed := c.Req.Form.Get("body")
|
||||||
|
editReason := c.Req.Form.Get("editreason")
|
||||||
|
|
||||||
|
createForumPostVersion(c.Context(), tx, postId, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
|
||||||
|
|
||||||
|
err = tx.Commit(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
|
||||||
|
}
|
||||||
|
|
||||||
|
postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, postId)
|
||||||
|
return c.Redirect(postUrl, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForumPostDelete(c *RequestContext) ResponseData {
|
||||||
|
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
|
||||||
|
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
|
||||||
|
// Consider compressing this later.
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||||
|
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||||
|
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
|
||||||
|
if !valid {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that the user is allowed to delete this post
|
||||||
|
canEdit, err := canEditPost(c.Context(), c.Conn, requestedPostId, *c.CurrentUser)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||||||
|
} else if !canEdit {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch post to delete")
|
||||||
|
type postQuery struct {
|
||||||
|
Thread models.Thread `db:"thread"`
|
||||||
|
Post models.Post `db:"post"`
|
||||||
|
CurrentVersion models.PostVersion `db:"ver"`
|
||||||
|
Author *models.User `db:"author"`
|
||||||
|
Editor *models.User `db:"editor"`
|
||||||
|
}
|
||||||
|
postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_thread AS thread
|
||||||
|
JOIN handmade_post AS post ON post.thread_id = thread.id
|
||||||
|
JOIN handmade_postversion AS ver ON post.current_id = ver.id
|
||||||
|
LEFT JOIN auth_user AS author ON post.author_id = author.id
|
||||||
|
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
|
||||||
|
WHERE
|
||||||
|
post.category_id = $1
|
||||||
|
AND post.thread_id = $2
|
||||||
|
AND post.id = $3
|
||||||
|
AND NOT post.deleted
|
||||||
|
ORDER BY postdate
|
||||||
|
`,
|
||||||
|
currentCatId,
|
||||||
|
requestedThreadId,
|
||||||
|
requestedPostId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
|
return FourOhFour(c)
|
||||||
|
} else {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch post to delete"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := postQueryResult.(*postQuery)
|
||||||
|
|
||||||
|
baseData := getBaseData(c)
|
||||||
|
baseData.Title = fmt.Sprintf("Deleting post in \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name)
|
||||||
|
baseData.MathjaxEnabled = true
|
||||||
|
// TODO(ben): Set breadcrumbs
|
||||||
|
|
||||||
|
templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme)
|
||||||
|
templatePost.AddContentVersion(result.CurrentVersion, result.Editor)
|
||||||
|
|
||||||
|
type forumPostDeleteData struct {
|
||||||
|
templates.BaseData
|
||||||
|
Post templates.Post
|
||||||
|
SubmitUrl string
|
||||||
|
}
|
||||||
|
|
||||||
|
var res ResponseData
|
||||||
|
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
|
||||||
|
BaseData: baseData,
|
||||||
|
SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId),
|
||||||
|
Post: templatePost,
|
||||||
|
}, c.Perf)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
|
||||||
|
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
|
||||||
|
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
|
||||||
|
// Consider compressing this later.
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||||
|
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||||
|
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
|
||||||
|
if !valid {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
threadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
postId, err := strconv.Atoi(c.PathParams["postid"])
|
||||||
|
if err != nil {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that the user is allowed to delete this post
|
||||||
|
canEdit, err := canEditPost(c.Context(), c.Conn, postId, *c.CurrentUser)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, err)
|
||||||
|
} else if !canEdit {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(c.Context())
|
||||||
|
|
||||||
|
isFirstPost, err := db.QueryBool(c.Context(), tx,
|
||||||
|
`
|
||||||
|
SELECT thread.first_id = $1
|
||||||
|
FROM
|
||||||
|
handmade_thread AS thread
|
||||||
|
WHERE
|
||||||
|
thread.id = $2
|
||||||
|
`,
|
||||||
|
postId,
|
||||||
|
threadId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check if post was the first post in the thread"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if isFirstPost {
|
||||||
|
// Just delete the whole thread and all its posts.
|
||||||
|
_, err = tx.Exec(c.Context(),
|
||||||
|
`
|
||||||
|
UPDATE handmade_thread
|
||||||
|
SET deleted = TRUE
|
||||||
|
WHERE id = $1
|
||||||
|
`,
|
||||||
|
threadId,
|
||||||
|
)
|
||||||
|
_, err = tx.Exec(c.Context(),
|
||||||
|
`
|
||||||
|
UPDATE handmade_post
|
||||||
|
SET deleted = TRUE
|
||||||
|
WHERE thread_id = $1
|
||||||
|
`,
|
||||||
|
threadId,
|
||||||
|
)
|
||||||
|
|
||||||
|
err = tx.Commit(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete thread and posts when deleting the first post"))
|
||||||
|
}
|
||||||
|
|
||||||
|
forumUrl := hmnurl.BuildForumCategory(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), 1)
|
||||||
|
return c.Redirect(forumUrl, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(c.Context(),
|
||||||
|
`
|
||||||
|
UPDATE handmade_post
|
||||||
|
SET deleted = TRUE
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
`,
|
||||||
|
postId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to mark forum post as deleted"))
|
||||||
|
}
|
||||||
|
|
||||||
|
err = fixThreadPostIds(c.Context(), tx, threadId)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errThreadEmpty) {
|
||||||
|
panic("it shouldn't be possible to delete the last remaining post in a thread, without it also being the first post in the thread and thus resulting in the whole thread getting deleted earlier")
|
||||||
|
} else {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fix up thread post ids"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
|
||||||
|
}
|
||||||
|
|
||||||
|
threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
|
||||||
|
return c.Redirect(threadUrl, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
|
||||||
|
// Create post
|
||||||
|
err := tx.QueryRow(ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO handmade_post (postdate, category_id, thread_id, current_id, author_id, category_kind, project_id, reply_id, preview)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
time.Now(),
|
||||||
|
catId,
|
||||||
|
threadId,
|
||||||
|
-1,
|
||||||
|
userId,
|
||||||
|
models.CatKindForum,
|
||||||
|
projectId,
|
||||||
|
replyId,
|
||||||
|
"", // empty preview, will be updated later
|
||||||
|
).Scan(&postId)
|
||||||
|
if err != nil {
|
||||||
|
panic(oops.New(err, "failed to create post"))
|
||||||
|
}
|
||||||
|
|
||||||
|
versionId = createForumPostVersion(ctx, tx, postId, unparsedContent, ipString, "", nil)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func createForumPostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) {
|
||||||
|
parsed := parsing.ParsePostInput(unparsedContent, parsing.RealMarkdown)
|
||||||
|
ip := net.ParseIP(ipString)
|
||||||
|
|
||||||
|
const previewMaxLength = 100
|
||||||
|
parsedPlaintext := parsing.ParsePostInput(unparsedContent, parsing.PlaintextMarkdown)
|
||||||
|
preview := parsedPlaintext
|
||||||
|
if len(preview) > previewMaxLength-1 {
|
||||||
|
preview = preview[:previewMaxLength-1] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create post version
|
||||||
|
err := tx.QueryRow(ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
postId,
|
||||||
|
unparsedContent,
|
||||||
|
parsed,
|
||||||
|
ip,
|
||||||
|
time.Now(),
|
||||||
|
editReason,
|
||||||
|
editorId,
|
||||||
|
).Scan(&versionId)
|
||||||
|
if err != nil {
|
||||||
|
panic(oops.New(err, "failed to create post version"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update post with version id and preview
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
UPDATE handmade_post
|
||||||
|
SET current_id = $1, preview = $2
|
||||||
|
WHERE id = $3
|
||||||
|
`,
|
||||||
|
versionId,
|
||||||
|
preview,
|
||||||
|
postId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(oops.New(err, "failed to set current post version and preview"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
|
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
|
||||||
if project.ForumID == nil {
|
if project.ForumID == nil {
|
||||||
return -1, false
|
return -1, false
|
||||||
|
@ -721,3 +1212,92 @@ func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *m
|
||||||
}
|
}
|
||||||
return subforumCatId, valid
|
return subforumCatId, valid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func canEditPost(ctx context.Context, conn *pgxpool.Pool, postId int, currentUser models.User) (bool, error) {
|
||||||
|
if currentUser.IsStaff {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type postResult struct {
|
||||||
|
AuthorID *int `db:"author.id"`
|
||||||
|
}
|
||||||
|
iresult, err := db.QueryOne(ctx, conn, postResult{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_post AS post
|
||||||
|
LEFT JOIN auth_user AS author ON post.author_id = author.id
|
||||||
|
WHERE
|
||||||
|
post.id = $1
|
||||||
|
AND NOT post.deleted
|
||||||
|
ORDER BY postdate
|
||||||
|
`,
|
||||||
|
postId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
|
return false, nil
|
||||||
|
} else {
|
||||||
|
return false, oops.New(err, "failed to get author of post when checking permissions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := iresult.(*postResult)
|
||||||
|
|
||||||
|
return result.AuthorID != nil && *result.AuthorID == currentUser.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errThreadEmpty = errors.New("thread contained no non-deleted posts")
|
||||||
|
|
||||||
|
/*
|
||||||
|
Ensures that the first_id and last_id on the thread are still good.
|
||||||
|
|
||||||
|
Returns errThreadEmpty if the thread contains no visible posts any more.
|
||||||
|
You should probably mark the thread as deleted in this case.
|
||||||
|
*/
|
||||||
|
func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
|
||||||
|
postsIter, err := db.Query(ctx, tx, models.Post{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM handmade_post
|
||||||
|
WHERE
|
||||||
|
thread_id = $1
|
||||||
|
AND NOT deleted
|
||||||
|
`,
|
||||||
|
threadId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to fetch posts when fixing up thread")
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstPost, lastPost *models.Post
|
||||||
|
for _, ipost := range postsIter.ToSlice() {
|
||||||
|
post := ipost.(*models.Post)
|
||||||
|
|
||||||
|
if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
|
||||||
|
firstPost = post
|
||||||
|
}
|
||||||
|
if lastPost == nil || post.PostDate.After(lastPost.PostDate) {
|
||||||
|
lastPost = post
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstPost == nil || lastPost == nil {
|
||||||
|
return errThreadEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
UPDATE handmade_thread
|
||||||
|
SET first_id = $1, last_id = $2
|
||||||
|
WHERE id = $3
|
||||||
|
`,
|
||||||
|
firstPost.ID,
|
||||||
|
lastPost.ID,
|
||||||
|
threadId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to update thread first/last ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -186,7 +186,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(c)
|
baseData := getBaseData(c)
|
||||||
baseData.Title = "Project List"
|
baseData.Title = "Project List"
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("project_index.html", ProjectTemplateData{
|
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
|
|
||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
|
@ -203,9 +203,6 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
RegisterUrl: hmnurl.BuildRegister(),
|
RegisterUrl: hmnurl.BuildRegister(),
|
||||||
LoginUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
LoginUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -254,6 +254,13 @@ func (rd *ResponseData) WriteTemplate(name string, data interface{}, rp *perf.Re
|
||||||
return template.Execute(rd, data)
|
return template.Execute(rd, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rd *ResponseData) MustWriteTemplate(name string, data interface{}, rp *perf.RequestPerf) {
|
||||||
|
err := rd.WriteTemplate(name, data, rp)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ErrorResponse(status int, errs ...error) ResponseData {
|
func ErrorResponse(status int, errs ...error) ResponseData {
|
||||||
return ResponseData{
|
return ResponseData{
|
||||||
StatusCode: status,
|
StatusCode: status,
|
||||||
|
|
|
@ -153,11 +153,17 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
||||||
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
||||||
|
|
||||||
// NOTE(asaf): Any-project routes:
|
// NOTE(asaf): Any-project routes:
|
||||||
mainRoutes.Handle([]string{http.MethodGet, http.MethodPost}, hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
||||||
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
||||||
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
|
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
|
||||||
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
|
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
|
||||||
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
||||||
|
mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
|
||||||
|
mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReplySubmit))
|
||||||
|
mainRoutes.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
|
||||||
|
mainRoutes.POST(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEditSubmit))
|
||||||
|
mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
|
||||||
|
mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDeleteSubmit))
|
||||||
|
|
||||||
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
|
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
|
||||||
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
|
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
|
||||||
|
@ -308,10 +314,7 @@ func FourOhFour(c *RequestContext) ResponseData {
|
||||||
BaseData: getBaseData(c),
|
BaseData: getBaseData(c),
|
||||||
Wanted: c.FullUrl(),
|
Wanted: c.FullUrl(),
|
||||||
}
|
}
|
||||||
err := res.WriteTemplate("404.html", templateData, c.Perf)
|
res.MustWriteTemplate("404.html", templateData, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
res.Write([]byte("Not Found"))
|
res.Write([]byte("Not Found"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,13 +56,10 @@ func Showcase(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(c)
|
baseData := getBaseData(c)
|
||||||
baseData.Title = "Community Showcase"
|
baseData.Title = "Community Showcase"
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("showcase.html", ShowcaseData{
|
res.MustWriteTemplate("showcase.html", ShowcaseData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
ShowcaseItems: jsonItems,
|
ShowcaseItems: jsonItems,
|
||||||
ShowcaseAtomFeedUrl: hmnurl.BuildAtomFeedForShowcase(),
|
ShowcaseAtomFeedUrl: hmnurl.BuildAtomFeedForShowcase(),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,42 +63,42 @@ func Snippet(c *RequestContext) ResponseData {
|
||||||
snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme)
|
snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme)
|
||||||
|
|
||||||
opengraph := []templates.OpenGraphItem{
|
opengraph := []templates.OpenGraphItem{
|
||||||
templates.OpenGraphItem{Property: "og:site_name", Value: "Handmade.Network"},
|
{Property: "og:site_name", Value: "Handmade.Network"},
|
||||||
templates.OpenGraphItem{Property: "og:type", Value: "article"},
|
{Property: "og:type", Value: "article"},
|
||||||
templates.OpenGraphItem{Property: "og:url", Value: snippet.Url},
|
{Property: "og:url", Value: snippet.Url},
|
||||||
templates.OpenGraphItem{Property: "og:title", Value: fmt.Sprintf("Snippet by %s", snippet.OwnerName)},
|
{Property: "og:title", Value: fmt.Sprintf("Snippet by %s", snippet.OwnerName)},
|
||||||
templates.OpenGraphItem{Property: "og:description", Value: string(snippet.Description)},
|
{Property: "og:description", Value: string(snippet.Description)},
|
||||||
}
|
}
|
||||||
|
|
||||||
if snippet.Type == templates.TimelineTypeSnippetImage {
|
if snippet.Type == templates.TimelineTypeSnippetImage {
|
||||||
opengraphImage := []templates.OpenGraphItem{
|
opengraphImage := []templates.OpenGraphItem{
|
||||||
templates.OpenGraphItem{Property: "og:image", Value: snippet.AssetUrl},
|
{Property: "og:image", Value: snippet.AssetUrl},
|
||||||
templates.OpenGraphItem{Property: "og:image:width", Value: strconv.Itoa(snippet.Width)},
|
{Property: "og:image:width", Value: strconv.Itoa(snippet.Width)},
|
||||||
templates.OpenGraphItem{Property: "og:image:height", Value: strconv.Itoa(snippet.Height)},
|
{Property: "og:image:height", Value: strconv.Itoa(snippet.Height)},
|
||||||
templates.OpenGraphItem{Property: "og:image:type", Value: snippet.MimeType},
|
{Property: "og:image:type", Value: snippet.MimeType},
|
||||||
templates.OpenGraphItem{Name: "twitter:card", Value: "summary_large_image"},
|
{Name: "twitter:card", Value: "summary_large_image"},
|
||||||
}
|
}
|
||||||
opengraph = append(opengraph, opengraphImage...)
|
opengraph = append(opengraph, opengraphImage...)
|
||||||
} else if snippet.Type == templates.TimelineTypeSnippetVideo {
|
} else if snippet.Type == templates.TimelineTypeSnippetVideo {
|
||||||
opengraphVideo := []templates.OpenGraphItem{
|
opengraphVideo := []templates.OpenGraphItem{
|
||||||
templates.OpenGraphItem{Property: "og:video", Value: snippet.AssetUrl},
|
{Property: "og:video", Value: snippet.AssetUrl},
|
||||||
templates.OpenGraphItem{Property: "og:video:width", Value: strconv.Itoa(snippet.Width)},
|
{Property: "og:video:width", Value: strconv.Itoa(snippet.Width)},
|
||||||
templates.OpenGraphItem{Property: "og:video:height", Value: strconv.Itoa(snippet.Height)},
|
{Property: "og:video:height", Value: strconv.Itoa(snippet.Height)},
|
||||||
templates.OpenGraphItem{Property: "og:video:type", Value: snippet.MimeType},
|
{Property: "og:video:type", Value: snippet.MimeType},
|
||||||
templates.OpenGraphItem{Name: "twitter:card", Value: "player"},
|
{Name: "twitter:card", Value: "player"},
|
||||||
}
|
}
|
||||||
opengraph = append(opengraph, opengraphVideo...)
|
opengraph = append(opengraph, opengraphVideo...)
|
||||||
} else if snippet.Type == templates.TimelineTypeSnippetAudio {
|
} else if snippet.Type == templates.TimelineTypeSnippetAudio {
|
||||||
opengraphAudio := []templates.OpenGraphItem{
|
opengraphAudio := []templates.OpenGraphItem{
|
||||||
templates.OpenGraphItem{Property: "og:audio", Value: snippet.AssetUrl},
|
{Property: "og:audio", Value: snippet.AssetUrl},
|
||||||
templates.OpenGraphItem{Property: "og:audio:type", Value: snippet.MimeType},
|
{Property: "og:audio:type", Value: snippet.MimeType},
|
||||||
templates.OpenGraphItem{Name: "twitter:card", Value: "player"},
|
{Name: "twitter:card", Value: "player"},
|
||||||
}
|
}
|
||||||
opengraph = append(opengraph, opengraphAudio...)
|
opengraph = append(opengraph, opengraphAudio...)
|
||||||
} else if snippet.Type == templates.TimelineTypeSnippetYoutube {
|
} else if snippet.Type == templates.TimelineTypeSnippetYoutube {
|
||||||
opengraphYoutube := []templates.OpenGraphItem{
|
opengraphYoutube := []templates.OpenGraphItem{
|
||||||
templates.OpenGraphItem{Property: "og:video", Value: fmt.Sprintf("https://youtube.com/watch?v=%s", snippet.YoutubeID)},
|
{Property: "og:video", Value: fmt.Sprintf("https://youtube.com/watch?v=%s", snippet.YoutubeID)},
|
||||||
templates.OpenGraphItem{Name: "twitter:card", Value: "player"},
|
{Name: "twitter:card", Value: "player"},
|
||||||
}
|
}
|
||||||
opengraph = append(opengraph, opengraphYoutube...)
|
opengraph = append(opengraph, opengraphYoutube...)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,66 +1,43 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import ()
|
|
||||||
|
|
||||||
func Manifesto(c *RequestContext) ResponseData {
|
func Manifesto(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("manifesto.html", getBaseData(c), c.Perf)
|
res.MustWriteTemplate("manifesto.html", getBaseData(c), c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func About(c *RequestContext) ResponseData {
|
func About(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("about.html", getBaseData(c), c.Perf)
|
res.MustWriteTemplate("about.html", getBaseData(c), c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func CodeOfConduct(c *RequestContext) ResponseData {
|
func CodeOfConduct(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("code_of_conduct.html", getBaseData(c), c.Perf)
|
res.MustWriteTemplate("code_of_conduct.html", getBaseData(c), c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func CommunicationGuidelines(c *RequestContext) ResponseData {
|
func CommunicationGuidelines(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("communication_guidelines.html", getBaseData(c), c.Perf)
|
res.MustWriteTemplate("communication_guidelines.html", getBaseData(c), c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func ContactPage(c *RequestContext) ResponseData {
|
func ContactPage(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("contact.html", getBaseData(c), c.Perf)
|
res.MustWriteTemplate("contact.html", getBaseData(c), c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func MonthlyUpdatePolicy(c *RequestContext) ResponseData {
|
func MonthlyUpdatePolicy(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("monthly_update_policy.html", getBaseData(c), c.Perf)
|
res.MustWriteTemplate("monthly_update_policy.html", getBaseData(c), c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectSubmissionGuidelines(c *RequestContext) ResponseData {
|
func ProjectSubmissionGuidelines(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err := res.WriteTemplate("project_submission_guidelines.html", getBaseData(c), c.Perf)
|
res.MustWriteTemplate("project_submission_guidelines.html", getBaseData(c), c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,7 @@ var TimelineItemClassMap = map[templates.TimelineType]string{
|
||||||
var TimelineTypeTitleMap = map[templates.TimelineType]string{
|
var TimelineTypeTitleMap = map[templates.TimelineType]string{
|
||||||
templates.TimelineTypeUnknown: "",
|
templates.TimelineTypeUnknown: "",
|
||||||
|
|
||||||
templates.TimelineTypeForumThread: "New forums thread",
|
templates.TimelineTypeForumThread: "New forum thread",
|
||||||
templates.TimelineTypeForumReply: "Forum reply",
|
templates.TimelineTypeForumReply: "Forum reply",
|
||||||
|
|
||||||
templates.TimelineTypeBlogPost: "New blog post",
|
templates.TimelineTypeBlogPost: "New blog post",
|
||||||
|
|
|
@ -100,7 +100,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3)))
|
AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3)))
|
||||||
`,
|
`,
|
||||||
profileUser.ID,
|
profileUser.ID,
|
||||||
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsSuperuser)),
|
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),
|
||||||
models.VisibleProjectLifecycles,
|
models.VisibleProjectLifecycles,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -222,21 +222,12 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
c.Theme,
|
c.Theme,
|
||||||
)
|
)
|
||||||
switch timelineItem.Type {
|
switch timelineItem.Type {
|
||||||
case templates.TimelineTypeForumThread:
|
case templates.TimelineTypeForumThread, templates.TimelineTypeForumReply:
|
||||||
numForums += 1
|
numForums += 1
|
||||||
case templates.TimelineTypeForumReply:
|
case templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment:
|
||||||
numForums += 1
|
|
||||||
|
|
||||||
case templates.TimelineTypeBlogPost:
|
|
||||||
numBlogs += 1
|
numBlogs += 1
|
||||||
case templates.TimelineTypeBlogComment:
|
case templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk:
|
||||||
numBlogs += 1
|
|
||||||
|
|
||||||
case templates.TimelineTypeWikiCreate:
|
|
||||||
numWiki += 1
|
numWiki += 1
|
||||||
case templates.TimelineTypeWikiTalk:
|
|
||||||
numWiki += 1
|
|
||||||
|
|
||||||
case templates.TimelineTypeLibraryComment:
|
case templates.TimelineTypeLibraryComment:
|
||||||
numLibrary += 1
|
numLibrary += 1
|
||||||
}
|
}
|
||||||
|
@ -283,7 +274,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
baseData := getBaseData(c)
|
baseData := getBaseData(c)
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("user_profile.html", UserProfileTemplateData{
|
res.MustWriteTemplate("user_profile.html", UserProfileTemplateData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
ProfileUser: templates.UserToTemplate(profileUser, c.Theme),
|
ProfileUser: templates.UserToTemplate(profileUser, c.Theme),
|
||||||
ProfileUserLinks: profileUserLinks,
|
ProfileUserLinks: profileUserLinks,
|
||||||
|
@ -295,8 +286,5 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
NumLibrary: numLibrary,
|
NumLibrary: numLibrary,
|
||||||
NumSnippets: numSnippets,
|
NumSnippets: numSnippets,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue