12 Commits

Author SHA1 Message Date
1774b432aa gitea actions test again
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 3s
2025-10-21 11:14:08 -04:00
1ddbd1bc8b gitea actions test 2025-10-21 11:12:21 -04:00
8b1468fc1f fix logging
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
2024-06-26 22:57:53 -04:00
c98fc938c9 middleware with status codes
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
2024-06-19 22:16:45 -04:00
025596162c fix address binding
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
2024-06-19 14:57:41 -04:00
399cfd827d logging
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
2024-06-19 11:46:22 -04:00
1b528a85c8 Merge branch 'feature/database'
All checks were successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
2024-06-18 15:15:43 -04:00
20460925b0 update dockerfile
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
2024-06-18 15:14:41 -04:00
3cc4cbe069 Merge pull request 'Postgres Database Support' (#2) from feature/database into main
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
Reviewed-on: #2
2024-06-18 18:40:37 +00:00
c7c4f35bb4 fixed no env file case
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
ci/woodpecker/pr/publish-latest Pipeline was successful
ci/woodpecker/pr/publish-tag Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/dryrun Pipeline was successful
ci/woodpecker/pull_request_closed/build Pipeline was successful
ci/woodpecker/pull_request_closed/dryrun Pipeline was successful
ci/woodpecker/pull_request_closed/publish-latest Pipeline was successful
ci/woodpecker/pull_request_closed/publish-tag Pipeline was successful
2024-06-18 14:23:15 -04:00
3f137e4d68 fixed tailwind
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
2024-06-17 23:40:22 -04:00
6f7bcc9503 postgres and such
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/dryrun Pipeline was successful
ci/woodpecker/push/publish-tag Pipeline was successful
ci/woodpecker/push/publish-latest Pipeline was successful
2024-06-17 23:37:43 -04:00
21 changed files with 478 additions and 57 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL=postgres://postgres@localhost:5432/todos

View File

@@ -0,0 +1,19 @@
name: Gitea Actions Demo
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
on: [push]
jobs:
Explore-Gitea-Actions:
runs-on: ubuntu-latest
steps:
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
- name: Check out repository code
uses: actions/checkout@v4
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
- name: List files in the repository
run: |
ls ${{ gitea.workspace }}
- run: echo "🍏 This job's status is ${{ job.status }}."

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ go.work
.direnv/ .direnv/
tmp/ tmp/
.env

View File

@@ -11,6 +11,7 @@ FROM gcr.io/distroless/static-debian11 AS release-stage
WORKDIR / WORKDIR /
COPY --from=build-stage /entrypoint /entrypoint COPY --from=build-stage /entrypoint /entrypoint
COPY --from=build-stage /app/assets /assets COPY --from=build-stage /app/assets /assets
COPY --from=build-stage /app/sql /sql
EXPOSE 3000 EXPOSE 3000
USER nonroot:nonroot USER nonroot:nonroot
ENTRYPOINT ["/entrypoint"] ENTRYPOINT ["/entrypoint"]

View File

@@ -554,10 +554,6 @@ video {
--tw-contain-style: ; --tw-contain-style: ;
} }
.static {
position: static;
}
.flex { .flex {
display: flex; display: flex;
} }
@@ -566,14 +562,6 @@ video {
flex-direction: column; flex-direction: column;
} }
.content-center {
align-content: center;
}
.items-center {
align-items: center;
}
.border { .border {
border-width: 1px; border-width: 1px;
} }

14
go.mod
View File

@@ -4,4 +4,16 @@ go 1.22.1
require github.com/a-h/templ v0.2.707 require github.com/a-h/templ v0.2.707
require github.com/google/uuid v1.6.0 // indirect require (
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gofrs/uuid/v5 v5.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/joho/godotenv v1.5.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/text v0.14.0 // indirect
)

29
go.sum
View File

