feat(website): reorder blocks via up/down arrows in inspector

blocksStore.reorderBlock already existed but was never wired into the
editor, so sections could only be created or deleted — not moved. Adds
two arrows in the BlockInspector header next to the delete button.

- blocksStore: moveBlockUp / moveBlockDown helpers that look up the
  block's siblings (same page + parent), compute the fractional index
  that swaps with the neighbour, and delegate to reorderBlock
- BlockInspector: up / down buttons with disabled state at the bounds,
  plus siblings prop driving canMoveUp / canMoveDown
- EditorView: derives selectedSiblings (same-parent blocks on the
  current page) and passes them down

Works for top-level sections and for children inside containers,
since siblings are scoped to (pageId, parentBlockId).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 22:24:00 +02:00
parent 25314200b2
commit bdd4e05446
3 changed files with 123 additions and 8 deletions

View file

@ -8,10 +8,15 @@
interface Props {
block: WebsiteBlock;
siblings: WebsiteBlock[];
onDeleted?: () => void;
}
let { block, onDeleted }: Props = $props();
let { block, siblings, onDeleted }: Props = $props();
const siblingIndex = $derived(siblings.findIndex((b) => b.id === block.id));
const canMoveUp = $derived(siblingIndex > 0);
const canMoveDown = $derived(siblingIndex >= 0 && siblingIndex < siblings.length - 1);
const spec = $derived(getBlockSpec(block.type));
@ -57,6 +62,16 @@
onDeleted?.();
}
async function onMoveUp() {
if (!canMoveUp) return;
await blocksStore.moveBlockUp(block.id);
}
async function onMoveDown() {
if (!canMoveDown) return;
await blocksStore.moveBlockDown(block.id);
}
function asRegistryBlock(b: WebsiteBlock): Block<unknown> {
return {
id: b.id,
@ -77,7 +92,34 @@
<p class="wb-inspector__kind">{spec.category}</p>
<h3>{spec.label}</h3>
</div>
<button class="wb-inspector__delete" onclick={onDelete} title="Block löschen"> × </button>
<div class="wb-inspector__actions">
<button
class="wb-inspector__action"
onclick={onMoveUp}
disabled={!canMoveUp}
title="Nach oben verschieben"
aria-label="Nach oben verschieben"
>
</button>
<button
class="wb-inspector__action"
onclick={onMoveDown}
disabled={!canMoveDown}
title="Nach unten verschieben"
aria-label="Nach unten verschieben"
>
</button>
<button
class="wb-inspector__action wb-inspector__action--delete"
onclick={onDelete}
title="Block löschen"
aria-label="Block löschen"
>
×
</button>
</div>
</header>
<div class="wb-inspector__body">
@ -121,22 +163,43 @@
text-transform: uppercase;
letter-spacing: 0.05em;
}
.wb-inspector__delete {
.wb-inspector__actions {
display: flex;
gap: 0.25rem;
flex: 0 0 auto;
}
.wb-inspector__action {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
color: inherit;
padding: 0.1rem 0.5rem;
font-size: 1.15rem;
width: 1.75rem;
height: 1.75rem;
padding: 0;
font-size: 0.9rem;
line-height: 1;
border-radius: 0.375rem;
cursor: pointer;
opacity: 0.7;
display: inline-flex;
align-items: center;
justify-content: center;
}
.wb-inspector__delete:hover {
.wb-inspector__action:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.4);
opacity: 1;
}
.wb-inspector__action:disabled {
opacity: 0.25;
cursor: not-allowed;
}
.wb-inspector__action--delete {
font-size: 1.15rem;
}
.wb-inspector__action--delete:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.15);
border-color: rgba(248, 113, 113, 0.5);
color: rgb(248, 113, 113);
opacity: 1;
}
.wb-inspector__body {
flex: 1 1 auto;

View file

@ -160,4 +160,46 @@ export const blocksStore = {
});
await touchSiteForPage(existing.pageId);
},
/**
* Swap the block with its previous sibling (same page + parent). No-op
* if the block is already first or not found.
*/
async moveBlockUp(id: string) {
const siblings = await orderedSiblings(id);
if (!siblings) return;
const { ordered, idx } = siblings;
if (idx <= 0) return;
const prev = ordered[idx - 1];
const prevPrev = idx >= 2 ? ordered[idx - 2] : null;
await this.reorderBlock(id, prevPrev?.order ?? null, prev.order);
},
/**
* Swap the block with its next sibling (same page + parent). No-op if
* the block is already last or not found.
*/
async moveBlockDown(id: string) {
const siblings = await orderedSiblings(id);
if (!siblings) return;
const { ordered, idx } = siblings;
if (idx < 0 || idx >= ordered.length - 1) return;
const next = ordered[idx + 1];
const nextNext = idx + 2 < ordered.length ? ordered[idx + 2] : null;
await this.reorderBlock(id, next.order, nextNext?.order ?? null);
},
};
async function orderedSiblings(
id: string
): Promise<{ ordered: LocalWebsiteBlock[]; idx: number } | null> {
const block = await websiteBlocksTable.get(id);
if (!block) return null;
const parentId = block.parentBlockId ?? null;
const all = await websiteBlocksTable.where('pageId').equals(block.pageId).toArray();
const ordered = all
.filter((b) => !b.deletedAt && (b.parentBlockId ?? null) === parentId)
.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
const idx = ordered.findIndex((b) => b.id === id);
return { ordered, idx };
}

View file

@ -37,6 +37,12 @@
selectedBlockId ? (pageBlocks.find((b) => b.id === selectedBlockId) ?? null) : null
);
const selectedSiblings = $derived.by(() => {
if (!selectedBlock) return [];
const parentId = selectedBlock.parentBlockId ?? null;
return pageBlocks.filter((b) => (b.parentBlockId ?? null) === parentId);
});
// Clear selection when switching page.
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
@ -102,7 +108,11 @@
<aside class="wb-editor__right">
{#if selectedBlock}
<BlockInspector block={selectedBlock} onDeleted={() => (selectedBlockId = null)} />
<BlockInspector
block={selectedBlock}
siblings={selectedSiblings}
onDeleted={() => (selectedBlockId = null)}
/>
{:else}
<p class="wb-editor__inspector-empty">
Wähle einen Block in der Vorschau, um ihn zu bearbeiten.