Add beginning of web package
This commit is contained in:
parent
2670af3eb7
commit
10cc9342b1
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
sample_data/data
|
||||
*_templ.go
|
||||
|
21
cmd/main.go
21
cmd/main.go
@ -7,6 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
pkg_db "recipe_book/pkg/db"
|
||||
"recipe_book/pkg/web"
|
||||
)
|
||||
|
||||
const DB_FILENAME = "food.db"
|
||||
@ -27,6 +28,15 @@ func main() {
|
||||
switch args[0] {
|
||||
case "init":
|
||||
init_db()
|
||||
case "webserver":
|
||||
fs := flag.NewFlagSet("", flag.ExitOnError)
|
||||
should_auto_open := fs.Bool("auto-open", false, "")
|
||||
addr := fs.String("addr", "localhost:3080", "port to listen on") // Random port that's probably not in use
|
||||
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
start_webserver(*addr, *should_auto_open)
|
||||
default:
|
||||
fmt.Printf(COLOR_RED+"invalid subcommand: %q\n"+COLOR_RESET, args[0])
|
||||
os.Exit(1)
|
||||
@ -55,3 +65,14 @@ const (
|
||||
COLOR_GRAY = "\033[37m"
|
||||
COLOR_WHITE = "\033[97m"
|
||||
)
|
||||
|
||||
func start_webserver(addr string, should_auto_open bool) {
|
||||
db, err := pkg_db.DBConnect(filepath.Join(db_path, DB_FILENAME))
|
||||
if err != nil {
|
||||
fmt.Println(COLOR_RED + "opening database: " + err.Error() + COLOR_RESET)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app := web.NewApp(db)
|
||||
app.Run(addr, should_auto_open)
|
||||
}
|
||||
|
29
doc/ingredient-schema-questions.txt
Normal file
29
doc/ingredient-schema-questions.txt
Normal file
@ -0,0 +1,29 @@
|
||||
Questions:
|
||||
- should recipes have a reference to a food with its computed nutrition?
|
||||
- or the other way around-- store a
|
||||
- how should ingredients-that-are-recipes be linked to recipes?
|
||||
|
||||
|
||||
|
||||
|
||||
Options:
|
||||
1. - ingredients just store a food_id
|
||||
- foods store an optional recipe_id
|
||||
|
||||
Problems:
|
||||
- all recipes have to have an associated food entry
|
||||
- foods have to have a recipe_id-- shouldn't this be the other way around?
|
||||
|
||||
2. - ingredients just store a food_id
|
||||
- recipes store a food_id
|
||||
|
||||
Problems:
|
||||
- all recipes have to have an associated food entry
|
||||
- can't tell which ingredients are recipes without doing an indexed search
|
||||
|
||||
|
||||
3. - ingredients store a food_id and a recipe_id, only one is valid
|
||||
Implications: recipes and foods can be connected in any way
|
||||
|
||||
Problems:
|
||||
- joining is more complicated
|
1
go.mod
1
go.mod
@ -10,6 +10,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.2.793 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
2
go.sum
2
go.sum
@ -1,5 +1,7 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
|
||||
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
|
||||
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/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
|
9
pkg/db/errors.go
Normal file
9
pkg/db/errors.go
Normal file
@ -0,0 +1,9 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotInDB = errors.New("not in db")
|
||||
)
|
@ -1,36 +1,38 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type FoodID uint64
|
||||
|
||||
type Food struct {
|
||||
ID FoodID `db:"rowid"`
|
||||
Name string `db:"name"`
|
||||
ID FoodID `db:"rowid" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
|
||||
Cals float32 `db:"cals"`
|
||||
Carbs float32 `db:"carbs"`
|
||||
Protein float32 `db:"protein"`
|
||||
Fat float32 `db:"fat"`
|
||||
Sugar float32 `db:"sugar"`
|
||||
Alcohol float32 `db:"alcohol"`
|
||||
Cals float32 `db:"cals" json:"cals,string"`
|
||||
Carbs float32 `db:"carbs" json:"carbs,string"`
|
||||
Protein float32 `db:"protein" json:"protein,string"`
|
||||
Fat float32 `db:"fat" json:"fat,string"`
|
||||
Sugar float32 `db:"sugar" json:"sugar,string"`
|
||||
Alcohol float32 `db:"alcohol" json:"alcohol,string"`
|
||||
|
||||
Water float32 `db:"water"`
|
||||
Water float32 `db:"water" json:"water,string"`
|
||||
|
||||
Potassium float32 `db:"potassium"`
|
||||
Calcium float32 `db:"calcium"`
|
||||
Sodium float32 `db:"sodium"`
|
||||
Magnesium float32 `db:"magnesium"`
|
||||
Phosphorus float32 `db:"phosphorus"`
|
||||
Iron float32 `db:"iron"`
|
||||
Zinc float32 `db:"zinc"`
|
||||
Potassium float32 `db:"potassium" json:"potassium,string"`
|
||||
Calcium float32 `db:"calcium" json:"calcium,string"`
|
||||
Sodium float32 `db:"sodium" json:"sodium,string"`
|
||||
Magnesium float32 `db:"magnesium" json:"magnesium,string"`
|
||||
Phosphorus float32 `db:"phosphorus" json:"phosphorus,string"`
|
||||
Iron float32 `db:"iron" json:"iron,string"`
|
||||
Zinc float32 `db:"zinc" json:"zinc,string"`
|
||||
|
||||
Mass float32 `db:"mass"` // In grams
|
||||
Price float32 `db:"price"`
|
||||
Density float32 `db:"density"`
|
||||
CookRatio float32 `db:"cook_ratio"`
|
||||
Mass float32 `db:"mass" json:"mass,string"` // In grams
|
||||
Price float32 `db:"price" json:"price,string"`
|
||||
Density float32 `db:"density" json:"density,string"`
|
||||
CookRatio float32 `db:"cook_ratio" json:"cook_ratio,string"`
|
||||
}
|
||||
|
||||
// Format as string
|
||||
@ -101,6 +103,9 @@ func (db *DB) GetFoodByID(id FoodID) (ret Food, err error) {
|
||||
from foods
|
||||
where rowid = ?
|
||||
`, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Food{}, ErrNotInDB
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
105
pkg/web/handler_ingredients.go
Normal file
105
pkg/web/handler_ingredients.go
Normal file
@ -0,0 +1,105 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/a-h/templ"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
. "recipe_book/pkg/db"
|
||||
|
||||
"recipe_book/pkg/web/tpl/pages"
|
||||
)
|
||||
|
||||
// Router: `/ingredients`
|
||||
func (app *Application) Ingredients(w http.ResponseWriter, r *http.Request) {
|
||||
app.traceLog.Printf("Ingredient: %s", r.URL.Path)
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
|
||||
if parts[0] == "" {
|
||||
// Index page
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
app.IngredientsIndex(w, r)
|
||||
case "POST":
|
||||
app.IngredientCreate(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Details page
|
||||
food_id, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
app.error_400(w, r, fmt.Sprintf("invalid ID: %s", parts[0]))
|
||||
return
|
||||
}
|
||||
food, err := app.DB.GetFoodByID(FoodID(food_id))
|
||||
if errors.Is(err, ErrNotInDB) {
|
||||
app.error_404(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
app.IngredientDetail(food, w, r)
|
||||
}
|
||||
|
||||
// Handler: `GET /ingredients`
|
||||
func (app *Application) IngredientsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
foods := app.DB.GetAllBaseFoods()
|
||||
err := pages.Base("Ingredients").Render(
|
||||
templ.WithChildren(
|
||||
context.Background(),
|
||||
pages.IngredientsIndex(foods),
|
||||
),
|
||||
w)
|
||||
panic_if(err)
|
||||
}
|
||||
|
||||
// Handler: `POST /ingredients`
|
||||
func (app *Application) IngredientCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var food Food
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = json.Unmarshal(data, &food)
|
||||
if err != nil {
|
||||
app.ErrorLog.Print(err)
|
||||
panic(err)
|
||||
}
|
||||
app.DB.SaveFood(&food)
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/ingredients/%d", food.ID), 303)
|
||||
}
|
||||
|
||||
// Handler: `GET /ingredients/:id`
|
||||
func (app *Application) IngredientDetail(food Food, w http.ResponseWriter, r *http.Request) {
|
||||
// If it's a POST request, update the food
|
||||
if r.Method == "POST" {
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = json.Unmarshal(data, &food)
|
||||
if err != nil {
|
||||
app.ErrorLog.Print(err)
|
||||
panic(err)
|
||||
}
|
||||
// Save the updated food
|
||||
app.DB.SaveFood(&food)
|
||||
app.traceLog.Printf("POST Ingredient Detail: %#v", food)
|
||||
}
|
||||
|
||||
err := pages.Base(fmt.Sprintf("Ingredient: %s", food.Name)).Render(
|
||||
templ.WithChildren(
|
||||
context.Background(),
|
||||
pages.IngredientDetail(food),
|
||||
),
|
||||
w)
|
||||
panic_if(err)
|
||||
}
|
85
pkg/web/handler_recipes.go
Normal file
85
pkg/web/handler_recipes.go
Normal file
@ -0,0 +1,85 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/a-h/templ"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
. "recipe_book/pkg/db"
|
||||
|
||||
"recipe_book/pkg/web/tpl/pages"
|
||||
)
|
||||
|
||||
// Router: `/ingredients`
|
||||
func (app *Application) Recipes(w http.ResponseWriter, r *http.Request) {
|
||||
app.traceLog.Printf("Recipe: %s", r.URL.Path)
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
|
||||
if parts[0] == "" {
|
||||
// Index page
|
||||
switch r.Method {
|
||||
case "GET":
|
||||
app.RecipesIndex(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Details page
|
||||
recipe_id, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
app.error_400(w, r, fmt.Sprintf("invalid ID: %s", parts[0]))
|
||||
return
|
||||
}
|
||||
recipe, err := app.DB.GetRecipeByID(RecipeID(recipe_id))
|
||||
if errors.Is(err, ErrNotInDB) {
|
||||
app.error_404(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
app.RecipeDetail(recipe, w, r)
|
||||
}
|
||||
|
||||
// Handler: `GET /recipes`
|
||||
func (app *Application) RecipesIndex(w http.ResponseWriter, r *http.Request) {
|
||||
foods := app.DB.GetAllRecipes()
|
||||
err := pages.Base("Ingredients").Render(
|
||||
templ.WithChildren(
|
||||
context.Background(),
|
||||
pages.RecipesIndex(foods),
|
||||
),
|
||||
w)
|
||||
panic_if(err)
|
||||
}
|
||||
|
||||
// // Handler: `POST /ingredients`
|
||||
// func (app *Application) IngredientCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// var food Food
|
||||
// data, err := io.ReadAll(r.Body)
|
||||
// if err != nil {
|
||||
// panic(err)
|
||||
// }
|
||||
// err = json.Unmarshal(data, &food)
|
||||
// if err != nil {
|
||||
// app.ErrorLog.Print(err)
|
||||
// panic(err)
|
||||
// }
|
||||
// app.DB.SaveFood(&food)
|
||||
|
||||
// http.Redirect(w, r, fmt.Sprintf("/ingredients/%d", food.ID), 303)
|
||||
// }
|
||||
|
||||
// Handler: `GET /ingredients/:id`
|
||||
func (app *Application) RecipeDetail(recipe Recipe, w http.ResponseWriter, r *http.Request) {
|
||||
err := pages.Base(recipe.Name).Render(
|
||||
templ.WithChildren(
|
||||
context.Background(),
|
||||
templ.Join(pages.RecipeDetail(recipe)),
|
||||
),
|
||||
w)
|
||||
panic_if(err)
|
||||
}
|
25
pkg/web/http_response_helpers.go
Normal file
25
pkg/web/http_response_helpers.go
Normal file
@ -0,0 +1,25 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func panic_if(err error) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) error_400(w http.ResponseWriter, r *http.Request, msg string) {
|
||||
http.Error(w, fmt.Sprintf("Bad Request\n\n%s", msg), 400)
|
||||
}
|
||||
|
||||
func (app *Application) error_404(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Not Found", 404)
|
||||
}
|
||||
|
||||
func (app *Application) error_500(w http.ResponseWriter, r *http.Request, err error) {
|
||||
panic("TODO")
|
||||
}
|
||||
|
50
pkg/web/middlewares.go
Normal file
50
pkg/web/middlewares.go
Normal file
@ -0,0 +1,50 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Middlewares are wrappers around `http.Handler`s
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
func (app *Application) WithMiddlewares() http.Handler {
|
||||
var ret http.Handler = app
|
||||
for i := range app.Middlewares {
|
||||
ret = app.Middlewares[i](ret)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func secureHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "deny")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *Application) logRequest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
duration := time.Since(t)
|
||||
|
||||
app.accessLog.Printf("%s - %s %s %s\t%s", r.RemoteAddr, r.Proto, r.Method, r.URL.RequestURI(), duration)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *Application) recoverPanic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
w.Header().Set("Connection", "close")
|
||||
app.error_500(w, r, fmt.Errorf("%s", err))
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
95
pkg/web/server.go
Normal file
95
pkg/web/server.go
Normal file
@ -0,0 +1,95 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pkg_db "recipe_book/pkg/db"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
accessLog *log.Logger
|
||||
traceLog *log.Logger
|
||||
InfoLog *log.Logger
|
||||
ErrorLog *log.Logger
|
||||
|
||||
Middlewares []Middleware
|
||||
|
||||
DB pkg_db.DB
|
||||
}
|
||||
|
||||
func NewApp(db pkg_db.DB) Application {
|
||||
ret := Application{
|
||||
accessLog: log.New(os.Stdout, "ACCESS\t", log.Ldate|log.Ltime),
|
||||
traceLog: log.New(os.Stdout, "TRACE\t", log.Ldate|log.Ltime),
|
||||
InfoLog: log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime),
|
||||
ErrorLog: log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile),
|
||||
|
||||
DB: db,
|
||||
}
|
||||
ret.Middlewares = []Middleware{
|
||||
secureHeaders,
|
||||
ret.logRequest,
|
||||
ret.recoverPanic,
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// Manual router implementation.
|
||||
// I don't like the weird matching behavior of http.ServeMux, and it's not hard to write by hand.
|
||||
func (app *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
app.traceLog.Printf("base handler: %s", r.URL.Path)
|
||||
parts := strings.Split(r.URL.Path, "/")[1:]
|
||||
switch parts[0] {
|
||||
case "static":
|
||||
http.StripPrefix("/static", http.HandlerFunc(app.ServeStatic)).ServeHTTP(w, r)
|
||||
case "ingredients":
|
||||
http.StripPrefix("/ingredients", http.HandlerFunc(app.Ingredients)).ServeHTTP(w, r)
|
||||
case "recipes":
|
||||
http.StripPrefix("/recipes", http.HandlerFunc(app.Recipes)).ServeHTTP(w, r)
|
||||
default:
|
||||
app.error_404(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) Run(address string, should_auto_open bool) {
|
||||
srv := &http.Server{
|
||||
Addr: address,
|
||||
ErrorLog: app.ErrorLog,
|
||||
Handler: app.WithMiddlewares(),
|
||||
TLSConfig: &tls.Config{
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
||||
},
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
app.InfoLog.Printf("Starting server on %s", address)
|
||||
|
||||
if should_auto_open {
|
||||
go func(url string) {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin": // macOS
|
||||
cmd = exec.Command("open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
default: // Linux and others
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("Failed to open homepage: %s", err.Error())
|
||||
}
|
||||
}("http://" + address)
|
||||
}
|
||||
err := srv.ListenAndServe()
|
||||
app.ErrorLog.Fatal(err)
|
||||
}
|
33
pkg/web/static.go
Normal file
33
pkg/web/static.go
Normal file
@ -0,0 +1,33 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"path"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
//go:embed "static"
|
||||
var embedded_files embed.FS
|
||||
|
||||
var use_embedded = ""
|
||||
|
||||
var this_dir string
|
||||
|
||||
func init() {
|
||||
_, this_file, _, _ := runtime.Caller(0) // `this_file` is absolute path to this source file
|
||||
this_dir = path.Dir(this_file)
|
||||
}
|
||||
|
||||
// Serve static assets, either from the disk (if running in development mode), or from go:embedded files
|
||||
func (app *Application) ServeStatic(w http.ResponseWriter, r *http.Request) {
|
||||
// Static files can be stored in browser cache
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
if use_embedded == "true" {
|
||||
// Serve directly from the embedded files
|
||||
http.FileServer(http.FS(embedded_files)).ServeHTTP(w, r)
|
||||
} else {
|
||||
// Serve from disk
|
||||
http.FileServer(http.Dir(path.Join(this_dir, "static"))).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
157
pkg/web/static/styles.css
Normal file
157
pkg/web/static/styles.css
Normal file
@ -0,0 +1,157 @@
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
}
|
||||
nav {
|
||||
width: 20em;
|
||||
max-width: 20%;
|
||||
border-right: 1px solid #ddd;
|
||||
min-height: 100vh;
|
||||
}
|
||||
input#search-bar {
|
||||
max-width: 96%;
|
||||
width: 20em;
|
||||
}
|
||||
main {
|
||||
flex-grow: 1;
|
||||
margin-right: 10%;
|
||||
padding-left: 2em;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
input[type='text'], select, textarea, span.select2-container {
|
||||
margin: 0 0.5em 0 0.2em;
|
||||
}
|
||||
|
||||
div.nothing {
|
||||
/* Used because the Hoon type checker requires a `manx` sometimes (can't use ~) */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.buttons-container {
|
||||
display: flex;
|
||||
margin: 0em 0 1em 0.2em;
|
||||
gap: 2em;
|
||||
}
|
||||
.share-container {
|
||||
display: flex;
|
||||
}
|
||||
button#share-this-recipe {
|
||||
}
|
||||
#shareLink {
|
||||
width: 30em;
|
||||
}
|
||||
#shareLink.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.labelled-input {
|
||||
display: flex;
|
||||
}
|
||||
div.labelled-input label {
|
||||
flex-basis: 12em;
|
||||
text-align: right;
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
div.labelled-input input {
|
||||
flex-basis: 30em;
|
||||
}
|
||||
div.labelled-input .title-text-parent {
|
||||
color: #888;
|
||||
position: relative;
|
||||
}
|
||||
.ingredient-editor input[type="submit"] {
|
||||
margin: 2em 0 0 41em;
|
||||
}
|
||||
.new-item-button {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.title-text {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: max-content;
|
||||
border: 1px solid black;
|
||||
padding: 0.2em;
|
||||
background-color: white;
|
||||
margin-top: 0.3em;
|
||||
text-align: left;
|
||||
}
|
||||
.title-text-parent:focus .title-text, .title-text-parent:hover .title-text {
|
||||
display: revert;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
padding: 0 0.3em;
|
||||
}
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.3em 0.6em;
|
||||
}
|
||||
|
||||
td.name {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td.total {
|
||||
border-top: 1px solid darkgray;
|
||||
}
|
||||
|
||||
form.x-button {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
form.x-button input {
|
||||
padding: 0.1em 0.3em;
|
||||
}
|
||||
|
||||
form.add-instr {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
ol#instructions {
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
ol#instructions li {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin: 0.3em 0;
|
||||
}
|
||||
|
||||
ol#instructions li span {
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
ol#instructions li span p {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
ol#instructions li .instr-number{
|
||||
margin-left: 1em;
|
||||
margin-right: 0.5em;
|
||||
cursor: move;
|
||||
cursor: -webkit-grab;
|
||||
}
|
||||
img#logo {
|
||||
width: 60%;
|
||||
display: block;
|
||||
margin: 1em auto 3em auto;
|
||||
background-color: #cef;
|
||||
border-radius: 50%;
|
||||
border: 5px solid #8bd;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
div.original-author-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2em;
|
||||
}
|
||||
div.original-author-container form {
|
||||
margin: 0;
|
||||
}
|
35
pkg/web/static/vendor/htmx-extension-json-enc.js
vendored
Normal file
35
pkg/web/static/vendor/htmx-extension-json-enc.js
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
(function() {
|
||||
let api
|
||||
htmx.defineExtension('json-enc', {
|
||||
init: function(apiRef) {
|
||||
api = apiRef
|
||||
},
|
||||
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:configRequest') {
|
||||
evt.detail.headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
},
|
||||
|
||||
encodeParameters: function(xhr, parameters, elt) {
|
||||
xhr.overrideMimeType('text/json')
|
||||
|
||||
const vals = api.getExpressionVars(elt)
|
||||
const object = {}
|
||||
parameters.forEach(function(value, key) {
|
||||
// FormData encodes values as strings, restore hx-vals/hx-vars with their initial types
|
||||
const typedValue = Object.hasOwn(vals, key) ? vals[key] : value
|
||||
if (Object.hasOwn(object, key)) {
|
||||
if (!Array.isArray(object[key])) {
|
||||
object[key] = [object[key]]
|
||||
}
|
||||
object[key].push(typedValue)
|
||||
} else {
|
||||
object[key] = typedValue
|
||||
}
|
||||
})
|
||||
|
||||
return (JSON.stringify(object))
|
||||
}
|
||||
})
|
||||
})()
|
1
pkg/web/static/vendor/htmx.min.js
vendored
Normal file
1
pkg/web/static/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
120
pkg/web/tpl/pages/IngredientDetail.templ
Normal file
120
pkg/web/tpl/pages/IngredientDetail.templ
Normal file
@ -0,0 +1,120 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "recipe_book/pkg/db"
|
||||
)
|
||||
|
||||
templ IngredientDetail(f Food) {
|
||||
<h1>{ f.Name }</h1>
|
||||
|
||||
<form hx-post={ fmt.Sprintf("/ingredients/%d", f.ID) } hx-ext="json-enc" hx-target="body">
|
||||
<div class="labelled-input">
|
||||
<label>name</label>
|
||||
<input type="name" name="name" value={ f.Name }>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="labelled-input">
|
||||
<label>calories</label>
|
||||
<input type="number" name="cals" value={ fmt.Sprint(f.Cals) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>carbs</label>
|
||||
<input type="number" name="carbs" value={ fmt.Sprint(f.Carbs) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>protein</label>
|
||||
<input type="number" name="protein" value={ fmt.Sprint(f.Protein) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>fat</label>
|
||||
<input type="number" name="fat" value={ fmt.Sprint(f.Fat) }>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="labelled-input">
|
||||
<label>sugar</label>
|
||||
<input type="number" name="sugar" value={ fmt.Sprint(f.Sugar) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>alcohol</label>
|
||||
<input type="number" name="alcohol" value={ fmt.Sprint(f.Alcohol) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>water</label>
|
||||
<input type="number" name="water" value={ fmt.Sprint(f.Water) }>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="labelled-input">
|
||||
<label>potassium</label>
|
||||
<input type="number" name="potassium" value={ fmt.Sprint(f.Potassium) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>calcium</label>
|
||||
<input type="number" name="calcium" value={ fmt.Sprint(f.Calcium) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>sodium</label>
|
||||
<input type="number" name="sodium" value={ fmt.Sprint(f.Sodium) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>magnesium</label>
|
||||
<input type="number" name="magnesium" value={ fmt.Sprint(f.Magnesium) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>phosphorus</label>
|
||||
<input type="number" name="phosphorus" value={ fmt.Sprint(f.Phosphorus) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>iron</label>
|
||||
<input type="number" name="iron" value={ fmt.Sprint(f.Iron) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>zinc</label>
|
||||
<input type="number" name="zinc" value={ fmt.Sprint(f.Zinc) }>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<div class="labelled-input">
|
||||
<label>
|
||||
<span class="title-text-parent">(?)<div class="title-text">Mass of one serving or 'unit' of this food. in grams</div></span>
|
||||
serving size (g)
|
||||
</label>
|
||||
<input type="number" name="mass" value={ fmt.Sprint(f.Mass) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>price</label>
|
||||
<input type="number" name="price" value={ fmt.Sprint(f.Price) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>density</label>
|
||||
<input type="number" name="density" value={ fmt.Sprint(f.Density) }>
|
||||
</div>
|
||||
<div class="labelled-input">
|
||||
<label>
|
||||
<span class="title-text-parent">(?)
|
||||
<div class="title-text">
|
||||
How much will it weigh after cooking, as a multiple of its original weight?
|
||||
<br><br>
|
||||
1 = no change
|
||||
<br>
|
||||
<1 = weighs less than before (e.g., it released some water)
|
||||
<br>
|
||||
>1 = weighs more than before (e.g., it soaked up water)
|
||||
</div>
|
||||
</span>
|
||||
cook ratio
|
||||
</label>
|
||||
<input type="number" name="cook_ratio" value={ fmt.Sprint(f.CookRatio) }>
|
||||
</div>
|
||||
|
||||
<input type="submit" value="Save">
|
||||
</form>
|
||||
}
|
43
pkg/web/tpl/pages/IngredientsIndex.templ
Normal file
43
pkg/web/tpl/pages/IngredientsIndex.templ
Normal file
@ -0,0 +1,43 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "recipe_book/pkg/db"
|
||||
)
|
||||
|
||||
templ IngredientsIndex(foods []Food) {
|
||||
<h1>Ingredients</h1>
|
||||
<dialog id="newIngredientDialog">
|
||||
<h3>Create new ingredient</h3>
|
||||
<form hx-post="/ingredients" hx-ext="json-enc" hx-target="body" hx-push-url="true">
|
||||
<label for="name">Name</label>
|
||||
<input name="name" />
|
||||
<input type="submit" value="Create" />
|
||||
</form>
|
||||
<button onclick="newIngredientDialog.close()">Cancel</button>
|
||||
</dialog>
|
||||
<button class=".new-item-button" onclick="newIngredientDialog.showModal()">New Ingredient</button>
|
||||
<table>
|
||||
<thead>
|
||||
<th>Name</th>
|
||||
<th>Cals</th>
|
||||
<th>Carbs</th>
|
||||
<th>Protein</th>
|
||||
<th>Fat</th>
|
||||
<th>Sugar</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, f := range foods {
|
||||
<tr>
|
||||
<td><a href={ templ.URL(fmt.Sprintf("/ingredients/%d", f.ID)) } hx-boost="true">{ f.Name }</a></td>
|
||||
<td>{ fmt.Sprint(f.Cals) }</td>
|
||||
<td>{ fmt.Sprint(f.Carbs) }</td>
|
||||
<td>{ fmt.Sprint(f.Protein) }</td>
|
||||
<td>{ fmt.Sprint(f.Fat) }</td>
|
||||
<td>{ fmt.Sprint(f.Sugar) }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
86
pkg/web/tpl/pages/RecipeDetail.templ
Normal file
86
pkg/web/tpl/pages/RecipeDetail.templ
Normal file
@ -0,0 +1,86 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
. "recipe_book/pkg/db"
|
||||
)
|
||||
|
||||
func to_food_list(r Recipe) []Food {
|
||||
ret := make([]Food, len(r.Ingredients))
|
||||
for i := range r.Ingredients {
|
||||
ret[i] = r.Ingredients[i].Food
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
templ RecipeDetail(recipe Recipe) {
|
||||
<h1>{ recipe.Name }</h1>
|
||||
<dialog id="renameRecipeDialog">
|
||||
<h3>Rename recipe</h3>
|
||||
<form hx-post={ fmt.Sprintf("/recipes/%d", recipe.ID) } hx-ext="json-enc" hx-target="body" hx-push-url="true">
|
||||
<label for="name">New name:</label>
|
||||
<input name="name" />
|
||||
<input type="submit" value="Update" />
|
||||
</form>
|
||||
<button onclick="renameRecipeDialog.close()">Cancel</button>
|
||||
</dialog>
|
||||
<button class="new-item-button" onclick="renameRecipeDialog.showModal()">Rename</button>
|
||||
|
||||
<table class="recipe-table">
|
||||
<thead>
|
||||
<th></th>
|
||||
<th>Amount</th>
|
||||
<th>Ingredient</th>
|
||||
<th>Calories</th>
|
||||
<th>Carbs</th>
|
||||
<th>Protein</th>
|
||||
<th>Fat</th>
|
||||
<th>Sugar</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, i := range recipe.Ingredients {
|
||||
<tr>
|
||||
<td class="delete-button"></td>
|
||||
<td class="amount">{ i.DisplayAmount() }</td>
|
||||
<td class="name">{ i.Food.Name }</td>
|
||||
<td class="cals">{ fmt.Sprint(int(i.Food.Cals * i.Quantity)) }</td>
|
||||
<td class="carbs">{ fmt.Sprint(int(i.Food.Carbs * i.Quantity)) }</td>
|
||||
<td class="protein">{ fmt.Sprint(int(i.Food.Protein * i.Quantity)) }</td>
|
||||
<td class="fat">{ fmt.Sprint(int(i.Food.Fat * i.Quantity)) }</td>
|
||||
<td class="sugar">{ fmt.Sprint(int(i.Food.Sugar * i.Quantity)) }</td>
|
||||
</tr>
|
||||
}
|
||||
{{ computed_food := recipe.ComputeFood() }}
|
||||
<tr class="recipe-table__total-row">
|
||||
<td class="delete-button"></td>
|
||||
<td class="amount"></td>
|
||||
<td class="name">Total</td>
|
||||
<td class="cals">{ fmt.Sprint(int(computed_food.Cals)) }</td>
|
||||
<td class="carbs">{ fmt.Sprint(int(computed_food.Carbs)) }</td>
|
||||
<td class="protein">{ fmt.Sprint(int(computed_food.Protein)) }</td>
|
||||
<td class="fat">{ fmt.Sprint(int(computed_food.Fat)) }</td>
|
||||
<td class="sugar">{ fmt.Sprint(int(computed_food.Sugar)) }</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
if recipe.Blurb != "" {
|
||||
<h2>Blurb</h2>
|
||||
for _, line := range strings.Split(recipe.Blurb, "\n") {
|
||||
<p><i>{ line }</i></p>
|
||||
}
|
||||
}
|
||||
|
||||
<h2>Instructions</h2>
|
||||
<ol class="instructions-list">
|
||||
for _, instr := range recipe.Instructions {
|
||||
<li>
|
||||
for _, line := range strings.Split(instr, "\n") {
|
||||
<p>{ line }</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
26
pkg/web/tpl/pages/RecipesIndex.templ
Normal file
26
pkg/web/tpl/pages/RecipesIndex.templ
Normal file
@ -0,0 +1,26 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
. "recipe_book/pkg/db"
|
||||
)
|
||||
|
||||
templ RecipesIndex(recipes []Recipe) {
|
||||
<h1>Recipes</h1>
|
||||
<dialog id="newRecipeDialog">
|
||||
<h3>Create new recipe</h3>
|
||||
<form hx-post="/recipes" hx-ext="json-enc" hx-target="body" hx-push-url="true">
|
||||
<label for="name">Name</label>
|
||||
<input name="name" />
|
||||
<input type="submit" value="Create" />
|
||||
</form>
|
||||
<button onclick="newRecipeDialog.close()">Cancel</button>
|
||||
</dialog>
|
||||
<button class="new-item-button" onclick="newRecipeDialog.showModal()">New Recipe</button>
|
||||
<ul>
|
||||
for _, r := range recipes {
|
||||
<li><a hx-post={ fmt.Sprintf("/recipes/%d", r.ID) } hx-target="body" hx-push-url="true">{ r.Name }</a></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
36
pkg/web/tpl/pages/base.templ
Normal file
36
pkg/web/tpl/pages/base.templ
Normal file
@ -0,0 +1,36 @@
|
||||
package pages
|
||||
|
||||
templ Base(title string) {
|
||||
<!doctype html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<title>{ title } | Recipe Book</title>
|
||||
|
||||
// Page content
|
||||
<link rel='shortcut icon' href='/static/chili-garlic.png' type='image/x-icon'>
|
||||
<link rel='stylesheet' href='/static/styles.css'>
|
||||
|
||||
// HTMX
|
||||
<script src="/static/vendor/htmx.min.js" integrity="sha384-oecSB0HeI5gdFcssloeKf3nByrZ7XjyAKxoykSkH8A4WPwT6suR+Ie4wGSLaQJBu" crossorigin="anonymous"></script>
|
||||
<script src="/static/vendor/htmx-extension-json-enc.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<img id="logo" src="/static/chili-garlic.png">
|
||||
<ul>
|
||||
<li><a hx-get="/ingredients" hx-target="body" hx-push-url="true">Ingredients</a></li>
|
||||
<li><a hx-get="/recipes" hx-target="body" hx-push-url="true">Recipes</a></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li><a hx-get="/about" hx-target="body" hx-push-url="true">About this app</a></li>
|
||||
<li><a hx-get="/help" hx-target="body" hx-push-url="true">Help</a></li>
|
||||
<li><a hx-get="/changelog" hx-target="body" hx-push-url="true">Changelog</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main>
|
||||
{ children... }
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
6
sample_data/reset.sh
Executable file
6
sample_data/reset.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
test -e sample_data/data/food.db && rm sample_data/data/food.db
|
||||
|
||||
go run ./cmd init
|
||||
sqlite3 sample_data/data/food.db < sample_data/seed.sql
|
Loading…
x
Reference in New Issue
Block a user