Add beginning of web package
Some checks failed
Build / release (push) Blocked by required conditions
Build / build (push) Has been cancelled

This commit is contained in:
Alessio 2024-11-18 16:25:39 -08:00
parent 2670af3eb7
commit 10cc9342b1
22 changed files with 991 additions and 20 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
sample_data/data
*_templ.go

View File

@ -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)
}

View 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
View File

@ -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
View File

@ -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
View File

@ -0,0 +1,9 @@
package db
import (
"errors"
)
var (
ErrNotInDB = errors.New("not in db")
)

View File

@ -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
}

View 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)
}

View 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)
}

View 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
View 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
View 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
View 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
View 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;
}

View 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

File diff suppressed because one or more lines are too long

View 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>
&lt;1 = weighs less than before (e.g., it released some water)
<br>
&gt;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>
}

View 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>
}

View 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>
}

View 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>
}

View 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
View 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