Add manual page ordering with drag-and-drop (#45)
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

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
This commit is contained in:
Claude Opus 4.8 2026-07-09 12:18:44 +02:00
parent 03e72242d3
commit 69b00fcf2f
14 changed files with 654 additions and 6 deletions

View File

@ -174,6 +174,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/labels.spec.ts
- name: Reset login rate limit before reorder pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run reorder pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/reorder.spec.ts
- name: Dump server logs on failure
if: failure()
run: tail -50 /tmp/api.log /tmp/collab.log /tmp/web.log || true

View File

@ -18,8 +18,10 @@ import {
PageListItemView,
PageStateView,
PageView,
RepositionPageInput,
UpdatePageInput,
createPageInputSchema,
repositionPageInputSchema,
updatePageInputSchema,
} from '@dorfteich/shared';
import type { Response } from 'express';
@ -103,6 +105,16 @@ export class PagesController {
});
}
/** Reposition a page in the manual sidebar order (issue #45). */
@Patch('pages/:id/position')
async reposition(
@Param('id') id: string,
@Body(new ZodValidationPipe(repositionPageInputSchema)) input: RepositionPageInput,
@Req() request: AuthedRequest,
): Promise<PageView> {
return this.pages.reposition(request.user!, id, input);
}
@Patch('pages/:id')
async update(
@Param('id') id: string,

View File

@ -5,6 +5,7 @@ import {
PageListItemView,
PageStateView,
PageView,
RepositionPageInput,
SidebarSortMode,
UpdatePageInput,
pondSettingsSchema,
@ -18,6 +19,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { InterimAccessService } from '../ponds/interim-access.service';
import { PrismaService } from '../prisma/prisma.service';
import { evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
import { deriveContent, DerivedPageContent, emptyPageState } from './yjs-content';
/** `outline` is a plain JSON-serializable array; Prisma's Json input just needs the cast. */
@ -230,6 +232,79 @@ export class PagesService {
return this.viewOf(updated);
}
/**
* Reposition a page in the manual sidebar order (issue #45). Recomputes only
* the moved page's `sort_key` to a value between its two new neighbours; when
* that key would grow too long (or the client's neighbours are stale) the
* whole pond is rebalanced to evenly-spaced keys with the page dropped at the
* target slot. The order is server-authoritative, so every viewer sees the
* same sequence. Requires write access; the sort mode does not have to be
* `manual` (the key is stored regardless, just not applied in other modes).
*/
async reposition(user: User, id: string, input: RepositionPageInput): Promise<PageView> {
const page = await this.findModifiablePage(user, id);
const { afterId, beforeId } = input;
if (afterId === id || beforeId === id) {
throw new ConflictException({ code: 'bad_request' });
}
const [afterPage, beforePage] = await Promise.all([
afterId
? this.prisma.page.findFirst({
where: { id: afterId, pondId: page.pondId, deletedAt: null },
select: { sortKey: true },
})
: null,
beforeId
? this.prisma.page.findFirst({
where: { id: beforeId, pondId: page.pondId, deletedAt: null },
select: { sortKey: true },
})
: null,
]);
if (afterId && !afterPage) throw new NotFoundException();
if (beforeId && !beforePage) throw new NotFoundException();
const key = nextKeyOrRebalance(afterPage?.sortKey ?? null, beforePage?.sortKey ?? null);
if (key !== null) {
const updated = await this.prisma.page.update({ where: { id }, data: { sortKey: key } });
return this.viewOf(updated);
}
return this.rebalanceAndPlace(page.pondId, id, afterId, beforeId);
}
/**
* Reassign evenly-spaced `sort_key`s to every page in the pond, with the
* moved page inserted at the slot implied by `afterId`/`beforeId`. Runs in one
* transaction so the order is never observed half-rebalanced.
*/
private async rebalanceAndPlace(
pondId: string,
movedId: string,
afterId: string | null,
beforeId: string | null,
): Promise<PageView> {
return this.prisma.$transaction(async (tx) => {
const pages = await tx.page.findMany({
where: { pondId, deletedAt: null },
orderBy: { sortKey: 'asc' },
select: { id: true },
});
const order = pages.map((p) => p.id).filter((pid) => pid !== movedId);
let index = order.length;
if (afterId) index = order.indexOf(afterId) + 1;
else if (beforeId) index = Math.max(0, order.indexOf(beforeId));
order.splice(index, 0, movedId);
const keys = evenlySpacedKeys(order.length);
await Promise.all(
order.map((pid, i) => tx.page.update({ where: { id: pid }, data: { sortKey: keys[i]! } })),
);
this.logger.info({ pondId, movedId, pages: order.length }, 'audit: sort keys rebalanced');
return this.viewOf(await tx.page.findUniqueOrThrow({ where: { id: movedId } }));
});
}
/** Markdown export (issue #30) serves the already-derived
* `page_content_cache.markdown` (refreshed on every state save, #23)
* rather than re-decoding the Yjs state, so export always matches what

View File

@ -0,0 +1,108 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient, User } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { PagesService } from './pages.service';
describe.skipIf(!hasTestDb)('PagesService.reposition (db, issue #45)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let pages: PagesService;
const suffix = uniqueSuffix();
let owner: User;
let pondId: string;
/** Current manual order of the pond's pages, as the sidebar would render it. */
async function manualOrder(): Promise<string[]> {
const list = await pages.list(owner, pondId);
return list.map((p) => p.title);
}
async function pageIdByTitle(title: string): Promise<string> {
const p = await prisma.page.findFirstOrThrow({ where: { pondId, title } });
return p.id;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
pages = app.get(PagesService);
owner = await prisma.user.create({
data: {
username: `pos-owner-${suffix}`,
email: `pos-owner-${suffix}@example.test`,
displayName: 'Position Owner',
},
});
const pond = await prisma.pond.create({
data: {
slug: `pos-pond-${suffix}`,
name: 'Position Pond',
type: 'PERSONAL',
ownerId: owner.id,
},
});
pondId = pond.id;
// Manual mode so `list` orders by sort_key.
await prisma.pond.update({
where: { id: pondId },
data: { settings: { sidebarSort: 'manual' } },
});
// Four pages created in order A, B, C, D (each appended at the end).
for (const title of ['A', 'B', 'C', 'D']) {
await pages.create(owner, pondId, { title });
}
});
afterAll(async () => {
await prisma.page.deleteMany({ where: { pondId } });
await prisma.pond.deleteMany({ where: { id: pondId } });
await prisma.user.deleteMany({ where: { id: owner.id } });
await prisma.$disconnect();
await app.close();
});
it('starts in creation order', async () => {
expect(await manualOrder()).toEqual(['A', 'B', 'C', 'D']);
});
it('moves a page and the new order is server-ordered (persisted for everyone)', async () => {
const d = await pageIdByTitle('D');
const a = await pageIdByTitle('A');
const b = await pageIdByTitle('B');
// Move D to sit between A and B → A, D, B, C.
await pages.reposition(owner, d, { afterId: a, beforeId: b });
expect(await manualOrder()).toEqual(['A', 'D', 'B', 'C']);
// A fresh read (any other user's view) sees the same order — it lives in
// sort_key, not client state.
const reread = (await pages.list(owner, pondId)).map((p) => p.title);
expect(reread).toEqual(['A', 'D', 'B', 'C']);
});
it('moves a page to the very top (afterId null)', async () => {
const c = await pageIdByTitle('C');
const a = await pageIdByTitle('A');
await pages.reposition(owner, c, { afterId: null, beforeId: a });
expect(await manualOrder()).toEqual(['C', 'A', 'D', 'B']);
});
it('keeps the manual order when the sort mode changes (just not applied)', async () => {
await prisma.pond.update({
where: { id: pondId },
data: { settings: { sidebarSort: 'alpha' } },
});
expect(await manualOrder()).toEqual(['A', 'B', 'C', 'D']); // alpha ignores sort_key
await prisma.pond.update({
where: { id: pondId },
data: { settings: { sidebarSort: 'manual' } },
});
// The manual order from before is intact — switching modes never rewrote keys.
expect(await manualOrder()).toEqual(['C', 'A', 'D', 'B']);
});
});

