Kirjaa now works like the catalog: opening a day, picking a dish, saving, deleting, cancelling and "Näytä lisää" all patch the list where it stands. Nothing loads a page, so the scroll position never moves. One builder serves all three paths. buildLog takes what the screen should show — the day, an open dish, whether the entry is being changed or a delete confirmed — and the page render, the patch and the post-write response all go through it. After a save it is called with only the date, so the day comes back closed rather than reopening the sides step it was just submitted from. Links stay links and forms stay forms, with data-on:click__prevent and data-on:submit__prevent layered over them, so it all still works with JavaScript off. Each response patches a single element, so plain text/html is enough here; the SSE writer is only needed by the catalog, where the list and both forms have to move together. The anchors added in the previous attempt are gone. They could never have worked: the browser positions an anchor without knowing where the page was scrolled, so it jumped regardless.
727 lines
22 KiB
Go
727 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/a-h/templ"
|
|
)
|
|
|
|
// maxUpload caps a pasted or uploaded bundle. A household catalog is a few
|
|
// kilobytes; a megabyte is already absurd generosity.
|
|
const maxUpload = 1 << 20
|
|
|
|
const (
|
|
// historyDays is one window of the history under the logger, and the step
|
|
// that "show more" grows it by. Older days arrive a window at a time
|
|
// rather than all at once.
|
|
historyDays = 30
|
|
|
|
// maxHistoryDays caps what a hand-edited URL can ask for, so ?paivat=
|
|
// cannot be turned into a request to render a decade of rows.
|
|
maxHistoryDays = 366 * 5
|
|
)
|
|
|
|
// historyWindow reads ?paivat=, the number of days of history to show.
|
|
func historyWindow(r *http.Request) int {
|
|
days := historyDays
|
|
if raw := r.URL.Query().Get("paivat"); raw != "" {
|
|
if n, err := strconv.Atoi(raw); err == nil && n > days {
|
|
days = min(n, maxHistoryDays)
|
|
}
|
|
}
|
|
return days
|
|
}
|
|
|
|
type app struct {
|
|
db *sql.DB
|
|
loc *time.Location
|
|
}
|
|
|
|
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := c.Render(r.Context(), w); err != nil {
|
|
log.Printf("render %s: %v", r.URL.Path, err)
|
|
}
|
|
}
|
|
|
|
// date reads the ?pvm= parameter, falling back to today. An unparseable value
|
|
// is treated as today rather than an error: a mangled URL should not be a
|
|
// dead end.
|
|
//
|
|
// A future date is clamped to today. This is a record of what was eaten, so
|
|
// there is nothing to write down for a dinner that has not happened, and a
|
|
// stray entry dated next year would sit at the top of the history forever.
|
|
// Every read and write goes through here, so the clamp covers them all.
|
|
func (a *app) date(r *http.Request) time.Time {
|
|
now := today(a.loc)
|
|
if raw := r.FormValue("pvm"); raw != "" {
|
|
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
|
|
if d.After(now) {
|
|
return now
|
|
}
|
|
return d
|
|
}
|
|
}
|
|
return now
|
|
}
|
|
|
|
// logView is everything the log screen needs. Logging and history are one
|
|
// page: every history row was already a link back into the logger, and the
|
|
// day switcher made them two views of the same thing.
|
|
type logView struct {
|
|
Date time.Time
|
|
Today time.Time
|
|
Search string
|
|
Entry *Entry // what is already logged for Date, if anything
|
|
Chosen *Dish // dish picked, so the sides step is showing
|
|
Checked map[int64]bool // sides ticked in that step
|
|
ShowBoard bool
|
|
Dishes []Dish // flat, only to know whether anything matched
|
|
Groups []DishGroup // what the board actually renders
|
|
Special []Dish // Tähteet and the like: loggable, but not food
|
|
Sides []Side
|
|
New mainForm // inline "add the dish you were looking for"
|
|
History HistoryPage
|
|
HistoryDays int // size of the window currently shown
|
|
HistoryMore int // the window size the "show more" link asks for
|
|
|
|
// Deleting a logged meal drops the row outright, unlike a dish which is
|
|
// only soft-deleted, so it asks first.
|
|
Confirming bool
|
|
}
|
|
|
|
// logOptions is what the Kirjaa screen is being asked to show. Pulled out of
|
|
// the request for a page load or a patch, and set directly after a write,
|
|
// where the answer is simply "that day, nothing else open".
|
|
type logOptions struct {
|
|
Date time.Time
|
|
Dish string // ?ruoka=, opening the sides step
|
|
Changing bool // ?muuta=, swapping the dish on a logged day
|
|
Confirming bool // ?poista=, asking before deleting the entry
|
|
Search string
|
|
}
|
|
|
|
func (a *app) logOptionsFrom(r *http.Request) logOptions {
|
|
q := r.URL.Query()
|
|
return logOptions{
|
|
Date: a.date(r),
|
|
Dish: q.Get("ruoka"),
|
|
Changing: q.Get("muuta") != "",
|
|
Confirming: q.Get("poista") != "",
|
|
Search: strings.TrimSpace(q.Get("haku")),
|
|
}
|
|
}
|
|
|
|
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|
render(w, r, logPage(a.buildLog(r, a.logOptionsFrom(r))))
|
|
}
|
|
|
|
// day patches the list in place. Every link in it calls here rather than
|
|
// loading a page, so opening a day leaves the scroll position alone.
|
|
func (a *app) day(w http.ResponseWriter, r *http.Request) {
|
|
fragment(w, r, dayList(a.buildLog(r, a.logOptionsFrom(r))))
|
|
}
|
|
|
|
// finishDay answers a write: a patch for Datastar, a redirect otherwise.
|
|
func (a *app) finishDay(w http.ResponseWriter, r *http.Request, date time.Time) {
|
|
if isDatastar(r) {
|
|
fragment(w, r, dayList(a.buildLog(r, logOptions{Date: date})))
|
|
return
|
|
}
|
|
a.redirectToDay(w, r, date)
|
|
}
|
|
|
|
func (a *app) buildLog(r *http.Request, o logOptions) logView {
|
|
date := o.Date
|
|
v := logView{
|
|
Date: date,
|
|
Today: today(a.loc),
|
|
Search: o.Search,
|
|
Checked: map[int64]bool{},
|
|
Confirming: o.Confirming,
|
|
}
|
|
|
|
entry, err := entryFor(a.db, date)
|
|
if err != nil {
|
|
log.Printf("entry for %s: %v", date.Format(dateLayout), err)
|
|
}
|
|
v.Entry = entry
|
|
|
|
// ?ruoka= opens the sides step for that dish. When it is the dish already
|
|
// logged, the existing sides come back ticked, which makes editing an
|
|
// entry the same screen as creating one.
|
|
if o.Dish != "" {
|
|
if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil {
|
|
if dish, err := dishByID(a.db, id); err == nil {
|
|
v.Chosen = dish
|
|
if entry != nil && entry.Main.ID == id {
|
|
for _, s := range entry.Sides {
|
|
v.Checked[s.ID] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The board shows when there is nothing logged yet, or when the entry is
|
|
// being changed. "Muokkaa" on a logged day sets ?muuta=1 and lands here,
|
|
// so swapping the dish and picking one for the first time are one path.
|
|
changing := o.Changing || v.Search != ""
|
|
if v.Chosen == nil && (v.Entry == nil || changing) {
|
|
v.ShowBoard = true
|
|
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
|
log.Printf("list dishes: %v", err)
|
|
}
|
|
// listDishes already orders by frequency then name, so grouping keeps
|
|
// the favourites at the top of each category.
|
|
v.Groups = groupDishes(v.Dishes)
|
|
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
|
log.Printf("list special: %v", err)
|
|
}
|
|
// Seed the inline add form with whatever was searched for, so a miss
|
|
// turns straight into "add it" without retyping.
|
|
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
|
}
|
|
if v.Chosen != nil && v.Chosen.HasSides {
|
|
if v.Sides, err = listSides(a.db, ""); err != nil {
|
|
log.Printf("list sides: %v", err)
|
|
}
|
|
}
|
|
|
|
a.loadDays(r, &v)
|
|
return v
|
|
}
|
|
|
|
// loadDays fills the day list. The selected day expands inside it rather than
|
|
// in a panel above it, so choosing a day from the list does not reorder the
|
|
// list underneath the tap.
|
|
func (a *app) loadDays(r *http.Request, v *logView) {
|
|
v.HistoryDays = historyWindow(r)
|
|
|
|
// The window has to reach the selected day, or it would have nowhere to
|
|
// expand.
|
|
if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays {
|
|
v.HistoryDays = min(reach, maxHistoryDays)
|
|
}
|
|
v.HistoryMore = v.HistoryDays + historyDays
|
|
|
|
page, err := history(a.db, a.loc, v.Today, v.HistoryDays)
|
|
if err != nil {
|
|
log.Printf("history: %v", err)
|
|
}
|
|
// Nothing logged ever: the selected day is still the one being worked on,
|
|
// so it needs a row of its own to open in.
|
|
if len(page.Rows) == 0 {
|
|
page.Rows = []HistoryRow{{Date: v.Date, Entry: v.Entry}}
|
|
}
|
|
v.History = page
|
|
}
|
|
|
|
// searchSignals is what Datastar sends back: for a GET it JSON-encodes the
|
|
// signals into the `datastar` query parameter.
|
|
type searchSignals struct {
|
|
Haku string `json:"haku"`
|
|
}
|
|
|
|
// readSignals decodes that parameter.
|
|
//
|
|
// ponytail: the Datastar SDK does this too, but pulling it in for one JSON
|
|
// decode dragged along four modules — an HTTP compression stack among them —
|
|
// for an SSE generator this app never uses. Absent or empty is not an error:
|
|
// the first request carries no signals.
|
|
func readSignals(r *http.Request, into any) error {
|
|
raw := r.URL.Query().Get("datastar")
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
return json.Unmarshal([]byte(raw), into)
|
|
}
|
|
|
|
// fragment renders a piece of a page for Datastar to patch in. A plain
|
|
// text/html response is enough — Datastar matches the returned element by its
|
|
// id and replaces it, so there is no SSE stream to manage.
|
|
func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := c.Render(r.Context(), w); err != nil {
|
|
log.Printf("fragment %s: %v", r.URL.Path, err)
|
|
}
|
|
}
|
|
|
|
// isDatastar reports whether the request came from the client library, which
|
|
// tags its own. Everything below keeps working without JavaScript: the same
|
|
// handlers redirect instead of patching when the header is absent.
|
|
func isDatastar(r *http.Request) bool {
|
|
return r.Header.Get("Datastar-Request") != ""
|
|
}
|
|
|
|
// patchElements sends one Datastar event carrying several elements, each
|
|
// matched to the page by its id. A text/html response can only replace one
|
|
// element, and the catalog has to move its list and its forms together —
|
|
// opening an edit form also has to un-highlight whatever was open before.
|
|
//
|
|
// ponytail: about twenty lines instead of the SDK, which brought four modules
|
|
// for an SSE generator we would otherwise never call.
|
|
func patchElements(w http.ResponseWriter, r *http.Request, components ...templ.Component) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
|
|
var out strings.Builder
|
|
out.WriteString("event: datastar-patch-elements\n")
|
|
for _, c := range components {
|
|
var html strings.Builder
|
|
if err := c.Render(r.Context(), &html); err != nil {
|
|
log.Printf("patch %s: %v", r.URL.Path, err)
|
|
return
|
|
}
|
|
// One `data: elements` line per line of HTML, as the protocol wants.
|
|
for _, line := range strings.Split(html.String(), "\n") {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
out.WriteString("data: elements ")
|
|
out.WriteString(line)
|
|
out.WriteString("\n")
|
|
}
|
|
}
|
|
out.WriteString("\n")
|
|
|
|
io.WriteString(w, out.String())
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// searchBoard re-renders the dish board as the search box is typed into.
|
|
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
|
|
var signals searchSignals
|
|
if err := readSignals(r, &signals); err != nil {
|
|
http.Error(w, "bad signals", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
v := logView{
|
|
Date: a.date(r),
|
|
Today: today(a.loc),
|
|
Search: strings.TrimSpace(signals.Haku),
|
|
}
|
|
dishes, err := listDishes(a.db, v.Search)
|
|
if err != nil {
|
|
log.Printf("search dishes: %v", err)
|
|
}
|
|
v.Dishes = dishes
|
|
v.Groups = groupDishes(dishes)
|
|
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
|
log.Printf("search special: %v", err)
|
|
}
|
|
v.New = mainForm{Name: v.Search, Categories: map[string]bool{}, HasSides: true}
|
|
|
|
fragment(w, r, boardList(v))
|
|
}
|
|
|
|
// searchCatalog re-renders the catalog lists as the search box is typed into.
|
|
func (a *app) searchCatalog(w http.ResponseWriter, r *http.Request) {
|
|
var signals searchSignals
|
|
if err := readSignals(r, &signals); err != nil {
|
|
http.Error(w, "bad signals", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
v := catalogView{Search: strings.TrimSpace(signals.Haku)}
|
|
mains, err := listDishes(a.db, v.Search)
|
|
if err != nil {
|
|
log.Printf("search catalog: %v", err)
|
|
}
|
|
v.Mains = len(mains)
|
|
sortByName(mains)
|
|
v.Groups = groupDishes(mains)
|
|
if v.Sides, err = listSides(a.db, v.Search); err != nil {
|
|
log.Printf("search sides: %v", err)
|
|
}
|
|
|
|
fragment(w, r, catalogList(v))
|
|
}
|
|
|
|
// quickAdd creates a dish from the Kirjaa screen and goes straight on to
|
|
// logging it. Hunting for something that is not in the catalog yet should not
|
|
// mean a detour through Ruoat and a lost train of thought.
|
|
func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
|
|
date := a.date(r)
|
|
|
|
form := mainForm{
|
|
Name: normalizeName(r.FormValue("nimi")),
|
|
Categories: map[string]bool{},
|
|
HasSides: r.FormValue("lisukkeita") != "",
|
|
}
|
|
var categories []string
|
|
for _, c := range r.Form["kategoria"] {
|
|
if validCategories[c] {
|
|
categories = append(categories, c)
|
|
form.Categories[c] = true
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case form.Name == "":
|
|
form.Err = "Anna nimi."
|
|
case len(categories) == 0:
|
|
form.Err = "Valitse vähintään yksi kategoria."
|
|
}
|
|
|
|
if form.Err == "" {
|
|
id, err := createMain(a.db, form.Name, categories, form.HasSides)
|
|
switch {
|
|
case errors.Is(err, errNameTaken):
|
|
form.Err = "Nimi on jo listalla."
|
|
case err != nil:
|
|
log.Printf("quick add: %v", err)
|
|
form.Err = "Tallennus epäonnistui."
|
|
default:
|
|
// Created: straight on to its sides step.
|
|
opts := logOptions{Date: date, Dish: strconv.FormatInt(id, 10)}
|
|
if isDatastar(r) {
|
|
fragment(w, r, dayList(a.buildLog(r, opts)))
|
|
return
|
|
}
|
|
a.redirectToPick(w, r, date, id)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Rejected: back to the board with the form filled in and the search
|
|
// still narrowed, so the add card stays on screen.
|
|
v := a.buildLog(r, logOptions{Date: date, Search: form.Name})
|
|
v.New = form
|
|
if isDatastar(r) {
|
|
fragment(w, r, dayList(v))
|
|
return
|
|
}
|
|
render(w, r, logPage(v))
|
|
}
|
|
|
|
func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.Time, id int64) {
|
|
target := dayURL("/", date, today(a.loc))
|
|
sep := "?"
|
|
if strings.Contains(target, "?") {
|
|
sep = "&"
|
|
}
|
|
http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
|
}
|
|
|
|
// save records the meal and redirects, so a refresh cannot double-post.
|
|
func (a *app) save(w http.ResponseWriter, r *http.Request) {
|
|
date := a.date(r)
|
|
|
|
mainID, err := strconv.ParseInt(r.FormValue("ruoka"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "tuntematon ruoka", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var sideIDs []int64
|
|
for _, raw := range r.Form["lisuke"] {
|
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
sideIDs = append(sideIDs, id)
|
|
}
|
|
}
|
|
|
|
if err := saveEntry(a.db, date, mainID, sideIDs); err != nil {
|
|
log.Printf("save entry: %v", err)
|
|
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.finishDay(w, r, date)
|
|
}
|
|
|
|
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
|
date := a.date(r)
|
|
if err := deleteEntry(a.db, date); err != nil {
|
|
log.Printf("delete entry: %v", err)
|
|
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.finishDay(w, r, date)
|
|
}
|
|
|
|
// redirectToDay is the no-JavaScript path back after a write.
|
|
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
|
|
http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther)
|
|
}
|
|
|
|
// mainForm and sideForm carry what the user typed, so a rejected submission
|
|
// comes back filled in rather than blank.
|
|
type mainForm struct {
|
|
ID int64
|
|
Name string
|
|
Categories map[string]bool
|
|
HasSides bool
|
|
Err string
|
|
}
|
|
|
|
type sideForm struct {
|
|
ID int64
|
|
Name string
|
|
Err string
|
|
}
|
|
|
|
type catalogView struct {
|
|
Groups []DishGroup
|
|
Sides []Side
|
|
Main mainForm
|
|
Side sideForm
|
|
Report *ImportReport
|
|
Mains int // count, for the header
|
|
Search string
|
|
|
|
// The row awaiting a delete confirmation, if any. A trash icon is easy to
|
|
// hit by accident, so the row asks before anything happens.
|
|
DeleteID int64
|
|
DeleteKind string
|
|
}
|
|
|
|
// catalog renders the whole page. show patches the same state in place, so
|
|
// nothing navigates: both build the view the same way.
|
|
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
|
a.renderCatalog(w, r, a.catalogState(r))
|
|
}
|
|
|
|
// show is what every catalog link actually calls. It patches the list and both
|
|
// forms rather than loading a page, so opening an edit form or asking to
|
|
// delete a row leaves the scroll position exactly where it was.
|
|
func (a *app) show(w http.ResponseWriter, r *http.Request) {
|
|
a.patchCatalog(w, r, a.catalogState(r))
|
|
}
|
|
|
|
func (a *app) catalogState(r *http.Request) catalogView {
|
|
v := catalogView{
|
|
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
|
|
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
|
|
}
|
|
|
|
// ?muokkaa= loads a dish into its form; the same form adds and edits.
|
|
if raw := r.URL.Query().Get("muokkaa"); raw != "" {
|
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
if dish, err := dishByID(a.db, id); err == nil {
|
|
v.Main = mainForm{
|
|
ID: dish.ID,
|
|
Name: dish.Name,
|
|
Categories: map[string]bool{},
|
|
HasSides: dish.HasSides,
|
|
}
|
|
for _, c := range dish.Categories {
|
|
v.Main.Categories[c] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if raw := r.URL.Query().Get("muokkaa-lisuke"); raw != "" {
|
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
if side, err := sideByID(a.db, id); err == nil {
|
|
v.Side = sideForm{ID: side.ID, Name: side.Name}
|
|
}
|
|
}
|
|
}
|
|
if raw := r.URL.Query().Get("poista"); raw != "" {
|
|
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
|
v.DeleteID = id
|
|
v.DeleteKind = r.URL.Query().Get("tyyppi")
|
|
}
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// fillCatalog loads the lists into a view built from the request.
|
|
func (a *app) fillCatalog(v *catalogView) {
|
|
if v.Main.Categories == nil {
|
|
v.Main.Categories = map[string]bool{}
|
|
}
|
|
|
|
mains, err := listDishes(a.db, v.Search)
|
|
if err != nil {
|
|
log.Printf("list mains: %v", err)
|
|
}
|
|
v.Mains = len(mains)
|
|
sortByName(mains) // the catalog is managed, so position should be predictable
|
|
v.Groups = groupDishes(mains)
|
|
|
|
if v.Sides, err = listSides(a.db, v.Search); err != nil {
|
|
log.Printf("list sides: %v", err)
|
|
}
|
|
}
|
|
|
|
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
|
|
a.fillCatalog(&v)
|
|
render(w, r, catalogPage(v))
|
|
}
|
|
|
|
// patchCatalog swaps the list and both forms in one event. They move together:
|
|
// opening an edit form also has to clear whatever delete was being confirmed.
|
|
func (a *app) patchCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
|
|
a.fillCatalog(&v)
|
|
patchElements(w, r, catalogList(v), mainForm_(v.Main), sideForm_(v.Side))
|
|
}
|
|
|
|
// saveMain adds or updates a main dish. A rejected form is re-rendered with
|
|
// the values still in it; a good one redirects, so refresh cannot re-submit.
|
|
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
|
|
form := mainForm{
|
|
Name: normalizeName(r.FormValue("nimi")),
|
|
Categories: map[string]bool{},
|
|
HasSides: r.FormValue("lisukkeita") != "",
|
|
}
|
|
if raw := r.FormValue("id"); raw != "" {
|
|
form.ID, _ = strconv.ParseInt(raw, 10, 64)
|
|
}
|
|
|
|
var categories []string
|
|
for _, c := range r.Form["kategoria"] {
|
|
if validCategories[c] {
|
|
categories = append(categories, c)
|
|
form.Categories[c] = true
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case form.Name == "":
|
|
form.Err = "Anna nimi."
|
|
case len(categories) == 0:
|
|
form.Err = "Valitse vähintään yksi kategoria."
|
|
}
|
|
if form.Err == "" {
|
|
var err error
|
|
if form.ID == 0 {
|
|
_, err = createMain(a.db, form.Name, categories, form.HasSides)
|
|
} else {
|
|
err = updateMain(a.db, form.ID, form.Name, categories, form.HasSides)
|
|
}
|
|
switch {
|
|
case errors.Is(err, errNameTaken):
|
|
form.Err = "Nimi on jo listalla."
|
|
case err != nil:
|
|
log.Printf("save main: %v", err)
|
|
form.Err = "Tallennus epäonnistui."
|
|
default:
|
|
// Saved: hand back a blank form so it collapses, and a list with
|
|
// the dish in it.
|
|
a.finishCatalog(w, r, catalogView{})
|
|
return
|
|
}
|
|
}
|
|
a.finishCatalog(w, r, catalogView{Main: form})
|
|
}
|
|
|
|
// finishCatalog answers a catalog write: a patch for Datastar, a redirect for
|
|
// a plain form post. Without the redirect, submitting with JavaScript off
|
|
// would leave the browser sitting on a POST it could not reload.
|
|
func (a *app) finishCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
|
|
if isDatastar(r) {
|
|
a.patchCatalog(w, r, v)
|
|
return
|
|
}
|
|
if v.Main.Err != "" || v.Side.Err != "" {
|
|
a.renderCatalog(w, r, v)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/ruuat", http.StatusSeeOther)
|
|
}
|
|
|
|
func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
|
|
form := sideForm{Name: normalizeName(r.FormValue("nimi"))}
|
|
if raw := r.FormValue("id"); raw != "" {
|
|
form.ID, _ = strconv.ParseInt(raw, 10, 64)
|
|
}
|
|
|
|
if form.Name == "" {
|
|
form.Err = "Anna nimi."
|
|
} else {
|
|
var err error
|
|
if form.ID == 0 {
|
|
err = createSide(a.db, form.Name)
|
|
} else {
|
|
err = updateSide(a.db, form.ID, form.Name)
|
|
}
|
|
switch {
|
|
case errors.Is(err, errNameTaken):
|
|
form.Err = "Nimi on jo listalla."
|
|
case err != nil:
|
|
log.Printf("save side: %v", err)
|
|
form.Err = "Tallennus epäonnistui."
|
|
default:
|
|
a.finishCatalog(w, r, catalogView{})
|
|
return
|
|
}
|
|
}
|
|
a.finishCatalog(w, r, catalogView{Side: form})
|
|
}
|
|
|
|
// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6).
|
|
//
|
|
// ponytail: no confirmation step. The row survives and history still reads
|
|
// correctly; add a confirm or an undo list if anyone actually mis-taps.
|
|
func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
|
if err != nil {
|
|
http.Error(w, "tuntematon ruoka", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if r.FormValue("tyyppi") == "lisuke" {
|
|
err = softDeleteSide(a.db, id)
|
|
} else {
|
|
err = softDeleteMain(a.db, id)
|
|
}
|
|
if err != nil {
|
|
log.Printf("delete dish: %v", err)
|
|
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
a.finishCatalog(w, r, catalogView{})
|
|
}
|
|
|
|
// importDishes takes a bundle either pasted into the textarea or uploaded as a
|
|
// file, and reports row by row what happened (PRD §7.3).
|
|
//
|
|
// ponytail: a plain multipart form rather than a Datastar round trip. The
|
|
// result is a whole-page report, not a fragment, and a form needs no client
|
|
// code at all.
|
|
func (a *app) importDishes(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
|
|
|
|
if err := r.ParseMultipartForm(maxUpload); err != nil {
|
|
a.renderCatalog(w, r, catalogView{Report: failed("Tiedosto on liian suuri tai vioittunut.")})
|
|
return
|
|
}
|
|
|
|
var src io.Reader
|
|
if file, _, err := r.FormFile("tiedosto"); err == nil {
|
|
defer file.Close()
|
|
src = file
|
|
} else if pasted := strings.TrimSpace(r.FormValue("json")); pasted != "" {
|
|
src = strings.NewReader(pasted)
|
|
} else {
|
|
a.renderCatalog(w, r, catalogView{Report: failed("Ei tuotavaa: liitä JSON tai valitse tiedosto.")})
|
|
return
|
|
}
|
|
|
|
report, err := importBundle(a.db, src)
|
|
if err != nil {
|
|
a.renderCatalog(w, r, catalogView{Report: failed("JSON ei kelpaa: " + err.Error())})
|
|
return
|
|
}
|
|
a.renderCatalog(w, r, catalogView{Report: report})
|
|
}
|
|
|
|
// failed builds a report for a whole-request failure, so the view only ever
|
|
// has one shape to render.
|
|
func failed(note string) *ImportReport {
|
|
return &ImportReport{Skipped: 1, Notes: []string{note}}
|
|
}
|