feat(sidebar): de faste visninger kan sorteres, og rækkefølgen følger kontoen
Release / Build and publish image (push) Failing after 9m57s
Release / Build and publish image (push) Failing after 9m57s
I dag, Kommende, Venter og Indbakken kan trækkes i den rækkefølge, man vil have dem. På kontoen frem for i browseren: sidebjælkens bredde er en kendsgerning om skærmen, og svaret på en telefon er ikke svaret på en skærm — men hvilken rækkefølge man vil have dem i, er en kendsgerning om personen. En liste af nøgler frem for et tal pr. visning. Ukendte nøgler droppes og manglende føjes til sidst, så en visning kan tilføjes eller fjernes uden at strande nogens rækkefølge — hvilket allerede er sket én gang, da Færdige forlod menuen. To ting, prøverne fandt undervejs, og begge var mine: Rækken som trækgreb gjorde alt indeni trækbart, og sidebjælken er allerede modtager for opgaver — en opgave trukket til I dag holdt op med at lande, fordi rækken havde taget gestussen. Et lille greb tager ingenting, før man griber det. Og drop-håndteringen slugte begivenheden, før den tjekkede, om den overhovedet var en menuflytning. Migration 0019. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
90bdf4b725
commit
f557d319ca
@@ -263,6 +263,36 @@ paths:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
|
||||
/api/v1/auth/nav-order:
|
||||
put:
|
||||
tags: [auth]
|
||||
summary: The order of the fixed views in the sidebar.
|
||||
description: |
|
||||
I dag, Kommende, Venter and the inbox, in whatever order this person wants
|
||||
them. On the account rather than in the browser: which order you want them
|
||||
in is a fact about you and should be the same on the phone as on the laptop.
|
||||
|
||||
A list of keys rather than a number per view. The set of views changes with
|
||||
the program, and a key that no longer exists costs nothing — the sidebar
|
||||
draws what it recognises and appends anything new at the end, which is what
|
||||
makes adding a view later not a migration. An empty list means the order the
|
||||
program ships with.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
order:
|
||||
type: array
|
||||
maxItems: 20
|
||||
items: { type: string }
|
||||
example: ["inbox", "today", "upcoming", "delegated"]
|
||||
responses:
|
||||
"204": { description: Saved. }
|
||||
"400": { $ref: "#/components/responses/BadRequest" }
|
||||
|
||||
/api/v1/auth/me:
|
||||
get:
|
||||
tags: [auth]
|
||||
|
||||
@@ -32,6 +32,7 @@ type userJSON struct {
|
||||
// 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"`
|
||||
NavOrder []string `json:"nav_order"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -42,10 +43,14 @@ func toUserJSON(u *store.User) userJSON {
|
||||
if collapsed == nil {
|
||||
collapsed = []string{}
|
||||
}
|
||||
order := u.NavOrder
|
||||
if order == nil {
|
||||
order = []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, SidebarCollapsed: collapsed,
|
||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
@@ -83,6 +88,30 @@ func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleSetNavOrder records the order of the fixed views in the sidebar.
|
||||
//
|
||||
// Its own route for the same reason as the folds: this writes on every drop, and
|
||||
// sharing the profile's path would make a reorder send a name and a timezone it
|
||||
// was never asked to change.
|
||||
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Order []string `json:"order"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if len(req.Order) > 20 {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
|
||||
s.storeError(w, r, "set nav order", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- sign in -----------------------------------------------------------------
|
||||
|
||||
type loginRequest struct {
|
||||
|
||||
@@ -178,6 +178,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/me", s.handleMe)
|
||||
r.Patch("/me", s.handleUpdateProfile)
|
||||
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||
r.Put("/nav-order", s.handleSetNavOrder)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Post("/password/change", s.handleChangePassword)
|
||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- The order of the fixed views in the sidebar.
|
||||
--
|
||||
-- On the account rather than in the browser, and for the reason the sidebar's
|
||||
-- width is the other way round: a width is a fact about the screen you are at, and
|
||||
-- the answer on a phone is not the answer on a monitor. Which order you want I dag,
|
||||
-- Kommende, Venter and Indbakken in is a fact about you, and it should be the same
|
||||
-- on the laptop as on the phone.
|
||||
--
|
||||
-- A list of keys rather than a number per row, for the same reason sidebar_collapsed
|
||||
-- is: the set of views changes with the program, and a key that no longer exists
|
||||
-- costs nothing — the sidebar draws what it recognises and appends anything new at
|
||||
-- the end. That is what makes adding a view later not a migration.
|
||||
ALTER TABLE users ADD COLUMN nav_order TEXT NOT NULL DEFAULT '[]';
|
||||
+33
-5
@@ -36,8 +36,13 @@ type User struct {
|
||||
// is as true on the laptop as on the desktop. The sidebar's *width* is the
|
||||
// opposite case and is stored per browser.
|
||||
SidebarCollapsed []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
|
||||
// NavOrder is the order this person wants the fixed views in, by key. Empty
|
||||
// means the order the program ships with. Unknown keys are ignored and missing
|
||||
// ones are appended, so adding a view later is not a migration.
|
||||
NavOrder []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
||||
@@ -104,7 +109,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
||||
}
|
||||
|
||||
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
|
||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
|
||||
|
||||
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
||||
var u User
|
||||
@@ -112,11 +117,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
var totpEnabled, isAdmin int
|
||||
var created, updated int64
|
||||
|
||||
var collapsed string
|
||||
var collapsed, navOrder string
|
||||
|
||||
err := db.QueryRowContext(ctx, query, args...).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
|
||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -131,11 +136,34 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
||||
// worst case is a sidebar that opens fully, which is the state it shipped in.
|
||||
u.SidebarCollapsed = []string{}
|
||||
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
||||
u.NavOrder = []string{}
|
||||
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
|
||||
u.CreatedAt = time.Unix(created, 0).UTC()
|
||||
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// SetNavOrder records the order of the fixed views.
|
||||
func (db *DB) SetNavOrder(ctx context.Context, userID string, order []string) error {
|
||||
if order == nil {
|
||||
order = []string{}
|
||||
}
|
||||
raw, err := json.Marshal(order)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE users SET nav_order = ?, updated_at = ? WHERE id = ?`,
|
||||
string(raw), time.Now().Unix(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSidebarCollapsed records which sidebar headings are folded.
|
||||
//
|
||||
// Its own method rather than a field on UpdateProfile: that one backs a form with
|
||||
|
||||
@@ -1875,6 +1875,13 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
|
||||
await box.fill('første i dag');
|
||||
await box.press('Enter');
|
||||
|
||||
// In the project itself before looking at the sidebar. Without this the count
|
||||
// can be read while the task is still on its way, or — worse — after it landed
|
||||
// in the inbox because the box that took the text belonged to the previous
|
||||
// view. Then the assertion below fails for a reason that has nothing to do with
|
||||
// what it is testing.
|
||||
await expect(page.getByText('første', { exact: true })).toBeVisible();
|
||||
|
||||
// Reloaded before looking. What is asserted is that the number is open tasks
|
||||
// and comes from the server, which holds on any load.
|
||||
//
|
||||
|
||||
@@ -188,6 +188,7 @@ export const api = {
|
||||
updateProfile: (data) => patch('/auth/me', data),
|
||||
// Its own route rather than a field on the profile: that one backs a form with
|
||||
// a save button, and this writes on every click of a chevron.
|
||||
setNavOrder: (order) => put('/auth/nav-order', { order }),
|
||||
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
||||
signup: (data) => post('/auth/signup', data),
|
||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||
|
||||
@@ -117,6 +117,70 @@
|
||||
}
|
||||
|
||||
|
||||
// --- the fixed views ---------------------------------------------------------
|
||||
|
||||
// Delegated is only offered where delegating is possible at all: on an instance
|
||||
// with one person that view can never have anything in it, and a permanent empty
|
||||
// entry is a question the sidebar keeps asking and answering "no".
|
||||
let navItems = $derived(
|
||||
[
|
||||
{ key: 'today', href: '/', label: 'nav.today' },
|
||||
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||
app.projects.some((p) => p.shared)
|
||||
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||
: null,
|
||||
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||
// its id. It is in this list only so it can be dragged with the others.
|
||||
{ key: 'inbox', label: 'nav.inbox' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
let orderedNav = $derived(
|
||||
app
|
||||
.navOrder(navItems.map((i) => i.key))
|
||||
.map((key) => navItems.find((i) => i.key === key))
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
let draggingNav = $state(null);
|
||||
let overNav = $state(null);
|
||||
|
||||
function onNavDragStart(event, key) {
|
||||
draggingNav = key;
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
// A payload, because Firefox refuses to start a drag without one.
|
||||
event.dataTransfer.setData('text/plain', key);
|
||||
}
|
||||
|
||||
function onNavDragOver(event, key) {
|
||||
if (!draggingNav || key === draggingNav) return;
|
||||
event.preventDefault();
|
||||
overNav = key;
|
||||
}
|
||||
|
||||
function clearNavDrag() {
|
||||
draggingNav = null;
|
||||
overNav = null;
|
||||
}
|
||||
|
||||
async function onNavDrop(event, key) {
|
||||
const moved = draggingNav;
|
||||
// Checked before anything is swallowed. Stopping the event first meant a task
|
||||
// dragged onto I dag never reached the handler that gives it a date — the row
|
||||
// took the drop and then discovered it was not for it.
|
||||
if (!moved || moved === key) {
|
||||
clearNavDrag();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearNavDrag();
|
||||
|
||||
const keys = orderedNav.map((i) => i.key).filter((k) => k !== moved);
|
||||
keys.splice(keys.indexOf(key), 0, moved);
|
||||
await app.setNavOrder(keys);
|
||||
}
|
||||
|
||||
// --- resizing -------------------------------------------------------------------
|
||||
|
||||
let resizing = $state(false);
|
||||
@@ -340,43 +404,60 @@
|
||||
|
||||
<div class="views">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<a
|
||||
href="/"
|
||||
class:active={current === '/'}
|
||||
class:receiving={overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (!carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={onDropOnToday}
|
||||
>
|
||||
<span class="dot today" aria-hidden="true"></span>
|
||||
{t('nav.today')}
|
||||
</a>
|
||||
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('nav.upcoming')}
|
||||
</a>
|
||||
{#if app.projects.some((p) => p.shared)}
|
||||
<!-- Only where delegating is possible at all. On an instance with one
|
||||
person this view can never have anything in it, and a permanent empty
|
||||
entry is a question the sidebar keeps asking and answering "no". -->
|
||||
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
{t('nav.delegated')}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Færdige is not in this list any more. It used to sit here on the argument
|
||||
that somebody hunting a task they closed by mistake does not know which
|
||||
view they closed it in — which is true, and is also a rare errand next to
|
||||
four entries that are read every day. It lives under Settings → Data, with
|
||||
the trash, where the other "where did that go" questions are answered. -->
|
||||
{#if app.inbox}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
{/if}
|
||||
<!-- The fixed views, in the order this person put them.
|
||||
Drawn from a list rather than written out, because they can be reordered
|
||||
now and a hand-written run cannot be. Unknown keys are dropped and new
|
||||
ones appended, so adding a view later does not strand anybody's order. -->
|
||||
{#each orderedNav as item (item.key)}
|
||||
{#if item.key === 'inbox'}
|
||||
{#if app.inbox}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="navrow"
|
||||
class:dragging={draggingNav === item.key}
|
||||
class:over={overNav === item.key}
|
||||
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||
ondragleave={() => (overNav = null)}
|
||||
ondrop={(e) => onNavDrop(e, item.key)}
|
||||
>
|
||||
{@render grip(item.key)}
|
||||
{@render projectRow(app.inbox, false)}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="navrow"
|
||||
class:dragging={draggingNav === item.key}
|
||||
class:over={overNav === item.key}
|
||||
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||
ondragleave={() => (overNav = null)}
|
||||
ondrop={(e) => onNavDrop(e, item.key)}
|
||||
>
|
||||
{@render grip(item.key)}
|
||||
<!-- I dag also takes a task dropped on it — the most-made rescheduling
|
||||
there is, and the sidebar is where the pointer already is. The
|
||||
handlers were lost when this run became a list; the test caught
|
||||
it, which is the second time that drop has earned its test. -->
|
||||
<a
|
||||
href={item.href}
|
||||
class:active={current === item.href}
|
||||
class:receiving={item.key === 'today' && overToday}
|
||||
onclick={onnavigate}
|
||||
ondragover={(e) => {
|
||||
if (item.key !== 'today' || !carries(e, TASK)) return;
|
||||
accept(e);
|
||||
overToday = true;
|
||||
}}
|
||||
ondragleave={() => (overToday = false)}
|
||||
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
||||
>
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{t(item.label)}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Notes stand apart, with a rule above them.
|
||||
@@ -395,6 +476,21 @@
|
||||
three copies of that would be three chances to fix a bug in two of them.
|
||||
Only your own reorder: `sort_order` is a column on the project, so a shared
|
||||
one stays where its owner put it. -->
|
||||
<!-- A handle rather than the whole row.
|
||||
Making the row draggable made everything inside it draggable too, and the
|
||||
sidebar is already a drop target for tasks — a task dragged onto I dag
|
||||
stopped landing, because the row had claimed the gesture. A grip claims
|
||||
nothing until it is grabbed. -->
|
||||
{#snippet grip(key)}
|
||||
<span
|
||||
class="grip"
|
||||
draggable="true"
|
||||
ondragstart={(e) => onNavDragStart(e, key)}
|
||||
ondragend={clearNavDrag}
|
||||
aria-hidden="true">⠿</span
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
{#snippet projectRow(project, sortable)}
|
||||
<a
|
||||
href="/projekt/{project.id}"
|
||||
@@ -871,6 +967,53 @@
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.navrow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Takes no room until it is wanted: it sits in the padding the row already has,
|
||||
so nothing moves when it appears. */
|
||||
.grip {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--ink-faint);
|
||||
cursor: grab;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.navrow:hover .grip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navrow > :global(a) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.navrow.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A line where it will land, not a box around what it will land on: the first
|
||||
reads as a position and the second as a target. */
|
||||
.navrow.over::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: var(--s2);
|
||||
right: var(--s2);
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
||||
half of the program, not a fifth way of looking at the same tasks. */
|
||||
.apart {
|
||||
|
||||
@@ -562,6 +562,31 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The order of the fixed views, as keys.
|
||||
*
|
||||
* Unknown keys are dropped and missing ones appended, so the stored order never
|
||||
* has to be migrated when a view is added or taken away — which has already
|
||||
* happened once, when Færdige left the sidebar.
|
||||
*/
|
||||
navOrder(keys) {
|
||||
const saved = this.user?.nav_order ?? [];
|
||||
const known = new Set(keys);
|
||||
const ordered = saved.filter((k) => known.has(k));
|
||||
return [...ordered, ...keys.filter((k) => !ordered.includes(k))];
|
||||
}
|
||||
|
||||
async setNavOrder(order) {
|
||||
const before = this.user?.nav_order ?? [];
|
||||
this.user = { ...this.user, nav_order: order };
|
||||
try {
|
||||
await api.setNavOrder(order);
|
||||
} catch (e) {
|
||||
this.user = { ...this.user, nav_order: before };
|
||||
this.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
sectionCollapsed(key) {
|
||||
return (this.user?.sidebar_collapsed ?? []).includes(key);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user