forked from mirror/crumbs-original
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e87be60a4b
commit
fa698574bd
@@ -1,23 +1,62 @@
|
||||
<script lang="ts">
|
||||
import NoteCard from './NoteCard.svelte';
|
||||
import { dndzone, type DndEvent } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
notes: Note[];
|
||||
label?: string;
|
||||
onEdit: (note: Note) => void;
|
||||
draggable?: boolean;
|
||||
onReorder?: (noteIds: string[]) => void;
|
||||
}
|
||||
|
||||
let { notes, label = '', onEdit }: Props = $props();
|
||||
let { notes, label = '', onEdit, draggable = false, onReorder }: Props = $props();
|
||||
|
||||
let localItems = $state<Note[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
localItems = [...notes];
|
||||
});
|
||||
|
||||
const flipDurationMs = 150;
|
||||
|
||||
function handleConsider(e: CustomEvent<DndEvent<Note>>) {
|
||||
localItems = e.detail.items;
|
||||
}
|
||||
|
||||
function handleFinalize(e: CustomEvent<DndEvent<Note>>) {
|
||||
localItems = e.detail.items;
|
||||
onReorder?.(localItems.map((n) => n.id));
|
||||
}
|
||||
|
||||
let displayItems = $derived(draggable ? localItems : notes);
|
||||
</script>
|
||||
|
||||
{#if notes.length > 0}
|
||||
{#if displayItems.length > 0}
|
||||
{#if label}
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each notes as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if draggable}
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
|
||||
data-testid="note-grid"
|
||||
use:dndzone={{ items: localItems, flipDurationMs, type: 'notes', dropTargetStyle: {} }}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each localItems as note (note.id)}
|
||||
<div animate:flip={{ duration: flipDurationMs }}>
|
||||
<NoteCard {note} {onEdit} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each displayItems as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import CalendarPlus from 'lucide-svelte/icons/calendar-plus';
|
||||
import Hand from 'lucide-svelte/icons/hand';
|
||||
import { sortMode, type SortMode } from '$lib/stores/sort.js';
|
||||
|
||||
const modes: { value: SortMode; label: string; icon: typeof Clock }[] = [
|
||||
{ value: 'updated', label: 'Updated', icon: Clock },
|
||||
{ value: 'created', label: 'Created', icon: CalendarPlus },
|
||||
{ value: 'custom', label: 'Custom', icon: Hand }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="inline-flex rounded-sm border border-[var(--border-subtle)]" data-testid="sort-selector">
|
||||
{#each modes as mode}
|
||||
<button
|
||||
onclick={() => sortMode.set(mode.value)}
|
||||
class="flex items-center gap-1 px-2 py-1 text-xs transition-colors duration-150
|
||||
{$sortMode === mode.value
|
||||
? 'bg-[var(--primary)] text-white'
|
||||
: 'text-[var(--text-muted)] hover:text-[var(--text)]'}"
|
||||
data-testid="sort-{mode.value}"
|
||||
aria-label="Sort by {mode.label}"
|
||||
>
|
||||
<mode.icon class="h-3.5 w-3.5" />
|
||||
<span class="hidden sm:inline">{mode.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
+40
-4
@@ -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<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('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<Note | null> {
|
||||
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise<boolean> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SortMode>(getInitial());
|
||||
|
||||
if (browser) {
|
||||
sortMode.subscribe((value) => {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
});
|
||||
}
|
||||
@@ -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 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{#if $currentFilter === 'all'}
|
||||
<div class="mb-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<TagFilter />
|
||||
<SortSelector />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<TagFilter />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $currentFilter === 'archived'}
|
||||
<h2 class="mb-4 text-lg font-medium text-[var(--text-muted)]">Archive</h2>
|
||||
@@ -65,7 +79,7 @@
|
||||
|
||||
{#if $pinnedNotes.length > 0}
|
||||
<div class="mb-6">
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} />
|
||||
<NoteGrid notes={$pinnedNotes} label="Pinned" onEdit={openEditor} draggable={$sortMode === 'custom'} onReorder={handleReorder} />
|
||||
</div>
|
||||
{/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}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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\)\]/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user