@@ -1,6 +1,35 @@
github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U= github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8= github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M=
github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 h1:QWdhlQz98hUe1xmjADOl2mr8ERLrOqj0KWLdkrnNsRQ=
github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2/go.mod h1:Ti7pyNDU/UpXKmBTeFgxTvzYDM9xHLiYKMsLdt4b9cg=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -3,7 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"github.com/google/uuid" "github.com/gofrs/uuid/v5"
"michaelthomson.dev/mthomson/go-todos-app/services" "michaelthomson.dev/mthomson/go-todos-app/services"
"michaelthomson.dev/mthomson/go-todos-app/templates/partials" "michaelthomson.dev/mthomson/go-todos-app/templates/partials"
) )
@@ -42,10 +42,74 @@ func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
} }
func (h *TodoHandler) Done(w http.ResponseWriter, r *http.Request) {
var err error
id, err := uuid.FromString(r.PathValue("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
todo, err := h.ts.GetTodoById(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
todo, err = h.ts.UpdateTodo(id, todo.Name, true)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = partials.Todo(todo).Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (h *TodoHandler) Undone(w http.ResponseWriter, r *http.Request) {
var err error
id, err := uuid.FromString(r.PathValue("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
todo, err := h.ts.GetTodoById(id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
todo, err = h.ts.UpdateTodo(id, todo.Name, false)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = partials.Todo(todo).Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (h *TodoHandler) Delete(w http.ResponseWriter, r *http.Request) { func (h *TodoHandler) Delete(w http.ResponseWriter, r *http.Request) {
var err error var err error
id, err := uuid.Parse(r.PathValue("id")) id, err := uuid.FromString(r.PathValue("id"))
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)

117
main.go
View File

@@ -1,20 +1,121 @@
package main package main
import ( import (
"context"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"michaelthomson.dev/mthomson/go-todos-app/db" pgxuuid "github.com/jackc/pgx-gofrs-uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
"michaelthomson.dev/mthomson/go-todos-app/handlers" "michaelthomson.dev/mthomson/go-todos-app/handlers"
"michaelthomson.dev/mthomson/go-todos-app/middleware"
"michaelthomson.dev/mthomson/go-todos-app/repositories"
"michaelthomson.dev/mthomson/go-todos-app/services" "michaelthomson.dev/mthomson/go-todos-app/services"
) )
func main() { func main() {
todosStore := db.NewTodoStore() // load environment
ts := services.NewTodoService(&todosStore) err := godotenv.Load()
homeHandler := handlers.NewHomeHandler(ts) if err != nil {
todoHandler := handlers.NewTodoHandler(ts) log.Printf("Error loading .env file: %v", err)
}
databaseUrl := os.Getenv("DATABASE_URL")
// connect to Db
dbconfig, err := pgxpool.ParseConfig(databaseUrl)
if err != nil {
log.Fatalf("Unable to parse db config: %v", err)
}
dbconfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
pgxuuid.Register(conn.TypeMap())
return nil
}
dbpool, err := pgxpool.New(context.Background(), databaseUrl)
if err != nil {
log.Fatalf("Unable to create connection pool: %v", err)
}
defer dbpool.Close()
// apply initial sql
log.Print("Applying migrations table")
content, err := os.ReadFile(filepath.Join("sql", "init.sql"))
if err != nil {
log.Fatal(err)
os.Exit(1)
}
_, err = dbpool.Exec(context.Background(), string(content))
if err != nil {
log.Fatal(err)
os.Exit(1)
}
//apply migrations
log.Print("Starting migrations...")
files, err := os.ReadDir("./sql/migrations")
if err != nil {
log.Fatal(err)
os.Exit(1)
}
for _, file := range files {
path := filepath.Join("sql", "migrations", file.Name())
content, err := os.ReadFile(path)
if err != nil {
log.Fatal(err)
os.Exit(1)
}
var count int
err = dbpool.QueryRow(context.Background(), "SELECT COUNT(1) FROM migration WHERE name=$1", file.Name()).Scan(&count)
if err != nil {
log.Fatal(err)
os.Exit(1)
}
if count == 0 {
log.Printf("Running migration: %s", file.Name())
_, err = dbpool.Exec(context.Background(), string(content))
if err != nil {
log.Fatal(err)
os.Exit(1)
}
_, err = dbpool.Exec(context.Background(), "INSERT INTO migration(name) values($1)", file.Name())
if err != nil {
log.Fatal(err)
os.Exit(1)
}
} else {
log.Printf("Migration already exists: %s", file.Name())
}
}
log.Print("Migrations complete")
// set up repositories
todoRepository := repositories.NewPostgresTodoRepository(dbpool)
// set up services
todoService := services.NewTodoService(&todoRepository)
// set up handlers
homeHandler := handlers.NewHomeHandler(todoService)
todoHandler := handlers.NewTodoHandler(todoService)
router := http.NewServeMux() router := http.NewServeMux()
@@ -25,10 +126,12 @@ func main() {
router.HandleFunc("GET /{$}", homeHandler.Home) router.HandleFunc("GET /{$}", homeHandler.Home)
router.HandleFunc("POST /todos", todoHandler.Create) router.HandleFunc("POST /todos", todoHandler.Create)
router.HandleFunc("DELETE /todos/{id}", todoHandler.Delete) router.HandleFunc("DELETE /todos/{id}", todoHandler.Delete)
router.HandleFunc("PATCH /todos/{id}/done", todoHandler.Done)
router.HandleFunc("PATCH /todos/{id}/undone", todoHandler.Undone)
server := http.Server{ server := http.Server{
Addr: "localhost:3000", Addr: ":3000",
Handler: router, Handler: middleware.LoggingMiddleware(router),
} }
log.Fatal(server.ListenAndServe()) log.Fatal(server.ListenAndServe())

31
middleware/logging.go Normal file
View File

@@ -0,0 +1,31 @@
package middleware
import (
"log"
"net/http"
"time"
)
type wrappedWriter struct {
http.ResponseWriter
statusCode int
}
func (w * wrappedWriter) WriteHeader(statusCode int) {
w.ResponseWriter.WriteHeader(statusCode)
w.statusCode = statusCode
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &wrappedWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
log.Println(wrapped.statusCode, r.Method, r.URL.Path, time.Since(start))
})
}

View File

@@ -1,6 +1,6 @@
package models package models
import "github.com/google/uuid" import "github.com/gofrs/uuid/v5"
type Todo struct { type Todo struct {
Id uuid.UUID Id uuid.UUID
@@ -8,9 +8,9 @@ type Todo struct {
Done bool Done bool
} }
func NewTodo(name string, done bool) Todo { func NewTodo(id uuid.UUID, name string, done bool) Todo {
return Todo{ return Todo{
Id: uuid.New(), Id: id,
Name: name, Name: name,
Done: done, Done: done,
} }

View File

@@ -1,21 +1,21 @@
package db package repositories
import ( import (
"fmt" "fmt"
"github.com/google/uuid" "github.com/gofrs/uuid/v5"
"michaelthomson.dev/mthomson/go-todos-app/models" "michaelthomson.dev/mthomson/go-todos-app/models"
) )
type TodosStore struct { type InMemoryTodoRepository struct {
Todos []models.Todo Todos []models.Todo
} }
func (ts *TodosStore) List() (todo []models.Todo, err error) { func (ts *InMemoryTodoRepository) List() (todo []models.Todo, err error) {
return ts.Todos, nil return ts.Todos, nil
} }
func (ts *TodosStore) Get(id uuid.UUID) (todo models.Todo, err error) { func (ts *InMemoryTodoRepository) Get(id uuid.UUID) (todo models.Todo, err error) {
index := -1 index := -1
for i, todo := range ts.Todos { for i, todo := range ts.Todos {
if id == todo.Id { if id == todo.Id {
@@ -32,12 +32,12 @@ func (ts *TodosStore) Get(id uuid.UUID) (todo models.Todo, err error) {
return ts.Todos[index], nil return ts.Todos[index], nil
} }
func (ts *TodosStore) Add(todo models.Todo) (addedTodo models.Todo, err error) { func (ts *InMemoryTodoRepository) Add(todo models.Todo) (addedTodo models.Todo, err error) {
ts.Todos = append(ts.Todos, todo) ts.Todos = append(ts.Todos, todo)
return todo, nil return todo, nil
} }
func (ts *TodosStore) Delete(id uuid.UUID) (err error) { func (ts *InMemoryTodoRepository) Delete(id uuid.UUID) (err error) {
index := -1 index := -1
for i, todo := range ts.Todos { for i, todo := range ts.Todos {
if id == todo.Id { if id == todo.Id {
@@ -54,8 +54,8 @@ func (ts *TodosStore) Delete(id uuid.UUID) (err error) {
return nil return nil
} }
func NewTodoStore() TodosStore { func NewInMemoryTodoRepository() InMemoryTodoRepository {
return TodosStore{ return InMemoryTodoRepository{
Todos: []models.Todo{}, Todos: []models.Todo{},
} }
} }

View File

@@ -0,0 +1,85 @@
package repositories
import (
"context"
"log"
"github.com/gofrs/uuid/v5"
"github.com/jackc/pgx/v5/pgxpool"
"michaelthomson.dev/mthomson/go-todos-app/models"
)
type PostgresTodoRepository struct {
db *pgxpool.Pool
}
func (ts *PostgresTodoRepository) List() ([]models.Todo, error) {
var todos []models.Todo
rows, err := ts.db.Query(context.Background(), "SELECT id, name, done FROM todo")
if err != nil {
log.Printf("Database error: %v", err)
return todos, err
}
for rows.Next() {
var r models.Todo
err := rows.Scan(&r.Id, &r.Name, &r.Done)
if err != nil {
return todos, err
}
todos = append(todos, r)
}
return todos, err
}
func (ts *PostgresTodoRepository) Get(id uuid.UUID) (models.Todo, error) {
var todo models.Todo
err := ts.db.QueryRow(context.Background(), "SELECT id, name, done FROM todo WHERE id=$1", id).Scan(&todo.Id, &todo.Name, &todo.Done)
if err != nil {
log.Printf("Database error: %v", err)
return todo, err
}
return todo, err
}
func (ts *PostgresTodoRepository) Update(id uuid.UUID, name string, done bool) error {
_, err := ts.db.Exec(context.Background(), "UPDATE todo SET name=$1, done=$2 WHERE id=$3", name, done, id)
if err != nil {
log.Printf("Database error: %v", err)
}
return err
}
func (ts *PostgresTodoRepository) Add(todo models.Todo) error {
_, err := ts.db.Exec(context.Background(), "INSERT INTO todo(id, name, done) values($1, $2, $3)", todo.Id, todo.Name, todo.Done)
if err != nil {
log.Printf("Database error: %v", err)
return err
}
return err
}
func (ts *PostgresTodoRepository) Delete(id uuid.UUID) error {
_, err := ts.db.Exec(context.Background(), "DELETE FROM todo where id=$1", id)
if err != nil {
log.Printf("Database error: %v", err)
return err
}
return err
}
func NewPostgresTodoRepository(db *pgxpool.Pool) PostgresTodoRepository {
return PostgresTodoRepository{
db: db,
}
}

View File

@@ -3,15 +3,16 @@ package services
import ( import (
"fmt" "fmt"
"github.com/google/uuid" "github.com/gofrs/uuid/v5"
"michaelthomson.dev/mthomson/go-todos-app/models" "michaelthomson.dev/mthomson/go-todos-app/models"
) )
type Db interface { type Db interface {
List() (todo []models.Todo, err error) List() ([]models.Todo, error)
Get(id uuid.UUID) (todo models.Todo, err error) Get(id uuid.UUID) (models.Todo, error)
Add(todo models.Todo) (addedTodo models.Todo, err error) Add(todo models.Todo) error
Delete(id uuid.UUID) (err error) Delete(id uuid.UUID) error
Update(id uuid.UUID, name string, done bool) error
} }
type TodoService struct { type TodoService struct {
@@ -24,21 +25,43 @@ func NewTodoService(db Db) *TodoService {
} }
} }
func (ts *TodoService) AddTodo(name string) (todo models.Todo, err error) { func (ts *TodoService) AddTodo(name string) (models.Todo, error) {
if name == "" { if name == "" {
return models.Todo{}, fmt.Errorf("Must provide a name") return models.Todo{}, fmt.Errorf("Must provide a name")
} }
return ts.db.Add(models.NewTodo(name, false))
}
func (ts *TodoService) GetTodos() (todos []models.Todo, err error) { id, err := uuid.NewV4()
return ts.db.List() if err != nil {
} return models.Todo{}, err
}
err = ts.db.Add(models.NewTodo(id, name, false))
if err != nil {
return models.Todo{}, err
}
func (ts *TodoService) GetTodoById(id uuid.UUID) (todo models.Todo, err error) {
return ts.db.Get(id) return ts.db.Get(id)
} }
func (ts *TodoService) DeleteTodoById(id uuid.UUID) (err error) { func (ts *TodoService) UpdateTodo(id uuid.UUID, name string, done bool) (models.Todo, error) {
err := ts.db.Update(id, name, done)
if err != nil {
return models.Todo{}, err
}
return ts.db.Get(id)
}
func (ts *TodoService) GetTodos() ([]models.Todo, error) {
return ts.db.List()
}
func (ts *TodoService) GetTodoById(id uuid.UUID) (models.Todo, error) {
return ts.db.Get(id)
}
func (ts *TodoService) DeleteTodoById(id uuid.UUID) error {
return ts.db.Delete(id) return ts.db.Delete(id)
} }

5
sql/init.sql Normal file
View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS migration (
id serial primary key not null,
name text not null,
applied_at timestamp not null default now()
);

View File

@@ -0,0 +1,5 @@
CREATE TABLE todo (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
done BOOLEAN NOT NULL DEFAULT false
);

View File

@@ -1,6 +1,6 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
content: [ "./**/*.html", "./**/*.templ", "./**/*.go", ], content: [ "./**/*.html", "./**/*.templ" ],
theme: { theme: {
extend: {}, extend: {},
}, },

View File

@@ -6,7 +6,7 @@ import "michaelthomson.dev/mthomson/go-todos-app/models"
templ Home(todos []models.Todo) { templ Home(todos []models.Todo) {
@shared.Page("Todos") { @shared.Page("Todos") {
<div class="flex content-center items-center flex-col"> <div class="flex flex-col">
<h1 class="text-3xl">Todos</h1> <h1 class="text-3xl">Todos</h1>
<form hx-post="/todos" hx-target="#todos" hx-swap="beforeend"> <form hx-post="/todos" hx-target="#todos" hx-swap="beforeend">
<input name="name" type="text" class="border border-black" /> <input name="name" type="text" class="border border-black" />

View File

@@ -33,7 +33,7 @@ func Home(todos []models.Todo) templ.Component {
templ_7745c5c3_Buffer = templ.GetBuffer() templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer) defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"flex content-center items-center flex-col\"><h1 class=\"text-3xl\">Todos</h1><form hx-post=\"/todos\" hx-target=\"#todos\" hx-swap=\"beforeend\"><input name=\"name\" type=\"text\" class=\"border border-black\"> <button type=\"submit\">Submit</button></form><div id=\"todos\">") _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"flex flex-col\"><h1 class=\"text-3xl\">Todos</h1><form hx-post=\"/todos\" hx-target=\"#todos\" hx-swap=\"beforeend\"><input name=\"name\" type=\"text\" class=\"border border-black\"> <button type=\"submit\">Submit</button></form><div id=\"todos\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@@ -1,14 +1,23 @@
package partials package partials
import "michaelthomson.dev/mthomson/go-todos-app/models" import "michaelthomson.dev/mthomson/go-todos-app/models"
import "fmt"
func todoId(todo models.Todo) string { func todoId(todo models.Todo) string {
return "todo-" + todo.Id.String() return "todo-" + todo.Id.String()
} }
func patchUrl(todo models.Todo) string {
if todo.Done {
return string(templ.URL(fmt.Sprintf("/todos/%s/undone", todo.Id)))
}
return string(templ.URL(fmt.Sprintf("/todos/%s/done", todo.Id)))
}
templ Todo(todo models.Todo) { templ Todo(todo models.Todo) {
<div id={ todoId(todo) }> <div id={ todoId(todo) }>
{ todo.Name } { todo.Name }
<button hx-delete={ "/todos/" + todo.Id.String() } hx-target={ "#" + todoId(todo) } hx-swap="delete">Delete</button> <button hx-delete={ string(templ.URL(fmt.Sprintf("/todos/%s", todo.Id))) } hx-target={ "#" + todoId(todo) } hx-swap="delete">Delete</button>
<input type="checkbox" checked?={ todo.Done } hx-target={ "#" + todoId(todo) } hx-patch={ patchUrl(todo) } hx-swap="outerHTML" />
</div> </div>
} }

View File

@@ -11,11 +11,19 @@ import "io"
import "bytes" import "bytes"
import "michaelthomson.dev/mthomson/go-todos-app/models" import "michaelthomson.dev/mthomson/go-todos-app/models"
import "fmt"
func todoId(todo models.Todo) string { func todoId(todo models.Todo) string {
return "todo-" + todo.Id.String() return "todo-" + todo.Id.String()
} }
func patchUrl(todo models.Todo) string {
if todo.Done {
return string(templ.URL(fmt.Sprintf("/todos/%s/undone", todo.Id)))
}
return string(templ.URL(fmt.Sprintf("/todos/%s/done", todo.Id)))
}
func Todo(todo models.Todo) templ.Component { func Todo(todo models.Todo) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) { return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer) templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
@@ -36,7 +44,7 @@ func Todo(todo models.Todo) templ.Component {
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(todoId(todo)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(todoId(todo))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 10, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 18, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -49,7 +57,7 @@ func Todo(todo models.Todo) templ.Component {
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(todo.Name) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(todo.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 11, Col: 15} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 19, Col: 15}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -60,9 +68,9 @@ func Todo(todo models.Todo) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("/todos/" + todo.Id.String()) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/todos/%s", todo.Id))))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 12, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 20, Col: 76}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -75,13 +83,49 @@ func Todo(todo models.Todo) templ.Component {
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("#" + todoId(todo)) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("#" + todoId(todo))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 12, Col: 85} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 20, Col: 109}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-swap=\"delete\">Delete</button></div>") _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-swap=\"delete\">Delete</button> <input type=\"checkbox\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if todo.Done {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("#" + todoId(todo))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 21, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-patch=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(patchUrl(todo))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/partials/todo.templ`, Line: 21, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-swap=\"outerHTML\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }