feat(post-form): live photo editor on entry edit (media API + SortableJS)

Replace the FilePond photo path in edit mode with our own thumbnail grid that
talks straight to the media API (the gpx-manager pattern). Add/delete/reorder
each persist immediately, decoupled from the form's text-field Save:

- Add: HEIC->JPEG client-side, stock POST .../media per file, then ONE reorder
  after the batch (renumber photo-01..NN). On a failed reorder: auto-retry
  (idempotent), else roll the just-uploaded files back so no orphan stock-named
  image breaks cover=first. Upload progress shown per file.
- Delete: inline 'Delete? [Confirm] [Cancel]' (Confirm disabled in flight),
  stock DELETE, then renumber the survivors.
- Reorder: SortableJS drag -> POST /entry/<slug>/photos/order. On failure the
  move reverts to last-known-good; the shown grid never disagrees with disk
  without an inline error.
- Loading + empty states; first cell badged Cover; photo-NN URLs cache-busted
  since reorder reuses them for different bytes.

FilePond is fully decommissioned in edit mode (initPhotoConversion early-returns
under EDIT_MODE): no stale photo_order manifest is posted on text Save, so
cache-on-save can't delete a live-added photo. Create-mode FilePond is untouched.

Adds sortablejs (bundled into js/post via the post-form entry). SVG excluded in
the file-input accept; the server-side SVG block is a documented fast-follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 19:19:57 +02:00
co-authored by Claude Opus 4.8
parent 4fea522d5c
commit a4432d897e
6 changed files with 554 additions and 149 deletions
+143
View File
@@ -336,3 +336,146 @@
color: var(--color-ink);
}
.EasyMDEContainer .editor-preview a { color: var(--color-accent); }
/* ── Photo editor (edit mode only) ────────────────────────────────────────
Own thumbnail grid that talks straight to the media API (add/delete/reorder
live), replacing the FilePond photo path when editing. */
.photo-editor {
margin-bottom: var(--space-5);
}
.photo-editor__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.photo-editor__label {
font-family: var(--font-ui);
font-size: var(--text-base);
font-weight: 600;
color: var(--color-ink);
}
.photo-editor__add {
font-family: var(--font-ui);
font-size: var(--text-sm);
background: var(--color-accent);
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
cursor: pointer;
}
.photo-editor__add:disabled { opacity: 0.5; cursor: default; }
.photo-editor__status {
font-size: var(--text-sm);
color: var(--color-ink-muted);
min-height: 1.2em;
margin: 0 0 var(--space-2);
}
.photo-editor__status.error { color: var(--color-error); }
.photo-editor__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-2);
}
@media (min-width: 600px) {
.photo-editor__grid { grid-template-columns: repeat(4, 1fr); }
}
.photo-editor__loading,
.photo-editor__empty {
grid-column: 1 / -1;
color: var(--color-ink-muted);
font-size: var(--text-sm);
padding: var(--space-4) 0;
}
.photo-editor__cell {
position: relative;
aspect-ratio: 1 / 1;
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--color-surface-raised);
cursor: grab;
touch-action: none; /* let SortableJS own the touch-drag gesture */
}
.photo-editor__cell:active { cursor: grabbing; }
.photo-editor__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
-webkit-user-drag: none;
user-select: none;
pointer-events: none; /* the cell, not the image, is the drag handle */
}
/* First thumbnail is the feed cover. */
.photo-editor__cell:first-child::after {
content: 'Cover';
position: absolute;
left: var(--space-1);
bottom: var(--space-1);
font-family: var(--font-ui);
font-size: var(--text-xs, 0.7rem);
font-weight: 600;
color: #fff;
background: var(--color-accent);
padding: 1px 6px;
border-radius: var(--radius-sm);
pointer-events: none;
}
.photo-editor__del {
position: absolute;
top: var(--space-1);
right: var(--space-1);
width: 24px;
height: 24px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 0.85rem;
cursor: pointer;
}
.photo-editor__del:hover { background: rgba(0, 0, 0, 0.75); }
.photo-editor__del:disabled { opacity: 0.4; cursor: default; }
/* Inline delete confirm — covers the cell (option a: live delete, deliberate step). */
.photo-editor__confirm {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-2);
background: rgba(0, 0, 0, 0.72);
text-align: center;
}
.photo-editor__confirm-q {
color: #fff;
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.photo-editor__confirm button {
font-family: var(--font-ui);
font-size: var(--text-xs, 0.72rem);
border: none;
border-radius: var(--radius-sm);
padding: 3px 10px;
cursor: pointer;
}
.photo-editor__confirm-yes { background: var(--color-error); color: #fff; }
.photo-editor__confirm-no { background: #fff; color: var(--color-ink); }
.photo-editor__confirm button:disabled { opacity: 0.5; cursor: default; }
/* SortableJS drag feedback. */
.photo-editor__cell.sortable-ghost { opacity: 0.4; }
.photo-editor__cell.sortable-chosen { outline: 2px solid var(--color-accent); }