From fa698574bd5b51e4e0f0ec74c7d9416cbd6066a9 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Sat, 7 Mar 2026 21:40:04 +0100 Subject: [PATCH] feat: note sorting with drag-and-drop custom order - Add sort mode selector (Updated/Created/Custom) with localStorage persistence - Sort notes client-side via derived stores based on selected mode - Enable drag-to-reorder in Custom mode via svelte-dnd-action on NoteGrid - Add POST /api/notes/reorder endpoint for bulk sortOrder updates - E2E tests for sorting by updated/created and persistence across reload Closes #12 Co-Authored-By: Claude Opus 4.6 --- src/lib/components/NoteGrid.svelte | 53 ++++++++++++++++++--- src/lib/components/SortSelector.svelte | 29 ++++++++++++ src/lib/stores/notes.ts | 44 ++++++++++++++++-- src/lib/stores/sort.ts | 24 ++++++++++ src/routes/(app)/+page.svelte | 26 +++++++++-- src/routes/api/notes/reorder/+server.ts | 22 +++++++++ tests/e2e/organization.spec.ts | 61 +++++++++++++++++++++++++ 7 files changed, 243 insertions(+), 16 deletions(-) create mode 100644 src/lib/components/SortSelector.svelte create mode 100644 src/lib/stores/sort.ts create mode 100644 src/routes/api/notes/reorder/+server.ts diff --git a/src/lib/components/NoteGrid.svelte b/src/lib/components/NoteGrid.svelte index 8d4c22d..0f7e93a 100644 --- a/src/lib/components/NoteGrid.svelte +++ b/src/lib/components/NoteGrid.svelte @@ -1,23 +1,62 @@ -{#if notes.length > 0} +{#if displayItems.length > 0} {#if label}

{label}

