Three pieces of chrome that were missing or misleading. A header with the bowl mark and the app name, on every page. The page title bar below it is no longer sticky: on a phone a tall sticky header eats the screen, and the bottom tab bar already handles navigation. A theme toggle, remembered per device in localStorage, because a shared instance with no accounts should let the kitchen phone and the laptop disagree. Dark by default, and the default lives in the server-rendered markup so it survives with JavaScript off and never flashes. The button shows the theme that is on — moon while dark, sun while light — rather than the one a click would bring, and its label names the state before the action. Both icons ship on every page and CSS picks one, so the server never needs to know what this device chose. Following the system was considered and dropped: a third state costs a control harder to read than the choice is worth. The checkbox chips no longer hide the native control behind opacity: 0. With only a background colour to go on there was no way to tell "Tarjoillaan lisukkeiden kanssa" on from off. Colour is not a state indicator; a checkbox is. This covers the side, category and has_sides chips alike. Smoke checks cover the parts that would regress silently: that dark is the no-JS default, and that both theme icons are present for CSS to choose from.
559 lines
16 KiB
Templ
559 lines
16 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>
|
||
// 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"/>
|
||
<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"/>
|
||
<!-- 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 marks the active theme rather than labelling itself with the one
|
||
// a click would produce. aria-pressed is set by theme.js on load, because only
|
||
// the device knows what was chosen.
|
||
// 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>
|
||
}
|
||
|
||
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>
|
||
}
|