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;