Initial commit
This commit is contained in:
commit
ab4226a4b2
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
test.db
|
95
api/api.go
Normal file
95
api/api.go
Normal file
@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Cameron-Reed1/todo-web/db"
|
||||
"github.com/Cameron-Reed1/todo-web/types"
|
||||
)
|
||||
|
||||
func GetAll(w http.ResponseWriter, r *http.Request) {
|
||||
todos, err := db.GetAllTodos()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("{\"error\":\"Failed to get items\"}"))
|
||||
return
|
||||
}
|
||||
|
||||
response := "{\"items\":["
|
||||
|
||||
first := true
|
||||
for _, todo := range todos {
|
||||
if !first {
|
||||
response += ","
|
||||
}
|
||||
str := fmt.Sprintf("{\"id\":%d,\"start\":%d,\"due\":%d,\"text\":\"%s\"}", todo.Id, todo.Start, todo.Due, todo.Text)
|
||||
response += str
|
||||
first = false
|
||||
}
|
||||
|
||||
response += "]}"
|
||||
w.Write([]byte(response))
|
||||
}
|
||||
|
||||
func GetTodo(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
|
||||
if idStr == "" || err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("{\"error\":\"Invalid id\"}"))
|
||||
return
|
||||
}
|
||||
|
||||
todo, err := db.GetTodo(id)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("{\"error\":\"No item for id\"}"))
|
||||
return
|
||||
}
|
||||
|
||||
str := fmt.Sprintf("{\"id\":%d,\"start\":%d,\"due\":%d,\"text\":\"%s\"}", todo.Id, todo.Start, todo.Due, todo.Text)
|
||||
w.Write([]byte(str))
|
||||
}
|
||||
|
||||
func AddTodo(w http.ResponseWriter, r *http.Request) {
|
||||
var todo types.Todo
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
err := decoder.Decode(&todo)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("{\"error\":\"Failed to parse JSON\"}"))
|
||||
return
|
||||
}
|
||||
|
||||
if decoder.More() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("{\"error\":\"Extra data after JSON object\"}"))
|
||||
return
|
||||
}
|
||||
|
||||
if todo.Text == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("{\"error\":\"Invalid text\"}"))
|
||||
return
|
||||
}
|
||||
|
||||
err = db.AddTodo(&todo)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("{\"error\":\"Failed to add item\"}"))
|
||||
return
|
||||
}
|
||||
|
||||
res := fmt.Sprintf("{\"id\":%d}", todo.Id)
|
||||
w.Write([]byte(res))
|
||||
}
|
||||
|
||||
func InvalidEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("{\"error\":\"Endpoint not found\"}"))
|
||||
}
|
226
db/db.go
Normal file
226
db/db.go
Normal file
@ -0,0 +1,226 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"github.com/Cameron-Reed1/todo-web/types"
|
||||
)
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func Open(path string) {
|
||||
if db != nil {
|
||||
log.Fatal("Cannot init DB twice!")
|
||||
}
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
start INTEGER,
|
||||
due INTEGER,
|
||||
text TEXT NOT NULL,
|
||||
completed INTEGER NOT NULL
|
||||
);`
|
||||
|
||||
_, err = db.Exec(query)
|
||||
}
|
||||
|
||||
func AddTodo(todo *types.Todo) error {
|
||||
res, err := db.Exec("INSERT INTO items(start, due, text, completed) values(?, ?, ?, 0)", toNullInt64(todo.Start), toNullInt64(todo.Due), todo.Text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
todo.Id, err = res.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetTodo(id int) (types.Todo, error) {
|
||||
var todo types.Todo
|
||||
var start sql.NullInt64
|
||||
var due sql.NullInt64
|
||||
|
||||
row := db.QueryRow("SELECT * FROM items WHERE id=?", id)
|
||||
err := row.Scan(&todo.Id, &start, &due, &todo.Text, &todo.Completed)
|
||||
|
||||
todo.Start = fromNullInt64(start)
|
||||
todo.Due = fromNullInt64(due)
|
||||
|
||||
return todo, err
|
||||
}
|
||||
|
||||
func GetAllTodos() ([]types.Todo, error) {
|
||||
var todos []types.Todo
|
||||
|
||||
rows, err := db.Query("SELECT * FROM items")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var todo types.Todo
|
||||
var start sql.NullInt64
|
||||
var due sql.NullInt64
|
||||
|
||||
err = rows.Scan(&todo.Id, &start, &due, &todo.Text, &todo.Completed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
todo.Start = fromNullInt64(start)
|
||||
todo.Due = fromNullInt64(due)
|
||||
|
||||
todos = append(todos, todo)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return todos, nil
|
||||
}
|
||||
|
||||
func GetOverdueTodos() ([]types.Todo, error) {
|
||||
var todos []types.Todo
|
||||
|
||||
rows, err := db.Query("SELECT * FROM items WHERE due < ? AND due IS NOT NULL ORDER BY due", time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var todo types.Todo
|
||||
var start sql.NullInt64
|
||||
var due sql.NullInt64
|
||||
|
||||
err = rows.Scan(&todo.Id, &start, &due, &todo.Text, &todo.Completed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
todo.Start = fromNullInt64(start)
|
||||
todo.Due = fromNullInt64(due)
|
||||
|
||||
todos = append(todos, todo)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return todos, nil
|
||||
}
|
||||
|
||||
func GetTodayTodos() ([]types.Todo, error) {
|
||||
var todos []types.Todo
|
||||
|
||||
now := time.Now()
|
||||
year, month, day := now.Date()
|
||||
today := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
|
||||
rows, err := db.Query("SELECT * FROM items WHERE (start <= ? OR start IS NULL) AND (due >= ? OR due IS NULL) ORDER BY due NULLS LAST", today.Unix(), now.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var todo types.Todo
|
||||
var start sql.NullInt64
|
||||
var due sql.NullInt64
|
||||
|
||||
err = rows.Scan(&todo.Id, &start, &due, &todo.Text, &todo.Completed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
todo.Start = fromNullInt64(start)
|
||||
todo.Due = fromNullInt64(due)
|
||||
|
||||
todos = append(todos, todo)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return todos, nil
|
||||
}
|
||||
|
||||
func GetUpcomingTodos() ([]types.Todo, error) {
|
||||
var todos []types.Todo
|
||||
|
||||
year, month, day := time.Now().Date()
|
||||
today := time.Date(year, month, day, 0, 0, 0, 0, time.Local)
|
||||
rows, err := db.Query("SELECT * FROM items WHERE start > ? ORDER BY start", today.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var todo types.Todo
|
||||
var start sql.NullInt64
|
||||
var due sql.NullInt64
|
||||
|
||||
err = rows.Scan(&todo.Id, &start, &due, &todo.Text, &todo.Completed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
todo.Start = fromNullInt64(start)
|
||||
todo.Due = fromNullInt64(due)
|
||||
|
||||
todos = append(todos, todo)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return todos, nil
|
||||
}
|
||||
|
||||
func SetCompleted(id int, completed bool) error {
|
||||
_, err := db.Exec("UPDATE items SET completed=? WHERE id=?", completed, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteTodo(id int) error {
|
||||
_, err := db.Exec("DELETE FROM items WHERE id=?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func Close() {
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func toNullInt64(num int64) sql.NullInt64 {
|
||||
if num == 0 {
|
||||
return sql.NullInt64{Int64: 0, Valid: false}
|
||||
}
|
||||
return sql.NullInt64{Int64: num, Valid: true}
|
||||
}
|
||||
|
||||
func fromNullInt64(num sql.NullInt64) int64 {
|
||||
if num.Valid {
|
||||
return num.Int64
|
||||
}
|
||||
return 0
|
||||
}
|
7
go.mod
Normal file
7
go.mod
Normal file
@ -0,0 +1,7 @@
|
||||
module github.com/Cameron-Reed1/todo-web
|
||||
|
||||
go 1.22.6
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.22
|
||||
|
||||
require github.com/a-h/templ v0.2.747 // indirect
|
4
go.sum
Normal file
4
go.sum
Normal file
@ -0,0 +1,4 @@
|
||||
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/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=
|
82
main.go
Normal file
82
main.go
Normal file
@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Cameron-Reed1/todo-web/api"
|
||||
"github.com/Cameron-Reed1/todo-web/db"
|
||||
"github.com/Cameron-Reed1/todo-web/pages"
|
||||
)
|
||||
|
||||
func main() {
|
||||
bind_port := flag.Int("p", 8080, "Port to bind to")
|
||||
bind_addr := flag.String("a", "0.0.0.0", "Address to bind to")
|
||||
noFront := flag.Bool("no-frontend", false, "Disable the frontend endpoints")
|
||||
a := false; noBack := &a // flag.Bool("no-backend", false, "Disable the backend endpoints")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
if *noFront && *noBack {
|
||||
fmt.Println("What do you want me to do?")
|
||||
return
|
||||
}
|
||||
|
||||
if !*noFront {
|
||||
addFrontendEndpoints(mux)
|
||||
}
|
||||
|
||||
if !*noBack {
|
||||
addBackendEndpoints(mux)
|
||||
}
|
||||
|
||||
db.Open("test.db")
|
||||
defer db.Close()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", *bind_addr, *bind_port)
|
||||
server := http.Server{ Addr: addr, Handler: mux }
|
||||
fmt.Printf("Starting server on %s...\n", addr)
|
||||
err := server.ListenAndServe()
|
||||
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
fmt.Printf("Server closed\n")
|
||||
} else if err != nil {
|
||||
fmt.Printf("Error starting server: %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func addFrontendEndpoints(mux *http.ServeMux) {
|
||||
fmt.Println("Frontend enabled")
|
||||
|
||||
mux.HandleFunc("/", Error404)
|
||||
|
||||
mux.HandleFunc("/{$}", pages.RootPage)
|
||||
mux.HandleFunc("/overdue", pages.OverdueFragment)
|
||||
mux.HandleFunc("/today", pages.TodayFragment)
|
||||
mux.HandleFunc("/upcoming", pages.UpcomingFragment)
|
||||
mux.HandleFunc("DELETE /delete/{id}", pages.DeleteItem)
|
||||
mux.HandleFunc("PATCH /set/{id}", pages.SetItemCompleted)
|
||||
mux.HandleFunc("POST /new", pages.CreateItem)
|
||||
|
||||
fileServer := http.FileServer(http.Dir("./static"))
|
||||
mux.Handle("/css/", fileServer)
|
||||
mux.Handle("/js/", fileServer)
|
||||
}
|
||||
|
||||
func addBackendEndpoints(mux *http.ServeMux) {
|
||||
fmt.Println("Backend enabled")
|
||||
|
||||
mux.HandleFunc("/api/", api.InvalidEndpoint)
|
||||
|
||||
mux.HandleFunc("GET /api/get", api.GetAll)
|
||||
mux.HandleFunc("GET /api/get/{id}", api.GetTodo)
|
||||
mux.HandleFunc("POST /api/new", api.AddTodo)
|
||||
}
|
||||
|
||||
func Error404(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("Error 404: Page not found\n"))
|
||||
}
|
122
pages/fragments.go
Normal file
122
pages/fragments.go
Normal file
@ -0,0 +1,122 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Cameron-Reed1/todo-web/db"
|
||||
"github.com/Cameron-Reed1/todo-web/pages/templates"
|
||||
"github.com/Cameron-Reed1/todo-web/types"
|
||||
)
|
||||
|
||||
func OverdueFragment(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := db.GetOverdueTodos()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
templates.TodoList(items).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func TodayFragment(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := db.GetTodayTodos()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
templates.TodoList(items).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func UpcomingFragment(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := db.GetUpcomingTodos()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
templates.TodoList(items).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func CreateItem(w http.ResponseWriter, r *http.Request) {
|
||||
var todo types.Todo
|
||||
var err error
|
||||
|
||||
todo.Text = r.FormValue("name")
|
||||
start := r.FormValue("start")
|
||||
due := r.FormValue("due")
|
||||
|
||||
if start != "" {
|
||||
start_time, err := time.Parse("2006-01-02T03:04", start)
|
||||
if err != nil {
|
||||
fmt.Printf("Bad start time: %s\n", start)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
todo.Start = start_time.Unix()
|
||||
} else {
|
||||
todo.Start = 0
|
||||
}
|
||||
|
||||
if due != "" {
|
||||
due_time, err := time.Parse("2006-01-02T15:04", due)
|
||||
if err != nil {
|
||||
fmt.Printf("Bad due time: %s\n", due)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
todo.Due = due_time.Unix()
|
||||
} else {
|
||||
todo.Due = 0
|
||||
}
|
||||
|
||||
fmt.Printf("New item: %s: %d - %d\n", todo.Text, todo.Start, todo.Due)
|
||||
|
||||
err = db.AddTodo(&todo)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte{})
|
||||
}
|
||||
|
||||
func DeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
|
||||
if idStr == "" || err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err = db.DeleteTodo(id)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte{})
|
||||
}
|
||||
|
||||
func SetItemCompleted(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
|
||||
if idStr == "" || err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
completed := r.FormValue("completed") == "on"
|
||||
if err = db.SetCompleted(id, completed); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte{})
|
||||
}
|
11
pages/root.go
Normal file
11
pages/root.go
Normal file
@ -0,0 +1,11 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Cameron-Reed1/todo-web/pages/templates"
|
||||
)
|
||||
|
||||
func RootPage(w http.ResponseWriter, r *http.Request) {
|
||||
templates.RootPage().Render(r.Context(), w)
|
||||
}
|
99
pages/templates/root.templ
Normal file
99
pages/templates/root.templ
Normal file
@ -0,0 +1,99 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Cameron-Reed1/todo-web/types"
|
||||
)
|
||||
|
||||
templ RootPage() {
|
||||
<!Doctype HTML>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Todo</title>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
|
||||
<link rel="stylesheet" href="/css/styles.css"/>
|
||||
|
||||
<script src="/js/lib/htmx.min.js"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<script src="https://kit.fontawesome.com/469cdddb31.js" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav>
|
||||
<div id="nav-container">
|
||||
<div id="nav-left">
|
||||
<a id="new-button" href="#create-item">New</a>
|
||||
</div>
|
||||
<div id="nav-center"></div>
|
||||
<div id="nav-right"></div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="main-content">
|
||||
<div id="lists">
|
||||
<div id="overdue-list" class="todo-list">
|
||||
<div class="todo-list-title">Overdue</div>
|
||||
<div class="todo-list-items" hx-get="/overdue" hx-trigger="load" hx-swap="outerHTML"></div>
|
||||
</div>
|
||||
<div id="today-list" class="todo-list">
|
||||
<div class="todo-list-title">Today</div>
|
||||
<div class="todo-list-items" hx-get="/today" hx-trigger="load" hx-swap="outerHTML"></div>
|
||||
</div>
|
||||
<div id="upcoming-list" class="todo-list">
|
||||
<div class="todo-list-title">Upcoming</div>
|
||||
<div class="todo-list-items" hx-get="/upcoming" hx-trigger="load" hx-swap="outerHTML"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="create-item" hx-post="/new" hx-swap="none" hx-push-url="#">
|
||||
<div id="create-item-title">Create new Todo</div>
|
||||
<div id="create-item-form-container">
|
||||
<div class="create-item-form-column">
|
||||
<div class="create-item-form-column">
|
||||
<label for="name">Name</label>
|
||||
<br/>
|
||||
<input type="text" name="name"/>
|
||||
<br/>
|
||||
<label for="start">Start</label>
|
||||
<br/>
|
||||
<input name="start" type="datetime-local"/>
|
||||
<br/>
|
||||
<label for="due">Due</label>
|
||||
<br/>
|
||||
<input name="due" type="datetime-local"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="create-item-form-column"></div>
|
||||
</div>
|
||||
<div id="create-item-button-container">
|
||||
<button id="create-item-save-button" class="button" type="submit" onclick="setTimeout(() => window.location = window.location.origin, 100)">Save</button>
|
||||
<a id="create-item-close-button" class="button" href="#">Close</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ TodoItem(item types.Todo) {
|
||||
<div class="todo-item">
|
||||
<input type="checkbox" name="completed" checked?={ item.Completed } hx-patch={ string(templ.URL(fmt.Sprintf("/set/%d", item.Id))) }/>
|
||||
<div class="todo-text">{ item.Text }</div>
|
||||
<div class="todo-item-actions">
|
||||
<i class="action-edit fa-solid fa-pencil"></i>
|
||||
<i class="action-delete fa-solid fa-trash" hx-delete={ fmt.Sprintf("/delete/%d", item.Id) } hx-target="closest .todo-item" hx-swap="outerHTML"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TodoList(items []types.Todo) {
|
||||
<div class="todo-list-items">
|
||||
for _, item := range items {
|
||||
@TodoItem(item)
|
||||
}
|
||||
</div>
|
||||
}
|
148
pages/templates/root_templ.go
Normal file
148
pages/templates/root_templ.go
Normal file
@ -0,0 +1,148 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.2.663
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import "context"
|
||||
import "io"
|
||||
import "bytes"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Cameron-Reed1/todo-web/types"
|
||||
)
|
||||
|
||||
func RootPage() templ.Component {
|
||||
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)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype HTML><html lang=\"en-US\"><head><title>Todo</title><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link rel=\"stylesheet\" href=\"/css/styles.css\"><script src=\"/js/lib/htmx.min.js\"></script><!-- Font Awesome --><script src=\"https://kit.fontawesome.com/469cdddb31.js\" crossorigin=\"anonymous\"></script></head><body><nav><div id=\"nav-container\"><div id=\"nav-left\"><a id=\"new-button\" href=\"#create-item\">New</a></div><div id=\"nav-center\"></div><div id=\"nav-right\"></div></div></nav><div id=\"main-content\"><div id=\"lists\"><div id=\"overdue-list\" class=\"todo-list\"><div class=\"todo-list-title\">Overdue</div><div class=\"todo-list-items\" hx-get=\"/overdue\" hx-trigger=\"load\" hx-swap=\"outerHTML\"></div></div><div id=\"today-list\" class=\"todo-list\"><div class=\"todo-list-title\">Today</div><div class=\"todo-list-items\" hx-get=\"/today\" hx-trigger=\"load\" hx-swap=\"outerHTML\"></div></div><div id=\"upcoming-list\" class=\"todo-list\"><div class=\"todo-list-title\">Upcoming</div><div class=\"todo-list-items\" hx-get=\"/upcoming\" hx-trigger=\"load\" hx-swap=\"outerHTML\"></div></div></div><form id=\"create-item\" hx-post=\"/new\" hx-swap=\"none\" hx-push-url=\"#\"><div id=\"create-item-title\">Create new Todo</div><div id=\"create-item-form-container\"><div class=\"create-item-form-column\"><div class=\"create-item-form-column\"><label for=\"name\">Name</label><br><input type=\"text\" name=\"name\"><br><label for=\"start\">Start</label><br><input name=\"start\" type=\"datetime-local\"><br><label for=\"due\">Due</label><br><input name=\"due\" type=\"datetime-local\"></div></div><div class=\"create-item-form-column\"></div></div><div id=\"create-item-button-container\"><button id=\"create-item-save-button\" class=\"button\" type=\"submit\" onclick=\"setTimeout(() => window.location = window.location.origin, 100)\">Save</button> <a id=\"create-item-close-button\" class=\"button\" href=\"#\">Close</a></div></form></div></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
||||
|
||||
func TodoItem(item types.Todo) templ.Component {
|
||||
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)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var2 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var2 == nil {
|
||||
templ_7745c5c3_Var2 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"todo-item\"><input type=\"checkbox\" name=\"completed\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.Completed {
|
||||
_, 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-patch=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/set/%d", item.Id))))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/templates/root.templ`, Line: 84, Col: 137}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"todo-text\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Text)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/templates/root.templ`, Line: 85, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div><div class=\"todo-item-actions\"><i class=\"action-edit fa-solid fa-pencil\"></i> <i class=\"action-delete fa-solid fa-trash\" hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/delete/%d", item.Id))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/templates/root.templ`, Line: 88, Col: 101}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" hx-target=\"closest .todo-item\" hx-swap=\"outerHTML\"></i></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
||||
|
||||
func TodoList(items []types.Todo) templ.Component {
|
||||
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)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
templ_7745c5c3_Buffer = templ.GetBuffer()
|
||||
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var6 == nil {
|
||||
templ_7745c5c3_Var6 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"todo-list-items\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range items {
|
||||
templ_7745c5c3_Err = TodoItem(item).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
|
||||
}
|
||||
return templ_7745c5c3_Err
|
||||
})
|
||||
}
|
214
static/css/styles.css
Normal file
214
static/css/styles.css
Normal file
@ -0,0 +1,214 @@
|
||||
* {
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
nav {
|
||||
height: 48px;
|
||||
border-bottom: 2px solid black;
|
||||
}
|
||||
|
||||
#nav-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#new-button {
|
||||
width: 100px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
line-height: 48px;
|
||||
font-size: 2em;
|
||||
font-weight: 600;
|
||||
font-family: sans-serif;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#new-button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
height: calc(100vh - 100px);
|
||||
height: calc(100lvh - 100px);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#lists {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 38px;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
#overdue-list {
|
||||
border: 4px solid hsl(0, 90%, 67.72%);
|
||||
}
|
||||
|
||||
#today-list {
|
||||
border: 4px solid hsl(210, 57.14%, 58.04%);
|
||||
}
|
||||
|
||||
#upcoming-list {
|
||||
border: 4px solid hsl(262.5, 44.44%, 58.62%);
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
width: 300px;
|
||||
height: min(calc(100lvh - 100px), 450px);
|
||||
border: 4px solid black;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.todo-list-title {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.todo-list-items {
|
||||
max-height: min(calc(100lvh - 150px), 400px);
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
#overdue-list > .todo-list-title {
|
||||
background-color: #FAA0A0;
|
||||
}
|
||||
|
||||
#today-list > .todo-list-title {
|
||||
background-color: #A7C7E7;
|
||||
}
|
||||
|
||||
#upcoming-list > .todo-list-title {
|
||||
background-color: #C3B1E1;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
height: 40px;
|
||||
margin: 6px;
|
||||
padding: 0 12px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid gray;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
flex: 1;
|
||||
text-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.todo-item-actions {
|
||||
max-width: 0px;
|
||||
overflow: hidden;
|
||||
transition: max-width .2s;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.todo-item:hover > .todo-item-actions {
|
||||
max-width: 30%;
|
||||
transition: max-width .7s;
|
||||
}
|
||||
|
||||
.todo-item-actions > i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#create-item {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 2px solid black;
|
||||
border-radius: 10px;
|
||||
width: min(calc(100vw - 110px), 900px);
|
||||
height: min(calc(100lvh - 250px), 440px);
|
||||
background: white;
|
||||
box-shadow: black 5px 5px 5px -3px;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: visibility 1s, top .5s, opacity .5s linear;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
#create-item:target {
|
||||
top: 48%;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transition: visibility 0s, top .5s, opacity .5s linear;
|
||||
}
|
||||
|
||||
#create-item-title {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
#create-item-form-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.create-item-form-column {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.create-item-form-column input {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#create-item-button-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
margin: 0 10px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
border: 2px solid;
|
||||
border-radius: 6px;
|
||||
box-shadow: black 2px 2px 3px 0;
|
||||
background-color: white;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
#create-item-close-button {
|
||||
color: initial;
|
||||
}
|
||||
|
||||
#create-item-save-button {
|
||||
color: blue;
|
||||
border-color: blue;
|
||||
}
|
1
static/js/lib/htmx.min.js
vendored
Normal file
1
static/js/lib/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
types/todo.go
Normal file
9
types/todo.go
Normal file
@ -0,0 +1,9 @@
|
||||
package types
|
||||
|
||||
type Todo struct {
|
||||
Id int64
|
||||
Start int64
|
||||
Due int64
|
||||
Text string
|
||||
Completed bool
|
||||
}
|
Loading…
Reference in New Issue
Block a user