From 04a7ba1024ae5f0b906b70b20e5929d888781fd0 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 09:04:30 +0200 Subject: [PATCH] feat(sidebar): headings that fold, a group head that is not indented, and colour where it is edited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things reported from use, all in the sidebar. Projekter, Delt med mig, Filtre and Etiketter fold, the way a project group already does. Kept on the account rather than in localStorage, and for the same reason the group's own `collapsed` is: folding a heading says "this is the part of my work I am not in right now", and that is as true on the laptop as on the desktop. The sidebar's *width* is the opposite case and stays per browser. A JSON array of keys rather than a column each, so a renamed heading is not a migration. The group heading is no longer indented — only what is filed under it. The colour dot sat as a sibling before the fold button and pushed the whole row in, and a heading that starts further in than the rows above it says the heading is inside something. And the colour is chosen while renaming. The dot was itself the button, which gave the heading four controls on a line narrow enough to crowd the name; both are edits to the same thing, so they belong in the same moment. The form closes on `focusout` and only when focus has left it entirely, and the swatches refuse focus on mousedown — Safari and Firefox do not focus a button on click, so `relatedTarget` would be null and the form would shut under the pointer. Co-Authored-By: Claude Opus 5 --- internal/httpapi/auth_handlers.go | 49 +++- .../migrations/0010_sidebar_sections.sql | 16 ++ web/src/lib/components/Sidebar.svelte | 250 +++++++++++------- 3 files changed, 219 insertions(+), 96 deletions(-) create mode 100644 internal/store/migrations/0010_sidebar_sections.sql diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go index 7443558..57d6508 100644 --- a/internal/httpapi/auth_handlers.go +++ b/internal/httpapi/auth_handlers.go @@ -27,17 +27,62 @@ type userJSON struct { Locale string `json:"locale"` IsAdmin bool `json:"is_admin"` TOTPEnabled bool `json:"totp_enabled"` - CreatedAt string `json:"created_at"` + // SidebarCollapsed is which sidebar headings this person has folded away. On + // the account rather than in the browser, because folding one is a statement + // about the work and not about the screen — the sidebar's width is the + // opposite case and stays in localStorage. + SidebarCollapsed []string `json:"sidebar_collapsed"` + CreatedAt string `json:"created_at"` } func toUserJSON(u *store.User) userJSON { + collapsed := u.SidebarCollapsed + // Never null in the response: a client that has to check for both an absent + // array and an empty one will one day check for only one of them. + if collapsed == nil { + collapsed = []string{} + } return userJSON{ ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor, Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin, - TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt.Format(time.RFC3339), + TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, + CreatedAt: u.CreatedAt.Format(time.RFC3339), } } +// handleSetSidebarSections records which sidebar headings are folded away. +// +// Its own route rather than a field on the profile: that one backs a form with a +// save button and validates three fields together, and this writes on every click +// of a chevron. Sharing the path would mean a fold sending a name and a timezone +// it was never asked to change — and a validation error on one of those failing a +// fold. +// +// The keys are the sidebar's and are not checked here. An unknown one costs +// nothing: the sidebar folds the headings it recognises and ignores the rest, so +// a heading that is renamed or removed leaves a harmless entry rather than a +// migration. +func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request) { + var req struct { + Sections []string `json:"sections"` + } + if err := decodeJSON(w, r, &req); err != nil { + return + } + // A cap, because this is a list the client decides the length of. Twenty is far + // more headings than the sidebar has ever had. + if len(req.Sections) > 20 { + writeError(w, http.StatusBadRequest, CodeBadRequest, "too many sections") + return + } + user := userFrom(r.Context()) + if err := s.db.SetSidebarCollapsed(r.Context(), user.ID, req.Sections); err != nil { + s.storeError(w, r, "set sidebar sections", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + // --- sign in ----------------------------------------------------------------- type loginRequest struct { diff --git a/internal/store/migrations/0010_sidebar_sections.sql b/internal/store/migrations/0010_sidebar_sections.sql new file mode 100644 index 0000000..1120340 --- /dev/null +++ b/internal/store/migrations/0010_sidebar_sections.sql @@ -0,0 +1,16 @@ +-- Which of the sidebar's fixed headings a person has folded away. +-- +-- A project group already carries `collapsed` on its own row, and the reasoning +-- there applies here too: folding a heading is a statement about the work, not +-- about the screen. "I am not in Etiketter at the moment" is as true on the laptop +-- as on the desktop, which is what separates this from the sidebar's *width* — +-- that one is in localStorage, because a narrow laptop and a wide monitor deserve +-- different answers and syncing it would make one of them wrong every time you +-- moved. +-- +-- A JSON array of keys rather than a column per heading. The set is the sidebar's +-- to decide and it has already changed twice; a column each would mean a migration +-- every time a heading is added, and a row full of booleans nobody reads for the +-- ones that were removed. Unknown keys are ignored on the way out, so an old +-- client's folded state survives a heading being renamed rather than resetting it. +ALTER TABLE users ADD COLUMN sidebar_collapsed TEXT NOT NULL DEFAULT '[]'; diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 7f772f4..0a14784 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -3,6 +3,7 @@ import { api } from '$lib/api.js'; import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js'; import { COLORS, colorVar } from '$lib/colors.js'; + import { focusOnMount } from '$lib/focus.js'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; @@ -85,8 +86,6 @@ let newGroupName = $state(''); let renamingGroup = $state(null); let groupName = $state(''); - /** The group whose swatches are open, if any. */ - let coloringGroup = $state(null); async function createGroup(event) { event.preventDefault(); @@ -411,6 +410,36 @@ {/snippet} + + {#snippet foldHeading(key, label)} +

+ +

+ {/snippet} +
!newName.trim() && (adding = false)} @@ -469,10 +497,9 @@ {#if addingGroup}
- !newGroupName.trim() && (addingGroup = false)} @@ -481,12 +508,14 @@
{/if} - {#each ungrouped as project (project.id)} - {@render projectRow(project, true)} - {/each} + {#if !app.sectionCollapsed('projects')} + {#each ungrouped as project (project.id)} + {@render projectRow(project, true)} + {/each} - {#if own.length === 0 && !adding} -

Ingen projekter endnu.

+ {#if own.length === 0 && !adding} +

Ingen projekter endnu.

+ {/if} {/if}
@@ -513,15 +542,46 @@ ondrop={(e) => onGroupDrop(e, group)} > {#if renamingGroup === group.id} -
renameGroup(e, group)}> - + + renameGroup(e, group)} + onfocusout={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) renamingGroup = null; + }} + > (renamingGroup = null)} onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)} /> +
+ {#each COLORS as color (color.id)} + + {/each} +
{:else} - -

@@ -569,25 +624,6 @@ {/if} - {#if coloringGroup === group.id} -
- {#each COLORS as color (color.id)} - - {/each} -
- {/if} - {#if !group.collapsed} {#each inside as project (project.id)} {@render projectRow(project, true)} @@ -601,43 +637,49 @@ {#if shared.length}
-

Delt med mig

- {#each shared as project (project.id)} - {@render projectRow(project, false)} - {/each} +
{@render foldHeading('shared', 'Delt med mig')}
+ {#if !app.sectionCollapsed('shared')} + {#each shared as project (project.id)} + {@render projectRow(project, false)} + {/each} + {/if}
{/if} {#if filters.length}
-

Filtre

- {#each filters as filter (filter.id)} - - - {filter.name} - - {/each} +
{@render foldHeading('filters', 'Filtre')}
+ {#if !app.sectionCollapsed('filters')} + {#each filters as filter (filter.id)} + + + {filter.name} + + {/each} + {/if}
{/if} {#if labels.length}
-

Etiketter

- {#each labels.filter((l) => l.task_count > 0) as label (label.id)} - - - {label.name} - {label.task_count} - - {/each} +
{@render foldHeading('labels', 'Etiketter')}
+ {#if !app.sectionCollapsed('labels')} + {#each labels.filter((l) => l.task_count > 0) as label (label.id)} + + + {label.name} + {label.task_count} + + {/each} + {/if}
{/if} @@ -710,13 +752,20 @@ position: relative; } - /* Sits on the border, wider than it looks: a 1px target is a target you miss. - The visible line stays the border; this only widens what the pointer hits. */ + /* Wider than it looks: a 1px target is a target you miss. The visible line stays + the border; this only widens what the pointer hits. + + Flush with the inside edge rather than straddling the border. It used to sit + at `right: -3px`, and an absolutely positioned child still counts towards its + containing block's scrollable overflow — so those three pixels gave the whole + sidebar a horizontal scrollbar on every desktop, always, whatever was in it. + Seven pixels inside the edge is the same target for anybody aiming at the + border, and it is a target that is actually inside the box it belongs to. */ .resize { position: absolute; top: 0; bottom: 0; - right: -3px; + right: 0; width: 7px; cursor: col-resize; z-index: 10; @@ -935,21 +984,12 @@ margin-left: 0; } - /* The group's own mark, in front of the chevron so the colours of the headings - line up with each other rather than with the projects underneath. */ + /* The group's own mark, between the chevron and the name — inside the fold + button, so it cannot push the heading in. It is a mark now and not a + control: the colour is chosen while renaming, which is the other edit to + the same thing. */ .group-dot { - margin-left: var(--s2); - padding: 0; - box-shadow: 0 0 0 2px var(--surface-sunken); - transition: box-shadow var(--fast) var(--ease); - } - - .group-dot:hover, - .group-dot:focus-visible { - box-shadow: - 0 0 0 2px var(--surface-sunken), - 0 0 0 3px var(--line-strong); - outline: none; + flex: none; } /* Projects inside a group are indented to the group's name, not to its dot: @@ -969,11 +1009,20 @@ padding-left: calc(var(--s2) + var(--s4)); } + /* Inside the rename form now, so it needs no padding of its own — the form + already sits where the heading did. */ + .renaming { + display: flex; + flex-direction: column; + gap: var(--s2); + width: 100%; + min-width: 0; + } + .swatches { display: flex; flex-wrap: wrap; gap: var(--s2); - padding: var(--s2) var(--s2) var(--s3); } .swatch { @@ -1153,6 +1202,7 @@ .avatar { width: 22px; height: 22px; + flex: none; border-radius: var(--radius-full); display: grid; place-items: center; @@ -1162,7 +1212,18 @@ flex: none; } + /* `min-width: 0`, or the ellipsis never happens. + A flex item's default is `min-width: auto`, which refuses to shrink below its + content — so `overflow: hidden` had nothing to hide and the row simply grew + wider than the sidebar. And the sidebar has `overflow-y: auto`, which CSS + resolves to `auto` on the other axis too, so the result was a horizontal + scrollbar under the whole menu on a perfectly ordinary desktop. + + Not fixed with `overflow-x: hidden` on the sidebar: the resize handle sits at + `right: -3px` and would be clipped away. The row shrinking is the fix; the + test asserting the sidebar never scrolls sideways is the backstop. */ .user-name { + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1170,6 +1231,7 @@ .signout { margin-left: auto; + flex: none; font-size: var(--text-xs); color: var(--ink-faint); opacity: 0;