Merge branch 'mai/kahn/phase-5j-views-redesign' (phase 5j slice G: show_count badges + icon registry)
This commit is contained in:
43
web/icons.go
Normal file
43
web/icons.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
|
// Phase 5j slice G — icon registry per m's Q6 pick (2026-05-29). The
|
||||||
|
// curated set of keys mirrors the editor's <select> options so the round-
|
||||||
|
// trip works: editor save persists the key string, layout renders the SVG
|
||||||
|
// at look-up time. Unknown / empty keys fall back to the default folder
|
||||||
|
// glyph.
|
||||||
|
//
|
||||||
|
// Stored as html/template.HTML so layout.tmpl can emit the markup
|
||||||
|
// directly without html-escaping the angle brackets. Each SVG is sized
|
||||||
|
// to 18px square and inherits currentColor like the existing nav-icon
|
||||||
|
// glyphs.
|
||||||
|
|
||||||
|
var iconRegistry = map[string]template.HTML{
|
||||||
|
"folder": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`),
|
||||||
|
"clock": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`),
|
||||||
|
"star": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`),
|
||||||
|
"tag": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>`),
|
||||||
|
"inbox": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`),
|
||||||
|
"box": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`),
|
||||||
|
"file-text": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderViewIcon returns the SVG for an icon key, falling back to the
|
||||||
|
// folder default for nil or unknown keys. Template-callable so
|
||||||
|
// layout.tmpl can emit `{{renderIcon .Icon}}`.
|
||||||
|
func RenderViewIcon(icon *string) template.HTML {
|
||||||
|
key := "folder"
|
||||||
|
if icon != nil && *icon != "" {
|
||||||
|
if _, ok := iconRegistry[*icon]; ok {
|
||||||
|
key = *icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return iconRegistry[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
// IconRegistryKeys returns the available icon keys in display order, for
|
||||||
|
// the editor's <select>. The first key (folder) is the default.
|
||||||
|
func IconRegistryKeys() []string {
|
||||||
|
return []string{"folder", "clock", "star", "tag", "inbox", "box", "file-text"}
|
||||||
|
}
|
||||||
@@ -133,6 +133,10 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|||||||
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
|
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
|
||||||
"subF": 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) },
|
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
|
||||||
|
// Phase 5j slice G — sidebar icon registry. layout.tmpl calls
|
||||||
|
// `renderIcon .View.Icon` to emit the matching SVG, falling back to
|
||||||
|
// the folder default for nil / unknown keys.
|
||||||
|
"renderIcon": RenderViewIcon,
|
||||||
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
||||||
next := []string{}
|
next := []string{}
|
||||||
if isActive {
|
if isActive {
|
||||||
@@ -1048,6 +1052,38 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
|
|||||||
if _, set := data["UserViews"]; !set && name != "login" && s.Store != nil {
|
if _, set := data["UserViews"]; !set && name != "login" && s.Store != nil {
|
||||||
if uv, err := s.Store.ListViews(r.Context()); err == nil {
|
if uv, err := s.Store.ListViews(r.Context()); err == nil {
|
||||||
data["UserViews"] = uv
|
data["UserViews"] = uv
|
||||||
|
// Phase 5j slice G — show_count badges. For every view with
|
||||||
|
// ShowCount=true, run its persisted filter against ListAll and
|
||||||
|
// pass a slug→count map to the template. Caching is one
|
||||||
|
// ListAll per render shared across all show-count views.
|
||||||
|
counts := map[string]int{}
|
||||||
|
needsCount := false
|
||||||
|
for _, v := range uv {
|
||||||
|
if v.ShowCount {
|
||||||
|
needsCount = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needsCount {
|
||||||
|
items, err := s.Store.ListAll(r.Context())
|
||||||
|
if err == nil {
|
||||||
|
linkKinds, _ := s.linkKindsByItem(r.Context())
|
||||||
|
for _, v := range uv {
|
||||||
|
if !v.ShowCount {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, _, _ := decodeViewSpec(v.FilterJSON)
|
||||||
|
n := 0
|
||||||
|
for _, it := range items {
|
||||||
|
if f.Matches(it, linkKinds[it.ID]) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts[v.Slug] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["UserViewCounts"] = counts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry := "layout"
|
entry := "layout"
|
||||||
|
|||||||
@@ -1220,6 +1220,14 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .brand-label {
|
|||||||
.projax-sidebar .nav-item-user-view.active { padding-left: 22px; }
|
.projax-sidebar .nav-item-user-view.active { padding-left: 22px; }
|
||||||
.projax-sidebar .user-view-icon { width: 1em; text-align: center; }
|
.projax-sidebar .user-view-icon { width: 1em; text-align: center; }
|
||||||
.projax-sidebar .nav-item-new-view { color: var(--muted); }
|
.projax-sidebar .nav-item-new-view { color: var(--muted); }
|
||||||
|
.projax-sidebar .nav-badge {
|
||||||
|
margin-left: auto; font-size: 0.78em; color: var(--muted);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 0 6px;
|
||||||
|
}
|
||||||
|
.projax-sidebar .nav-item-user-view.active .nav-badge {
|
||||||
|
color: var(--accent); border-color: var(--accent);
|
||||||
|
}
|
||||||
.projax-sidebar .nav-icon {
|
.projax-sidebar .nav-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
|
|||||||
@@ -99,6 +99,53 @@ VALUES ($1, 'P5jE Sidebar', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSidebarShowCountBadge — slice G: a saved view with show_count=true
|
||||||
|
// renders a row-count badge in the sidebar reflecting the filter's match
|
||||||
|
// count against ListAll().
|
||||||
|
func TestSidebarShowCountBadge(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
ctx := context.Background()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-g-badge-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
// Seed a view scoped to dev → its count = count of items under dev that
|
||||||
|
// match status=active (default).
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json, show_count)
|
||||||
|
VALUES ($1, 'P5jG Badge', '{"view_type":"list","project_path":"dev"}'::jsonb, true)`,
|
||||||
|
slug); err != nil {
|
||||||
|
t.Fatalf("seed view: %v", err)
|
||||||
|
}
|
||||||
|
_, body := get(t, h, "/views/tree")
|
||||||
|
if !strings.Contains(body, `class="nav-badge"`) {
|
||||||
|
t.Error("show_count view should render a nav-badge in the sidebar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSidebarIconRenders — slice G: a view with an icon key emits the
|
||||||
|
// SVG from the registry; missing key falls back to folder default.
|
||||||
|
func TestSidebarIconRenders(t *testing.T) {
|
||||||
|
srv, pool := mustServer(t)
|
||||||
|
defer pool.Close()
|
||||||
|
h := srv.Routes()
|
||||||
|
ctx := context.Background()
|
||||||
|
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||||
|
slug := "p5j-g-icon-" + stamp
|
||||||
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO projax.views (slug, name, filter_json, icon)
|
||||||
|
VALUES ($1, 'P5jG Icon', '{"view_type":"list"}'::jsonb, 'star')`, slug); err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
_, body := get(t, h, "/views/tree")
|
||||||
|
// The star icon's SVG path includes its distinctive 5-point polygon.
|
||||||
|
if !strings.Contains(body, `polygon points="12 2 15.09 8.26`) {
|
||||||
|
t.Error("sidebar should render the star icon SVG for icon=star")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay
|
// TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay
|
||||||
// `?view=<uuid>` param, the redirect resolves the uuid to the current
|
// `?view=<uuid>` param, the redirect resolves the uuid to the current
|
||||||
// slug (per m's Q3 pick), so old bookmarks land on the right user view.
|
// slug (per m's Q3 pick), so old bookmarks land on the right user view.
|
||||||
|
|||||||
@@ -90,15 +90,16 @@
|
|||||||
<span class="nav-label">Views</span>
|
<span class="nav-label">Views</span>
|
||||||
</a>
|
</a>
|
||||||
{{if .UserViews}}
|
{{if .UserViews}}
|
||||||
|
{{$counts := .UserViewCounts}}
|
||||||
<div class="sidebar-user-views" aria-label="Saved views">
|
<div class="sidebar-user-views" aria-label="Saved views">
|
||||||
{{range .UserViews}}
|
{{range .UserViews}}
|
||||||
|
{{$slug := .Slug}}
|
||||||
<a href="/views/{{.Slug}}"
|
<a href="/views/{{.Slug}}"
|
||||||
class="nav-item nav-item-user-view{{if eq $path (printf "/views/%s" .Slug)}} active{{end}}"
|
class="nav-item nav-item-user-view{{if eq $path (printf "/views/%s" .Slug)}} active{{end}}"
|
||||||
title="{{.Name}}">
|
title="{{.Name}}">
|
||||||
<span class="nav-icon user-view-icon" aria-hidden="true">
|
{{renderIcon .Icon}}
|
||||||
{{if and .Icon (ne (deref .Icon) "")}}◆{{else}}◆{{end}}
|
|
||||||
</span>
|
|
||||||
<span class="nav-label">{{.Name}}</span>
|
<span class="nav-label">{{.Name}}</span>
|
||||||
|
{{if .ShowCount}}<span class="nav-badge" aria-label="Item count">{{index $counts $slug}}</span>{{end}}
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
<a href="/views/new" class="nav-item nav-item-user-view nav-item-new-view" title="New view">
|
<a href="/views/new" class="nav-item nav-item-user-view nav-item-new-view" title="New view">
|
||||||
|
|||||||
@@ -16,13 +16,9 @@
|
|||||||
<select name="icon">
|
<select name="icon">
|
||||||
{{$cur := ""}}
|
{{$cur := ""}}
|
||||||
{{if and .View .View.Icon}}{{$cur = deref .View.Icon}}{{end}}
|
{{if and .View .View.Icon}}{{$cur = deref .View.Icon}}{{end}}
|
||||||
<option value="">— folder (default)</option>
|
{{range .IconKeys}}
|
||||||
<option value="clock" {{if eq $cur "clock"}}selected{{end}}>clock</option>
|
<option value="{{.}}"{{if eq . $cur}} selected{{end}}>{{.}}</option>
|
||||||
<option value="star" {{if eq $cur "star"}}selected{{end}}>star</option>
|
{{end}}
|
||||||
<option value="tag" {{if eq $cur "tag"}}selected{{end}}>tag</option>
|
|
||||||
<option value="inbox" {{if eq $cur "inbox"}}selected{{end}}>inbox</option>
|
|
||||||
<option value="box" {{if eq $cur "box"}}selected{{end}}>box</option>
|
|
||||||
<option value="file-text" {{if eq $cur "file-text"}}selected{{end}}>file-text</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<fieldset class="view-type-radios">
|
<fieldset class="view-type-radios">
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ func (s *Server) handleViewEditor(w http.ResponseWriter, r *http.Request) {
|
|||||||
"CurrentVT": currentViewType,
|
"CurrentVT": currentViewType,
|
||||||
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
|
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
|
||||||
"SortDirOptions": []string{"", "asc", "desc"},
|
"SortDirOptions": []string{"", "asc", "desc"},
|
||||||
|
"IconKeys": IconRegistryKeys(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user