View File

@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { MAX_SORT_KEY_LENGTH, evenlySpacedKeys, nextKeyOrRebalance } from './sort-key';
describe('sort-key helpers (issue #45)', () => {
it('produces a key strictly between two neighbours', () => {
const key = nextKeyOrRebalance('a0', 'a1');
expect(key).not.toBeNull();
expect(key! > 'a0' && key! < 'a1').toBe(true);
});
it('signals rebalance (null) when neighbours are out of order', () => {
expect(nextKeyOrRebalance('a1', 'a0')).toBeNull();
expect(nextKeyOrRebalance('a0', 'a0')).toBeNull();
});
it('evenlySpacedKeys returns n sorted, unique keys', () => {
const keys = evenlySpacedKeys(100);
expect(keys).toHaveLength(100);
expect(new Set(keys).size).toBe(100);
expect([...keys].sort()).toEqual(keys);
});
/**
* Property test (acceptance criterion): 10.000 reorders in the adversarial
* pattern repeatedly drop the last page between the first two must never
* collide and never overflow the key length, because the caller rebalances
* when {@link nextKeyOrRebalance} returns null.
*/
it('10.000 adversarial reorders never collide or overflow (rebalance verified)', () => {
// Start with five pages in a fixed order.
let order = evenlySpacedKeys(5).map((key, i) => ({ id: `p${i}`, key }));
let rebalances = 0;
const rebalance = (): void => {
const keys = evenlySpacedKeys(order.length);
order = order.map((page, i) => ({ ...page, key: keys[i]! }));
rebalances += 1;
};
for (let i = 0; i < 10_000; i += 1) {
// Move the last page to sit between the first and second — the tightest
// possible gap, which is what grows key length fastest.
const moved = order[order.length - 1]!;
const rest = order.slice(0, -1);
const afterKey = rest[0]!.key;
const beforeKey = rest[1]!.key;
const key = nextKeyOrRebalance(afterKey, beforeKey);
if (key === null) {
// Rebalance keeps the CURRENT order, then retry the move once.
rebalance();
const k2 = nextKeyOrRebalance(order[0]!.key, order[1]!.key);
expect(k2).not.toBeNull();
order = [order[0]!, { ...moved, key: k2! }, ...order.slice(1)];
} else {
order = [rest[0]!, { ...moved, key }, ...rest.slice(1)];
}
// Invariants after every move: keys unique, bounded, and consistent with
// the intended array order.
const keys = order.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
expect(Math.max(...keys.map((k) => k.length))).toBeLessThanOrEqual(MAX_SORT_KEY_LENGTH);
for (let j = 1; j < keys.length; j += 1) {
expect(keys[j - 1]! < keys[j]!).toBe(true);
}
}
// The adversarial pattern must have forced at least one rebalance.
expect(rebalances).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,52 @@
import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
/**
* Fractional-index keys for manual page ordering (issue #45, data-model.md
* `sort_key`). A move recomputes only the moved page's key as a value strictly
* between its two new neighbours, so no sibling is rewritten. Repeatedly
* inserting between two very close keys grows the key length; once a fresh key
* would exceed {@link MAX_SORT_KEY_LENGTH} the caller rebalances the whole pond
* to evenly-spaced keys instead. These helpers are pure so the rebalance
* behaviour can be property-tested without a database.
*/
/**
* Length at which a newly generated key triggers a rebalance. Fractional keys
* only grow under adversarial "always insert between the same tight pair"
* sequences; a healthy tree stays far below this. 40 leaves generous headroom
* over normal use while capping unbounded growth.
*/
export const MAX_SORT_KEY_LENGTH = 40;
/** A key strictly between `after` and `before` (either `null` for an open end). */
export function keyBetween(after: string | null, before: string | null): string {
return generateKeyBetween(after, before);
}
/** `n` evenly-spaced keys spanning the whole range — used to rebalance a pond. */
export function evenlySpacedKeys(n: number): string[] {
if (n <= 0) return [];
return generateNKeysBetween(null, null, n);
}
/**
* The key for a page moved between `afterKey` and `beforeKey`, or `null` when
* the result would be too long (or the neighbours are out of order, e.g. from a
* stale client) and the caller must rebalance instead. Never throws.
*/
export function nextKeyOrRebalance(
afterKey: string | null,
beforeKey: string | null,
): string | null {
// Guard reversed/equal neighbours ourselves: generateKeyBetween throws only
// on equal keys and silently returns a wrong key when after > before, so a
// stale client could otherwise corrupt the order — force a rebalance instead.
if (afterKey !== null && beforeKey !== null && afterKey >= beforeKey) return null;
let key: string;
try {
key = keyBetween(afterKey, beforeKey);
} catch {
return null;
}
return key.length > MAX_SORT_KEY_LENGTH ? null : key;
}

View File

@ -0,0 +1,92 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Manual page ordering pack (issue #45). Exercises the keyboard reorder path
* (up/down buttons) the same `moveTo` `PATCH /pages/:id/position` code the
* drag-and-drop uses because native HTML5 drag events are unreliable to
* simulate. Verifies the order is server-authoritative (persists across a
* reload and is identical on a fresh read) and that moves are announced via
* aria-live. Selectors are language-independent (CSS classes + arrow glyphs).
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
type Ctx = Awaited<ReturnType<typeof contextForUser>>;
async function personalPond(context: Ctx): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
async function createPage(context: Ctx, pondId: string, title: string): Promise<{ slug: string }> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } });
return created.json();
}
/** Relative order of the given titles among the sidebar's page links. */
function relativeOrder(listTexts: string[], titles: string[]): number[] {
return titles.map((title) => listTexts.findIndex((text) => text === title));
}
test('manual reorder persists server-side and is announced', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { sidebarSort: 'manual' } });
const ts = Date.now();
const first = `R-First ${ts}`;
const second = `R-Second ${ts}`;
const third = `R-Third ${ts}`;
const created = await createPage(context, pond.id, first);
await createPage(context, pond.id, second);
await createPage(context, pond.id, third);
const page = await context.newPage();
try {
await page.goto(`/p/${pond.slug}/${created.slug}`);
const links = page.locator('.sidebar__pages .sidebar__page');
await expect(links.filter({ hasText: third }).first()).toBeVisible();
// Manual mode shows creation order: first, second, third (relatively).
const before = relativeOrder(await links.allTextContents(), [first, second, third]);
expect(before[0]! < before[1]! && before[1]! < before[2]!).toBe(true);
// Move "first" down once via the keyboard button → order second, first, third.
const firstRow = page.locator('.sidebar__page-item', {
has: page.getByText(first, { exact: true }),
});
await firstRow.locator('.sidebar__reorder-btn', { hasText: '↓' }).click();
// aria-live announced the move (contains the title and a position number).
const status = page.locator('.sidebar__announce');
await expect(status).toContainText(first);
await expect(status).toContainText(/\d/);
await expect(async () => {
const order = relativeOrder(await links.allTextContents(), [second, first, third]);
expect(order[0]! < order[1]! && order[1]! < order[2]!).toBe(true);
}).toPass();
// Persisted server-side: a reload keeps the new order…
await page.reload();
await expect(links.filter({ hasText: first }).first()).toBeVisible();
await expect(async () => {
const order = relativeOrder(await links.allTextContents(), [second, first, third]);
expect(order[0]! < order[1]! && order[1]! < order[2]!).toBe(true);
}).toPass();
// …and a fresh read (any other user's view) sees the identical order.
const reread = await (await context.request.get(`/api/v1/ponds/${pond.id}/pages`)).json();
const titles = reread.map((p: { title: string }) => p.title);
const idx = (t: string) => titles.indexOf(t);
expect(idx(second) < idx(first) && idx(first) < idx(third)).toBe(true);
} finally {
// Reset so this fixture pond does not leak a non-default sort mode.
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { sidebarSort: 'alpha' } });
}
await context.close();
});

