Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.
Schema (migration 0017_views_redesign.sql):
- id (uuid), slug (text, format-CHECK'd, UNIQUE), name, icon,
filter_json (jsonb — INCLUDES view_type per m's Q2), sort_field,
sort_dir, group_by, sort_order, show_count, last_used_at,
created_at, updated_at.
- DROPPED: pinned, is_default_for, view_type column. m's Q9 picked
MRU (last_used_at) over per-page-default; Q2 placed view_type
inside filter_json so the JSON owns the canonical render spec.
- Constraints: slug regex, sort_dir enum. NO view_type CHECK — the
JSON-shape validator owns it now.
- Indexes: slug UNIQUE, (sort_order, name), (last_used_at DESC).
- updated_at trigger reused; projax_admin ownership preserved.
Store (store/views.go rewrite):
- View struct: Slug as the user-facing key; uuid kept on ID for the
legacy `?view=<uuid>` 302-redirect path that lands in slice C.
- ListViews ordered by sort_order, name (matches sidebar).
- GetView(slug) + GetViewByID(uuid). MostRecentView() drives the
/views landing redirect (slice B).
- TouchView(slug) bumps last_used_at fire-and-forget.
- ReorderViews([]slugs) wires the column for slice G's drag UI.
- CreateView server-assigns sort_order = MAX+1 inside the tx.
- UpdateView replaces every writeable field; renames are supported.
- Validation: slug format regex + reserved-list rejection +
filter_json JSON well-formed check before round-trip.
- ErrViewNotFound / ErrViewSlugTaken / ErrViewSlugReserved /
ErrViewSlugFormat surface to handlers as the typed error set.
Cleanup of the 5i overlay (drops what the new shape obsoletes):
- web/views.go: gutted to a stub. applySavedView, applyDefaultView,
overlayURLFields, filterQueryToJSON, filterJSONToQuery,
filterFromJSONPayload, anySliceToStrings + every old handler
(handleViewsIndex, handleViewCreate, handleViewWrite, handleViewEdit,
handleViewRedirect, handleViewDelete) deleted.
- web/server.go: dropped the /views route registrations and the
applySavedView + applyDefaultView calls in handleTree.
DefaultBanner data-map field removed.
- web/tree_filter.go: TreeFilter.ViewID field removed; ParseTreeFilter
and QueryString stop reading/emitting ?view=.
- web/templates/views.tmpl and view_edit.tmpl deleted.
- web/templates/tree_section.tmpl: default-banner block deleted.
- web/views_test.go: deleted (every test was against the 5i shape).
Between slice A and slice B, /views/* URLs return 404 by design.
Slice B reintroduces the route family in paliad-shape:
GET /views → MRU landing
GET /views/{slug} → render
GET /views/new → editor
GET /views/{slug}/edit → editor
POST /views, /views/{slug}, /views/{slug}/delete → CRUD
Tests (store/views_test.go, new):
- TestViewSlugCRUD — create / get-by-slug / get-by-id / rename /
delete round-trip, including rename-leaves-old-slug-gone.
- TestViewSlugFormatRejected — uppercase, underscore, leading dash,
length-cap, empty all surface ErrViewSlugFormat.
- TestViewReservedSlugRejected — tree/dashboard/calendar/timeline/graph
and friends all reject with ErrViewSlugReserved.
- TestViewSlugCollision — duplicate slug surfaces ErrViewSlugTaken.
- TestViewMRU — TouchView + MostRecentView ordering against a
controlled pair of slugs (resilient to other suites' touched views).
- TestViewReorder — ReorderViews rewrites sort_order ascending.
Web tests stay green (the 5i overlay tests are gone, the rest don't
touch the views shape).
1044 lines
34 KiB
Go
1044 lines
34 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/m/projax/internal/aggregate"
|
|
"github.com/m/projax/internal/cache"
|
|
"github.com/m/projax/internal/itemwrite"
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// itemWriteFailure surfaces an *itemwrite.ValidationError to the client.
|
|
// HTTP code: 400 for invalid input. The body is a one-line human banner
|
|
// keyed on Kind so handlers don't have to duplicate copy-table fragments.
|
|
// Phase 5c uses this instead of the pre-existing raw-pgErr-on-failure
|
|
// pattern in handleDetailWrite / handleNewSubmit / handleReparent.
|
|
func (s *Server) itemWriteFailure(w http.ResponseWriter, r *http.Request, ve *itemwrite.ValidationError) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintln(w, itemWriteBannerCopy(ve))
|
|
s.Logger.Warn("itemwrite reject", "path", r.URL.Path, "kind", ve.Kind, "detail", ve.Detail)
|
|
}
|
|
|
|
// itemWriteBannerCopy maps a ValidationError.Kind to the human-facing
|
|
// banner copy. Centralised so web/server.go + web/bulk.go share one
|
|
// authoritative phrasing.
|
|
func itemWriteBannerCopy(ve *itemwrite.ValidationError) string {
|
|
switch ve.Kind {
|
|
case itemwrite.KindMissingRequired:
|
|
return "Missing required field: " + ve.Detail
|
|
case itemwrite.KindInvalidSlugFormat:
|
|
return ve.Detail
|
|
case itemwrite.KindInvalidStatus:
|
|
return ve.Detail
|
|
case itemwrite.KindSelfParent:
|
|
return "An item cannot be its own parent."
|
|
case itemwrite.KindUnknownParent:
|
|
return ve.Detail
|
|
case itemwrite.KindSlugCollision:
|
|
return ve.Detail
|
|
case itemwrite.KindCycle:
|
|
return "Cannot reparent: this move would put the item in its own ancestor closure."
|
|
case itemwrite.KindUnresolvablePath:
|
|
return ve.Detail
|
|
}
|
|
return "Invalid input: " + ve.Detail
|
|
}
|
|
|
|
// Register MIME types stdlib doesn't ship by default. The web-app manifest
|
|
// spec requires application/manifest+json for the `<link rel=manifest>` →
|
|
// without this Go's FileServer falls back to text/plain and Chrome refuses
|
|
// to treat the file as a manifest.
|
|
func init() {
|
|
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
|
}
|
|
|
|
//go:embed templates/*.tmpl
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
// Server bundles handlers, templates, and the store.
|
|
type Server struct {
|
|
Store *store.Store
|
|
pages map[string]*template.Template
|
|
Logger *slog.Logger
|
|
Auth *AuthConfig // nil → no auth (local dev / tests)
|
|
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
|
Gitea *GiteaDeps // nil → Gitea integration disabled
|
|
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
|
|
Version string // build-time -ldflags injection; surfaced on /admin
|
|
dashboard *cache.TTLCache[*dashboardPayload]
|
|
timeline *cache.TTLCache[*TimelinePayload]
|
|
calendar *cache.TTLCache[*calendarPayload]
|
|
adminHealth *adminHealthCache
|
|
}
|
|
|
|
// Aggregator builds a fresh *aggregate.Aggregator wired to the server's
|
|
// current CalDAV/Gitea deps. Per-call construction so main.go can install
|
|
// CalDAV/Gitea after web.New without having to wire a re-init hook.
|
|
func (s *Server) Aggregator() *aggregate.Aggregator {
|
|
var cal aggregate.CalDAVClient
|
|
if s.CalDAV != nil {
|
|
cal = s.CalDAV.Client
|
|
}
|
|
var git aggregate.GiteaClient
|
|
var cache aggregate.IssueCache
|
|
if s.Gitea != nil {
|
|
git = s.Gitea.Client
|
|
cache = s.Gitea.Cache
|
|
}
|
|
return aggregate.New(s.Store, cal, git, cache, s.Logger)
|
|
}
|
|
|
|
// New builds a Server. Each page is parsed alongside the layout into its own
|
|
// Template so per-page `define "content"` blocks don't shadow each other. The
|
|
// login page is intentionally NOT wrapped in the regular layout (chrome would
|
|
// imply you're already inside the app).
|
|
func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
funcs := template.FuncMap{
|
|
"deref": func(p *string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
},
|
|
"join": func(sep string, parts []string) string { return strings.Join(parts, sep) },
|
|
"contains": func(haystack []string, needle string) bool {
|
|
for _, h := range haystack {
|
|
if h == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
|
|
"subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) },
|
|
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
|
|
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
|
next := []string{}
|
|
if isActive {
|
|
for _, t := range active {
|
|
if t != tag {
|
|
next = append(next, t)
|
|
}
|
|
}
|
|
} else {
|
|
next = append(next, active...)
|
|
next = append(next, tag)
|
|
}
|
|
if len(next) == 0 {
|
|
return "/"
|
|
}
|
|
return "/?tag=" + strings.Join(next, ",")
|
|
},
|
|
}
|
|
pages := map[string]*template.Template{}
|
|
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error"} {
|
|
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/"+name+".tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", name, err)
|
|
}
|
|
pages[name] = t
|
|
}
|
|
// tree bundles the tree-section partial so HTMX swaps and the initial
|
|
// page render share definitions. project_chip.tmpl is the Phase 5i Slice
|
|
// A shared partial that every Views-supporting page includes inside its
|
|
// filter strip.
|
|
treeTmpl, err := template.New("tree").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/tree.tmpl",
|
|
"templates/tree_section.tmpl",
|
|
"templates/tree_card.tmpl",
|
|
"templates/tree_kanban.tmpl",
|
|
"templates/project_chip.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse tree: %w", err)
|
|
}
|
|
pages["tree"] = treeTmpl
|
|
// Standalone tree-section template for HTMX fragment responses.
|
|
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/tree_section.tmpl",
|
|
"templates/tree_card.tmpl",
|
|
"templates/tree_kanban.tmpl",
|
|
"templates/project_chip.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse tree_section: %w", err)
|
|
}
|
|
pages["tree_section"] = treeSection
|
|
// detail bundles the shared tasks-section + issues-section partials so
|
|
// HTMX swaps and the initial page render hit the same template definitions.
|
|
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/detail.tmpl",
|
|
"templates/tasks_section.tmpl",
|
|
"templates/issues_section.tmpl",
|
|
"templates/documents_section.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse detail: %w", err)
|
|
}
|
|
pages["detail"] = detailTmpl
|
|
// Standalone tasks-section template for HTMX fragment responses.
|
|
tasksFragment, err := template.New("tasks_section").Funcs(funcs).ParseFS(templatesFS, "templates/tasks_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse tasks_section: %w", err)
|
|
}
|
|
pages["tasks_section"] = tasksFragment
|
|
// Standalone issues-section template for HTMX fragment responses (Phase 3h
|
|
// writeback re-renders the issues card after a close/comment/create).
|
|
issuesFragment, err := template.New("issues_section").Funcs(funcs).ParseFS(templatesFS, "templates/issues_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse issues_section: %w", err)
|
|
}
|
|
pages["issues_section"] = issuesFragment
|
|
// Standalone documents-section template for HTMX fragment responses.
|
|
docsFragment, err := template.New("documents_section").Funcs(funcs).ParseFS(templatesFS, "templates/documents_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse documents_section: %w", err)
|
|
}
|
|
pages["documents_section"] = docsFragment
|
|
loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse login: %w", err)
|
|
}
|
|
pages["login"] = loginTmpl
|
|
|
|
// Graph page (layout chrome + SVG body) and a standalone SVG entry for
|
|
// the ?download=svg path.
|
|
graphTmpl, err := template.New("graph").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/graph.tmpl",
|
|
"templates/graph_svg.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse graph: %w", err)
|
|
}
|
|
pages["graph"] = graphTmpl
|
|
graphSVG, err := template.New("graph_svg").Funcs(funcs).ParseFS(templatesFS, "templates/graph_svg.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse graph_svg: %w", err)
|
|
}
|
|
pages["graph_svg"] = graphSVG
|
|
|
|
// Admin index — landing page with the 3 admin cards + system health panel.
|
|
adminTmpl, err := template.New("admin").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/admin.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse admin: %w", err)
|
|
}
|
|
pages["admin"] = adminTmpl
|
|
|
|
// Dashboard page + its section fragment. Phase 5h: the section fragment
|
|
// dispatches to one of three view partials (tiles / cards / events-view),
|
|
// so the tiles partial joins both bundles.
|
|
dashTmpl, err := template.New("dashboard").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/dashboard.tmpl",
|
|
"templates/dashboard_section.tmpl",
|
|
"templates/dashboard_tiles.tmpl",
|
|
"templates/project_chip.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse dashboard: %w", err)
|
|
}
|
|
pages["dashboard"] = dashTmpl
|
|
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/dashboard_section.tmpl",
|
|
"templates/dashboard_tiles.tmpl",
|
|
"templates/project_chip.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse dashboard_section: %w", err)
|
|
}
|
|
pages["dashboard_section"] = dashSection
|
|
|
|
// Timeline page + its section fragment.
|
|
timelineTmpl, err := template.New("timeline").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/timeline.tmpl",
|
|
"templates/timeline_section.tmpl",
|
|
"templates/project_chip.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse timeline: %w", err)
|
|
}
|
|
pages["timeline"] = timelineTmpl
|
|
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/timeline_section.tmpl",
|
|
"templates/project_chip.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse timeline_section: %w", err)
|
|
}
|
|
pages["timeline_section"] = timelineSection
|
|
|
|
// Calendar page — month grid view, Phase 5e. Bundles the section
|
|
// partial so HTMX swaps (filter chip strip) and the full-page render
|
|
// share definitions.
|
|
calTmpl, err := template.New("calendar").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/calendar.tmpl",
|
|
"templates/calendar_section.tmpl",
|
|
"templates/project_chip.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse calendar: %w", err)
|
|
}
|
|
pages["calendar"] = calTmpl
|
|
calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/calendar_section.tmpl",
|
|
"templates/project_chip.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse calendar_section: %w", err)
|
|
}
|
|
pages["calendar_section"] = calSection
|
|
|
|
// Bulk-edit page + its fragment + per-row chip cells. The chip cells share
|
|
// definitions with bulk_section so we parse them together every time.
|
|
bulkTmpl, err := template.New("bulk").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/bulk.tmpl",
|
|
"templates/bulk_section.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse bulk: %w", err)
|
|
}
|
|
pages["bulk"] = bulkTmpl
|
|
bulkSection, err := template.New("bulk_section").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse bulk_section: %w", err)
|
|
}
|
|
pages["bulk_section"] = bulkSection
|
|
bulkChipTags, err := template.New("bulk_chip_tags").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse bulk_chip_tags: %w", err)
|
|
}
|
|
pages["bulk_chip_tags"] = bulkChipTags
|
|
bulkChipMgmt, err := template.New("bulk_chip_mgmt").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse bulk_chip_mgmt: %w", err)
|
|
}
|
|
pages["bulk_chip_mgmt"] = bulkChipMgmt
|
|
|
|
return &Server{
|
|
Store: s,
|
|
pages: pages,
|
|
Logger: logger,
|
|
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
|
|
timeline: cache.NewTTL[*TimelinePayload](timelineCacheTTL),
|
|
calendar: cache.NewTTL[*calendarPayload](calendarCacheTTL),
|
|
adminHealth: newAdminHealthCache(),
|
|
}, nil
|
|
}
|
|
|
|
// Routes wires every URL to a handler and returns the mux.
|
|
func (s *Server) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /", s.handleTree)
|
|
mux.HandleFunc("GET /i/", s.handleDetail)
|
|
mux.HandleFunc("POST /i/", s.handleDetailWrite)
|
|
mux.HandleFunc("GET /new", s.handleNewForm)
|
|
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
|
mux.HandleFunc("GET /admin", s.handleAdminIndex)
|
|
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
|
mux.HandleFunc("GET /dashboard", s.handleDashboard)
|
|
mux.HandleFunc("GET /timeline", s.handleTimeline)
|
|
mux.HandleFunc("GET /calendar", s.handleCalendar)
|
|
mux.HandleFunc("GET /graph", s.handleGraph)
|
|
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
|
|
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
|
|
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
|
|
mux.HandleFunc("POST /dashboard/pin", s.handleDashboardPin)
|
|
mux.HandleFunc("GET /admin/bulk", s.handleBulk)
|
|
mux.HandleFunc("POST /admin/bulk/apply", s.handleBulkApply)
|
|
mux.HandleFunc("POST /admin/bulk/chip", s.handleBulkChip)
|
|
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
|
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
|
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
|
// /views routes land in slice B (paliad-shape: GET /views, GET
|
|
// /views/{slug}, GET /views/new, GET /views/{slug}/edit, plus POST CRUD).
|
|
// Between slice A and slice B these URLs 404 by design.
|
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
|
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.Store.Pool.Ping(r.Context()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
// Surface the build-time git SHA so any worker can verify "deploy
|
|
// rolled" without needing an authed session. Body is two
|
|
// human-readable lines so curl piped to head still reads cleanly.
|
|
fmt.Fprintln(w, "ok")
|
|
fmt.Fprintf(w, "version: %s\n", s.Version)
|
|
})
|
|
|
|
if s.MCP != nil {
|
|
// Mount MCP routes with explicit method+path patterns. A prefix pattern
|
|
// like `/mcp/` would conflict with `GET /` under Go 1.22's strict
|
|
// ServeMux (the prefix matches more methods than the subtree root).
|
|
mcpHandler := http.StripPrefix("/mcp", s.MCP)
|
|
mux.Handle("POST /mcp/rpc", mcpHandler)
|
|
mux.Handle("GET /mcp/rpc", mcpHandler)
|
|
}
|
|
|
|
static, _ := fs.Sub(staticFS, "static")
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
|
|
|
var h http.Handler = mux
|
|
if s.Auth != nil {
|
|
h = authMiddleware(*s.Auth, s.Logger, h)
|
|
}
|
|
return logging(s.Logger, h)
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
type treeNode struct {
|
|
Item *store.Item
|
|
Children []*treeNode
|
|
}
|
|
|
|
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
items, err := s.Store.ListAll(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
tags, err := s.Store.AllTags(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
linkKinds, err := s.linkKindsByItem(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
filter := ParseTreeFilter(r.URL.Query())
|
|
viewSet := PageViewTypes("/")
|
|
view := ParseViewType(r.URL.Query(), viewSet)
|
|
// Phase 5j: ?view= overlay + is_default_for resolution deleted with the
|
|
// 5i shape. /views/{slug} (slice B+) renders saved views as their own
|
|
// pages; legacy ?view=<uuid> URLs are 302-redirected from a dedicated
|
|
// handler (slice C). handleTree stays focused on the tree-as-tree
|
|
// surface and no longer hijacks itself based on a query param.
|
|
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
|
counts := computeChipCounts(items, filter, linkKinds, tags)
|
|
// Phase 5i Slice B: the card view renders a flat grid of matched items
|
|
// (no tree structure). Build from items + filter directly rather than
|
|
// reusing the post-prune `roots` (which still keeps ancestors).
|
|
cardItems := flatMatchedItems(items, filter, linkKinds)
|
|
// Phase 5i Slice C: kanban groups the same matched set into columns.
|
|
groupBy := ParseGroupBy(r.URL.Query())
|
|
kanban := BuildKanbanBoard(cardItems, groupBy)
|
|
groupByChips := GroupByChips("/", filter, groupBy)
|
|
data := map[string]any{
|
|
"Title": "tree",
|
|
"Roots": roots,
|
|
"Orphans": orphans,
|
|
"Total": total,
|
|
"OrphanN": orphanN,
|
|
"Matched": matched,
|
|
"AllTags": tags,
|
|
"Filter": filter,
|
|
"Counts": counts,
|
|
"Projects": parentOptionsFromItems(items),
|
|
"BasePath": "/",
|
|
"ProjectChipTarget": "#tree-section",
|
|
"ViewType": view,
|
|
"ViewTypeChips": ViewTypeChips("/", filter, view),
|
|
"CardItems": cardItems,
|
|
"Kanban": kanban,
|
|
"GroupBy": groupBy,
|
|
"GroupByChips": groupByChips,
|
|
// ActiveTags kept for backwards-compat with the old template path; removed
|
|
// after the template migrates fully.
|
|
"ActiveTags": filter.Tags,
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
// Fragment swap: only the tree section. The browser keeps the chip
|
|
// chrome (which itself is HTMX-driven) up to date because we push the
|
|
// URL via hx-push-url at chip-click time.
|
|
s.render(w, r, "tree_section", data)
|
|
return
|
|
}
|
|
s.render(w, r, "tree", data)
|
|
}
|
|
|
|
// linkKindsByItem returns a map: itemID → set of ref_types attached to that item.
|
|
// Used by the tree filter for has-link chips. Two ref_types matter at v1:
|
|
// caldav-list and gitea-repo.
|
|
func (s *Server) linkKindsByItem(ctx context.Context) (map[string]map[string]struct{}, error) {
|
|
out := map[string]map[string]struct{}{}
|
|
for _, t := range []string{"caldav-list", "gitea-repo"} {
|
|
links, err := s.Store.LinksByRefType(ctx, t)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, l := range links {
|
|
set, ok := out[l.ItemID]
|
|
if !ok {
|
|
set = map[string]struct{}{}
|
|
out[l.ItemID] = set
|
|
}
|
|
set[t] = struct{}{}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
|
if path == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
// PER URL resolution: try the full path first; if it 404s and the trailing
|
|
// segment looks like YYMMDD, retry against the shorter path and surface
|
|
// the date as a render hint to scroll/highlight the matching row.
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
var highlight *time.Time
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
if base, d := parsePER(path); d != nil {
|
|
if it2, err2 := s.Store.GetByPath(r.Context(), base); err2 == nil {
|
|
it, err, highlight = it2, nil, d
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parents, err := s.parentOptions(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
tasks, err := s.detailTodos(r.Context(), it)
|
|
if err != nil {
|
|
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
|
|
}
|
|
issues, err := s.detailIssues(r.Context(), it)
|
|
if err != nil {
|
|
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
|
|
}
|
|
openTotal := 0
|
|
for _, ri := range issues {
|
|
openTotal += ri.OpenCount
|
|
}
|
|
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
|
|
if err != nil {
|
|
s.Logger.Warn("detail docs", "path", it.PrimaryPath(), "err", err)
|
|
}
|
|
documents := computePERs(it.PrimaryPath(), docs)
|
|
s.render(w, r, "detail", map[string]any{
|
|
"Title": it.Title,
|
|
"Item": it,
|
|
"ParentOptions": parents,
|
|
"StatusOptions": []string{"active", "done", "archived"},
|
|
"Tasks": tasks,
|
|
"CalDAVOn": s.CalDAV != nil,
|
|
"Issues": issues,
|
|
"IssuesOpenTotal": openTotal,
|
|
"GiteaOn": s.Gitea != nil,
|
|
"Documents": documents,
|
|
"HighlightDate": highlight,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
|
if base, ok := strings.CutSuffix(path, "/reparent"); ok {
|
|
s.handleReparent(w, r, base)
|
|
return
|
|
}
|
|
if base, ok := strings.CutSuffix(path, "/caldav/create"); ok {
|
|
s.handleCalDAVCreate(w, r, base)
|
|
return
|
|
}
|
|
for _, action := range []string{"complete", "reopen", "edit", "delete", "todo-create"} {
|
|
if base, ok := strings.CutSuffix(path, "/caldav/todo/"+action); ok {
|
|
s.handleCalDAVTodoAction(w, r, base, action)
|
|
return
|
|
}
|
|
}
|
|
for _, action := range []string{"close", "reopen", "comment", "create"} {
|
|
if base, ok := strings.CutSuffix(path, "/issues/"+action); ok {
|
|
s.handleIssueAction(w, r, base, action)
|
|
return
|
|
}
|
|
}
|
|
if base, ok := strings.CutSuffix(path, "/links/add"); ok {
|
|
s.handleLinksAdd(w, r, base)
|
|
return
|
|
}
|
|
if base, ok := strings.CutSuffix(path, "/links/remove"); ok {
|
|
s.handleLinksRemove(w, r, base)
|
|
return
|
|
}
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
// Legacy single-value field for the classify HTMX action.
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
title := strings.TrimSpace(r.FormValue("title"))
|
|
slug := strings.TrimSpace(r.FormValue("slug"))
|
|
status := strings.TrimSpace(r.FormValue("status"))
|
|
if ve := itemwrite.ValidateFormat(itemwrite.Input{
|
|
ID: it.ID, Title: title, Slug: slug, Status: status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
|
ID: it.ID, Title: title, Slug: slug, Status: status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
in := store.UpdateInput{
|
|
Title: title,
|
|
Slug: slug,
|
|
ParentIDs: parentIDs,
|
|
ContentMD: r.FormValue("content_md"),
|
|
Status: status,
|
|
Pinned: r.FormValue("pinned") == "1",
|
|
Archived: r.FormValue("archived") == "1",
|
|
Tags: parseCSV(r.FormValue("tags")),
|
|
Management: parseCSV(r.FormValue("management")),
|
|
// Phase 4d public-listing fields. The form includes the toggle + four
|
|
// inputs whenever the user has edit access; missing fields fall through
|
|
// to zero (false / "" / empty array), which matches "make private +
|
|
// clear values" semantics — by design.
|
|
Public: r.FormValue("public") == "1",
|
|
PublicDescription: r.FormValue("public_description"),
|
|
PublicLiveURL: strings.TrimSpace(r.FormValue("public_live_url")),
|
|
PublicSourceURL: strings.TrimSpace(r.FormValue("public_source_url")),
|
|
PublicScreenshots: parseScreenshotList(r.Form["public_screenshots"]),
|
|
// Phase 4f: timeline-exclude form field is a multi-value checkbox set
|
|
// (`name="timeline_exclude" value="todos"`, …). parseTimelineExcludeList
|
|
// keeps only the known kinds so a stray value can't poison the array.
|
|
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
|
|
}
|
|
updated, err := s.Store.Update(r.Context(), it.ID, in)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+updated.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
// handleReparent replaces parent_ids. /admin/classify uses this to move
|
|
// a root mai-managed item under a chosen parent without touching other fields.
|
|
// HTMX-friendly: returns a fragment when HX-Request is set.
|
|
func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path string) {
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
if len(parentIDs) == 0 {
|
|
http.Error(w, "reparent: parent_ids required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Reparent doesn't change title/slug/status, so the validator only
|
|
// exercises rules around parent_ids: self-parent, unknown-parent,
|
|
// cycle. Format check runs against the existing item's fields.
|
|
if ve := itemwrite.ValidateFormat(itemwrite.Input{
|
|
ID: it.ID, Title: it.Title, Slug: it.Slug, Status: it.Status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
|
ID: it.ID, Title: it.Title, Slug: it.Slug, Status: it.Status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
moved, err := s.Store.Reparent(r.Context(), it.ID, parentIDs)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
fmt.Fprintf(w, `<tr class="classified"><td colspan="5">Moved to <a href="/i/%s">%s</a></td></tr>`,
|
|
template.HTMLEscapeString(moved.PrimaryPath()), template.HTMLEscapeString(moved.PrimaryPath()))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+moved.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
// dedupeStrings preserves order, drops empties.
|
|
func dedupeStrings(in []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseScreenshotList trims each entry and drops empties, preserving order.
|
|
// Used by the Public-listing form whose list editor submits one URL per
|
|
// repeated `public_screenshots` field. Order matters — the public renderer
|
|
// shows them top-down — so no deduping or sorting here.
|
|
func parseScreenshotList(raw []string) []string {
|
|
out := make([]string, 0, len(raw))
|
|
for _, v := range raw {
|
|
s := strings.TrimSpace(v)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseTimelineExcludeList accepts the multi-value `timeline_exclude` form
|
|
// field and returns the deduplicated subset of recognised kinds. Any
|
|
// unknown value is dropped silently — the form is a fixed checkbox set,
|
|
// so unknown values only appear via a crafted POST.
|
|
func parseTimelineExcludeList(raw []string) []string {
|
|
allowed := map[string]struct{}{
|
|
"todos": {},
|
|
"events": {},
|
|
"docs": {},
|
|
"creation": {},
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(raw))
|
|
for _, v := range raw {
|
|
v = strings.TrimSpace(v)
|
|
if _, ok := allowed[v]; !ok {
|
|
continue
|
|
}
|
|
if _, dup := seen[v]; dup {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseCSV splits a comma/space-delimited chip input into a deduplicated,
|
|
// trimmed lowercase string slice. Empty input → []string{} (nil avoided so
|
|
// JSON/SQL writes get an explicit empty array).
|
|
// parseValues collects every value for `key` from a url.Values map and
|
|
// splits each on the same comma/whitespace separators parseCSV accepts.
|
|
// Handles both filter-strip styles:
|
|
// - `?tag=foo,bar` (tree page hidden-input chip pattern)
|
|
// - `?tag=foo&tag=bar` (HTMX multi-select form submission)
|
|
//
|
|
// Mixed shapes work too (`?tag=foo,bar&tag=baz` → [foo bar baz]).
|
|
// Without this, `q.Get(key)` returned only the first value, so the
|
|
// second tag/mgmt/has selection from any <select multiple> filter strip
|
|
// silently dropped.
|
|
func parseValues(q url.Values, key string) []string {
|
|
if vs, ok := q[key]; ok {
|
|
return parseCSV(strings.Join(vs, ","))
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
func parseCSV(raw string) []string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return []string{}
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := []string{}
|
|
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
|
|
return r == ',' || r == ' ' || r == '\t' || r == '\n'
|
|
}) {
|
|
t := strings.ToLower(strings.TrimSpace(part))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[t]; ok {
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
|
parentPath := r.URL.Query().Get("parent")
|
|
var parent *store.Item
|
|
if parentPath != "" {
|
|
p, err := s.Store.GetByPath(r.Context(), parentPath)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parent = p
|
|
}
|
|
s.render(w, r, "new", map[string]any{
|
|
"Title": "new",
|
|
"Parent": parent,
|
|
"StatusOptions": []string{"active", "done", "archived"},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
kind := strings.TrimSpace(r.FormValue("kind"))
|
|
if kind == "" {
|
|
kind = "project"
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
title := strings.TrimSpace(r.FormValue("title"))
|
|
slug := strings.TrimSpace(r.FormValue("slug"))
|
|
status := strings.TrimSpace(r.FormValue("status"))
|
|
// New items have no ID yet — pre-flight format checks (title/slug/status)
|
|
// then DB-aware checks (parent existence + slug collision under parents).
|
|
if ve := itemwrite.ValidateFormat(itemwrite.Input{
|
|
Title: title, Slug: slug, Status: status, ParentIDs: parentIDs,
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
|
Title: title, Slug: slug, Status: status, ParentIDs: parentIDs,
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
in := store.CreateInput{
|
|
Kind: []string{kind},
|
|
Title: title,
|
|
Slug: slug,
|
|
ParentIDs: parentIDs,
|
|
ContentMD: r.FormValue("content_md"),
|
|
Status: status,
|
|
Tags: parseCSV(r.FormValue("tags")),
|
|
Management: parseCSV(r.FormValue("management")),
|
|
}
|
|
it, err := s.Store.Create(r.Context(), in)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
|
orphans, err := s.Store.MaiOrphans(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parents, err := s.parentOptions(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
s.render(w, r, "classify", map[string]any{
|
|
"Title": "classify",
|
|
"Orphans": orphans,
|
|
"ParentOptions": parents,
|
|
})
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
// ParentOption is a flat option for the parent <select>.
|
|
type ParentOption struct {
|
|
ID string
|
|
Path string
|
|
}
|
|
|
|
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
|
|
items, err := s.Store.ListAll(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parentOptionsFromItems(items), nil
|
|
}
|
|
|
|
// parentOptionsFromItems builds the same flat option list parentOptions
|
|
// returns, but from an already-loaded items slice. Callers that have already
|
|
// fetched items (handleTree, handleDashboard, …) use this to avoid a second
|
|
// ListAll round-trip when they only need the picker options.
|
|
func parentOptionsFromItems(items []*store.Item) []ParentOption {
|
|
out := make([]ParentOption, 0, len(items))
|
|
for _, it := range items {
|
|
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
|
return out
|
|
}
|
|
|
|
// (buildForest + nodeHasAllTags removed in Phase 3b — superseded by
|
|
// applyTreeFilter in tree_filter.go which handles every filter dimension.)
|
|
|
|
// render writes the named page to w, looking up the user's chosen theme from
|
|
// the projax_theme cookie on r so the layout's `<html data-theme=…>` and
|
|
// `<meta name="theme-color">` flip together. Templates that omit the layout
|
|
// (HTMX fragments, the login page) ignore the injection silently.
|
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
|
|
t, ok := s.pages[name]
|
|
if !ok {
|
|
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if data == nil {
|
|
data = map[string]any{}
|
|
}
|
|
theme := themeFromRequest(r)
|
|
// Don't clobber if a caller set it explicitly (e.g. tests).
|
|
if _, set := data["Theme"]; !set {
|
|
data["Theme"] = theme
|
|
}
|
|
if _, set := data["ThemeColor"]; !set {
|
|
data["ThemeColor"] = themeColorForMeta(theme)
|
|
}
|
|
// Phase 5g: layout.tmpl marks the active sidebar/bottom-nav item by
|
|
// comparing .Path to each item's href. Each request gets its own copy;
|
|
// tests can still override with an explicit Path value.
|
|
if _, set := data["Path"]; !set {
|
|
data["Path"] = r.URL.Path
|
|
}
|
|
entry := "layout"
|
|
switch name {
|
|
case "login":
|
|
// Login page is intentionally standalone — no nav chrome.
|
|
entry = "login"
|
|
case "tasks_section":
|
|
// HTMX fragment — no layout chrome.
|
|
entry = "tasks-section"
|
|
case "issues_section":
|
|
entry = "issues-section"
|
|
case "tree_section":
|
|
entry = "tree-section"
|
|
case "documents_section":
|
|
entry = "documents-section"
|
|
case "bulk_section":
|
|
entry = "bulk-section"
|
|
case "dashboard_section":
|
|
entry = "dashboard-section"
|
|
case "timeline_section":
|
|
entry = "timeline-section"
|
|
case "calendar_section":
|
|
entry = "calendar-section"
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := t.ExecuteTemplate(w, entry, data); err != nil {
|
|
s.Logger.Error("render", "page", name, "err", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
w.WriteHeader(status)
|
|
s.render(w, r, "error", map[string]any{
|
|
"Title": "error",
|
|
"Message": err.Error(),
|
|
})
|
|
s.Logger.Error("handler", "path", r.URL.Path, "err", err)
|
|
}
|
|
|
|
// toFloat coerces template numeric inputs (int, int64, float, etc.) to
|
|
// float64 so the SVG template's coordinate math composes without per-call
|
|
// type juggling.
|
|
func toFloat(v any) float64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x
|
|
case float32:
|
|
return float64(x)
|
|
case int:
|
|
return float64(x)
|
|
case int64:
|
|
return float64(x)
|
|
case int32:
|
|
return float64(x)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// logging wraps the mux with a tiny access log.
|
|
func logging(logger *slog.Logger, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
next.ServeHTTP(w, r)
|
|
logger.Info("req", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
|
|
})
|
|
}
|