Files
foodster/cmd/foodster/views.templ
T
Esa Kataja b8a46cdd30 Patch the day list in place instead of navigating
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.
2026-09-05 23:45:53 +03:00

918 lines
27 KiB
Templ
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
// Finnish weekday and month names. Go formats dates in English only, and this
// app has exactly one locale (PRD §5).
var (
weekdaysFI = [...]string{"sunnuntai", "maanantai", "tiistai", "keskiviikko",
"torstai", "perjantai", "lauantai"}
weekdayAbbrFI = [...]string{"su", "ma", "ti", "ke", "to", "pe", "la"}
monthsFI = [...]string{"", "tammikuu", "helmikuu", "maaliskuu", "huhtikuu",
"toukokuu", "kesäkuu", "heinäkuu", "elokuu", "syyskuu", "lokakuu",
"marraskuu", "joulukuu"}
)
func longDateFI(t time.Time) string {
return weekdaysFI[t.Weekday()] + " " + t.Format("2.1.2006")
}
func dayLabelFI(t time.Time) string {
return weekdayAbbrFI[t.Weekday()] + " " + t.Format("2.1.")
}
func monthFI(t time.Time) string {
return monthsFI[int(t.Month())]
}
func isoDate(t time.Time) string {
return t.Format(dateLayout)
}
// dayURL links to a specific day, leaving today as the bare path.
func dayURL(base string, d, now time.Time) string {
if d.Equal(now) {
return base
}
return base + "?pvm=" + isoDate(d)
}
// showURL turns a catalog page link into the patch endpoint behind it, so the
// href and the Datastar call never drift apart.
func showURL(pageURL string) string {
return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1)
}
// dayPatch is the endpoint behind every link in the day list. The href beside
// it stays a real page URL for anyone without JavaScript; Datastar calls this
// instead and swaps the list where it stands.
func dayPatch(d time.Time, param string) string {
url := "/paiva?pvm=" + isoDate(d)
if param != "" {
url += "&" + param
}
return url
}
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
// any day but today.
func pickSeparator(v logView) string {
if v.Date.Equal(v.Today) {
return "?"
}
return "&"
}
// stepURL is the page URL for a link inside the open day: the fallback when
// there is no JavaScript to intercept it.
func stepURL(v logView, param string) string {
url := dayURL("/", v.Date, v.Today)
if param != "" {
url += pickSeparator(v) + param
}
return url
}
// jsString renders a Go string as a JavaScript literal, for the data-signals
// attribute that seeds the search box.
func jsString(s string) string {
b, err := json.Marshal(s)
if err != nil {
return `""`
}
return string(b)
}
// searchURL is where the live search posts back to. The day travels in the
// path so the board keeps rendering links for the right date; the search text
// travels as a Datastar signal.
func searchURL(v logView) string {
if v.Date.Equal(v.Today) {
return "/etsi"
}
return "/etsi?pvm=" + isoDate(v.Date)
}
// categoryLabels lists a dish's categories in Finnish, for the catalog rows.
func categoryLabels(d Dish) string {
names := make([]string, 0, len(d.Categories))
for _, c := range d.Categories {
names = append(names, categoryFI[c])
}
return strings.Join(names, ", ")
}
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
// after every number except one.
func countFI(n int, one, many string) string {
if n == 1 {
return fmt.Sprintf("%d %s", n, one)
}
return fmt.Sprintf("%d %s", n, many)
}
templ page(title, current string) {
<!DOCTYPE html>
// Dark is the default, set here so it holds even before theme.js runs and
// for anyone without JavaScript.
<html lang="fi" data-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"/>
<meta name="color-scheme" content="light dark"/>
// Matches --header in app.css so the browser chrome continues the
// header rather than butting against it.
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#FFFFFF"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
<title>{ title }</title>
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
<!-- use-credentials: the manifest is fetched behind Basic auth and
would otherwise come back 401 and be ignored. -->
<link rel="manifest" href="/static/manifest.webmanifest" crossorigin="use-credentials"/>
<link rel="stylesheet" href="/static/app.css"/>
<!-- Not deferred: it applies the stored theme before first paint. -->
<script src="/static/theme.js"></script>
<script type="module" src="/static/datastar.js"></script>
</head>
<body>
@brandbar()
{ children... }
@tabbar(current)
</body>
</html>
}
templ brandbar() {
<header class="brandbar">
<a class="brand" href="/">
<img src="/static/favicon.svg" width="24" height="24" alt=""/>
<span>Foodster</span>
</a>
@themeSwitch()
</header>
}
// themeSwitch shows the theme that is on right now — moon while dark, sun
// while light — and clicking swaps it. Both icons are in the markup and CSS
// picks one, so the server never has to know the device's choice.
// theme.js writes the label, which names the state and then the action.
templ themeSwitch() {
<button class="themetoggle" type="button" data-theme-toggle aria-label="Teema" title="Teema">
@iconSun()
@iconMoon()
</button>
}
templ iconSun() {
<svg class="i-sun" viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false">
<circle cx="8" cy="8" r="3.1" fill="currentColor"></circle>
<path
d="M8 1.1v1.7M8 13.2v1.7M1.1 8h1.7M13.2 8h1.7M3.2 3.2l1.2 1.2M11.6 11.6l1.2 1.2M12.8 3.2l-1.2 1.2M4.4 11.6l-1.2 1.2"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
></path>
</svg>
}
templ iconMoon() {
<svg class="i-moon" viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false">
<path d="M13.5 10.4A6 6 0 0 1 5.6 2.5 6.3 6.3 0 1 0 13.5 10.4z" fill="currentColor"></path>
</svg>
}
// categoryIcon draws a dish's category as colour *and* shape. Colour on its
// own was not telling: a red blob and a yellow blob only differ once you have
// learned the legend.
templ categoryIcon(key string) {
switch key {
case "liha":
<span class="cat c-liha">
@glyphLiha()
</span>
case "kana":
<span class="cat c-kana">
@glyphKana()
</span>
case "kala":
<span class="cat c-kala">
@glyphKala()
</span>
case "kasvis":
<span class="cat c-kasvis">
@glyphKasvis()
</span>
case "tahteet":
<span class="cat c-tahteet">
@glyphTahteet()
</span>
default:
<span class="cat">
@glyphSekalaiset()
</span>
}
}
// A lidded tub. Tähteet is not food and not a mixture of categories, so it
// gets neither a category colour nor the quartered mark.
templ glyphTahteet() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="currentColor">
<rect x="1.4" y="2.6" width="13.2" height="3" rx="1.3"></rect>
<path d="M2.8 6.8h10.4l-.9 6.6a1.6 1.6 0 0 1-1.6 1.4H5.3a1.6 1.6 0 0 1-1.6-1.4z"></path>
</svg>
}
// A steak, its bone knocked out with fill-rule so the hole is transparent on
// whatever background the icon lands on.
templ glyphLiha() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fill-rule="evenodd"
d="M8 1.8c3.4 0 6.1 2.3 6.1 5.1 0 1.7-1 3.2-2.5 4.1-.4 1.9-1.9 3.2-3.6 3.2-3.4 0-6.1-2.5-6.1-5.6C1.9 5 4.6 1.8 8 1.8zm2.7 7.5a1.6 1.6 0 1 0-3.2 0 1.6 1.6 0 0 0 3.2 0z"
></path>
</svg>
}
// A drumstick. The bone is what keeps it apart from the steak at small sizes,
// where both are otherwise warm blobs.
templ glyphKana() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" d="M7.8 8.2l3.1-3.1"></path>
<circle cx="5.4" cy="10.6" r="3.6" fill="currentColor"></circle>
<circle cx="12.2" cy="3.8" r="2.1" fill="currentColor"></circle>
<circle cx="13.4" cy="5.6" r="1.7" fill="currentColor"></circle>
</svg>
}
templ glyphKala() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fill-rule="evenodd"
d="M14.4 8c-1.8 2.7-4.4 4.2-7 4.2-1.3 0-2.6-.4-3.6-1.1L1.4 13.2V2.8l2.4 2.1c1-.7 2.3-1.1 3.6-1.1 2.6 0 5.2 1.5 7 4.2zm-3.6-1.1a.95.95 0 1 0 0 1.9.95.95 0 0 0 0-1.9z"
></path>
</svg>
}
// No midrib: stroking one in the card colour only works on a card, and these
// also sit on the page background in the history list.
templ glyphKasvis() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M14 1.6C6.9 1.4 2.6 4.7 2.6 9.5c0 1.2.3 2.2.8 3.1l-1.7 1.7 1.3 1.3 1.7-1.7c.9.5 1.9.8 3.1.8 4.8 0 7-4.4 6.2-13z"
></path>
</svg>
}
// Quartered, one wedge per category: the mark already means "all of them".
templ glyphSekalaiset() {
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path fill="var(--liha)" d="M8 8V1.4A6.6 6.6 0 0 1 14.6 8z"></path>
<path fill="var(--kana)" d="M8 8h6.6A6.6 6.6 0 0 1 8 14.6z"></path>
<path fill="var(--kala)" d="M8 8v6.6A6.6 6.6 0 0 1 1.4 8z"></path>
<path fill="var(--kasvis)" d="M8 8H1.4A6.6 6.6 0 0 1 8 1.4z"></path>
</svg>
}
templ tabbar(current string) {
<nav class="tabbar">
@tab("/", "Kirjaa", current)
@tab("/ruuat", "Ruuat", current)
</nav>
}
templ tab(href, label, current string) {
if href == current {
<a href={ templ.SafeURL(href) } aria-current="page">{ label }</a>
} else {
<a href={ templ.SafeURL(href) }>{ label }</a>
}
}
// ---------------------------------------------------------------- Kirjaa
templ logPage(v logView) {
@page("Foodster", "/") {
<header class="appbar">
<h2>Mitä syötiin?</h2>
<p class="meta">{ longDateFI(v.Date) }</p>
@daySwitch(v)
</header>
<main class="pad">
@dayList(v)
</main>
}
}
// dayList is the whole page: every day back through the window, with the
// selected one expanded where it sits. Opening a day used to swap in a panel
// above the list and drop that day out of it, so the rows below jumped up
// under the tap. Now nothing moves — the row grows.
templ dayList(v logView) {
<section class="history" id="paivat">
for i, row := range v.History.Rows {
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Date.Equal(v.Date) {
<div class="open">
<p class="openday">
if row.Date.Equal(v.Today) {
Tänään
} else {
{ longDateFI(row.Date) }
}
</p>
switch {
case v.Chosen != nil:
@sidesStep(v)
case v.ShowBoard:
@board(v)
default:
@loggedCard(v)
}
</div>
} else if row.Entry != nil {
<a
class="entry"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
@categoryIcon(row.Entry.Main.CategoryKey())
{ row.Entry.Main.Name }
</div>
<div class="sd">{ row.Entry.SidesLabel() }</div>
</div>
<span class="chev"></span>
</a>
} else {
<a
class="gapline"
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
>
<time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span>
<span class="act">Merkitse</span>
</a>
}
}
if v.History.More {
<a
class="more"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "paivat="+strconv.Itoa(v.HistoryMore)) + "')" }
>Näytä lisää</a>
}
</section>
}
templ daySwitch(v logView) {
<div class="dayseg">
@dayButton("Tänään", v.Today, v.Date, v.Today)
@dayButton("Eilen", v.Today.AddDate(0, 0, -1), v.Date, v.Today)
<form method="get" action="/" class="daypick">
// max stops the picker offering days that have not happened yet;
// the server clamps anyway, this just avoids the dead end.
<input type="date" name="pvm" value={ isoDate(v.Date) } max={ isoDate(v.Today) } aria-label="Muu päivä"/>
<button type="submit">Näytä</button>
</form>
</div>
}
templ dayButton(label string, target, selected, now time.Time) {
if target.Equal(selected) {
<a class="seg" aria-current="true" href={ templ.SafeURL(dayURL("/", target, now)) }>{ label }</a>
} else {
<a class="seg" href={ templ.SafeURL(dayURL("/", target, now)) }>{ label }</a>
}
}
// The form still works on its own: submitting reloads the page with ?haku=.
// Datastar binds the same box to a signal and re-renders just the list as it
// is typed into, so the live version is an enhancement rather than a
// requirement.
templ board(v logView) {
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/" class="searchrow">
if !v.Date.Equal(v.Today) {
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
}
<input
class="filter"
type="search"
name="haku"
value={ v.Search }
placeholder="Etsi tai lisää uusi"
aria-label="Etsi"
data-bind:haku
data-on:input__debounce.250ms={ "@get('" + searchURL(v) + "')" }
/>
</form>
@boardList(v)
</div>
}
// boardList is what Datastar patches: it carries the id, so a plain text/html
// response is matched to it and swapped in place.
templ boardList(v logView) {
<div id="lauta">
// Grouped by category, and inside each group the most-eaten first — so
// a dish keeps a predictable neighbourhood while favourites still
// surface at the top of it.
for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3>
<div class="board">
for _, d := range g.Dishes {
@dishPill(d, v)
}
</div>
}
// Tähteet is not food, so it sits apart from the categories rather
// than inside one. Fixed size: it will be among the most-logged
// entries, and it should not tower over the actual cooking.
if len(v.Special) > 0 {
<div class="special">
for _, d := range v.Special {
<a
class="pill plain"
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
>
@categoryIcon(d.CategoryKey())
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
}
</a>
}
</div>
}
// Tähteet always matches an empty search, so the add card keys off the
// real dishes only: otherwise a fresh install would show leftovers and
// no way to add anything.
if len(v.Dishes) == 0 {
@quickAddCard(v)
}
</div>
}
// quickAddCard turns a search that found nothing into the thing to do next.
// Adding a dish here creates it and goes straight to logging it, so the user
// never leaves Kirjaa mid-thought.
templ quickAddCard(v logView) {
<section class="card">
if v.Search == "" {
<h3>Lisää ensimmäinen ruoka</h3>
<p class="muted small">
Ruokalista on tyhjä. Lisää ruoka tästä, tai tuo koko lista kerralla Ruuat-välilehdeltä.
</p>
} else {
<h3>Ei osumia. Lisätäänkö?</h3>
}
if v.New.Err != "" {
<p class="formerr">{ v.New.Err }</p>
}
<form
method="post"
action="/lisaa"
data-on:submit__prevent="@post('/lisaa', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ v.New.Name } autocomplete="off" required/>
</label>
<div class="field">
<span>Kategoriat</span>
<div class="chips">
@categoryChip("meat", "liha", v.New)
@categoryChip("chicken", "kana", v.New)
@categoryChip("fish", "kala", v.New)
@categoryChip("vegetarian", "kasvis", v.New)
</div>
</div>
<label class="chip wide">
if v.New.HasSides {
<input type="checkbox" name="lisukkeita" value="1" checked/>
} else {
<input type="checkbox" name="lisukkeita" value="1"/>
}
<span>Tarjoillaan lisukkeiden kanssa</span>
</label>
<button class="primary" type="submit">Lisää ja kirjaa</button>
</form>
</section>
}
templ dishPill(d Dish, v logView) {
<a
class={ "pill", d.Size() }
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "ruoka="+strconv.FormatInt(d.ID, 10)) + "')" }
>
@categoryIcon(d.CategoryKey())
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
}
</a>
}
templ sidesStep(v logView) {
<section class="card">
<h3>
@categoryIcon(v.Chosen.CategoryKey())
{ v.Chosen.Name }
</h3>
<form
method="post"
action="/kirjaa"
data-on:submit__prevent="@post('/kirjaa', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
if v.Chosen.HasSides && len(v.Sides) > 0 {
<p class="q">Lisukkeita?</p>
<div class="chips">
for _, s := range v.Sides {
<label class="chip">
if v.Checked[s.ID] {
<input type="checkbox" name="lisuke" value={ strconv.FormatInt(s.ID, 10) } checked/>
} else {
<input type="checkbox" name="lisuke" value={ strconv.FormatInt(s.ID, 10) }/>
}
<span>{ s.Name }</span>
</label>
}
</div>
} else {
<p class="q">Tarjoillaan sellaisenaan.</p>
}
<button class="primary" type="submit">Tallenna</button>
</form>
<a
class="ghost"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</section>
}
templ loggedCard(v logView) {
<section class="card logged">
<p class="k">
if v.Date.Equal(v.Today) {
Tänään kirjattu
} else {
{ dayLabelFI(v.Date) } kirjattu
}
</p>
<p class="nm">
@categoryIcon(v.Entry.Main.CategoryKey())
{ v.Entry.Main.Name }
</p>
<p class="sd">{ v.Entry.SidesLabel() }</p>
if v.Confirming {
<p class="q">Poistetaanko merkintä?</p>
<div class="pair">
<form
method="post"
action="/poista"
data-on:submit__prevent="@post('/poista', {contentType: 'form'})"
>
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Kyllä, poista</button>
</form>
<a
class="btn"
href={ templ.SafeURL(stepURL(v, "")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
>Peruuta</a>
</div>
} else {
<div class="pair">
<a
class="btn"
href={ templ.SafeURL(stepURL(v, "muuta=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "muuta=1") + "')" }
>Muokkaa</a>
<a
class="btn del"
href={ templ.SafeURL(stepURL(v, "poista=1")) }
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "poista=1") + "')" }
>Poista</a>
</div>
}
</section>
}
// ---------------------------------------------------------------- Ruuat
templ catalogPage(v catalogView) {
@page("Ruuat — Foodster", "/ruuat") {
<header class="appbar">
<h2>Ruuat</h2>
<p class="meta">
{ countFI(v.Mains, "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
</p>
</header>
<main class="pad">
if v.Report != nil {
@importReport(v.Report)
}
<div data-signals:haku={ jsString(v.Search) }>
<form method="get" action="/ruuat" class="searchrow">
<input
class="filter"
type="search"
name="haku"
value={ v.Search }
placeholder="Etsi ruokaa"
aria-label="Etsi"
data-bind:haku
data-on:input__debounce.250ms="@get('/ruuat/etsi')"
/>
</form>
// The add and edit forms stay outside the patched fragment, or
// typing in the search box would collapse a form mid-edit.
@mainForm_(v.Main)
@sideForm_(v.Side)
@catalogList(v)
</div>
<details class="card">
<summary>Tuo ruokia tiedostosta</summary>
@importForm()
</details>
</main>
}
}
// catalogList carries the id Datastar patches, so typing in the search box
// swaps the lists without touching the forms above them.
//
// Two levels of heading, because there are two: Pääruuat and Lisukkeet are
// the halves of the catalog, and the categories are subdivisions of the
// first. They were previously styled the same, which made a category look
// like a peer of the entire side-dish list.
templ catalogList(v catalogView) {
<div id="ruokalista">
<section class="section">
@sectionTitle("Pääruuat", v.Mains)
if v.Mains == 0 {
@emptyNote(v.Search)
}
// Grouped by category, alphabetical inside. The catalog is a list
// you manage, so a predictable position beats a useful one.
for _, g := range v.Groups {
<h3 class="sechead">{ g.Label }</h3>
for _, d := range g.Dishes {
<div class="row">
@categoryIcon(d.CategoryKey())
<div class="rowtext">
<div class="nm">{ d.Name }</div>
if !d.HasSides {
<div class="sd">Ei lisukkeita</div>
}
</div>
@rowActions(v, "/ruuat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div>
}
}
</section>
<section class="section">
@sectionTitle("Lisukkeet", len(v.Sides))
if len(v.Sides) == 0 {
@emptyNote(v.Search)
}
for _, s := range v.Sides {
<div class="row">
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
@rowActions(v, "/ruuat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div>
}
</section>
</div>
}
templ sectionTitle(label string, n int) {
<h2 class="sectiontitle">
{ label }
<span class="count">{ strconv.Itoa(n) }</span>
</h2>
}
templ emptyNote(search string) {
<p class="muted small">
if search == "" {
Ei vielä mitään.
} else {
Ei osumia haulle { search }.
}
</p>
}
// rowActions is a pencil and a bin, until the bin is tapped: then the row
// asks. An icon is a smaller target to hit by accident than a word, and the
// dish disappears from every picker the moment it goes.
// Every control here is a real link or form, so the page still works without
// JavaScript. Datastar intercepts them and patches the list in place instead,
// which is the whole point: a delete confirmation halfway down a long list
// must not send the browser back to the top.
templ rowActions(v catalogView, editURL string, id int64, kind string) {
if v.DeleteID == id && v.DeleteKind == kind {
<div class="rowactions confirming">
<span>Poista?</span>
<form
method="post"
action="/ruuat/poista"
data-on:submit__prevent="@post('/ruuat/poista', {contentType: 'form'})"
>
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del">Kyllä</button>
</form>
<a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
</div>
} else {
<div class="rowactions">
<a
href={ templ.SafeURL(editURL) }
data-on:click__prevent={ "@get('" + showURL(editURL) + "')" }
aria-label="Muokkaa"
title="Muokkaa"
>
@iconPencil()
</a>
<a
class="del"
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) }
data-on:click__prevent={ "@get('/ruuat/nayta?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind + "')" }
aria-label="Poista"
title="Poista"
>
@iconTrash()
</a>
</div>
}
}
templ iconPencil() {
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M11.1 1.6a1.7 1.7 0 0 1 2.4 0l0.9 0.9a1.7 1.7 0 0 1 0 2.4l-0.8 0.8-3.3-3.3z"></path>
<path d="M9.4 3.3l3.3 3.3-6.6 6.6-4 0.7 0.7-4z"></path>
</svg>
}
templ iconTrash() {
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" focusable="false" fill="currentColor">
<path d="M6.2 1.3h3.6a1 1 0 0 1 1 1v0.6h3.1v1.7H2.1V2.9h3.1v-.6a1 1 0 0 1 1-1z"></path>
<path d="M3.3 6.2h9.4l-0.7 7.3a1.5 1.5 0 0 1-1.5 1.3H5.5a1.5 1.5 0 0 1-1.5-1.3z"></path>
</svg>
}
// Collapsed by default so the page opens on the catalog rather than on two
// screens of empty form. Forced open when editing or after a rejected
// submission, since the form is then the thing that needs attention.
templ mainForm_(f mainForm) {
<details class="card addform" id="paaruoka" open?={ f.ID != 0 || f.Err != "" }>
<summary>
if f.ID == 0 {
Lisää pääruoka
} else {
Muokkaa pääruokaa
}
</summary>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form
method="post"
action="/ruuat/paaruoka"
data-on:submit__prevent="@post('/ruuat/paaruoka', {contentType: 'form'})"
>
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ f.Name } autocomplete="off" required/>
</label>
<div class="field">
<span>Kategoriat</span>
<div class="chips">
@categoryChip("meat", "liha", f)
@categoryChip("chicken", "kana", f)
@categoryChip("fish", "kala", f)
@categoryChip("vegetarian", "kasvis", f)
</div>
</div>
<label class="chip wide">
if f.HasSides {
<input type="checkbox" name="lisukkeita" value="1" checked/>
} else {
<input type="checkbox" name="lisukkeita" value="1"/>
}
<span>Tarjoillaan lisukkeiden kanssa</span>
</label>
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
}
</details>
}
templ categoryChip(value, label string, f mainForm) {
<label class="chip">
if f.Categories[value] {
<input type="checkbox" name="kategoria" value={ value } checked/>
} else {
<input type="checkbox" name="kategoria" value={ value }/>
}
@categoryIcon(categoryFI[value])
<span>{ label }</span>
</label>
}
templ sideForm_(f sideForm) {
<details class="card addform" id="lisuke" open?={ f.ID != 0 || f.Err != "" }>
<summary>
if f.ID == 0 {
Lisää lisuke
} else {
Muokkaa lisuketta
}
</summary>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form
method="post"
action="/ruuat/lisuke"
data-on:submit__prevent="@post('/ruuat/lisuke', {contentType: 'form'})"
>
if f.ID != 0 {
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
}
<label class="field">
<span>Nimi</span>
<input type="text" name="nimi" value={ f.Name } autocomplete="off" required/>
</label>
<button class="primary" type="submit">Tallenna</button>
</form>
if f.ID != 0 {
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
}
</details>
}
templ importForm() {
<section class="card">
<h3>Tuo ruokia</h3>
<p class="muted small">
Liitä JSON tai valitse tiedosto. Kelvolliset rivit lisätään, virheelliset ohitetaan.
</p>
<form method="post" action="/ruuat/tuonti" enctype="multipart/form-data">
<label class="field">
<span>JSON</span>
<textarea
name="json"
rows="8"
spellcheck="false"
placeholder={ `{"mains": [{"name": "Kanacurry", "categories": ["chicken"]}], "sides": [{"name": "Riisi"}]}` }
></textarea>
</label>
<label class="field">
<span>tai tiedosto</span>
<input type="file" name="tiedosto" accept="application/json,.json"/>
</label>
<button class="primary" type="submit">Tuo</button>
</form>
</section>
}
templ importReport(r *ImportReport) {
<section class={ "card", "report", templ.KV("bad", r.Added == 0 && r.Skipped > 0) }>
<p class="tally">{ fmt.Sprintf("Lisätty %d, ohitettu %d", r.Added, r.Skipped) }</p>
if len(r.Notes) > 0 {
<ul class="notes">
for _, note := range r.Notes {
<li>{ note }</li>
}
</ul>
}
</section>
}