public setup
Some checks failed
Build Docker Image / build (push) Failing after 6m38s

This commit is contained in:
Alex Wittern 2024-08-19 14:56:20 +02:00
commit 4fac3fbfa3
18 changed files with 682 additions and 0 deletions

34
.air.toml Normal file
View file

@ -0,0 +1,34 @@
root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/main"
# cmd = "templ generate && go build -o ./tmp/main ./cmd/."
cmd = "templ generate && go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor"]
exclude_file = []
exclude_regex = [".*_templ.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "templ", "html"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
time = false
[misc]
clean_on_exit = false

27
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Build Docker Image
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.wittern.io/public/echo-todos
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '^1.22.5'
- run: go version
- name: Build the Docker image
run: |
go install github.com/a-h/templ/cmd/templ@latest
templ generate
docker build . --file Dockerfile --tag git.wittern.io/public/echo-todos
docker push git.wittern.io/public/echo-todos

32
.gitignore vendored Normal file
View file

@ -0,0 +1,32 @@
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# Mac filesystem jank.
.DS_Store
# generated templ files
**/*_templ.go
tmp/
data/*
todos.db
todos-app
.env

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
# Stage 1: Builder
FROM golang:1.22.5-alpine AS builder
RUN apk add --no-cache gcc g++
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
RUN go install github.com/a-h/templ/cmd/templ@latest
RUN templ generate
COPY . .
RUN go build -o todos-app
# Stage 2: Final Image
FROM alpine:3.19
RUN apk add --no-cache bash
WORKDIR /app
VOLUME /app/data
COPY --from=builder /app/todos-app .
COPY --from=builder /app/static /app/static
CMD ["./todos-app"]

7
compose.yml Normal file
View file

@ -0,0 +1,7 @@
services:
todos-app:
image: todos-app
volumes:
- ./data:/app/data
ports:
- "1337:1337"

24
go.mod Normal file
View file

@ -0,0 +1,24 @@
module git.wittern.io/public/echo-todos
go 1.22.5
require (
github.com/a-h/templ v0.2.747
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.12.0
github.com/mattn/go-sqlite3 v1.14.22
)
require (
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.17.0 // indirect
golang.org/x/time v0.6.0 // indirect
)

43
go.sum Normal file
View file

@ -0,0 +1,43 @@
github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg=
github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

114
handler/handler.go Normal file
View file

@ -0,0 +1,114 @@
package handler
import (
"context"
"database/sql"
"log/slog"
"net/http"
"strconv"
"strings"
"git.wittern.io/public/echo-todos/model"
"git.wittern.io/public/echo-todos/pkg/db"
"git.wittern.io/public/echo-todos/view"
"github.com/a-h/templ"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func render(ctx echo.Context, status int, t templ.Component) error {
ctx.Response().Writer.WriteHeader(status)
err := t.Render(context.Background(), ctx.Response().Writer)
if err != nil {
return ctx.String(http.StatusInternalServerError, "failed to render response template")
}
return nil
}
type DBHandler struct {
DB *sql.DB
}
func (h DBHandler) Index(c echo.Context) error {
todos, err := db.ListTodos(h.DB)
if err != nil {
slog.Error("failed to get todos", "Error", err)
render(c, http.StatusInternalServerError, view.ErrorPage("failed to get todos"))
return c.String(http.StatusInternalServerError, "failed to get todos")
}
render(c, http.StatusOK, view.Index(todos))
return nil
}
func (h DBHandler) getTodos(c echo.Context) error {
todos, err := db.ListTodos(h.DB)
if err != nil {
return c.String(http.StatusInternalServerError, "failed to get todos")
}
render(c, http.StatusOK, view.Index(todos))
return nil
}
func (h DBHandler) Post(c echo.Context) error {
title := c.FormValue("todo")
todo := model.Todo{Title: title, Done: false}
db.InsertTodo(h.DB, todo)
todos, _ := db.ListTodos(h.DB)
render(c, http.StatusCreated, view.TodosList(todos))
return nil
}
func (h DBHandler) Delete(c echo.Context) error {
todoIdParam := c.Param("id")
id := extractTodoID(todoIdParam)
err := db.DeleteTodoByID(h.DB, id)
if err != nil {
return c.String(http.StatusInternalServerError, "failed to delete todo")
}
todos, _ := db.ListTodos(h.DB)
render(c, http.StatusOK, view.TodosList(todos))
return nil
}
func (h DBHandler) Toggle(c echo.Context) error {
todoIdParam := c.Param("id")
id := extractTodoID(todoIdParam)
todos, err := db.ToggleTodoByID(h.DB, id)
if err != nil {
return c.String(http.StatusInternalServerError, "failed to toggle todo")
}
render(c, http.StatusOK, view.TodosList(todos))
return nil
}
func AddRoutes(h DBHandler, e *echo.Echo) {
e.Use(middleware.Static("static"))
e.Use(middleware.Logger())
e.HTTPErrorHandler = func(err error, c echo.Context) {
slog.Error(err.Error())
render(c, http.StatusInternalServerError, view.ErrorPage(err.Error()))
}
e.GET("/todos", h.getTodos)
e.POST("/todos", h.Post)
e.PUT("/todos/:id", h.Toggle)
e.DELETE("/todos/:id", h.Delete)
e.GET("/", h.Index)
// e.POST("/", h.Post(e, h))
}
func extractTodoID(str string) int {
parts := strings.Split(str, "-")
todoID, _ := strconv.Atoi(parts[1])
return todoID
}

21
helper.go Normal file
View file

@ -0,0 +1,21 @@
package main
import (
"context"
"net/http"
"github.com/a-h/templ"
"github.com/labstack/echo/v4"
_ "github.com/mattn/go-sqlite3"
)
func render(ctx echo.Context, status int, t templ.Component) error {
ctx.Response().Writer.WriteHeader(status)
err := t.Render(context.Background(), ctx.Response().Writer)
if err != nil {
return ctx.String(http.StatusInternalServerError, "failed to render response template")
}
return nil
}

36
main.go Normal file
View file

@ -0,0 +1,36 @@
package main
import (
"log/slog"
"os"
"git.wittern.io/public/echo-todos/handler"
"git.wittern.io/public/echo-todos/pkg/db"
"git.wittern.io/public/echo-todos/view"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
_ "github.com/mattn/go-sqlite3"
)
var Username = "My"
func main() {
WEBURL := "0.0.0.0:1337"
e := echo.New()
err := godotenv.Load()
Username = os.Getenv("USERNAME")
view.Username = Username
database, err := db.InitDB()
if err != nil {
slog.Error("failed to open database", "Error", err)
}
defer database.Close()
dbhandler := handler.DBHandler{DB: database}
handler.AddRoutes(dbhandler, e)
e.Start(WEBURL)
}

9
model/model.go Normal file
View file

@ -0,0 +1,9 @@
package model
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
type Todos []Todo

119
pkg/db/db.go Normal file
View file

@ -0,0 +1,119 @@
package db
import (
"database/sql"
"fmt"
"log/slog"
"git.wittern.io/public/echo-todos/model"
_ "github.com/mattn/go-sqlite3"
)
var DB *sql.DB
func InitDB() (*sql.DB, error) {
var err error
todosDB := "./data/todos.db"
DB, err = sql.Open("sqlite3", todosDB)
if err != nil {
slog.Error("cant open db", "Error", err)
}
if tableExists(DB, "todos") {
DB, err = sql.Open("sqlite3", todosDB)
if err != nil {
slog.Error("cant open db", "Error", err)
}
} else {
CreateTable(DB)
}
return DB, nil
}
func tableExists(db *sql.DB, tableName string) bool {
var err error
var result string
query := "SELECT name FROM sqlite_master WHERE type='table' AND name=?;"
err = db.QueryRow(query, tableName).Scan(&result)
if err == sql.ErrNoRows {
fmt.Println("Table does not exist, the database was likely just created.")
return false
} else if err != nil {
slog.Error("unexpected db error", "Error", err)
} else {
fmt.Println("Table exists, the database was already initialized.")
return true
}
return false
}
func CreateTable(db *sql.DB) error {
query := `
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
done BOOLEAN
);`
_, err := db.Exec(query)
return err
}
func InsertTodo(db *sql.DB, todo model.Todo) (error, int64) {
query := `
INSERT INTO todos (title, done )
VALUES (?, ?);`
result, err := db.Exec(query, todo.Title, 0)
if err != nil {
slog.Error("failed to insert todo", "Error", err)
return err, 0
}
todoId, err := result.LastInsertId()
if err != nil {
slog.Error("failed to get last insert id", "Error", err)
return err, 0
}
return nil, todoId
}
func ListTodos(db *sql.DB) ([]model.Todo, error) {
query := `SELECT id, title, done FROM todos;`
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var todos model.Todos
for rows.Next() {
var todo model.Todo
err := rows.Scan(&todo.ID, &todo.Title, &todo.Done)
if err != nil {
return nil, err
}
todos = append(todos, todo)
}
return todos, nil
}
func DeleteTodoByID(db *sql.DB, todoID int) error {
query := `DELETE FROM todos WHERE id = ?;`
_, err := db.Exec(query, todoID)
return err
}
func ToggleTodoByID(db *sql.DB, id int) ([]model.Todo, error) {
query := `UPDATE todos SET done = NOT done WHERE id = ?;`
_, err := db.Exec(query, id)
if err != nil {
return []model.Todo{}, err
}
todos, err := ListTodos(db)
if err != nil {
return []model.Todo{}, err
}
return todos, nil
}

4
static/css/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

85
static/css/style.css Normal file
View file

@ -0,0 +1,85 @@
/* body { */
/* transform: scale(0.8); /* 150% zoom */ */
/* /* transform-origin: 0 0; /* Ensure the scale starts from the top left */ */ */
/* } */
:root {
--pico-font-family-sans-serif: Inter, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, Helvetica, Arial, "Helvetica Neue", sans-serif, var(--pico-font-family-emoji);
--pico-font-size: 87.5%;
/* Original: 100% */
--pico-line-height: 1.25;
/* Original: 1.5 */
--pico-form-element-spacing-vertical: 0.5rem;
/* Original: 1rem */
--pico-form-element-spacing-horizontal: 1.0rem;
/* Original: 1.25rem */
--pico-border-radius: 0.375rem;
/* Original: 0.25rem */
}
@media (min-width: 576px) {
:root {
--pico-font-size: 87.5%;
/* Original: 106.25% */
}
}
@media (min-width: 768px) {
:root {
--pico-font-size: 87.5%;
/* Original: 112.5% */
}
}
@media (min-width: 1024px) {
:root {
--pico-font-size: 87.5%;
/* Original: 118.75% */
}
}
@media (min-width: 1280px) {
:root {
--pico-font-size: 87.5%;
/* Original: 125% */
}
}
@media (min-width: 1536px) {
:root {
--pico-font-size: 87.5%;
/* Original: 131.25% */
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
--pico-font-weight: 600;
/* Original: 700 */
}
article {
border: 1px solid var(--pico-muted-border-color);
/* Original doesn't have a border */
border-radius: calc(var(--pico-border-radius) * 2);
/* Original: var(--pico-border-radius) */
}
article>footer {
border-radius: calc(var(--pico-border-radius) * 2);
/* Original: var(--pico-border-radius) */
}
.centered-div {
color: black;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
align-content: center;
}

1
static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

20
view/base.templ Normal file
View file

@ -0,0 +1,20 @@
package view
var Username string
templ Base() {
<!DOCTYPE html>
<html lang="en">
<head>
<title>{ Username } Todos</title>
<meta charset="UTF-8"/>
<link href="/css/pico.min.css" rel="stylesheet"/>
<link href="/css/style.css" rel="stylesheet"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<script src="/js/htmx.min.js"></script>
</head>
<body>
{ children... }
</body>
</html>
}

66
view/components.templ Normal file
View file

@ -0,0 +1,66 @@
package view
import (
"git.wittern.io/public/echo-todos/model"
"strconv"
)
templ TodoForm() {
<form hx-on::after-request="this.reset()" hx-post="/todos" hx-target="#todoslist">
<fieldset role="group">
<input type="text" name="todo" required/><input value="Clear" type="reset"/>
<button type="submit">Add</button>
</fieldset>
</form>
}
templ TodosList(todos []model.Todo) {
<table class="striped" id="todoslist">
<thead>
<tr>
<th>Title</th>
<th>Done</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
for _, todo := range todos {
@TodoRow(todo)
}
</tbody>
</table>
}
templ TodoRow(todo model.Todo) {
<tr id={ "todo-" + strconv.Itoa(todo.ID) }>
<td>{ todo.Title }</td>
if todo.Done {
<td><input hx-put={ "/todos/todo-" + strconv.Itoa(todo.ID) } hx-target="#todoslist" type="checkbox" name="done" checked/></td>
} else {
<td><input hx-put={ "/todos/todo-" + strconv.Itoa(todo.ID) } hx-target="#todoslist" type="checkbox" name="done"/></td>
}
<td><button hx-delete={ "/todos/todo-" + strconv.Itoa(todo.ID) } hx-target={ "#todo-" + strconv.Itoa(todo.ID) } hx-swap="delete">Delete</button></td>
</tr>
}
templ ErrorPage(message string) {
@Base() {
<container>
<h1>{ message }</h1>
</container>
}
}
templ Hero(AppTitle string) {
@Centered() {
<div class="hero">
<h1>{ AppTitle }</h1>
</div>
}
}
templ Centered() {
<div id="centered-div" class="container centered">
{ children... }
</div>
}

13
view/index.templ Normal file
View file

@ -0,0 +1,13 @@
package view
import "git.wittern.io/public/echo-todos/model"
templ Index(todos []model.Todo) {
@Base() {
<main class="container">
<h1>{ Username } Todos</h1>
@TodoForm()
@TodosList(todos)
</main>
}
}