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. */
|
/** Reorder: `order` is the full list of link ids in their new order. */
|
||||||
orderLinks: async (event) => {
|
orderLinks: async (event) => {
|
||||||
if (!owner(event)) return fail(404, { message: 'Not Found' });
|
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
|
const ids = raw
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(Number)
|
.map(Number)
|
||||||
.filter((n) => Number.isInteger(n) && n > 0);
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
if (!ids.length || ids.length > 500) return fail(400, { message: 'Invalid order' });
|
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) => {
|
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())
|
for (const [i, id] of ids.entries())
|
||||||
await tx
|
await tx
|
||||||
.update(homeLinks)
|
.update(homeLinks)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { deserialize, enhance } from '$app/forms';
|
||||||
import { invalidate } from '$app/navigation';
|
import { invalidate, invalidateAll } from '$app/navigation';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
import {
|
import {
|
||||||
ago,
|
ago,
|
||||||
@@ -225,9 +225,12 @@
|
|||||||
};
|
};
|
||||||
const swapPct = $derived(host?.swap.total ? (100 * host.swap.used) / host.swap.total : 0);
|
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 sections = $derived.by(() => {
|
||||||
const groups: [string, typeof data.links][] = [];
|
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);
|
const g = groups.find(([name]) => name === l.section);
|
||||||
if (g) g[1].push(l);
|
if (g) g[1].push(l);
|
||||||
else groups.push([l.section, [l]]);
|
else groups.push([l.section, [l]]);
|
||||||
@@ -370,7 +373,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function move(id: number, dir: -1 | 1) {
|
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 i = ids.indexOf(id);
|
||||||
const j = i + dir;
|
const j = i + dir;
|
||||||
if (i < 0 || j < 0 || j >= ids.length) return '';
|
if (i < 0 || j < 0 || j >= ids.length) return '';
|
||||||
@@ -378,6 +381,91 @@
|
|||||||
return ids.join(',');
|
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) {
|
function host_(url: string) {
|
||||||
try {
|
try {
|
||||||
return new URL(url).host.replace(/^www\./, '');
|
return new URL(url).host.replace(/^www\./, '');
|
||||||
@@ -761,7 +849,7 @@
|
|||||||
<!-- Personal links -->
|
<!-- Personal links -->
|
||||||
<section class="card links">
|
<section class="card links">
|
||||||
<h2>
|
<h2>
|
||||||
Links <small class="count">{data.links.length}</small>
|
Links <small class="count">{allLinks.length}</small>
|
||||||
<input
|
<input
|
||||||
class="filter"
|
class="filter"
|
||||||
bind:this={linkFilterEl}
|
bind:this={linkFilterEl}
|
||||||
@@ -776,19 +864,31 @@
|
|||||||
>
|
>
|
||||||
</h2>
|
</h2>
|
||||||
{#if form?.message}<p class="bad sub" role="alert">{form.message}</p>{/if}
|
{#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}
|
{#if !editing}
|
||||||
{#each shownSections as [section, links] (section)}
|
{#each shownSections as [section, links] (section)}
|
||||||
{@const open = filtering || isOpen(section)}
|
{@const open = filtering || isOpen(section)}
|
||||||
<details
|
<details
|
||||||
class="group"
|
class={['group', dropEnd(section) && 'drop-end']}
|
||||||
{open}
|
{open}
|
||||||
ontoggle={(e) => toggleGroup(section, e.currentTarget.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>
|
<summary><span>{section}</span><small>{links.length}</small></summary>
|
||||||
{#if open}
|
{#if open}
|
||||||
<ul class="tiles icons">
|
<ul class="tiles icons">
|
||||||
{#each links as l (l.id)}
|
{#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}
|
<a href={l.url} title={l.url}
|
||||||
>{@render icon(l.url, l.title)}<span class="label">{l.title}</span></a
|
>{@render icon(l.url, l.title)}<span class="label">{l.title}</span></a
|
||||||
>
|
>
|
||||||
@@ -803,12 +903,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each editing ? sections : [] as [section, links] (section)}
|
{#each editing ? sections : [] as [section, links] (section)}
|
||||||
{#if sections.length > 1}<h3>{section}</h3>{/if}
|
{#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)}
|
{#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}
|
{#if editing && editId === l.id}
|
||||||
{@render linkForm(l)}
|
{@render linkForm(l)}
|
||||||
{:else if editing}
|
{: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>
|
<span class="name">{l.title} <small class="dim">{host_(l.url)}</small></span>
|
||||||
{@const up = move(l.id, -1)}
|
{@const up = move(l.id, -1)}
|
||||||
{@const down = move(l.id, 1)}
|
{@const down = move(l.id, 1)}
|
||||||
@@ -1675,6 +1790,26 @@
|
|||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-weight: 400;
|
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 {
|
.inline {
|
||||||
display: contents;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user