From 69b00fcf2ff5b020e671e1b73b13e99d8f7b986d Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Thu, 9 Jul 2026 12:18:44 +0200 Subject: [PATCH] Add manual page ordering with drag-and-drop (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY --- .gitea/workflows/ci.yml | 10 +++ apps/api/src/pages/pages.controller.ts | 12 +++ apps/api/src/pages/pages.service.ts | 75 ++++++++++++++++ apps/api/src/pages/reposition.db.test.ts | 108 +++++++++++++++++++++++ apps/api/src/pages/sort-key.test.ts | 73 +++++++++++++++ apps/api/src/pages/sort-key.ts | 52 +++++++++++ apps/web/e2e/reorder.spec.ts | 92 +++++++++++++++++++ apps/web/src/layout/Sidebar.tsx | 89 ++++++++++++++++++- apps/web/src/layout/reorder.test.ts | 37 ++++++++ apps/web/src/layout/reorder.ts | 37 ++++++++ apps/web/src/styles/base.css | 45 ++++++++++ packages/shared/i18n/de/common.json | 9 +- packages/shared/i18n/en/common.json | 9 +- packages/shared/src/pages.ts | 12 +++ 14 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/pages/reposition.db.test.ts create mode 100644 apps/api/src/pages/sort-key.test.ts create mode 100644 apps/api/src/pages/sort-key.ts create mode 100644 apps/web/e2e/reorder.spec.ts create mode 100644 apps/web/src/layout/reorder.test.ts create mode 100644 apps/web/src/layout/reorder.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c23e00f..6f4beb4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index c7a26d4..6351d06 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -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 { + return this.pages.reposition(request.user!, id, input); + } + @Patch('pages/:id') async update( @Param('id') id: string, diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 2d6f0ed..e10ee45 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -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 { + 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 { + 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 diff --git a/apps/api/src/pages/reposition.db.test.ts b/apps/api/src/pages/reposition.db.test.ts new file mode 100644 index 0000000..3e0e02d --- /dev/null +++ b/apps/api/src/pages/reposition.db.test.ts @@ -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 { + const list = await pages.list(owner, pondId); + return list.map((p) => p.title); + } + + async function pageIdByTitle(title: string): Promise { + 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']); + }); +}); diff --git a/apps/api/src/pages/sort-key.test.ts b/apps/api/src/pages/sort-key.test.ts new file mode 100644 index 0000000..2878034 --- /dev/null +++ b/apps/api/src/pages/sort-key.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/pages/sort-key.ts b/apps/api/src/pages/sort-key.ts new file mode 100644 index 0000000..fea0ff5 --- /dev/null +++ b/apps/api/src/pages/sort-key.ts @@ -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; +} diff --git a/apps/web/e2e/reorder.spec.ts b/apps/web/e2e/reorder.spec.ts new file mode 100644 index 0000000..8cf9053 --- /dev/null +++ b/apps/web/e2e/reorder.spec.ts @@ -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>; + +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(); +}); diff --git a/apps/web/src/layout/Sidebar.tsx b/apps/web/src/layout/Sidebar.tsx index 6b00c66..b2da673 100644 --- a/apps/web/src/layout/Sidebar.tsx +++ b/apps/web/src/layout/Sidebar.tsx @@ -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>(new Set()); + const [draggedId, setDraggedId] = useState(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 { + 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 ( diff --git a/apps/web/src/layout/reorder.test.ts b/apps/web/src/layout/reorder.test.ts new file mode 100644 index 0000000..5d9d81e --- /dev/null +++ b/apps/web/src/layout/reorder.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/layout/reorder.ts b/apps/web/src/layout/reorder.ts new file mode 100644 index 0000000..5a73624 --- /dev/null +++ b/apps/web/src/layout/reorder.ts @@ -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; +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 2283c7c..568f2b3 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -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); +} diff --git a/packages/shared/i18n/de/common.json b/packages/shared/i18n/de/common.json index 024bb26..ebd970e 100644 --- a/packages/shared/i18n/de/common.json +++ b/packages/shared/i18n/de/common.json @@ -9,7 +9,14 @@ "sortLabel": "Seiten sortieren", "sortMode": { "alpha": "A–Z", - "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", diff --git a/packages/shared/i18n/en/common.json b/packages/shared/i18n/en/common.json index 4115343..6121bff 100644 --- a/packages/shared/i18n/en/common.json +++ b/packages/shared/i18n/en/common.json @@ -9,7 +9,14 @@ "sortLabel": "Sort pages", "sortMode": { "alpha": "A–Z", - "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", diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index 41992ef..fb60508 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -31,6 +31,18 @@ export const updatePageInputSchema = z .partial(); export type UpdatePageInput = z.infer; +/** + * 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; + export const savePageStateInputSchema = z.object({ /** Base64-encoded Yjs state (`Y.encodeStateAsUpdate`). */ state: z.string().min(1, 'validation.required'),