View File

@ -10,14 +10,14 @@ import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels';
import { apiGet, apiPatch } from '../lib/api';
import { NewPageForm } from './NewPageForm';
import { dropIndex, neighborsForMove } from './reorder';
import { useCurrentPondRoute } from './use-pond-route';
interface SidebarProps {
collapsed: boolean;
}
/** Modes offered in the switch; manual reordering (drag-and-drop) arrives with #45. */
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created'];
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
/**
* Left sidebar: the current pond's page list, sort mode, active-page
@ -33,6 +33,8 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
const [draggedId, setDraggedId] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState('');
const pond = useQuery({
queryKey: ['pond', pondSlug],
@ -78,6 +80,25 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
}
// Reordering is offered only in manual mode, to the owner, and while no label
// filter narrows the list (the visible order would then be a subset).
const canReorder =
isOwner && pond.data?.settings.sidebarSort === 'manual' && filterIds.size === 0;
/** Move `movedId` to `newIndex` (in the list with itself removed), persist the
* new position server-side, and announce it for screen readers. */
async function moveTo(movedId: string, newIndex: number, title: string): Promise<void> {
if (!pond.data || !pages.data) return;
const orderedIds = pages.data.map((p) => p.id);
const { afterId, beforeId } = neighborsForMove(orderedIds, movedId, newIndex);
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
await queryClient.invalidateQueries({ queryKey: ['pages', pond.data.id] });
const position = Math.max(0, Math.min(newIndex, orderedIds.length - 1)) + 1;
setAnnouncement(
t('layout.sidebar.reorder.moved', { title, position, count: orderedIds.length }),
);
}
return (
<nav
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
@ -158,17 +179,72 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
{visiblePages && visiblePages.length > 0 ? (
<ul className="sidebar__pages">
{visiblePages.map((p) => (
<li key={p.id}>
{visiblePages.map((p, index) => (
<li
key={p.id}
className={
canReorder ? 'sidebar__page-item sidebar__page-item--draggable' : undefined
}
draggable={canReorder}
onDragStart={
canReorder
? (event) => {
setDraggedId(p.id);
event.dataTransfer.effectAllowed = 'move';
}
: undefined
}
onDragEnd={canReorder ? () => setDraggedId(null) : undefined}
onDragOver={canReorder ? (event) => event.preventDefault() : undefined}
onDrop={
canReorder
? (event) => {
event.preventDefault();
if (!draggedId || draggedId === p.id || !pages.data) return;
const rect = event.currentTarget.getBoundingClientRect();
const after = event.clientY - rect.top > rect.height / 2;
const orderedIds = pages.data.map((page) => page.id);
const target = dropIndex(orderedIds, draggedId, p.id, after);
const title =
pages.data.find((page) => page.id === draggedId)?.title ?? '';
void moveTo(draggedId, target, title);
setDraggedId(null);
}
: undefined
}
>
<Link
to={`/p/${pondSlug}/${p.slug}`}
className={
p.slug === pageSlug ? 'sidebar__page sidebar__page--active' : 'sidebar__page'
}
aria-current={p.slug === pageSlug ? 'page' : undefined}
draggable={false}
>
{p.title}
</Link>
{canReorder && (
<span className="sidebar__reorder">
<button
type="button"
className="sidebar__reorder-btn"
aria-label={t('layout.sidebar.reorder.up')}
disabled={index === 0}
onClick={() => void moveTo(p.id, index - 1, p.title)}
>
</button>
<button
type="button"
className="sidebar__reorder-btn"
aria-label={t('layout.sidebar.reorder.down')}
disabled={index === visiblePages.length - 1}
onClick={() => void moveTo(p.id, index + 1, p.title)}
>
</button>
</span>
)}
<LabelChips labelIds={p.labelIds} byId={byId} />
</li>
))}
@ -198,6 +274,11 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
{t('layout.sidebar.newPage')}
</button>
)}
{/* Keyboard/drag reordering announcements for screen readers. */}
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
{announcement}
</p>
</>
)}
</nav>

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { dropIndex, neighborsForMove } from './reorder';
const ids = ['A', 'B', 'C', 'D'];
describe('neighborsForMove (issue #45)', () => {
it('moves up one: D between B and C', () => {
// D at index 3 → newIndex 2 in the without-array [A, B, C].
expect(neighborsForMove(ids, 'D', 2)).toEqual({ afterId: 'B', beforeId: 'C' });
});
it('moves to the top: afterId null', () => {
expect(neighborsForMove(ids, 'C', 0)).toEqual({ afterId: null, beforeId: 'A' });
});
it('moves to the bottom: beforeId null', () => {
expect(neighborsForMove(ids, 'A', 3)).toEqual({ afterId: 'D', beforeId: null });
});
it('clamps an out-of-range index', () => {
expect(neighborsForMove(ids, 'A', 99)).toEqual({ afterId: 'D', beforeId: null });
expect(neighborsForMove(ids, 'A', -5)).toEqual({ afterId: null, beforeId: 'B' });
});
});
describe('dropIndex (issue #45)', () => {
it('drops before the target by default', () => {
// Drop A onto C (upper half) → before C. without = [B, C, D], C at 1.
expect(dropIndex(ids, 'A', 'C', false)).toBe(1);
});
it('drops after the target on the lower half — reaches the bottom', () => {
// Drop A onto D lower half → after D. without = [B, C, D], D at 2 → 3 (end).
expect(dropIndex(ids, 'A', 'D', true)).toBe(3);
});
});

View File

@ -0,0 +1,37 @@
/**
* 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;
}

View File

@ -1129,3 +1129,48 @@ button {
.label-picker__empty {
color: var(--color-text-muted);
}
/* Manual page reordering (issue #45) ----------------------------------- */
.sidebar__page-item {
display: flex;
align-items: center;
gap: var(--space-1);
}
.sidebar__page-item .sidebar__page {
flex: 1 1 auto;
min-width: 0;
}
.sidebar__page-item--draggable {
cursor: grab;
}
.sidebar__reorder {
display: inline-flex;
gap: 2px;
flex: 0 0 auto;
}
.sidebar__reorder-btn {
border: 1px solid var(--color-border);
background: var(--color-bg);
border-radius: var(--radius);
color: var(--color-text-muted);
width: 1.4rem;
height: 1.4rem;
line-height: 1;
cursor: pointer;
padding: 0;
}
.sidebar__reorder-btn:disabled {
opacity: 0.35;
cursor: default;
}
.sidebar__reorder-btn:not(:disabled):hover {
background: var(--color-bg-subtle);
color: var(--color-text);
}

View File

@ -9,7 +9,14 @@
"sortLabel": "Seiten sortieren",
"sortMode": {
"alpha": "AZ",
"created": "Erstellungsdatum"
"created": "Erstellungsdatum",
"manual": "Manuelle Reihenfolge"
},
"reorder": {
"up": "Nach oben",
"down": "Nach unten",
"dragHint": "Zum Umsortieren ziehen",
"moved": "{{title}} an Position {{position}} von {{count}} verschoben."
},
"newPage": "+ Neue Seite",
"newPageTitle": "Titel",

View File

@ -9,7 +9,14 @@
"sortLabel": "Sort pages",
"sortMode": {
"alpha": "AZ",
"created": "Creation date"
"created": "Creation date",
"manual": "Manual order"
},
"reorder": {
"up": "Move up",
"down": "Move down",
"dragHint": "Drag to reorder",
"moved": "Moved {{title}} to position {{position}} of {{count}}."
},
"newPage": "+ New page",
"newPageTitle": "Title",

View File

@ -31,6 +31,18 @@ export const updatePageInputSchema = z
.partial();
export type UpdatePageInput = z.infer<typeof updatePageInputSchema>;
/**
* Move a page in the manual sidebar order (issue #45): place it between the
* `afterId` page (its new predecessor) and the `beforeId` page (its new
* successor); either is `null` at an end of the list. The server recomputes
* only the moved page's `sort_key`.
*/
export const repositionPageInputSchema = z.object({
afterId: z.string().min(1).nullable(),
beforeId: z.string().min(1).nullable(),
});
export type RepositionPageInput = z.infer<typeof repositionPageInputSchema>;
export const savePageStateInputSchema = z.object({
/** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */
state: z.string().min(1, 'validation.required'),