Files
foodster/cmd/foodster/views.templ
T
Esa Kataja 0538d61ba6 Add a favicon, home-screen icons and a web manifest
The favicon is a bowl of soup: this household's catalog is half keitto, so it
is at least honest. favicon.svg follows the system theme in the tab strip.

Home-screen icons cannot do the same. They must be opaque and must not change
with the theme, so assets/icon.svg is a separate fixed-colour source, with the
artwork inside the central 80% for Android to mask to any launcher shape. The
same 512 is declared maskable in the manifest.

`make icons` rasterises with rsvg-convert and compresses with
`oxipng -o max --zopfli`, which beat `optipng -o7` at every size — 5860 bytes
against 6109 for the three files. Plain `oxipng -o max` was not an upgrade: it
won the two small icons and lost the 512, ending up larger overall. All output
verified pixel-identical to the rasterizer. The PNGs are committed so the
build needs no rasterizer.

Two things that only fail on a real device, so both are covered by smoke
checks: Go has no mime entry for .webmanifest and served it as octet-stream,
which browsers ignore; and a manifest fetch carries no credentials by default,
so behind Basic auth it needs crossorigin="use-credentials" or it 401s.
2026-09-05 18:58:42 +03:00

511 lines
14 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 (
"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)
}
// 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 "&"
}
// 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>
<html lang="fi">
<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"/>
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ECEDE8"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#121316"/>
<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"/>
<script type="module" src="/static/datastar.js"></script>
</head>
<body>
{ children... }
@tabbar(current)
</body>
</html>
}
templ tabbar(current string) {
<nav class="tabbar">
@tab("/", "Kirjaa", current)
@tab("/historia", "Historia", current)
@tab("/ruoat", "Ruoat", 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">
switch {
case v.Chosen != nil:
@sidesStep(v)
case v.Entry != nil:
@loggedCard(v)
default:
@board(v)
}
</main>
}
}
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">
<input type="date" name="pvm" value={ isoDate(v.Date) } 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>
}
}
templ board(v logView) {
<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"/>
</form>
if len(v.Dishes) > 0 {
<div class="board">
for _, d := range v.Dishes {
@dishPill(d, v)
}
</div>
}
if len(v.Dishes) == 0 {
@quickAddCard(v)
}
}
// 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 Ruoat-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">
<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(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(d.ID, 10)) }
>
<i class={ "dot", "d-" + d.CategoryKey() }></i>
{ d.Name }
if d.TimesEaten > 0 {
<span class="n">{ strconv.Itoa(d.TimesEaten) }</span>
}
</a>
}
templ sidesStep(v logView) {
<section class="card">
<h3>
<i class={ "dot", "d-" + v.Chosen.CategoryKey() }></i>
{ v.Chosen.Name }
</h3>
<form method="post" action="/kirjaa">
<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(dayURL("/", v.Date, v.Today)) }>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">
<i class={ "dot", "d-" + v.Entry.Main.CategoryKey() }></i>
{ v.Entry.Main.Name }
</p>
<p class="sd">{ v.Entry.SidesLabel() }</p>
<div class="pair">
<a
class="btn"
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "ruoka=" + strconv.FormatInt(v.Entry.Main.ID, 10)) }
>Muokkaa</a>
<form method="post" action="/poista">
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
<button class="btn del" type="submit">Poista</button>
</form>
</div>
</section>
}
// ---------------------------------------------------------------- Historia
templ historyPage(rows []HistoryRow) {
@page("Historia — Foodster", "/historia") {
<header class="appbar">
<h2>Historia</h2>
</header>
<main class="pad">
if len(rows) == 0 {
<p class="muted">Ei vielä merkintöjä.</p>
}
for i, row := range rows {
if i == 0 || rows[i-1].Date.Month() != row.Date.Month() {
<p class="monthrule">{ monthFI(row.Date) }</p>
}
if row.Entry != nil {
<div class="entry">
<time>{ dayLabelFI(row.Date) }</time>
<div>
<div class="nm">
<i class={ "dot", "d-" + row.Entry.Main.CategoryKey() }></i>
{ row.Entry.Main.Name }
</div>
<div class="sd">{ row.Entry.SidesLabel() }</div>
</div>
<a class="chev" href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) } aria-label="Muokkaa"></a>
</div>
} else {
<div class="gapline">
<time>{ dayLabelFI(row.Date) }</time>
<span>Ei merkintää</span>
<a href={ templ.SafeURL("/?pvm=" + isoDate(row.Date)) }>Merkitse</a>
</div>
}
}
</main>
}
}
// ---------------------------------------------------------------- Ruoat
templ catalogPage(v catalogView) {
@page("Ruoat — Foodster", "/ruoat") {
<header class="appbar">
<h2>Ruoat</h2>
<p class="meta">
{ countFI(len(v.Mains), "pääruoka", "pääruokaa") }, { countFI(len(v.Sides), "lisuke", "lisuketta") }
</p>
</header>
<main class="pad">
if v.Report != nil {
@importReport(v.Report)
}
@mainForm_(v.Main)
<h3 class="sechead">Pääruoat</h3>
if len(v.Mains) == 0 {
<p class="muted small">Ei vielä pääruokia.</p>
}
for _, d := range v.Mains {
<div class="row">
<i class={ "dot", "d-" + d.CategoryKey() }></i>
<div class="rowtext">
<div class="nm">{ d.Name }</div>
<div class="sd">
{ categoryLabels(d) }
if !d.HasSides {
· ei lisukkeita
}
</div>
</div>
@rowActions("/ruoat?muokkaa="+strconv.FormatInt(d.ID, 10), d.ID, "paa")
</div>
}
@sideForm_(v.Side)
<h3 class="sechead">Lisukkeet</h3>
if len(v.Sides) == 0 {
<p class="muted small">Ei vielä lisukkeita.</p>
}
for _, s := range v.Sides {
<div class="row">
<div class="rowtext"><div class="nm">{ s.Name }</div></div>
@rowActions("/ruoat?muokkaa-lisuke="+strconv.FormatInt(s.ID, 10), s.ID, "lisuke")
</div>
}
<details class="card">
<summary>Tuo ruokia tiedostosta</summary>
@importForm()
</details>
</main>
}
}
templ rowActions(editURL string, id int64, kind string) {
<div class="rowactions">
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa">Muokkaa</a>
<form method="post" action="/ruoat/poista">
<input type="hidden" name="id" value={ strconv.FormatInt(id, 10) }/>
<input type="hidden" name="tyyppi" value={ kind }/>
<button type="submit" class="del" aria-label="Poista">Poista</button>
</form>
</div>
}
templ mainForm_(f mainForm) {
<section class="card" id="paaruoka">
<h3>
if f.ID == 0 {
Lisää pääruoka
} else {
Muokkaa pääruokaa
}
</h3>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruoat/paaruoka">
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="/ruoat">Peruuta</a>
}
</section>
}
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 }/>
}
<i class={ "dot", "d-" + categoryFI[value] }></i>
<span>{ label }</span>
</label>
}
templ sideForm_(f sideForm) {
<section class="card" id="lisuke">
<h3>
if f.ID == 0 {
Lisää lisuke
} else {
Muokkaa lisuketta
}
</h3>
if f.Err != "" {
<p class="formerr">{ f.Err }</p>
}
<form method="post" action="/ruoat/lisuke">
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="/ruoat">Peruuta</a>
}
</section>
}
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="/ruoat/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>
}