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:
Frederic Cabassut
2026-03-07 21:40:04 +01:00
co-authored by Claude Opus 4.6
parent e5688c99b9
commit 5ce8601a92
7 changed files with 243 additions and 16 deletions
+46 -7
View File
@@ -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}
+29
View File
@@ -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>