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.
511 lines
14 KiB
Templ
511 lines
14 KiB
Templ
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>
|
||
}
|