dorfteich/apps/web/src/layout/reorder.ts
Claude Opus 4.8 69b00fcf2f
All checks were successful
CD / Build and push images (push) Successful in 3m1s
CI / Lint, typecheck, test (push) Successful in 2m13s
CI / Auth e2e pack (push) Successful in 2m36s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add manual page ordering with drag-and-drop (#45)
Enable the third sidebar sort mode — a freely defined order.

- api: `PATCH /pages/:id/position` (before/after neighbour) recomputes only
  the moved page's fractional `sort_key`. Pure `sort-key.ts` helpers
  (`nextKeyOrRebalance`, `evenlySpacedKeys`) decide between the cheap
  single-key path and a full pond rebalance to evenly-spaced keys when a key
  would exceed MAX_SORT_KEY_LENGTH or the client's neighbours are stale;
  rebalance runs in one transaction. Order is server-authoritative.
- web: enable 'manual' in the sort-mode switch; in manual mode the owner can
  reorder via native drag-and-drop (drop above/below by pointer half) or the
  keyboard (per-row up/down buttons), each announced through an aria-live
  region. Reordering is hidden while a label filter narrows the list. New
  pages already append at the end (create uses generateKeyBetween(last, null)).
  Pure `reorder.ts` neighbour helpers, unit-tested.
- i18n: manual sort mode + reorder strings (de + en).
- tests: sort-key property test (10.000 adversarial reorders never collide or
  overflow — rebalance verified); reposition db test (persist, server-order,
  sort-mode switch keeps manual order); reorder e2e pack (keyboard reorder
  persists across reload + identical on a fresh read; aria-live announced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 12:18:44 +02:00

38 lines
1.4 KiB
TypeScript

/**
* Pure helpers for manual page reordering in the sidebar (issue #45). The
* server recomputes the moved page's `sort_key` between two neighbours, so the
* client only has to name them. Given the current order and where the page
* should land, these compute the `afterId`/`beforeId` the position endpoint
* expects — kept separate from the component so they are unit-testable.
*/
/** The moved page's new neighbours when it lands at `newIndex` in the list with
* itself removed. `afterId` is its predecessor (null at the top), `beforeId`
* its successor (null at the bottom). */
export function neighborsForMove(
orderedIds: string[],
movedId: string,
newIndex: number,
): { afterId: string | null; beforeId: string | null } {
const without = orderedIds.filter((id) => id !== movedId);
const clamped = Math.max(0, Math.min(newIndex, without.length));
return {
afterId: clamped > 0 ? without[clamped - 1]! : null,
beforeId: clamped < without.length ? without[clamped]! : null,
};
}
/** Target index for a drop onto `targetId`: before it, or after it when the
* pointer is over the item's lower half (so the very bottom is reachable). */
export function dropIndex(
orderedIds: string[],
movedId: string,
targetId: string,
after: boolean,
): number {
const without = orderedIds.filter((id) => id !== movedId);
const base = without.indexOf(targetId);
if (base === -1) return without.length;
return after ? base + 1 : base;
}