start: drag and drop links to reorder within a section or move them to another section (view and edit mode); orderLinks can also change the dragged link's section
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
All checks were successful
Deploy production / deploy (push) Successful in 1m14s
This commit is contained in:
@@ -171,13 +171,22 @@ export const actions: Actions = {
|
||||
/** Reorder: `order` is the full list of link ids in their new order. */
|
||||
orderLinks: async (event) => {
|
||||
if (!owner(event)) return fail(404, { message: 'Not Found' });
|
||||
const raw = String((await event.request.formData()).get('order') ?? '');
|
||||
const form = await event.request.formData();
|
||||
const raw = String(form.get('order') ?? '');
|
||||
const ids = raw
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
if (!ids.length || ids.length > 500) return fail(400, { message: 'Invalid order' });
|
||||
// Optional: drag-and-drop into another section moves link `id` there too.
|
||||
const moveId = intId(form.get('id'));
|
||||
const section = form.has('section')
|
||||
? linkInput.shape.section.safeParse(form.get('section'))
|
||||
: null;
|
||||
if (section && !section.success) return fail(400, { message: 'Invalid section' });
|
||||
await db.transaction(async (tx) => {
|
||||
if (moveId && section?.success)
|
||||
await tx.update(homeLinks).set({ section: section.data }).where(eq(homeLinks.id, moveId));
|
||||
for (const [i, id] of ids.entries())
|
||||
await tx
|
||||
.update(homeLinks)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { invalidate, invalidateAll } from '$app/navigation';
|
||||
import { Tween } from 'svelte/motion';
|
||||
import {
|
||||
ago,
|
||||
@@ -225,9 +225,12 @@
|
||||
};
|
||||
const swapPct = $derived(host?.swap.total ? (100 * host.swap.used) / host.swap.total : 0);
|
||||
|
||||
// Optimistic copy of the links while a drag-and-drop move is being saved.
|
||||
let pendingLinks = $state<typeof data.links | null>(null);
|
||||
const allLinks = $derived(pendingLinks ?? data.links);
|
||||
const sections = $derived.by(() => {
|
||||
const groups: [string, typeof data.links][] = [];
|
||||
for (const l of data.links) {
|
||||
for (const l of allLinks) {
|
||||
const g = groups.find(([name]) => name === l.section);
|
||||
if (g) g[1].push(l);
|
||||
else groups.push([l.section, [l]]);
|
||||
@@ -370,7 +373,7 @@
|
||||
}
|
||||
|
||||
function move(id: number, dir: -1 | 1) {
|
||||
const ids = data.links.map((l) => l.id);
|
||||
const ids = allLinks.map((l) => l.id);
|
||||
const i = ids.indexOf(id);
|
||||
const j = i + dir;
|
||||
if (i < 0 || j < 0 || j >= ids.length) return '';
|
||||
@@ -378,6 +381,91 @@
|
||||
return ids.join(',');
|
||||
}
|
||||
|
||||
// Drag and drop: reorder links within a section, or drop one into another section.
|
||||
let dragId = $state<number | null>(null);
|
||||
let dropAt = $state<{ section: string; before: number | null } | null>(null);
|
||||
let dropError = $state('');
|
||||
function onLinkDragStart(e: DragEvent, id: number) {
|
||||
dragId = id;
|
||||
dropError = '';
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/x-start-link', String(id));
|
||||
}
|
||||
}
|
||||
function onLinkDragEnd() {
|
||||
dragId = null;
|
||||
dropAt = null;
|
||||
}
|
||||
/** `over` is the link under the pointer (null = the section itself, drop at its end). */
|
||||
function onLinkDragOver(
|
||||
e: DragEvent,
|
||||
section: string,
|
||||
over: number | null,
|
||||
axis: 'x' | 'y' = 'x'
|
||||
) {
|
||||
if (dragId === null) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
let before = over;
|
||||
if (over !== null && e.currentTarget instanceof HTMLElement) {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const after =
|
||||
axis === 'x' ? e.clientX > r.left + r.width / 2 : e.clientY > r.top + r.height / 2;
|
||||
if (after) {
|
||||
const ls = sections.find(([name]) => name === section)?.[1] ?? [];
|
||||
before = ls[ls.findIndex((l) => l.id === over) + 1]?.id ?? null;
|
||||
}
|
||||
}
|
||||
if (before === dragId) return;
|
||||
if (dropAt?.section !== section || dropAt.before !== before) dropAt = { section, before };
|
||||
}
|
||||
async function onLinkDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = dragId;
|
||||
const target = dropAt;
|
||||
onLinkDragEnd();
|
||||
const link = allLinks.find((l) => l.id === id);
|
||||
if (id === null || !target || !link) return;
|
||||
// Rebuild in section order so moving a link never reshuffles the sections themselves.
|
||||
const groups = sections.map(
|
||||
([name, ls]) => [name, ls.filter((l) => l.id !== id)] as [string, typeof data.links]
|
||||
);
|
||||
let g = groups.find(([name]) => name === target.section);
|
||||
if (!g) groups.push((g = [target.section, []]));
|
||||
const at = target.before === null ? -1 : g[1].findIndex((l) => l.id === target.before);
|
||||
const moved = { ...link, section: target.section };
|
||||
if (at < 0) g[1].push(moved);
|
||||
else g[1].splice(at, 0, moved);
|
||||
const next = groups.flatMap(([, ls]) => ls);
|
||||
if (link.section === target.section && next.every((l, i) => l.id === allLinks[i]?.id)) return;
|
||||
pendingLinks = next;
|
||||
const body = new FormData();
|
||||
body.set('order', next.map((l) => l.id).join(','));
|
||||
body.set('id', String(id));
|
||||
body.set('section', target.section);
|
||||
try {
|
||||
const res = await fetch('?/orderLinks', {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'x-sveltekit-action': 'true' }
|
||||
});
|
||||
const result = deserialize(await res.text());
|
||||
if (result.type !== 'success') dropError = "Couldn't save the new order";
|
||||
await invalidateAll();
|
||||
} catch {
|
||||
dropError = "Couldn't save the new order";
|
||||
} finally {
|
||||
pendingLinks = null;
|
||||
}
|
||||
}
|
||||
const dropBefore = (section: string, id: number) =>
|
||||
dragId !== null && dropAt?.section === section && dropAt.before === id;
|
||||
const dropEnd = (section: string) =>
|
||||
dragId !== null && dropAt?.section === section && dropAt.before === null;
|
||||
|
||||
function host_(url: string) {
|
||||
try {
|
||||
return new URL(url).host.replace(/^www\./, '');
|
||||
@@ -761,7 +849,7 @@
|
||||
<!-- Personal links -->
|
||||
<section class="card links">
|
||||
<h2>
|
||||
Links <small class="count">{data.links.length}</small>
|
||||
Links <small class="count">{allLinks.length}</small>
|
||||
<input
|
||||
class="filter"
|
||||
bind:this={linkFilterEl}
|
||||
@@ -776,19 +864,31 @@
|
||||
>
|
||||
</h2>
|
||||
{#if form?.message}<p class="bad sub" role="alert">{form.message}</p>{/if}
|
||||
{#if dropError}<p class="bad sub" role="alert">{dropError}</p>{/if}
|
||||
{#if !editing}
|
||||
{#each shownSections as [section, links] (section)}
|
||||
{@const open = filtering || isOpen(section)}
|
||||
<details
|
||||
class="group"
|
||||
class={['group', dropEnd(section) && 'drop-end']}
|
||||
{open}
|
||||
ontoggle={(e) => toggleGroup(section, e.currentTarget.open)}
|
||||
ondragover={(e) => onLinkDragOver(e, section, null)}
|
||||
ondrop={onLinkDrop}
|
||||
>
|
||||
<summary><span>{section}</span><small>{links.length}</small></summary>
|
||||
{#if open}
|
||||
<ul class="tiles icons">
|
||||
{#each links as l (l.id)}
|
||||
<li>
|
||||
<li
|
||||
class={[
|
||||
dragId === l.id && 'dragging',
|
||||
dropBefore(section, l.id) && 'drop-before'
|
||||
]}
|
||||
ondragstart={(e) => onLinkDragStart(e, l.id)}
|
||||
ondragend={onLinkDragEnd}
|
||||
ondragover={(e) => onLinkDragOver(e, section, l.id, 'x')}
|
||||
ondrop={onLinkDrop}
|
||||
>
|
||||
<a href={l.url} title={l.url}
|
||||
>{@render icon(l.url, l.title)}<span class="label">{l.title}</span></a
|
||||
>
|
||||
@@ -803,12 +903,27 @@
|
||||
{/if}
|
||||
{#each editing ? sections : [] as [section, links] (section)}
|
||||
{#if sections.length > 1}<h3>{section}</h3>{/if}
|
||||
<ul class="list">
|
||||
<ul
|
||||
class={['list', dropEnd(section) && 'drop-end']}
|
||||
ondragover={(e) => onLinkDragOver(e, section, null, 'y')}
|
||||
ondrop={onLinkDrop}
|
||||
>
|
||||
{#each links as l (l.id)}
|
||||
<li>
|
||||
<li
|
||||
class={[
|
||||
dragId === l.id && 'dragging',
|
||||
dropBefore(section, l.id) && 'drop-before-y'
|
||||
]}
|
||||
draggable={editId !== l.id}
|
||||
ondragstart={(e) => onLinkDragStart(e, l.id)}
|
||||
ondragend={onLinkDragEnd}
|
||||
ondragover={(e) => onLinkDragOver(e, section, l.id, 'y')}
|
||||
ondrop={onLinkDrop}
|
||||
>
|
||||
{#if editing && editId === l.id}
|
||||
{@render linkForm(l)}
|
||||
{:else if editing}
|
||||
<span class="grip" aria-hidden="true" title="Drag to move">⠿</span>
|
||||
<span class="name">{l.title} <small class="dim">{host_(l.url)}</small></span>
|
||||
{@const up = move(l.id, -1)}
|
||||
{@const down = move(l.id, 1)}
|
||||
@@ -1675,6 +1790,26 @@
|
||||
text-transform: none;
|
||||
font-weight: 400;
|
||||
}
|
||||
/* Drag-and-drop of links */
|
||||
.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.tiles li.drop-before {
|
||||
box-shadow: -3px 0 0 0 var(--site-accent, #58a6ff);
|
||||
}
|
||||
.list li.drop-before-y {
|
||||
box-shadow: 0 -2px 0 0 var(--site-accent, #58a6ff);
|
||||
}
|
||||
.group.drop-end > summary,
|
||||
.list.drop-end {
|
||||
outline: 1px dashed var(--site-accent, #58a6ff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.grip {
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
user-select: none;
|
||||
}
|
||||
.inline {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user