diff --git a/internal/app/server.go b/internal/app/server.go index c5151b6..e30625e 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -107,6 +107,7 @@ type BoardFilter struct { AssigneeID int64 Priority string Active bool + Count int } type ReportTotals struct { @@ -786,6 +787,15 @@ func (s *Server) boardPage(w http.ResponseWriter, r *http.Request) { if !validBoardPriority(filter.Priority) { filter.Priority = "" } + if filter.TagID > 0 { + filter.Count++ + } + if filter.AssigneeID > 0 { + filter.Count++ + } + if filter.Priority != "" { + filter.Count++ + } filter.Active = filter.TagID > 0 || filter.AssigneeID > 0 || filter.Priority != "" if filter.Active { filtered := make([]BoardTask, 0, len(tasks)) diff --git a/internal/app/server_test.go b/internal/app/server_test.go index 409148f..41c7623 100644 --- a/internal/app/server_test.go +++ b/internal/app/server_test.go @@ -716,7 +716,7 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { if boardResponse.Code != http.StatusOK { t.Fatalf("board page status: %d body %s", boardResponse.Code, boardResponse.Body.String()) } - for _, expected := range []string{"Ship the onboarding flow", "Board Member", "Workspace Admin", "1405-05-06", "High", "Manage tags", "Tags", "select multiple", "Create a new tag", "People & importance", "Card controls", "Backend", "Frontend", "Urgent", "/static/board.js", `data-board-column="backlog"`} { + for _, expected := range []string{"Ship the onboarding flow", "Board Member", "Workspace Admin", "1405-05-06", "High", "Filters", "Manage tags", "Tags", "select multiple", "Create a new tag", "People & importance", "Card controls", "Backend", "Frontend", "Urgent", "/static/board.js", `data-board-column="backlog"`} { if !strings.Contains(boardResponse.Body.String(), expected) { t.Fatalf("board page does not include %q", expected) } @@ -762,7 +762,7 @@ func TestSharedBoardCreateAssignMoveAndPermissions(t *testing.T) { matchingFilterRequest.AddCookie(memberCookie) matchingFilter := httptest.NewRecorder() s.http.Handler.ServeHTTP(matchingFilter, matchingFilterRequest) - if matchingFilter.Code != http.StatusOK || !strings.Contains(matchingFilter.Body.String(), "Ship the onboarding flow") || !strings.Contains(matchingFilter.Body.String(), "Clear") { + if matchingFilter.Code != http.StatusOK || !strings.Contains(matchingFilter.Body.String(), "Ship the onboarding flow") || !strings.Contains(matchingFilter.Body.String(), "Filters (3)") || !strings.Contains(matchingFilter.Body.String(), "Clear all") { t.Fatalf("matching board filter: %d %s", matchingFilter.Code, matchingFilter.Body.String()) } for _, path := range []string{ diff --git a/internal/app/static/board.js b/internal/app/static/board.js index 6acf0de..694c571 100644 --- a/internal/app/static/board.js +++ b/internal/app/static/board.js @@ -7,6 +7,20 @@ return; } + var filterMenu = document.querySelector(".board-filter-menu"); + if (filterMenu) { + document.addEventListener("click", function (event) { + if (filterMenu.open && !filterMenu.contains(event.target)) { + filterMenu.open = false; + } + }); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + filterMenu.open = false; + } + }); + } + var dragged = null; var csrf = board.dataset.csrf; diff --git a/internal/app/static/style.css b/internal/app/static/style.css index dfd368d..62efbb9 100644 --- a/internal/app/static/style.css +++ b/internal/app/static/style.css @@ -314,16 +314,21 @@ blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; .assignee-picker label > span .avatar, .compact-assignee-picker label > span .avatar { width: 21px; height: 21px; font-size: .5rem; } .assignee-picker input:checked + span, .compact-assignee-picker input:checked + span { border-color: #5f8f78; background: #edf6f1; color: var(--green-2); box-shadow: inset 0 0 0 1px #5f8f78; } .assignee-picker input:focus-visible + span, .compact-assignee-picker input:focus-visible + span { outline: 2px solid var(--ink); outline-offset: 2px; } -.board-filter-bar { display: flex; gap: 16px; align-items: center; max-width: 1380px; margin: 0 auto 14px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); box-shadow: var(--shadow); } -.board-filter-title { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; padding-right: 15px; border-right: 1px solid var(--line); color: var(--ink); font-size: .66rem; } -.board-filter-title span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 8px; background: var(--mint); color: var(--green); font-size: .9rem; } -.board-filter-bar form { display: grid; grid-template-columns: repeat(3, minmax(135px, 180px)) auto; gap: 8px; align-items: end; } -.board-filter-bar label { min-width: 0; color: var(--muted); font-size: .52rem; } -.board-filter-bar label > span { display: block; padding-left: 2px; } -.board-filter-bar select { min-height: 33px; margin-top: 3px; padding: 6px 27px 6px 9px; border-radius: 8px; background-color: var(--paper); font-size: .59rem; } -.board-filter-actions { display: flex; gap: 7px; align-items: center; min-height: 33px; } -.board-filter-actions .button { min-height: 33px; padding: 6px 13px; font-size: .59rem; } -.board-filter-actions .text-button { padding: 6px 2px; color: var(--muted); font-size: .57rem; } +.board-filter-menu { position: relative; } +.board-filter-menu > summary { list-style: none; user-select: none; } +.board-filter-menu > summary::-webkit-details-marker { display: none; } +.board-filter-menu > summary span { display: inline-grid; min-width: 20px; height: 20px; margin-left: 4px; place-items: center; border-radius: 20px; background: var(--green); color: #fff; font-size: .53rem; } +.board-filter-menu[open] > summary { border-color: var(--green); background: var(--mint); color: var(--green-2); } +.board-filter-popover { position: absolute; z-index: 62; top: calc(100% + 9px); right: 0; width: min(330px, calc(100vw - 40px)); padding: 19px; border: 1px solid var(--line); border-radius: 14px; background: var(--white); box-shadow: 0 22px 60px rgba(24, 36, 31, .22); } +.board-filter-popover > header { display: flex; justify-content: space-between; gap: 15px; align-items: center; margin-bottom: 12px; } +.board-filter-popover h2 { margin: 0; font-size: 1.05rem; } +.board-filter-popover > header a { color: var(--green); font-size: .61rem; font-weight: 700; } +.board-filter-popover ul { margin: 0 0 13px; padding: 0; border: 1px solid var(--line); border-radius: 10px; list-style: none; overflow: hidden; } +.board-filter-popover li { padding: 11px 12px; border-bottom: 1px solid var(--line); } +.board-filter-popover li:last-child { border-bottom: 0; } +.board-filter-popover label > span { display: block; color: var(--muted); font-size: .57rem; } +.board-filter-popover select { min-height: 35px; margin-top: 5px; padding: 7px 29px 7px 9px; border: 0; background-color: var(--paper); font-size: .62rem; } +.board-filter-popover .button { min-height: 37px; font-size: .62rem; } .kanban-board { display: grid; grid-template-columns: repeat(4, minmax(270px, 1fr)); gap: 14px; align-items: start; max-width: 1380px; margin: 0 auto; overflow-x: auto; padding-bottom: 18px; } .kanban-column { min-width: 270px; padding: 13px; border: 1px solid var(--line); border-radius: 15px; background: #eef2ef; } .kanban-column-head { display: flex; justify-content: space-between; align-items: start; min-height: 51px; padding: 4px 5px 12px; } @@ -621,6 +626,7 @@ html[data-theme="dark"] .metric.pending, html[data-theme="dark"] .pending-icon { background: #453820; color: #e3bd72; } html[data-theme="dark"] .new-task-form { box-shadow: 0 22px 65px rgba(0, 0, 0, .52); } html[data-theme="dark"] .tag-manager, +html[data-theme="dark"] .board-filter-popover, html[data-theme="dark"] .card-tag-editor form, html[data-theme="dark"] .card-detail-editor form { box-shadow: 0 22px 65px rgba(0, 0, 0, .52); } html[data-theme="dark"] .assignee-picker input:checked + span, @@ -718,12 +724,7 @@ html[data-theme="dark"] .kanban-cards.drag-over { background: rgba(107, 172, 142 .tag-manager { position: fixed; top: 76px; right: 15px; left: 15px; width: auto; max-height: calc(100vh - 100px); overflow-y: auto; } .tag-create-form { grid-template-columns: 1fr 1fr; } .tag-create-form .button { grid-column: 1 / -1; } - .board-filter-bar { display: block; padding: 10px; } - .board-filter-title { margin-bottom: 9px; padding: 0 0 8px; border-right: 0; border-bottom: 1px solid var(--line); } - .board-filter-title span { width: 27px; height: 27px; } - .board-filter-bar form { grid-template-columns: 1fr 1fr; } - .board-filter-bar form > label:nth-child(3) { grid-column: 1 / -1; } - .board-filter-actions { justify-content: flex-end; } + .board-filter-popover { position: fixed; top: 76px; right: 15px; left: 15px; width: auto; max-height: calc(100vh - 100px); overflow-y: auto; } .kanban-board { grid-template-columns: repeat(4, 82vw); margin-right: -15px; padding-right: 0; padding-left: 15px; scroll-snap-type: x mandatory; } .kanban-column { scroll-snap-align: start; } .archived-card { align-items: flex-start; flex-direction: column; gap: 11px; } diff --git a/internal/app/templates/board.html b/internal/app/templates/board.html index e871c79..7c65d0a 100644 --- a/internal/app/templates/board.html +++ b/internal/app/templates/board.html @@ -1,8 +1,35 @@ {{define "board.html"}} {{template "shell-start" .}} +{{$filter := .BoardFilter}}

SHARED WORKSPACE

Team board

Plan, assign, and move work forward together.

+
+ ⌕ Filters{{if $filter.Active}} {{$filter.Count}}{{end}} +
+

BOARD VIEW

Filters{{if $filter.Active}} ({{$filter.Count}}){{end}}

{{if $filter.Active}}Clear all{{end}}
+
+
    +
  • +
  • +
  • +
+ +
+
+
◉ Manage tags
@@ -58,32 +85,6 @@
{{template "notice" .}} -{{$filter := .BoardFilter}} -
-
Filter cards
-
- - - -
- {{if $filter.Active}}Clear{{end}} - -
-
-
-
{{range .Board}}