Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6a8532569 | ||
|
|
b8a46cdd30 | ||
|
|
e5e020457a | ||
|
|
f8b908b39e |
+14
-7
@@ -1,22 +1,29 @@
|
|||||||
# Copy to .env and fill in. .env is gitignored — the real registry hostname
|
# Copy to .env and fill in. .env is gitignored — the real registry hostname
|
||||||
# must not end up in the repository.
|
# must not end up in the repository.
|
||||||
|
|
||||||
# Image coordinates. FOODSTER_REPO carries no tag.
|
# Image coordinates. REPO carries no tag.
|
||||||
FOODSTER_REPO=registry.example.com/you/foodster
|
REPO=registry.example.com/you/foodster
|
||||||
FOODSTER_TAG=latest
|
TAG=latest
|
||||||
|
|
||||||
# Shared household password. The app will not start without it.
|
# Shared household password. The app will not start without it.
|
||||||
FOODSTER_PASSWORD=changeme
|
PASSWORD=changeme
|
||||||
|
|
||||||
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no
|
# Hostname Traefik routes to. Kept here rather than in compose.yaml so no
|
||||||
# infrastructure detail is committed.
|
# infrastructure detail is committed.
|
||||||
FOODSTER_HOST=foodster.example.com
|
HOST=foodster.example.com
|
||||||
|
|
||||||
|
# Anything other than prod is written into the browser tab title, so a dev
|
||||||
|
# instance open beside the real one can be told apart.
|
||||||
|
ENV=prod
|
||||||
|
|
||||||
# The database lives in ./data, bind-mounted into the container. These must
|
# The database lives in ./data, bind-mounted into the container. These must
|
||||||
# match whoever owns that directory on the host, or the container cannot
|
# match whoever owns that directory on the host, or the container cannot
|
||||||
# write to it. `id -u` and `id -g` will tell you.
|
# write to it. `id -u` and `id -g` will tell you.
|
||||||
FOODSTER_UID=1000
|
#
|
||||||
FOODSTER_GID=1000
|
# Named PUID/PGID because UID is read-only in bash and a plain UID here would
|
||||||
|
# be quietly replaced by the invoking shell's own.
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
# Used for every calendar-day calculation. Set it in development too: under
|
# Used for every calendar-day calculation. Set it in development too: under
|
||||||
# UTC the date rolls over three hours late, which is exactly when dinner
|
# UTC the date rolls over three hours late, which is exactly when dinner
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ build: generate ## Build ./foodster
|
|||||||
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
|
-ldflags="-s -w -X main.version=dev" -o $(BIN) $(PKG)
|
||||||
|
|
||||||
run: generate ## Run locally on :8080 (database in ./data)
|
run: generate ## Run locally on :8080 (database in ./data)
|
||||||
FOODSTER_PASSWORD=$${FOODSTER_PASSWORD:-dev} go run $(PKG)
|
PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG)
|
||||||
|
|
||||||
seed: ## Import a dish bundle (SEED=seeds/testi.json)
|
seed: ## Import a dish bundle (SEED=seeds/testi.json)
|
||||||
go run $(PKG) -import $(SEED)
|
go run $(PKG) -import $(SEED)
|
||||||
@@ -85,7 +85,7 @@ fix: ## Format Go and templ sources, tidy go.mod
|
|||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
|
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
|
||||||
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
|
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
|
||||||
@# A release tag must point into main, or the tag records a commit that
|
@# A release tag must point into main, or the tag records a commit that
|
||||||
@# was never released.
|
@# was never released.
|
||||||
@branch=$$(git symbolic-ref --short HEAD); \
|
@branch=$$(git symbolic-ref --short HEAD); \
|
||||||
@@ -99,7 +99,7 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
|
|||||||
echo "==> $$tag"; \
|
echo "==> $$tag"; \
|
||||||
git tag "$$tag"; \
|
git tag "$$tag"; \
|
||||||
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
|
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
|
||||||
-t "$(FOODSTER_REPO):$$tag" -t "$(FOODSTER_REPO):latest" . ; \
|
-t "$(REPO):$$tag" -t "$(REPO):latest" . ; \
|
||||||
echo "$$tag" > $(TAGFILE)
|
echo "$$tag" > $(TAGFILE)
|
||||||
|
|
||||||
# Pushing reported success while uploading the previous release once, because
|
# Pushing reported success while uploading the previous release once, because
|
||||||
@@ -107,18 +107,18 @@ image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
|
|||||||
# registry what it actually serves for each tag and fail if it is not the
|
# registry what it actually serves for each tag and fail if it is not the
|
||||||
# image we just built.
|
# image we just built.
|
||||||
push: ## Push the newest tag and :latest, then verify the registry
|
push: ## Push the newest tag and :latest, then verify the registry
|
||||||
@test -n "$(FOODSTER_REPO)" || { echo "set FOODSTER_REPO in .env"; exit 1; }
|
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
|
||||||
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
|
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
|
||||||
tag=$$(cat $(TAGFILE)); \
|
tag=$$(cat $(TAGFILE)); \
|
||||||
built=$$(podman image inspect "$(FOODSTER_REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
|
built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
|
||||||
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \
|
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \
|
||||||
podman push "$(FOODSTER_REPO):$$tag"; \
|
podman push "$(REPO):$$tag"; \
|
||||||
podman push "$(FOODSTER_REPO):latest"; \
|
podman push "$(REPO):latest"; \
|
||||||
echo "==> verifying $$tag"; \
|
echo "==> verifying $$tag"; \
|
||||||
for ref in "$$tag" latest; do \
|
for ref in "$$tag" latest; do \
|
||||||
podman pull -q "$(FOODSTER_REPO):$$ref" >/dev/null 2>&1 || \
|
podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \
|
||||||
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \
|
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \
|
||||||
served=$$(podman image inspect "$(FOODSTER_REPO):$$ref" --format '{{.Id}}'); \
|
served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \
|
||||||
if [ "$$served" != "$$built" ]; then \
|
if [ "$$served" != "$$built" ]; then \
|
||||||
echo " FAIL $$ref serves $$served"; \
|
echo " FAIL $$ref serves $$served"; \
|
||||||
echo " expected $$built"; \
|
echo " expected $$built"; \
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ build and no asset bundler.
|
|||||||
is imported because the runtime image carries no zoneinfo. All date logic
|
is imported because the runtime image carries no zoneinfo. All date logic
|
||||||
uses that location explicitly and never `time.Local`.
|
uses that location explicitly and never `time.Local`.
|
||||||
- **Auth**: HTTP Basic with one shared household password read from
|
- **Auth**: HTTP Basic with one shared household password read from
|
||||||
`FOODSTER_PASSWORD`; the username is ignored. Compared using
|
`PASSWORD`; the username is ignored. Compared using
|
||||||
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
|
`subtle.ConstantTimeCompare` over SHA-256 digests so neither the value nor
|
||||||
its length leaks through timing. `/healthz` is the only route outside auth.
|
its length leaks through timing. `/healthz` is the only route outside auth.
|
||||||
- **Exposure**: the app is served on a public hostname behind Traefik, which
|
- **Exposure**: the app is served on a public hostname behind Traefik, which
|
||||||
@@ -395,21 +395,25 @@ on the server and run with Docker Compose.
|
|||||||
`/data/foodster.db`, bind-mounted from `./data` on the host rather than
|
`/data/foodster.db`, bind-mounted from `./data` on the host rather than
|
||||||
kept in a named volume, so the file can be listed and copied without going
|
kept in a named volume, so the file can be listed and copied without going
|
||||||
through the container engine. Backup is `cp -r data`. Because the image
|
through the container engine. Backup is `cp -r data`. Because the image
|
||||||
runs as UID 65534, compose sets `user:` from `FOODSTER_UID`/`FOODSTER_GID`
|
runs as UID 65534, compose sets `user:` from `PUID`/`PGID`
|
||||||
to match whoever owns that directory. `restart: unless-stopped`.
|
to match whoever owns that directory. `restart: unless-stopped`.
|
||||||
- **Configuration**, entirely through environment variables (see
|
- **Configuration**, entirely through environment variables (see
|
||||||
`.env.example`):
|
`.env.example`):
|
||||||
- `FOODSTER_REPO` and `FOODSTER_TAG` — image coordinates.
|
Names carry no application prefix: the container namespaces them already.
|
||||||
- `FOODSTER_PASSWORD` — the shared password. Required; the app refuses to
|
- `REPO` and `TAG` — image coordinates.
|
||||||
start without it.
|
- `PASSWORD` — the shared password. Required; the app refuses to start
|
||||||
- `FOODSTER_DB` — database file path, default `./data/foodster.db`. The
|
without it.
|
||||||
directory is created on startup if missing.
|
- `DB` — database file path, default `./data/foodster.db`. The directory is
|
||||||
- `FOODSTER_UID` / `FOODSTER_GID` — host owner of `./data`.
|
created on startup if missing.
|
||||||
|
- `ENV` — anything but `prod` is prefixed to the browser tab title, so a
|
||||||
|
dev instance open beside the real one can be told apart.
|
||||||
|
- `PUID` / `PGID` — host owner of `./data`. Not `UID`, which is read-only
|
||||||
|
in bash and would be replaced by the invoking shell's own value.
|
||||||
- `TZ` — default `Europe/Helsinki`.
|
- `TZ` — default `Europe/Helsinki`.
|
||||||
- The registry hostname exists only in `.env`, which is gitignored, because
|
- The registry hostname exists only in `.env`, which is gitignored, because
|
||||||
§11 leaves open the possibility of publishing this repository.
|
§11 leaves open the possibility of publishing this repository.
|
||||||
- **Routing**: Traefik on an external `traefik` network, matching on
|
- **Routing**: Traefik on an external `traefik` network, matching on
|
||||||
`FOODSTER_HOST` and terminating TLS. The container publishes no ports —
|
`HOST` and terminating TLS. The container publishes no ports —
|
||||||
doing so would put an unencrypted copy of the app on the host, bypassing
|
doing so would put an unencrypted copy of the app on the host, bypassing
|
||||||
the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
|
the proxy. The hostname lives in `.env` rather than `compose.yaml`, so no
|
||||||
infrastructure detail is committed.
|
infrastructure detail is committed.
|
||||||
|
|||||||
@@ -173,13 +173,19 @@ Everything is environment variables. `.env` is gitignored; start from
|
|||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `FOODSTER_PASSWORD` | *required* | Shared password. The app will not start without it. |
|
| `PASSWORD` | *required* | Shared password. The app will not start without it. |
|
||||||
| `FOODSTER_DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
|
| `DB` | `./data/foodster.db` | SQLite file path; the directory is created if missing. |
|
||||||
| `FOODSTER_UID` / `FOODSTER_GID` | `1000` | Host owner of `./data`, for the bind mount. |
|
| `ENV` | `prod` | Anything else is prefixed to the tab title (`dev · Foodster`). |
|
||||||
|
| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. |
|
||||||
|
| `PUID` / `PGID` | `1000` | Host owner of `./data`, for the bind mount. |
|
||||||
| `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
|
| `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
|
||||||
| `FOODSTER_REPO` | *required to build* | Image repository, no tag. |
|
|
||||||
| `FOODSTER_TAG` | `latest` | Tag to run under compose. |
|
Names carry no prefix: the container gives them their own namespace already.
|
||||||
| `FOODSTER_PORT` | `8080` | Host port to publish. |
|
`PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set
|
||||||
|
in `.env` would be silently replaced by the invoking shell's own.
|
||||||
|
| `REPO` | *required to build* | Image repository, no tag. |
|
||||||
|
| `TAG` | `latest` | Tag to run under compose. |
|
||||||
|
| `HOST` | *required to run* | Hostname Traefik routes to. |
|
||||||
|
|
||||||
Set `TZ` in development too. Under UTC the date rolls over three hours late,
|
Set `TZ` in development too. Under UTC the date rolls over three hours late,
|
||||||
which is exactly when dinner gets logged.
|
which is exactly when dinner gets logged.
|
||||||
@@ -204,7 +210,7 @@ the container, so a backup is `cp -r data` and you can inspect the file with
|
|||||||
any sqlite client without going through the engine.
|
any sqlite client without going through the engine.
|
||||||
|
|
||||||
That directory must exist and be owned by the user compose runs as — `make up`
|
That directory must exist and be owned by the user compose runs as — `make up`
|
||||||
creates it, and `FOODSTER_UID`/`FOODSTER_GID` in `.env` tell the container who
|
creates it, and `PUID`/`PGID` in `.env` tell the container who
|
||||||
that is. Get them from `id -u` and `id -g`.
|
that is. Get them from `id -u` and `id -g`.
|
||||||
|
|
||||||
If the app exits with `cannot open /data/foodster.db ... unable to open
|
If the app exits with `cannot open /data/foodster.db ... unable to open
|
||||||
@@ -213,7 +219,7 @@ bind-mount directory as root, and the container is not root:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
ls -ldn data # whose is it?
|
ls -ldn data # whose is it?
|
||||||
sudo chown -R 1000:1000 data # match FOODSTER_UID / FOODSTER_GID
|
sudo chown -R 1000:1000 data # match PUID / PGID
|
||||||
docker compose restart
|
docker compose restart
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -235,7 +241,7 @@ counting it would lock the household out for simply opening the app.
|
|||||||
address, meaning it arrived through the proxy. A client connecting directly
|
address, meaning it arrived through the proxy. A client connecting directly
|
||||||
could otherwise forge a new address per attempt and skip the limiter.
|
could otherwise forge a new address per attempt and skip the limiter.
|
||||||
|
|
||||||
**None of this replaces a strong `FOODSTER_PASSWORD`.** Rate limiting removes
|
**None of this replaces a strong `PASSWORD`.** Rate limiting removes
|
||||||
brute force as a practical route; it does not make a guessable password safe.
|
brute force as a practical route; it does not make a guessable password safe.
|
||||||
|
|
||||||
## Mockups
|
## Mockups
|
||||||
|
|||||||
+157
-39
@@ -98,17 +98,57 @@ type logView struct {
|
|||||||
Confirming bool
|
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) {
|
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
||||||
date := a.date(r)
|
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{
|
v := logView{
|
||||||
Date: date,
|
Date: date,
|
||||||
Today: today(a.loc),
|
Today: today(a.loc),
|
||||||
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
|
Search: o.Search,
|
||||||
Checked: map[int64]bool{},
|
Checked: map[int64]bool{},
|
||||||
|
Confirming: o.Confirming,
|
||||||
}
|
}
|
||||||
|
|
||||||
v.Confirming = r.URL.Query().Get("poista") != ""
|
|
||||||
|
|
||||||
entry, err := entryFor(a.db, date)
|
entry, err := entryFor(a.db, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("entry for %s: %v", date.Format(dateLayout), err)
|
log.Printf("entry for %s: %v", date.Format(dateLayout), err)
|
||||||
@@ -118,8 +158,8 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ?ruoka= opens the sides step for that dish. When it is the dish already
|
// ?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
|
// logged, the existing sides come back ticked, which makes editing an
|
||||||
// entry the same screen as creating one.
|
// entry the same screen as creating one.
|
||||||
if raw := r.URL.Query().Get("ruoka"); raw != "" {
|
if o.Dish != "" {
|
||||||
if id, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
if id, err := strconv.ParseInt(o.Dish, 10, 64); err == nil {
|
||||||
if dish, err := dishByID(a.db, id); err == nil {
|
if dish, err := dishByID(a.db, id); err == nil {
|
||||||
v.Chosen = dish
|
v.Chosen = dish
|
||||||
if entry != nil && entry.Main.ID == id {
|
if entry != nil && entry.Main.ID == id {
|
||||||
@@ -134,7 +174,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
// The board shows when there is nothing logged yet, or when the entry is
|
// 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,
|
// 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.
|
// so swapping the dish and picking one for the first time are one path.
|
||||||
changing := r.URL.Query().Get("muuta") != "" || v.Search != ""
|
changing := o.Changing || v.Search != ""
|
||||||
if v.Chosen == nil && (v.Entry == nil || changing) {
|
if v.Chosen == nil && (v.Entry == nil || changing) {
|
||||||
v.ShowBoard = true
|
v.ShowBoard = true
|
||||||
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
||||||
@@ -157,7 +197,7 @@ func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a.loadDays(r, &v)
|
a.loadDays(r, &v)
|
||||||
render(w, r, logPage(v))
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadDays fills the day list. The selected day expands inside it rather than
|
// loadDays fills the day list. The selected day expands inside it rather than
|
||||||
@@ -215,6 +255,50 @@ func fragment(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// searchBoard re-renders the dish board as the search box is typed into.
|
||||||
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
|
func (a *app) searchBoard(w http.ResponseWriter, r *http.Request) {
|
||||||
var signals searchSignals
|
var signals searchSignals
|
||||||
@@ -300,6 +384,12 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("quick add: %v", err)
|
log.Printf("quick add: %v", err)
|
||||||
form.Err = "Tallennus epäonnistui."
|
form.Err = "Tallennus epäonnistui."
|
||||||
default:
|
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)
|
a.redirectToPick(w, r, date, id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,23 +397,12 @@ func (a *app) quickAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Rejected: back to the board with the form filled in and the search
|
// Rejected: back to the board with the form filled in and the search
|
||||||
// still narrowed, so the add card stays on screen.
|
// still narrowed, so the add card stays on screen.
|
||||||
v := logView{
|
v := a.buildLog(r, logOptions{Date: date, Search: form.Name})
|
||||||
Date: date,
|
v.New = form
|
||||||
Today: today(a.loc),
|
if isDatastar(r) {
|
||||||
Search: form.Name,
|
fragment(w, r, dayList(v))
|
||||||
Checked: map[int64]bool{},
|
return
|
||||||
New: form,
|
|
||||||
ShowBoard: true,
|
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
if v.Dishes, err = listDishes(a.db, v.Search); err != nil {
|
|
||||||
log.Printf("list dishes: %v", err)
|
|
||||||
}
|
|
||||||
v.Groups = groupDishes(v.Dishes)
|
|
||||||
if v.Special, err = listSpecial(a.db, v.Search); err != nil {
|
|
||||||
log.Printf("list special: %v", err)
|
|
||||||
}
|
|
||||||
a.loadDays(r, &v)
|
|
||||||
render(w, r, logPage(v))
|
render(w, r, logPage(v))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,9 +412,7 @@ func (a *app) redirectToPick(w http.ResponseWriter, r *http.Request, date time.T
|
|||||||
if strings.Contains(target, "?") {
|
if strings.Contains(target, "?") {
|
||||||
sep = "&"
|
sep = "&"
|
||||||
}
|
}
|
||||||
http.Redirect(w, r,
|
http.Redirect(w, r, target+sep+"ruoka="+strconv.FormatInt(id, 10), http.StatusSeeOther)
|
||||||
target+sep+"ruoka="+strconv.FormatInt(id, 10)+"#"+dayAnchor(date),
|
|
||||||
http.StatusSeeOther)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// save records the meal and redirects, so a refresh cannot double-post.
|
// save records the meal and redirects, so a refresh cannot double-post.
|
||||||
@@ -360,7 +437,7 @@ func (a *app) save(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
|
http.Error(w, "tallennus epäonnistui", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.redirectToDay(w, r, date)
|
a.finishDay(w, r, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -370,13 +447,12 @@ func (a *app) delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.redirectToDay(w, r, date)
|
a.finishDay(w, r, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirectToDay returns to the day in the list, anchor included, so saving or
|
// redirectToDay is the no-JavaScript path back after a write.
|
||||||
// deleting leaves the viewport where the work was happening.
|
|
||||||
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
|
func (a *app) redirectToDay(w http.ResponseWriter, r *http.Request, date time.Time) {
|
||||||
http.Redirect(w, r, dayLink(date, today(a.loc)), http.StatusSeeOther)
|
http.Redirect(w, r, dayURL("/", date, today(a.loc)), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainForm and sideForm carry what the user typed, so a rejected submission
|
// mainForm and sideForm carry what the user typed, so a rejected submission
|
||||||
@@ -410,7 +486,20 @@ type catalogView struct {
|
|||||||
DeleteKind string
|
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) {
|
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{
|
v := catalogView{
|
||||||
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
|
Main: mainForm{Categories: map[string]bool{}, HasSides: true},
|
||||||
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
|
Search: strings.TrimSpace(r.URL.Query().Get("haku")),
|
||||||
@@ -446,10 +535,11 @@ func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.renderCatalog(w, r, v)
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogView) {
|
// fillCatalog loads the lists into a view built from the request.
|
||||||
|
func (a *app) fillCatalog(v *catalogView) {
|
||||||
if v.Main.Categories == nil {
|
if v.Main.Categories == nil {
|
||||||
v.Main.Categories = map[string]bool{}
|
v.Main.Categories = map[string]bool{}
|
||||||
}
|
}
|
||||||
@@ -465,9 +555,20 @@ func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, v catalogVie
|
|||||||
if v.Sides, err = listSides(a.db, v.Search); err != nil {
|
if v.Sides, err = listSides(a.db, v.Search); err != nil {
|
||||||
log.Printf("list sides: %v", err)
|
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))
|
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
|
// 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.
|
// the values still in it; a good one redirects, so refresh cannot re-submit.
|
||||||
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
|
func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -508,11 +609,28 @@ func (a *app) saveMain(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("save main: %v", err)
|
log.Printf("save main: %v", err)
|
||||||
form.Err = "Tallennus epäonnistui."
|
form.Err = "Tallennus epäonnistui."
|
||||||
default:
|
default:
|
||||||
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
// Saved: hand back a blank form so it collapses, and a list with
|
||||||
|
// the dish in it.
|
||||||
|
a.finishCatalog(w, r, catalogView{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.renderCatalog(w, r, catalogView{Main: form})
|
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) {
|
func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -537,11 +655,11 @@ func (a *app) saveSide(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("save side: %v", err)
|
log.Printf("save side: %v", err)
|
||||||
form.Err = "Tallennus epäonnistui."
|
form.Err = "Tallennus epäonnistui."
|
||||||
default:
|
default:
|
||||||
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
a.finishCatalog(w, r, catalogView{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.renderCatalog(w, r, catalogView{Side: form})
|
a.finishCatalog(w, r, catalogView{Side: form})
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6).
|
// deleteDish soft-deletes, so log entries keep resolving the name (PRD §6).
|
||||||
@@ -565,7 +683,7 @@ func (a *app) deleteDish(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
http.Error(w, "poisto epäonnistui", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/ruoat", http.StatusSeeOther)
|
a.finishCatalog(w, r, catalogView{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// importDishes takes a bundle either pasted into the textarea or uploaded as a
|
// importDishes takes a bundle either pasted into the textarea or uploaded as a
|
||||||
|
|||||||
+25
-6
@@ -35,6 +35,21 @@ var staticFS embed.FS
|
|||||||
// version is replaced at build time with the CalVer tag (see `make image`).
|
// version is replaced at build time with the CalVer tag (see `make image`).
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
|
// envTag marks the browser tab of anything that is not production, so a dev
|
||||||
|
// instance and the real one open side by side are told apart at a glance.
|
||||||
|
// Empty in production, which is the default.
|
||||||
|
var envTag string
|
||||||
|
|
||||||
|
func setEnvTag(value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" || strings.EqualFold(value, "prod") || strings.EqualFold(value, "production") {
|
||||||
|
envTag = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Whatever it says, so ENV=staging labels itself too.
|
||||||
|
envTag = strings.ToLower(value)
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
listenAddr = ":8080"
|
listenAddr = ":8080"
|
||||||
defaultTZ = "Europe/Helsinki"
|
defaultTZ = "Europe/Helsinki"
|
||||||
@@ -55,7 +70,7 @@ func run() error {
|
|||||||
"import a JSON dish bundle (PRD §7.3 shape) and exit")
|
"import a JSON dish bundle (PRD §7.3 shape) and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB))
|
db, err := openDB(cmp.Or(os.Getenv("DB"), defaultDB))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,9 +81,9 @@ func run() error {
|
|||||||
return runImport(db, *importPath)
|
return runImport(db, *importPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
password := os.Getenv("FOODSTER_PASSWORD")
|
password := os.Getenv("PASSWORD")
|
||||||
if password == "" {
|
if password == "" {
|
||||||
return errors.New("FOODSTER_PASSWORD is not set")
|
return errors.New("PASSWORD is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
|
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
|
||||||
@@ -79,9 +94,11 @@ func run() error {
|
|||||||
return fmt.Errorf("TZ: %w", err)
|
return fmt.Errorf("TZ: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The container always publishes :8080; FOODSTER_ADDR exists so tests and
|
setEnvTag(os.Getenv("ENV"))
|
||||||
// a second local instance can pick another port.
|
|
||||||
addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr)
|
// The container always publishes :8080; ADDR exists so tests and a second
|
||||||
|
// local instance can pick another port.
|
||||||
|
addr := cmp.Or(os.Getenv("ADDR"), listenAddr)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
@@ -159,9 +176,11 @@ func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
|
|||||||
mux.HandleFunc("POST /kirjaa", a.save)
|
mux.HandleFunc("POST /kirjaa", a.save)
|
||||||
mux.HandleFunc("POST /lisaa", a.quickAdd)
|
mux.HandleFunc("POST /lisaa", a.quickAdd)
|
||||||
mux.HandleFunc("GET /etsi", a.searchBoard)
|
mux.HandleFunc("GET /etsi", a.searchBoard)
|
||||||
|
mux.HandleFunc("GET /paiva", a.day)
|
||||||
mux.HandleFunc("POST /poista", a.delete)
|
mux.HandleFunc("POST /poista", a.delete)
|
||||||
mux.HandleFunc("GET /ruuat", a.catalog)
|
mux.HandleFunc("GET /ruuat", a.catalog)
|
||||||
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
|
mux.HandleFunc("GET /ruuat/etsi", a.searchCatalog)
|
||||||
|
mux.HandleFunc("GET /ruuat/nayta", a.show)
|
||||||
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
|
mux.HandleFunc("POST /ruuat/paaruoka", a.saveMain)
|
||||||
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
|
mux.HandleFunc("POST /ruuat/lisuke", a.saveSide)
|
||||||
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
mux.HandleFunc("POST /ruuat/poista", a.deleteDish)
|
||||||
|
|||||||
@@ -105,6 +105,29 @@ func TestReadSignals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnvTagMarksNonProduction(t *testing.T) {
|
||||||
|
t.Cleanup(func() { envTag = "" })
|
||||||
|
|
||||||
|
cases := []struct{ env, want string }{
|
||||||
|
// Production is the default and must stay unmarked: the tag exists to
|
||||||
|
// pick the dev tab out of two identical ones.
|
||||||
|
{"", "Foodster"},
|
||||||
|
{"prod", "Foodster"},
|
||||||
|
{"PRODUCTION", "Foodster"},
|
||||||
|
{" ", "Foodster"},
|
||||||
|
{"dev", "dev · Foodster"},
|
||||||
|
{"DEV", "dev · Foodster"},
|
||||||
|
{"staging", "staging · Foodster"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
setEnvTag(c.env)
|
||||||
|
if got := pageTitle("Foodster"); got != c.want {
|
||||||
|
t.Errorf("ENV=%q: title = %q, want %q", c.env, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMigrateCreatesSchema(t *testing.T) {
|
func TestMigrateCreatesSchema(t *testing.T) {
|
||||||
db, err := openDB(t.TempDir() + "/test.db")
|
db, err := openDB(t.TempDir() + "/test.db")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+98
-29
@@ -43,16 +43,21 @@ func dayURL(base string, d, now time.Time) string {
|
|||||||
return base + "?pvm=" + isoDate(d)
|
return base + "?pvm=" + isoDate(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dayAnchor names the element a day expands into.
|
// showURL turns a catalog page link into the patch endpoint behind it, so the
|
||||||
func dayAnchor(d time.Time) string {
|
// href and the Datastar call never drift apart.
|
||||||
return "paiva-" + isoDate(d)
|
func showURL(pageURL string) string {
|
||||||
|
return strings.Replace(pageURL, "/ruuat?", "/ruuat/nayta?", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dayLink opens a day and lands the viewport on it. Without the fragment the
|
// dayPatch is the endpoint behind every link in the day list. The href beside
|
||||||
// browser would jump to the top of a page whose selected day might be far
|
// it stays a real page URL for anyone without JavaScript; Datastar calls this
|
||||||
// down the list.
|
// instead and swaps the list where it stands.
|
||||||
func dayLink(d, now time.Time) string {
|
func dayPatch(d time.Time, param string) string {
|
||||||
return dayURL("/", d, now) + "#" + dayAnchor(d)
|
url := "/paiva?pvm=" + isoDate(d)
|
||||||
|
if param != "" {
|
||||||
|
url += "&" + param
|
||||||
|
}
|
||||||
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
|
// pickSeparator joins a dish onto a day URL, which already carries ?pvm= for
|
||||||
@@ -64,15 +69,14 @@ func pickSeparator(v logView) string {
|
|||||||
return "&"
|
return "&"
|
||||||
}
|
}
|
||||||
|
|
||||||
// stepURL is any link inside the open day. It keeps the anchor, so picking a
|
// stepURL is the page URL for a link inside the open day: the fallback when
|
||||||
// dish or cancelling stays where the day is instead of throwing the viewport
|
// there is no JavaScript to intercept it.
|
||||||
// back to the top of the list.
|
|
||||||
func stepURL(v logView, param string) string {
|
func stepURL(v logView, param string) string {
|
||||||
url := dayURL("/", v.Date, v.Today)
|
url := dayURL("/", v.Date, v.Today)
|
||||||
if param != "" {
|
if param != "" {
|
||||||
url += pickSeparator(v) + param
|
url += pickSeparator(v) + param
|
||||||
}
|
}
|
||||||
return url + "#" + dayAnchor(v.Date)
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
// jsString renders a Go string as a JavaScript literal, for the data-signals
|
// jsString renders a Go string as a JavaScript literal, for the data-signals
|
||||||
@@ -104,6 +108,16 @@ func categoryLabels(d Dish) string {
|
|||||||
return strings.Join(names, ", ")
|
return strings.Join(names, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pageTitle prefixes the tab title on any instance that is not production.
|
||||||
|
// The tab is the only place a browser shows which of two identical apps you
|
||||||
|
// are looking at.
|
||||||
|
func pageTitle(title string) string {
|
||||||
|
if envTag == "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
return envTag + " · " + title
|
||||||
|
}
|
||||||
|
|
||||||
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
|
// countFI renders "1 pääruoka" but "16 pääruokaa": Finnish takes the partitive
|
||||||
// after every number except one.
|
// after every number except one.
|
||||||
func countFI(n int, one, many string) string {
|
func countFI(n int, one, many string) string {
|
||||||
@@ -126,7 +140,7 @@ templ page(title, current string) {
|
|||||||
// header rather than butting against it.
|
// 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: light)" content="#FFFFFF"/>
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
|
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1C1E22"/>
|
||||||
<title>{ title }</title>
|
<title>{ pageTitle(title) }</title>
|
||||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
|
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/>
|
||||||
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
|
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/>
|
||||||
<!-- use-credentials: the manifest is fetched behind Basic auth and
|
<!-- use-credentials: the manifest is fetched behind Basic auth and
|
||||||
@@ -314,13 +328,13 @@ templ logPage(v logView) {
|
|||||||
// above the list and drop that day out of it, so the rows below jumped up
|
// 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.
|
// under the tap. Now nothing moves — the row grows.
|
||||||
templ dayList(v logView) {
|
templ dayList(v logView) {
|
||||||
<section class="history">
|
<section class="history" id="paivat">
|
||||||
for i, row := range v.History.Rows {
|
for i, row := range v.History.Rows {
|
||||||
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
|
if i == 0 || v.History.Rows[i-1].Date.Month() != row.Date.Month() {
|
||||||
<p class="monthrule">{ monthFI(row.Date) }</p>
|
<p class="monthrule">{ monthFI(row.Date) }</p>
|
||||||
}
|
}
|
||||||
if row.Date.Equal(v.Date) {
|
if row.Date.Equal(v.Date) {
|
||||||
<div class="open" id={ dayAnchor(row.Date) }>
|
<div class="open">
|
||||||
<p class="openday">
|
<p class="openday">
|
||||||
if row.Date.Equal(v.Today) {
|
if row.Date.Equal(v.Today) {
|
||||||
Tänään
|
Tänään
|
||||||
@@ -338,7 +352,11 @@ templ dayList(v logView) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} else if row.Entry != nil {
|
} else if row.Entry != nil {
|
||||||
<a class="entry" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
|
<a
|
||||||
|
class="entry"
|
||||||
|
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
|
||||||
|
>
|
||||||
<time>{ dayLabelFI(row.Date) }</time>
|
<time>{ dayLabelFI(row.Date) }</time>
|
||||||
<div>
|
<div>
|
||||||
<div class="nm">
|
<div class="nm">
|
||||||
@@ -350,7 +368,11 @@ templ dayList(v logView) {
|
|||||||
<span class="chev">›</span>
|
<span class="chev">›</span>
|
||||||
</a>
|
</a>
|
||||||
} else {
|
} else {
|
||||||
<a class="gapline" href={ templ.SafeURL(dayLink(row.Date, v.Today)) }>
|
<a
|
||||||
|
class="gapline"
|
||||||
|
href={ templ.SafeURL(dayURL("/", row.Date, v.Today)) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(row.Date, "") + "')" }
|
||||||
|
>
|
||||||
<time>{ dayLabelFI(row.Date) }</time>
|
<time>{ dayLabelFI(row.Date) }</time>
|
||||||
<span>Ei merkintää</span>
|
<span>Ei merkintää</span>
|
||||||
<span class="act">Merkitse</span>
|
<span class="act">Merkitse</span>
|
||||||
@@ -361,6 +383,7 @@ templ dayList(v logView) {
|
|||||||
<a
|
<a
|
||||||
class="more"
|
class="more"
|
||||||
href={ templ.SafeURL(dayURL("/", v.Date, v.Today) + pickSeparator(v) + "paivat=" + strconv.Itoa(v.HistoryMore)) }
|
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>
|
>Näytä lisää</a>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
@@ -436,6 +459,7 @@ templ boardList(v logView) {
|
|||||||
<a
|
<a
|
||||||
class="pill plain"
|
class="pill plain"
|
||||||
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
|
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())
|
@categoryIcon(d.CategoryKey())
|
||||||
{ d.Name }
|
{ d.Name }
|
||||||
@@ -471,7 +495,11 @@ templ quickAddCard(v logView) {
|
|||||||
if v.New.Err != "" {
|
if v.New.Err != "" {
|
||||||
<p class="formerr">{ v.New.Err }</p>
|
<p class="formerr">{ v.New.Err }</p>
|
||||||
}
|
}
|
||||||
<form method="post" action="/lisaa">
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/lisaa"
|
||||||
|
data-on:submit__prevent="@post('/lisaa', {contentType: 'form'})"
|
||||||
|
>
|
||||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Nimi</span>
|
<span>Nimi</span>
|
||||||
@@ -503,6 +531,7 @@ templ dishPill(d Dish, v logView) {
|
|||||||
<a
|
<a
|
||||||
class={ "pill", d.Size() }
|
class={ "pill", d.Size() }
|
||||||
href={ templ.SafeURL(stepURL(v, "ruoka="+strconv.FormatInt(d.ID, 10))) }
|
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())
|
@categoryIcon(d.CategoryKey())
|
||||||
{ d.Name }
|
{ d.Name }
|
||||||
@@ -518,7 +547,11 @@ templ sidesStep(v logView) {
|
|||||||
@categoryIcon(v.Chosen.CategoryKey())
|
@categoryIcon(v.Chosen.CategoryKey())
|
||||||
{ v.Chosen.Name }
|
{ v.Chosen.Name }
|
||||||
</h3>
|
</h3>
|
||||||
<form method="post" action="/kirjaa">
|
<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="pvm" value={ isoDate(v.Date) }/>
|
||||||
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
|
<input type="hidden" name="ruoka" value={ strconv.FormatInt(v.Chosen.ID, 10) }/>
|
||||||
if v.Chosen.HasSides && len(v.Sides) > 0 {
|
if v.Chosen.HasSides && len(v.Sides) > 0 {
|
||||||
@@ -540,7 +573,11 @@ templ sidesStep(v logView) {
|
|||||||
}
|
}
|
||||||
<button class="primary" type="submit">Tallenna</button>
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="ghost" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a>
|
<a
|
||||||
|
class="ghost"
|
||||||
|
href={ templ.SafeURL(stepURL(v, "")) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
|
||||||
|
>Peruuta</a>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,21 +598,31 @@ templ loggedCard(v logView) {
|
|||||||
if v.Confirming {
|
if v.Confirming {
|
||||||
<p class="q">Poistetaanko merkintä?</p>
|
<p class="q">Poistetaanko merkintä?</p>
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
<form method="post" action="/poista">
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/poista"
|
||||||
|
data-on:submit__prevent="@post('/poista', {contentType: 'form'})"
|
||||||
|
>
|
||||||
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
<input type="hidden" name="pvm" value={ isoDate(v.Date) }/>
|
||||||
<button class="btn del" type="submit">Kyllä, poista</button>
|
<button class="btn del" type="submit">Kyllä, poista</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn" href={ templ.SafeURL(stepURL(v, "")) }>Peruuta</a>
|
<a
|
||||||
|
class="btn"
|
||||||
|
href={ templ.SafeURL(stepURL(v, "")) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "") + "')" }
|
||||||
|
>Peruuta</a>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
<a
|
<a
|
||||||
class="btn"
|
class="btn"
|
||||||
href={ templ.SafeURL(stepURL(v, "muuta=1")) }
|
href={ templ.SafeURL(stepURL(v, "muuta=1")) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "muuta=1") + "')" }
|
||||||
>Muokkaa</a>
|
>Muokkaa</a>
|
||||||
<a
|
<a
|
||||||
class="btn del"
|
class="btn del"
|
||||||
href={ templ.SafeURL(stepURL(v, "poista=1")) }
|
href={ templ.SafeURL(stepURL(v, "poista=1")) }
|
||||||
|
data-on:click__prevent={ "@get('" + dayPatch(v.Date, "poista=1") + "')" }
|
||||||
>Poista</a>
|
>Poista</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -689,25 +736,39 @@ templ emptyNote(search string) {
|
|||||||
// rowActions is a pencil and a bin, until the bin is tapped: then the row
|
// 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
|
// 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.
|
// 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) {
|
templ rowActions(v catalogView, editURL string, id int64, kind string) {
|
||||||
if v.DeleteID == id && v.DeleteKind == kind {
|
if v.DeleteID == id && v.DeleteKind == kind {
|
||||||
<div class="rowactions confirming">
|
<div class="rowactions confirming">
|
||||||
<span>Poista?</span>
|
<span>Poista?</span>
|
||||||
<form method="post" action="/ruuat/poista">
|
<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="id" value={ strconv.FormatInt(id, 10) }/>
|
||||||
<input type="hidden" name="tyyppi" value={ kind }/>
|
<input type="hidden" name="tyyppi" value={ kind }/>
|
||||||
<button type="submit" class="del">Kyllä</button>
|
<button type="submit" class="del">Kyllä</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="/ruuat">Peruuta</a>
|
<a href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
|
||||||
</div>
|
</div>
|
||||||
} else {
|
} else {
|
||||||
<div class="rowactions">
|
<div class="rowactions">
|
||||||
<a href={ templ.SafeURL(editURL) } aria-label="Muokkaa" title="Muokkaa">
|
<a
|
||||||
|
href={ templ.SafeURL(editURL) }
|
||||||
|
data-on:click__prevent={ "@get('" + showURL(editURL) + "')" }
|
||||||
|
aria-label="Muokkaa"
|
||||||
|
title="Muokkaa"
|
||||||
|
>
|
||||||
@iconPencil()
|
@iconPencil()
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="del"
|
class="del"
|
||||||
href={ templ.SafeURL("/ruuat?poista=" + strconv.FormatInt(id, 10) + "&tyyppi=" + kind) }
|
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"
|
aria-label="Poista"
|
||||||
title="Poista"
|
title="Poista"
|
||||||
>
|
>
|
||||||
@@ -746,7 +807,11 @@ templ mainForm_(f mainForm) {
|
|||||||
if f.Err != "" {
|
if f.Err != "" {
|
||||||
<p class="formerr">{ f.Err }</p>
|
<p class="formerr">{ f.Err }</p>
|
||||||
}
|
}
|
||||||
<form method="post" action="/ruuat/paaruoka">
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/ruuat/paaruoka"
|
||||||
|
data-on:submit__prevent="@post('/ruuat/paaruoka', {contentType: 'form'})"
|
||||||
|
>
|
||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
||||||
}
|
}
|
||||||
@@ -774,7 +839,7 @@ templ mainForm_(f mainForm) {
|
|||||||
<button class="primary" type="submit">Tallenna</button>
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
</form>
|
</form>
|
||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<a class="ghost" href="/ruuat">Peruuta</a>
|
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
|
||||||
}
|
}
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
@@ -803,7 +868,11 @@ templ sideForm_(f sideForm) {
|
|||||||
if f.Err != "" {
|
if f.Err != "" {
|
||||||
<p class="formerr">{ f.Err }</p>
|
<p class="formerr">{ f.Err }</p>
|
||||||
}
|
}
|
||||||
<form method="post" action="/ruuat/lisuke">
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/ruuat/lisuke"
|
||||||
|
data-on:submit__prevent="@post('/ruuat/lisuke', {contentType: 'form'})"
|
||||||
|
>
|
||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
<input type="hidden" name="id" value={ strconv.FormatInt(f.ID, 10) }/>
|
||||||
}
|
}
|
||||||
@@ -814,7 +883,7 @@ templ sideForm_(f sideForm) {
|
|||||||
<button class="primary" type="submit">Tallenna</button>
|
<button class="primary" type="submit">Tallenna</button>
|
||||||
</form>
|
</form>
|
||||||
if f.ID != 0 {
|
if f.ID != 0 {
|
||||||
<a class="ghost" href="/ruuat">Peruuta</a>
|
<a class="ghost" href="/ruuat" data-on:click__prevent="@get('/ruuat/nayta')">Peruuta</a>
|
||||||
}
|
}
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-9
@@ -1,19 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: ${FOODSTER_REPO:?set FOODSTER_REPO in .env}:${FOODSTER_TAG:-latest}
|
image: ${REPO:?set REPO in .env}:${TAG:-latest}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# The database is a bind mount, not a named volume: it sits in ./data on
|
# A bind mount rather than a named volume: the database sits in ./data on
|
||||||
# the host where it can be listed, copied and opened with any sqlite
|
# the host, where it can be listed, copied and backed up without going
|
||||||
# client. The image runs as UID 65534, so the container has to be told
|
# through the container engine. The image runs as UID 65534, so the
|
||||||
# which host user owns that directory.
|
# container has to be told which host user owns that directory.
|
||||||
user: "${FOODSTER_UID:-1000}:${FOODSTER_GID:-1000}"
|
#
|
||||||
|
# PUID/PGID rather than UID/GID: UID is a read-only variable in bash, so a
|
||||||
|
# value set here would be silently replaced by the invoking shell's own.
|
||||||
|
user: "${PUID:-1000}:${PGID:-1000}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
FOODSTER_PASSWORD: ${FOODSTER_PASSWORD:?set FOODSTER_PASSWORD in .env}
|
PASSWORD: ${PASSWORD:?set PASSWORD in .env}
|
||||||
FOODSTER_DB: /data/foodster.db
|
DB: /data/foodster.db
|
||||||
|
ENV: ${ENV:-prod}
|
||||||
TZ: ${TZ:-Europe/Helsinki}
|
TZ: ${TZ:-Europe/Helsinki}
|
||||||
|
|
||||||
# No published ports: Traefik reaches the container over the shared
|
# No published ports: Traefik reaches the container over the shared
|
||||||
@@ -22,7 +26,7 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.foodster.entrypoints=websecure
|
- traefik.http.routers.foodster.entrypoints=websecure
|
||||||
- traefik.http.routers.foodster.rule=Host(`${FOODSTER_HOST:?set FOODSTER_HOST in .env}`)
|
- traefik.http.routers.foodster.rule=Host(`${HOST:?set HOST in .env}`)
|
||||||
- traefik.http.routers.foodster.tls=true
|
- traefik.http.routers.foodster.tls=true
|
||||||
- traefik.http.services.foodster.loadbalancer.server.port=8080
|
- traefik.http.services.foodster.loadbalancer.server.port=8080
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
|
|||||||
+64
-14
@@ -16,7 +16,7 @@ trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
|
|||||||
|
|
||||||
go build -o "$tmp/foodster" ./cmd/foodster
|
go build -o "$tmp/foodster" ./cmd/foodster
|
||||||
|
|
||||||
FOODSTER_PASSWORD="$pass" FOODSTER_DB="$tmp/smoke.db" FOODSTER_ADDR="$addr" \
|
PASSWORD="$pass" DB="$tmp/smoke.db" ADDR="$addr" \
|
||||||
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
|
"$tmp/foodster" >"$tmp/server.log" 2>&1 &
|
||||||
srv=$!
|
srv=$!
|
||||||
|
|
||||||
@@ -72,6 +72,8 @@ check "theme script is served" \
|
|||||||
|
|
||||||
home=$(curl -s -u ":$pass" "http://$addr/")
|
home=$(curl -s -u ":$pass" "http://$addr/")
|
||||||
check "the header carries the brand" "$home" "Foodster"
|
check "the header carries the brand" "$home" "Foodster"
|
||||||
|
# ENV is unset here, so this instance is production and unmarked.
|
||||||
|
check "production tabs are not tagged" "$home" "<title>Foodster</title>"
|
||||||
check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">'
|
check "dark is the default without JavaScript" "$home" '<html lang="fi" data-theme="dark">'
|
||||||
check "the theme toggle is present" "$home" "data-theme-toggle"
|
check "the theme toggle is present" "$home" "data-theme-toggle"
|
||||||
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
|
check "both theme icons ship so CSS can pick one" "$home" 'class="i-moon"'
|
||||||
@@ -140,13 +142,31 @@ check "the saved day shows what was eaten" \
|
|||||||
# The selected day expands inside the list rather than in a panel above it,
|
# The selected day expands inside the list rather than in a panel above it,
|
||||||
# so the rows below do not shift when one is tapped.
|
# so the rows below do not shift when one is tapped.
|
||||||
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
|
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
|
||||||
check "the selected day expands in place" "$day" 'id="paiva-2026-09-05"'
|
check "the selected day expands in place" "$day" 'class="open"'
|
||||||
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
||||||
check "links inside it keep the anchor" "$day" "#paiva-2026-09-05"
|
|
||||||
|
|
||||||
check "saving returns to the day, not the top" \
|
# ---- the day list patches in place instead of navigating ----------------
|
||||||
|
|
||||||
|
dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=2026-09-05")
|
||||||
|
check "opening a day patches the list" "$dayp" 'id="paivat"'
|
||||||
|
refute "and returns a fragment, not a page" "$dayp" "<html"
|
||||||
|
|
||||||
|
check "picking a dish patches to the sides step" \
|
||||||
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
||||||
|
"http://$addr/paiva?pvm=2026-09-05&ruoka=$ruoka")" "Tallenna"
|
||||||
|
|
||||||
|
check "saving from Datastar patches back" \
|
||||||
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
||||||
|
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
|
||||||
|
|
||||||
|
check "deleting from Datastar patches back" \
|
||||||
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
||||||
|
-d "pvm=2026-09-04" "http://$addr/poista")" 'id="paivat"'
|
||||||
|
|
||||||
|
# Without the header it must still redirect, for no JavaScript.
|
||||||
|
check "a plain save still redirects to the day" \
|
||||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||||
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "#paiva-2026-09-04"
|
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=2026-09-04"
|
||||||
|
|
||||||
# Deleting a logged meal drops the row outright, so it asks first.
|
# Deleting a logged meal drops the row outright, so it asks first.
|
||||||
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
|
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
|
||||||
@@ -220,9 +240,12 @@ check "the quick-added dish is on the board" \
|
|||||||
|
|
||||||
# ---- catalog CRUD from the UI -------------------------------------------
|
# ---- catalog CRUD from the UI -------------------------------------------
|
||||||
|
|
||||||
check "adding a main redirects" \
|
# Assert where it redirects, not just that it does: these pointed at the old
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
# /ruoat spelling for a while and every 303-only check was happy.
|
||||||
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" "303"
|
check "adding a main redirects back to the catalog" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||||
|
-d 'nimi=uunikala&kategoria=fish&lisukkeita=1' "http://$addr/ruuat/paaruoka")" \
|
||||||
|
"/ruuat"
|
||||||
|
|
||||||
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
|
catalog=$(curl -s -u ":$pass" "http://$addr/ruuat")
|
||||||
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
|
check "the new main is listed, sentence-cased" "$catalog" "Uunikala"
|
||||||
@@ -239,9 +262,10 @@ check "a nameless dish is refused" \
|
|||||||
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
"$(curl -s -u ":$pass" -d 'nimi=+++&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
||||||
"Anna nimi."
|
"Anna nimi."
|
||||||
|
|
||||||
check "adding a side redirects" \
|
check "adding a side redirects back to the catalog" \
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||||
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" "303"
|
-d 'nimi=lohkoperunat' "http://$addr/ruuat/lisuke")" \
|
||||||
|
"/ruuat"
|
||||||
|
|
||||||
check "the new side is listed" \
|
check "the new side is listed" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Lohkoperunat"
|
||||||
@@ -264,9 +288,35 @@ check "the bin asks before deleting" \
|
|||||||
check "the dish is still there while it asks" \
|
check "the dish is still there while it asks" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
|
"$(curl -s -u ":$pass" "http://$addr/ruuat?poista=$uusi&tyyppi=paa")" "Uunikala"
|
||||||
|
|
||||||
check "confirming the delete redirects" \
|
# ---- the catalog patches in place instead of navigating -----------------
|
||||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
|
||||||
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" "303"
|
# A delete confirmation halfway down a long list must not send the browser
|
||||||
|
# back to the top, so these answer with a Datastar patch rather than a page.
|
||||||
|
patch=$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
||||||
|
"http://$addr/ruuat/nayta?poista=$uusi&tyyppi=paa")
|
||||||
|
check "asking to delete patches rather than navigates" "$patch" "event: datastar-patch-elements"
|
||||||
|
check "the patch carries the list" "$patch" 'id="ruokalista"'
|
||||||
|
check "and both forms, so an open one closes" "$patch" 'id="paaruoka"'
|
||||||
|
check "the row it patches in is asking" "$patch" "Poista?"
|
||||||
|
|
||||||
|
check "patches are served as an event stream" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{content_type}' -u ":$pass" -H 'Datastar-Request: true' \
|
||||||
|
"http://$addr/ruuat/nayta")" "text/event-stream"
|
||||||
|
|
||||||
|
check "deleting from Datastar patches too" \
|
||||||
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
||||||
|
-d "id=$uusi&tyyppi=paa" "http://$addr/ruuat/poista")" \
|
||||||
|
"event: datastar-patch-elements"
|
||||||
|
|
||||||
|
refute "and the dish is gone from the patched list" \
|
||||||
|
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/ruuat/nayta")" \
|
||||||
|
"Uunikala"
|
||||||
|
|
||||||
|
# Without the header it must still be an ordinary redirect, for no JavaScript.
|
||||||
|
check "a plain form post still redirects" \
|
||||||
|
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||||
|
-d 'nimi=Testiruoka&kategoria=fish' "http://$addr/ruuat/paaruoka")" \
|
||||||
|
"/ruuat"
|
||||||
|
|
||||||
refute "the dish is gone once confirmed" \
|
refute "the dish is gone once confirmed" \
|
||||||
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"
|
"$(curl -s -u ":$pass" "http://$addr/ruuat")" "Uunikala"
|
||||||
|
|||||||
Reference in New Issue
Block a user