add handler, html templates, Makefile and migrations
This commit is contained in:
commit
a06e8db2ff
22 changed files with 1001 additions and 0 deletions
18
Makefile
Normal file
18
Makefile
Normal file
|
@ -0,0 +1,18 @@
|
|||
.PHONY: postgres adminer migrate
|
||||
|
||||
postgres:
|
||||
docker run --rm -ti --network host -e POSTGRES_PASSWORD=secret postgres
|
||||
|
||||
adminer:
|
||||
docker run --rm -ti --network host adminer
|
||||
|
||||
reflex:
|
||||
reflex -s go run cmd/goddit/main.go
|
||||
|
||||
migrate:
|
||||
migrate -source file://migrations \
|
||||
-database postgres://postgres:secret@localhost/postgres?sslmode=disable up
|
||||
|
||||
migrate-down:
|
||||
migrate -source file://migrations \
|
||||
-database postgres://postgres:secret@localhost/postgres?sslmode=disable down
|
19
cmd/goddit/main.go
Normal file
19
cmd/goddit/main.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.snrd.de/Spaenny/goddit/postgres"
|
||||
"git.snrd.de/Spaenny/goddit/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
store, err := postgres.NewStore("postgres://postgres:secret@localhost/postgres?sslmode=disable")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
h := web.NewHandler(store)
|
||||
http.ListenAndServe(":3000", h)
|
||||
}
|
10
go.mod
Normal file
10
go.mod
Normal file
|
@ -0,0 +1,10 @@
|
|||
module git.snrd.de/Spaenny/goddit
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi v1.5.4 // indirect
|
||||
github.com/google/uuid v1.3.0 // direct
|
||||
github.com/jmoiron/sqlx v1.3.4 // indirect
|
||||
github.com/lib/pq v1.10.2 // indirect
|
||||
)
|
26
go.sum
Normal file
26
go.sum
Normal file
|
@ -0,0 +1,26 @@
|
|||
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
||||
github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE=
|
||||
github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
|
||||
github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
|
||||
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w=
|
||||
github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
57
goddit.go
Normal file
57
goddit.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
package goddit
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type Thread struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Title string `db:"title"`
|
||||
Description string `db:"description"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
ThreadID uuid.UUID `db:"thread_id"`
|
||||
Title string `db:"title"`
|
||||
Content string `db:"content"`
|
||||
Votes int `db:"votes"`
|
||||
CommentsCount int `db:"comments_count"`
|
||||
ThreadTitle string `db:"thread_title"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
PostID uuid.UUID `db:"post_id"`
|
||||
Content string `db:"content"`
|
||||
Votes int `db:"votes"`
|
||||
}
|
||||
|
||||
type ThreadStore interface {
|
||||
Thread(id uuid.UUID) (Thread, error)
|
||||
Threads() ([]Thread, error)
|
||||
CreateThread(t *Thread) error
|
||||
UpdateThread(t *Thread) error
|
||||
DeleteThread(id uuid.UUID) error
|
||||
}
|
||||
|
||||
type PostStore interface {
|
||||
Post(id uuid.UUID) (Post, error)
|
||||
Posts() ([]Post, error)
|
||||
PostsByThread(threadID uuid.UUID) ([]Post, error)
|
||||
CreatePost(p *Post) error
|
||||
UpdatePost(p *Post) error
|
||||
DeletePost(id uuid.UUID) error
|
||||
}
|
||||
|
||||
type CommentStore interface {
|
||||
Comment(id uuid.UUID) (Comment, error)
|
||||
CommentsByPost(postID uuid.UUID) ([]Comment, error)
|
||||
CreateComment(c *Comment) error
|
||||
UpdateComment(c *Comment) error
|
||||
DeleteComment(id uuid.UUID) error
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
ThreadStore
|
||||
PostStore
|
||||
CommentStore
|
||||
}
|
3
migrations/1_create_tables.down.sql
Normal file
3
migrations/1_create_tables.down.sql
Normal file
|
@ -0,0 +1,3 @@
|
|||
DROP TABLE comments;
|
||||
DROP TABLE posts;
|
||||
DROP TABLE threads;
|
20
migrations/1_create_tables.up.sql
Normal file
20
migrations/1_create_tables.up.sql
Normal file
|
@ -0,0 +1,20 @@
|
|||
CREATE TABLE threads (
|
||||
id UUID PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id UUID PRIMARY KEY,
|
||||
thread_id UUID NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
votes INT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE comments (
|
||||
id UUID PRIMARY KEY,
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
votes INT NOT NULL
|
||||
);
|
58
postgres/comment_store.go
Normal file
58
postgres/comment_store.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.snrd.de/Spaenny/goddit"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type CommentStore struct {
|
||||
*sqlx.DB
|
||||
}
|
||||
|
||||
func (s *CommentStore) Comment(id uuid.UUID) (goddit.Comment, error) {
|
||||
var c goddit.Comment
|
||||
if err := s.Get(&c, `SELECT * FROM comments WHERE id = $1`, id); err != nil {
|
||||
return goddit.Comment{}, fmt.Errorf("error getting comment: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *CommentStore) CommentsByPost(postID uuid.UUID) ([]goddit.Comment, error) {
|
||||
var cc []goddit.Comment
|
||||
if err := s.Select(&cc, `SELECT * FROM comments WHERE post_id = $1 ORDER BY votes DESC`, postID); err != nil {
|
||||
return []goddit.Comment{}, fmt.Errorf("error getting comments: %w", err)
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
|
||||
func (s *CommentStore) CreateComment(c *goddit.Comment) error {
|
||||
if err := s.Get(c, `INSERT INTO comments VALUES($1, $2, $3, $4) RETURNING *`,
|
||||
c.ID,
|
||||
c.PostID,
|
||||
c.Content,
|
||||
c.Votes); err != nil {
|
||||
return fmt.Errorf("error creating comment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CommentStore) UpdateComment(c *goddit.Comment) error {
|
||||
if err := s.Get(c, `UPDATE comments SET post_id = $1, content = $2, votes = $3 WHERE id = $4 RETURNING *`,
|
||||
c.PostID,
|
||||
c.Content,
|
||||
c.Votes,
|
||||
c.ID); err != nil {
|
||||
return fmt.Errorf("error updating comments: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CommentStore) DeleteComment(id uuid.UUID) error {
|
||||
if _, err := s.Exec(`DELETE FROM comments WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("error deleting comment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
87
postgres/post_store.go
Normal file
87
postgres/post_store.go
Normal file
|
@ -0,0 +1,87 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.snrd.de/Spaenny/goddit"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type PostStore struct {
|
||||
*sqlx.DB
|
||||
}
|
||||
|
||||
func (s *PostStore) Post(id uuid.UUID) (goddit.Post, error) {
|
||||
var p goddit.Post
|
||||
if err := s.Get(&p, `SELECT * FROM posts WHERE id = $1`, id); err != nil {
|
||||
return goddit.Post{}, fmt.Errorf("error getting post: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *PostStore) PostsByThread(threadID uuid.UUID) ([]goddit.Post, error) {
|
||||
var pp []goddit.Post
|
||||
var query = `
|
||||
SELECT
|
||||
posts.*,
|
||||
COUNT(comments.*) AS comments_count
|
||||
FROM posts
|
||||
LEFT JOIN comments ON comments.post_id = posts.id
|
||||
WHERE thread_ID = $1
|
||||
GROUP BY posts.id
|
||||
ORDER BY votes DESC`
|
||||
if err := s.Select(&pp, query, threadID); err != nil {
|
||||
return []goddit.Post{}, fmt.Errorf("error gettings posts: %w", err)
|
||||
}
|
||||
return pp, nil
|
||||
}
|
||||
|
||||
func (s *PostStore) Posts() ([]goddit.Post, error) {
|
||||
var pp []goddit.Post
|
||||
var query = `
|
||||
SELECT
|
||||
posts.*,
|
||||
COUNT(comments.*) AS comments_count,
|
||||
threads.title AS thread_title
|
||||
FROM posts
|
||||
LEFT JOIN comments ON comments.post_id = posts.id
|
||||
JOIN threads ON threads.id = posts.thread_id
|
||||
GROUP BY posts.id, threads.title
|
||||
ORDER BY votes DESC`
|
||||
if err := s.Select(&pp, query); err != nil {
|
||||
return []goddit.Post{}, fmt.Errorf("error gettings posts: %w", err)
|
||||
}
|
||||
return pp, nil
|
||||
}
|
||||
|
||||
func (s *PostStore) CreatePost(p *goddit.Post) error {
|
||||
if err := s.Get(p, `INSERT INTO posts VALUES($1, $2, $3, $4, $5) RETURNING *`,
|
||||
p.ID,
|
||||
p.ThreadID,
|
||||
p.Title,
|
||||
p.Content,
|
||||
p.Votes); err != nil {
|
||||
return fmt.Errorf("error creating post: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostStore) UpdatePost(p *goddit.Post) error {
|
||||
if err := s.Get(p, `UPDATE posts set thread_id = $1, title = $2, content = $3, votes = $4 WHERE id = $5 RETURNING *`,
|
||||
p.ThreadID,
|
||||
p.Title,
|
||||
p.Content,
|
||||
p.Votes,
|
||||
p.ID); err != nil {
|
||||
return fmt.Errorf("error updating post: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostStore) DeletePost(id uuid.UUID) error {
|
||||
if _, err := s.Exec(`DELETE FROM posts WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("error deleting post: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
30
postgres/store.go
Normal file
30
postgres/store.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func NewStore(dataSourceName string) (*Store, error) {
|
||||
db, err := sqlx.Open("postgres", dataSourceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening database: %w", err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("error connecting to database: %w", err)
|
||||
}
|
||||
|
||||
return &Store{
|
||||
ThreadStore: &ThreadStore{DB: db},
|
||||
PostStore: &PostStore{DB: db},
|
||||
CommentStore: &CommentStore{DB: db},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
*ThreadStore
|
||||
*PostStore
|
||||
*CommentStore
|
||||
}
|
56
postgres/thread_store.go
Normal file
56
postgres/thread_store.go
Normal file
|
@ -0,0 +1,56 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.snrd.de/Spaenny/goddit"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
type ThreadStore struct {
|
||||
*sqlx.DB
|
||||
}
|
||||
|
||||
func (s *ThreadStore) Thread(id uuid.UUID) (goddit.Thread, error) {
|
||||
var t goddit.Thread
|
||||
if err := s.Get(&t, `SELECT * FROM threads WHERE id = $1`, id); err != nil {
|
||||
return goddit.Thread{}, fmt.Errorf("error getting thread: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *ThreadStore) Threads() ([]goddit.Thread, error) {
|
||||
var tt []goddit.Thread
|
||||
if err := s.Select(&tt, `SELECT * FROM threads`); err != nil {
|
||||
return []goddit.Thread{}, fmt.Errorf("error getting threads: %w", err)
|
||||
}
|
||||
return tt, nil
|
||||
}
|
||||
|
||||
func (s *ThreadStore) CreateThread(t *goddit.Thread) error {
|
||||
if err := s.Get(t, `INSERT INTO threads VALUES($1, $2, $3) RETURNING *`,
|
||||
t.ID,
|
||||
t.Title,
|
||||
t.Description); err != nil {
|
||||
return fmt.Errorf("error creating thread: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThreadStore) UpdateThread(t *goddit.Thread) error {
|
||||
if err := s.Get(t, `UPDATE INTO threads SET title = $1,description = $2 WHERE id = $3) RETURNING *`,
|
||||
t.Title,
|
||||
t.Description,
|
||||
t.ID); err != nil {
|
||||
return fmt.Errorf("error updating thread: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ThreadStore) DeleteThread(id uuid.UUID) error {
|
||||
if _, err := s.Exec(`DELETE FROM threads WHERE id = $1`, id); err != nil {
|
||||
return fmt.Errorf("error deleteing thread: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
44
templates/home.html
Normal file
44
templates/home.html
Normal file
|
@ -0,0 +1,44 @@
|
|||
|
||||
{{define "header"}}
|
||||
<h1 class="mb-0">Welcome to goddit</h1>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{range .Posts}}
|
||||
<div class="card mb-4">
|
||||
<div class="d-flex">
|
||||
<div class="py-4 pl-4 text-center flex-shrink-0" style="width: 3rem">
|
||||
<a href="/threads/{{.ThreadID}}/{{.ID}}/vote?dir=up" class="d-block text-body text-decoration-none">
|
||||
<svg viewBox="0 0 10 16" width="10" height="16">
|
||||
<path fill-rule="evenodd" d="M10 10l-1.5 1.5L5 7.75 1.5 11.5 0 10l5-5 5 5z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="mt-1">{{.Votes}}</div>
|
||||
<a href="/threads/{{.ThreadID}}/{{.ID}}/vote?dir=down" class="d-block text-body text-decoration-none">
|
||||
<svg viewBox="0 0 10 16" width="10" height="16">
|
||||
<path fill-rule="evenodd" d="M5 11L0 6l1.5-1.5L5 8.25 8.5 4.5 10 6l-5 5z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="/threads/{{.ThreadID}}" class="small text-secondary">{{.ThreadTitle}}</a>
|
||||
<a href="/threads/{{.ThreadID}}/{{.ID}}" class="d-block card-title text-body mt-1 h5">
|
||||
{{.Title}}
|
||||
</a>
|
||||
<p class="card-text">{{.Content}}</p>
|
||||
<a href="/threads/{{.ThreadID}}/{{.ID}}">{{.CommentsCount}} Comments</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "sidebar"}}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Explore interesting threads</h5>
|
||||
<p class="card-text">Browse through hundreds of interesting threads with great communities.</p>
|
||||
<a href="/threads" class="btn btn-primary btn-block">Browse Threads</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
31
templates/layout.html
Normal file
31
templates/layout.html
Normal file
|
@ -0,0 +1,31 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-light container">
|
||||
<a class="navbar-brand text-primary" href="/">goddit</a>
|
||||
</nav>
|
||||
<div class="header bg-light border-bottom border-top py-5">
|
||||
<div class="container">
|
||||
{{block "header" .}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="container py-5">
|
||||
<div class="row">
|
||||
<div class="col-xl-8 order-1 order-xl-0">
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
<div class="col-xl-4 order-0 order-xl-1">
|
||||
{{block "sidebar" .}}{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
46
templates/post.html
Normal file
46
templates/post.html
Normal file
|
@ -0,0 +1,46 @@
|
|||
{{define "header"}}
|
||||
<div class="row">
|
||||
<div class="col-xl-8">
|
||||
<a href="/threads/{{.Thread.ID}}" class="text-secondary mb-2 mt-2 d-flex align-items-center">
|
||||
<svg viewBox="0 0 8 16" width="8" height="16" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.5 3L7 4.5 3.25 8 7 11.5 5.5 13l-5-5 5-5z"></path>
|
||||
</svg>
|
||||
<span class="ml-2">Back</span>
|
||||
</a>
|
||||
<h1>{{.Post.Title}}</h1>
|
||||
<p class="m-0">
|
||||
{{.Post.Content}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="card mb-4">
|
||||
<div class="text-right">
|
||||
<form action="/threads/{{.Thread.ID}}/{{.Post.ID}}" method="POST">
|
||||
<textarea name="content" class="form-control border-0 border-bottom-1 p-3"
|
||||
placeholder="What are your thoughts?" rows="4"></textarea>
|
||||
<div class="border-top p-1">
|
||||
<button class="btn btn-primary btn-sm">Comment</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4 px-4">
|
||||
{{range .Comments}}
|
||||
<div class="d-flex my-4">
|
||||
<div class="text-center flex-shrink-0" style="width: 1.5rem">
|
||||
<a href="/comments/{{.ID}}/vote?dir=up" class="d-block text-body text-decoration-none">▲</a>
|
||||
<div>{{.Votes}}</div>
|
||||
<a href="/comments/{{.ID}}/vote?dir=down" class="d-block text-body text-decoration-none">▼</a>
|
||||
</div>
|
||||
<div class="pl-4">
|
||||
<p class="card-text" style="white-space: pre-line;">{{.Content}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
20
templates/post_create.html
Normal file
20
templates/post_create.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
|
||||
{{define "header"}}
|
||||
<h5>Create a new post in</h5>
|
||||
<h1 class="mb-0">{{.Thread.Title}}</h1>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<form action=/threads/{{.Thread.ID}} method="POST">
|
||||
<div class="form-group">
|
||||
<label>Title</label>
|
||||
<input name="title" type="text" class="form-control" placeholder="Give your post a great title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Text</label>
|
||||
<textarea name="content" class="form-control" rows="3"
|
||||
placeholder="Tell people about your thoughts"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Submit Post</button>
|
||||
</form>
|
||||
{{end}}
|
45
templates/thread.html
Normal file
45
templates/thread.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{{define "header"}}
|
||||
<h1 class="mb-0">{{.Thread.Title}}</h1>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{range .Posts}}
|
||||
<div class="card mb-4">
|
||||
<div class="d-flex">
|
||||
<div class="py-4 pl-4 text-center flex-shrink-0" style="width: 3rem">
|
||||
<a href="/threads/{{$.Thread.ID}}/{{.ID}}/vote?dir=up" class="d-block text-body text-decoration-none">
|
||||
<svg viewBox="0 0 10 16" width="10" height="16">
|
||||
<path fill-rule="evenodd" d="M10 10l-1.5 1.5L5 7.75 1.5 11.5 0 10l5-5 5 5z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="mt-1">{{.Votes}}</div>
|
||||
<a href="/threads/{{$.Thread.ID}}/{{.ID}}/vote?dir=down" class="d-block text-body text-decoration-none">
|
||||
<svg viewBox="0 0 10 16" width="10" height="16">
|
||||
<path fill-rule="evenodd" d="M5 11L0 6l1.5-1.5L5 8.25 8.5 4.5 10 6l-5 5z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{.Title}}</h5>
|
||||
<p class="card-text">{{.Content}}</p>
|
||||
<a href="/threads/{{$.Thread.ID}}/{{.ID}}">{{.CommentsCount}} Comments</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "sidebar"}}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">About Community</h5>
|
||||
<p class="card-text">{{.Thread.Description}}</p>
|
||||
<a href="/threads/{{.Thread.ID}}/new" class="btn btn-primary btn-block">Create Post</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<form action=/threads/{{.Thread.ID}}/delete method="POST">
|
||||
<button type="submit" class="text-danger btn-sm btn btn-link">Delete this thread</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
18
templates/thread_create.html
Normal file
18
templates/thread_create.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{{define "header"}}
|
||||
<h1 class="mb-0">Create a new thread</h1>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<form action="/threads" method="POST">
|
||||
<div class="form-group">
|
||||
<label>Title</label>
|
||||
<input name="title" type="text" class="form-control" placeholder="Give your thread a great title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea name="description" class="form-control" rows="3"
|
||||
placeholder="Tell people what your thread is about"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Create Thread</button>
|
||||
</form>
|
||||
{{end}}
|
28
templates/threads.html
Normal file
28
templates/threads.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
<!DOCTYPE html>
|
||||
{{define "header"}}
|
||||
<h1 class="mb-0">Explore threads</h1>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{range .Threads}}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<a href="#" class="d-block card-title text-body mt-1 h5">
|
||||
{{.Title}}
|
||||
</a>
|
||||
<p class="card-text">{{.Description}}</p>
|
||||
<a href="/threads/{{.ID}}" class="btn btn-primary">Browse Thread</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "sidebar"}}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Create a new thread</h5>
|
||||
<p class="card-text">Start a new community on goddit and share experiences.</p>
|
||||
<a href="/threads/new" class="btn btn-primary btn-block">Create Thread</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
69
web/comment_handler.go
Normal file
69
web/comment_handler.go
Normal file
|
@ -0,0 +1,69 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.snrd.de/Spaenny/goddit"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CommentHandler struct {
|
||||
store goddit.Store
|
||||
}
|
||||
|
||||
func (h *CommentHandler) Store() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
content := r.FormValue("content")
|
||||
idStr := chi.URLParam(r, "postID")
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.CreateComment(&goddit.Comment{
|
||||
ID: uuid.New(),
|
||||
PostID: id,
|
||||
Content: content,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, r.Referer(), http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CommentHandler) Vote() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c, err := h.store.Comment(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
dir := r.URL.Query().Get("dir")
|
||||
if dir == "up" {
|
||||
c.Votes++
|
||||
} else if dir == "down" {
|
||||
c.Votes--
|
||||
}
|
||||
|
||||
if err := h.store.UpdateComment(&c); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, r.Referer(), http.StatusFound)
|
||||
}
|
||||
}
|
63
web/handler.go
Normal file
63
web/handler.go
Normal file
|
@ -0,0 +1,63 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"git.snrd.de/Spaenny/goddit"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
)
|
||||
|
||||
func NewHandler(store goddit.Store) *Handler {
|
||||
h := &Handler{
|
||||
Mux: chi.NewMux(),
|
||||
store: store,
|
||||
}
|
||||
|
||||
threads := ThreadHandler{store: store}
|
||||
posts := PostHandler{store: store}
|
||||
comments := CommentHandler{store: store}
|
||||
|
||||
h.Use(middleware.Logger)
|
||||
|
||||
h.Get("/", h.Home())
|
||||
h.Route("/threads", func(r chi.Router) {
|
||||
r.Get("/", threads.List())
|
||||
r.Get("/new", threads.Create())
|
||||
r.Post("/", threads.Store())
|
||||
r.Get("/{id}", threads.Show())
|
||||
r.Post("/{id}/delete", threads.Delete())
|
||||
r.Get("/{id}/new", posts.Create())
|
||||
r.Post("/{id}", posts.Store())
|
||||
r.Get("/{threadID}/{postID}", posts.Show())
|
||||
r.Get("/{threadID}/{postID}/vote", posts.Vote())
|
||||
r.Post("/{threadID}/{postID}", comments.Store())
|
||||
})
|
||||
h.Get("/comments/{id}/vote", comments.Vote())
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
*chi.Mux
|
||||
|
||||
store goddit.Store
|
||||
}
|
||||
|
||||
func (h *Handler) Home() http.HandlerFunc {
|
||||
type data struct {
|
||||
Posts []goddit.Post
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/home.html"))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
pp, err := h.store.Posts()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl.Execute(w, data{Posts: pp})
|
||||
}
|
||||
}
|
150
web/post_handler.go
Normal file
150
web/post_handler.go
Normal file
|
@ -0,0 +1,150 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"git.snrd.de/Spaenny/goddit"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PostHandler struct {
|
||||
store goddit.Store
|
||||
}
|
||||
|
||||
func (h *PostHandler) Create() http.HandlerFunc {
|
||||
type data struct {
|
||||
Thread goddit.Thread
|
||||
}
|
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/post_create.html"))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := h.store.Thread(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl.Execute(w, data{Thread: t})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PostHandler) Show() http.HandlerFunc {
|
||||
type data struct {
|
||||
Thread goddit.Thread
|
||||
Post goddit.Post
|
||||
Comments []goddit.Comment
|
||||
}
|
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/post.html"))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
postIDStr := chi.URLParam(r, "postID")
|
||||
threadIDStr := chi.URLParam(r, "threadID")
|
||||
|
||||
postID, err := uuid.Parse(postIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
threadID, err := uuid.Parse(threadIDStr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := h.store.Post(postID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cc, err := h.store.CommentsByPost(p.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := h.store.Thread(threadID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl.Execute(w, data{Thread: t, Post: p, Comments: cc})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PostHandler) Store() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.FormValue("title")
|
||||
content := r.FormValue("content")
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
t, err := h.store.Thread(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p := &goddit.Post{
|
||||
ID: uuid.New(),
|
||||
ThreadID: t.ID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
if err := h.store.CreatePost(p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/threads/"+t.ID.String()+"/"+p.ID.String(), http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PostHandler) Vote() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "postID")
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := h.store.Post(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
dir := r.URL.Query().Get("dir")
|
||||
if dir == "up" {
|
||||
p.Votes++
|
||||
} else if dir == "down" {
|
||||
p.Votes--
|
||||
}
|
||||
|
||||
if err := h.store.UpdatePost(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, r.Referer(), http.StatusFound)
|
||||
}
|
||||
}
|
103
web/thread_handler.go
Normal file
103
web/thread_handler.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"git.snrd.de/Spaenny/goddit"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ThreadHandler struct {
|
||||
store goddit.Store
|
||||
}
|
||||
|
||||
func (h *ThreadHandler) List() http.HandlerFunc {
|
||||
type data struct {
|
||||
Threads []goddit.Thread
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/threads.html"))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tt, err := h.store.Threads()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tmpl.Execute(w, data{Threads: tt})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ThreadHandler) Create() http.HandlerFunc {
|
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/thread_create.html"))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl.Execute(w, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ThreadHandler) Show() http.HandlerFunc {
|
||||
type data struct {
|
||||
Thread goddit.Thread
|
||||
Posts []goddit.Post
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.ParseFiles("templates/layout.html", "templates/thread.html"))
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
t, err := h.store.Thread(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pp, err := h.store.PostsByThread(t.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl.Execute(w, data{Thread: t, Posts: pp})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ThreadHandler) Store() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.FormValue("title")
|
||||
description := r.FormValue("description")
|
||||
|
||||
if err := h.store.CreateThread(&goddit.Thread{
|
||||
ID: uuid.New(),
|
||||
Title: title,
|
||||
Description: description,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/threads", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ThreadHandler) Delete() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.DeleteThread(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/threads", http.StatusFound)
|
||||
}
|
||||
}
|
Reference in a new issue