{/if} -
- {#each notes as note (note.id)} - - {/each} -
+ {#if draggable} +
+ {#each localItems as note (note.id)} +
+ +
+ {/each} +
+ {:else} +
+ {#each displayItems as note (note.id)} + + {/each} +
+ {/if} {/if} diff --git a/src/lib/components/SortSelector.svelte b/src/lib/components/SortSelector.svelte new file mode 100644 index 0000000..10c5acc --- /dev/null +++ b/src/lib/components/SortSelector.svelte @@ -0,0 +1,29 @@ + + +
+ {#each modes as mode} + + {/each} +
diff --git a/src/lib/stores/notes.ts b/src/lib/stores/notes.ts index 0ac3aad..9d580a2 100644 --- a/src/lib/stores/notes.ts +++ b/src/lib/stores/notes.ts @@ -3,6 +3,7 @@ import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index. import { addToSyncQueue, putNote, deleteNoteFromIdb, getAllNotes, clearNotes as clearIdbNotes } from '$lib/sync/idb.js'; import { showToast } from '$lib/stores/toast.js'; import { extractTags } from '$lib/utils/tags.js'; +import { sortMode, type SortMode } from '$lib/stores/sort.js'; export const notes = writable([]); export const currentFilter = writable('all'); @@ -27,12 +28,25 @@ export const filteredNotes = derived( } ); -export const pinnedNotes = derived(filteredNotes, ($notes) => - $notes.filter((n) => n.pinned) +function sortNotes(list: Note[], mode: SortMode): Note[] { + return [...list].sort((a, b) => { + if (mode === 'created') { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + } + if (mode === 'custom') { + return a.sortOrder - b.sortOrder; + } + // default: updated + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); +} + +export const pinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) => + sortNotes($notes.filter((n) => n.pinned), $sortMode) ); -export const unpinnedNotes = derived(filteredNotes, ($notes) => - $notes.filter((n) => !n.pinned) +export const unpinnedNotes = derived([filteredNotes, sortMode], ([$notes, $sortMode]) => + sortNotes($notes.filter((n) => !n.pinned), $sortMode) ); export const allTags = derived(notes, ($notes) => { @@ -199,3 +213,25 @@ export async function unarchiveNote(id: string): Promise { export async function togglePin(id: string, currentPinned: boolean): Promise { return updateNote(id, { pinned: !currentPinned }); } + +export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise { + // Optimistic update + notes.update((list) => + list.map((n) => { + const order = orders.find((o) => o.id === n.id); + return order ? { ...n, sortOrder: order.sortOrder } : n; + }) + ); + + try { + const res = await fetch('/api/notes/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orders }) + }); + return res.ok; + } catch { + // Offline — optimistic update stands + return false; + } +} diff --git a/src/lib/stores/sort.ts b/src/lib/stores/sort.ts new file mode 100644 index 0000000..d037410 --- /dev/null +++ b/src/lib/stores/sort.ts @@ -0,0 +1,24 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export type SortMode = 'updated' | 'created' | 'custom'; + +const STORAGE_KEY = 'crumbs-sort-mode'; + +function getInitial(): SortMode { + if (browser) { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'updated' || stored === 'created' || stored === 'custom') { + return stored; + } + } + return 'updated'; +} + +export const sortMode = writable(getInitial()); + +if (browser) { + sortMode.subscribe((value) => { + localStorage.setItem(STORAGE_KEY, value); + }); +} diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 6b66013..c8249dd 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -2,7 +2,9 @@ import NoteGrid from '$lib/components/NoteGrid.svelte'; import NoteEditor from '$lib/components/NoteEditor.svelte'; import TagFilter from '$lib/components/TagFilter.svelte'; - import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded } from '$lib/stores/notes.js'; + import SortSelector from '$lib/components/SortSelector.svelte'; + import { pinnedNotes, unpinnedNotes, selectedTag, currentFilter, notes, notesLoaded, updateSortOrders } from '$lib/stores/notes.js'; + import { sortMode } from '$lib/stores/sort.js'; import { onMount } from 'svelte'; import { get } from 'svelte/store'; import type { Note } from '$lib/types/index.js'; @@ -22,6 +24,11 @@ history.replaceState(null, '', location.pathname); } + function handleReorder(noteIds: string[]) { + const orders = noteIds.map((id, index) => ({ id, sortOrder: index })); + updateSortOrders(orders); + } + onMount(() => { const hash = location.hash.slice(1); if (!hash) return; @@ -53,9 +60,16 @@ {/if} -
- -
+{#if $currentFilter === 'all'} +
+ + +
+{:else} +
+ +
+{/if} {#if $currentFilter === 'archived'}

Archive

@@ -65,7 +79,7 @@ {#if $pinnedNotes.length > 0}
- +
{/if} @@ -73,6 +87,8 @@ notes={$unpinnedNotes} label={$pinnedNotes.length > 0 ? 'Others' : ''} onEdit={openEditor} + draggable={$sortMode === 'custom'} + onReorder={handleReorder} /> {#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0} diff --git a/src/routes/api/notes/reorder/+server.ts b/src/routes/api/notes/reorder/+server.ts new file mode 100644 index 0000000..94550a2 --- /dev/null +++ b/src/routes/api/notes/reorder/+server.ts @@ -0,0 +1,22 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { db } from '$lib/server/db/index.js'; +import { notes } from '$lib/server/db/schema.js'; +import { eq } from 'drizzle-orm'; + +export const POST: RequestHandler = async ({ request }) => { + const body = await request.json(); + const orders: { id: string; sortOrder: number }[] = body.orders; + + if (!Array.isArray(orders)) { + return json({ error: 'Invalid payload' }, { status: 400 }); + } + + db.transaction((tx) => { + for (const { id, sortOrder } of orders) { + tx.update(notes).set({ sortOrder }).where(eq(notes.id, id)).run(); + } + }); + + return json({ ok: true }); +}; diff --git a/tests/e2e/organization.spec.ts b/tests/e2e/organization.spec.ts index 73e8d30..041da9b 100644 --- a/tests/e2e/organization.spec.ts +++ b/tests/e2e/organization.spec.ts @@ -68,3 +68,64 @@ test.describe('Organization Features', () => { } }); }); + +test.describe('Note Sorting', () => { + test('Scenario: Notes can be sorted by last updated', async ({ authenticatedPage: page }) => { + // Given two notes exist with different update times + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Sort-First'); + await page.getByTestId('close-editor-btn').click(); + await expect(noteCard(page, 'Sort-First')).toBeVisible(); + + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Sort-Second'); + await page.getByTestId('close-editor-btn').click(); + await expect(noteCard(page, 'Sort-Second')).toBeVisible(); + + // When the user selects "Updated" sort mode + await page.getByTestId('sort-updated').click(); + + // Then Sort-Second (most recently updated) appears before Sort-First + const sortFirst = noteCard(page, 'Sort-First'); + const sortSecond = noteCard(page, 'Sort-Second'); + const firstBox = await sortSecond.boundingBox(); + const secondBox = await sortFirst.boundingBox(); + expect(firstBox!.y).toBeLessThanOrEqual(secondBox!.y); + }); + + test('Scenario: Notes can be sorted by creation date', async ({ authenticatedPage: page }) => { + // Given two notes exist + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Created-Older'); + await page.getByTestId('close-editor-btn').click(); + await expect(noteCard(page, 'Created-Older')).toBeVisible(); + + await page.getByTestId('new-note-btn').click(); + await page.getByTestId('note-title-input').fill('Created-Newer'); + await page.getByTestId('close-editor-btn').click(); + await expect(noteCard(page, 'Created-Newer')).toBeVisible(); + + // When the user selects "Created" sort mode + await page.getByTestId('sort-created').click(); + + // Then Created-Newer appears before Created-Older + const newer = noteCard(page, 'Created-Newer'); + const older = noteCard(page, 'Created-Older'); + const newerBox = await newer.boundingBox(); + const olderBox = await older.boundingBox(); + expect(newerBox!.y).toBeLessThanOrEqual(olderBox!.y); + }); + + test('Scenario: Sort preference persists across page reload', async ({ authenticatedPage: page }) => { + // Given the user selects "Created" sort mode + await page.getByTestId('sort-created').click(); + + // When the page is reloaded + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Then the "Created" sort mode is still active + const createdBtn = page.getByTestId('sort-created'); + await expect(createdBtn).toHaveClass(/bg-\[var\(--primary\)\]/); + }); +});