Fix checklist item ordering and add move up/down buttons
The BASE_CHARS in fractional-index.ts had digits after letters (A-Za-z0-9), but PostgreSQL sorts digits before letters (0-9A-Za-z), causing items to appear out of order once positions reached the digit range. Reordered BASE_CHARS to match DB collation. Also added up/down arrow buttons on checklist items so users can reorder them within the list. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/utils/api.js';
|
||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||
|
||||
type ChecklistItem = { id: string; title: string; completed: boolean; position: string };
|
||||
type Checklist = { id: string; title: string; position: string; items: ChecklistItem[] };
|
||||
@@ -95,6 +96,41 @@
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveItem(clId: string, itemId: string, direction: 'up' | 'down') {
|
||||
const cl = checklists.find((c) => c.id === clId);
|
||||
if (!cl) return;
|
||||
const idx = cl.items.findIndex((i) => i.id === itemId);
|
||||
if (idx === -1) return;
|
||||
if (direction === 'up' && idx === 0) return;
|
||||
if (direction === 'down' && idx === cl.items.length - 1) return;
|
||||
|
||||
const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
|
||||
const before = direction === 'up'
|
||||
? (targetIdx > 0 ? cl.items[targetIdx - 1].position : null)
|
||||
: cl.items[targetIdx].position;
|
||||
const after = direction === 'up'
|
||||
? cl.items[targetIdx].position
|
||||
: (targetIdx < cl.items.length - 1 ? cl.items[targetIdx + 1].position : null);
|
||||
|
||||
const newPos = generatePosition(before, after);
|
||||
|
||||
const { ok } = await api(`${basePath}/${clId}/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ position: newPos })
|
||||
});
|
||||
if (ok) {
|
||||
const newItems = [...cl.items];
|
||||
newItems[idx] = { ...newItems[idx], position: newPos };
|
||||
// Re-sort by position
|
||||
newItems.sort((a, b) => a.position.localeCompare(b.position));
|
||||
onupdate(
|
||||
checklists.map((c) =>
|
||||
c.id === clId ? { ...c, items: newItems } : c
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -132,7 +168,7 @@
|
||||
|
||||
<!-- Items -->
|
||||
<div class="space-y-1">
|
||||
{#each cl.items as item}
|
||||
{#each cl.items as item, idx}
|
||||
<div class="flex items-center gap-2 group">
|
||||
{#if canEdit}
|
||||
<input
|
||||
@@ -153,14 +189,40 @@
|
||||
{item.title}
|
||||
</span>
|
||||
{#if canEdit}
|
||||
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
onclick={() => moveItem(cl.id, item.id, 'up')}
|
||||
disabled={idx === 0}
|
||||
class="text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400 disabled:opacity-30 disabled:cursor-default p-0.5"
|
||||
title="Move up"
|
||||
aria-label="Move item up"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => moveItem(cl.id, item.id, 'down')}
|
||||
disabled={idx === cl.items.length - 1}
|
||||
class="text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400 disabled:opacity-30 disabled:cursor-default p-0.5"
|
||||
title="Move down"
|
||||
aria-label="Move item down"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteItem(cl.id, item.id)}
|
||||
class="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition"
|
||||
class="text-gray-300 dark:text-gray-600 hover:text-red-500 p-0.5"
|
||||
title="Delete item"
|
||||
aria-label="Delete item"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* https://observablehq.com/@dgreensp/implementing-fractional-indexing
|
||||
*/
|
||||
|
||||
const BASE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const MIDPOINT = 'N'; // roughly middle of uppercase range
|
||||
const BASE_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const MIDPOINT = 'V'; // roughly middle of the charset
|
||||
|
||||
/**
|
||||
* Generate a position string between `before` and `after`.
|
||||
|
||||
Reference in New Issue
Block a user