feat(showcase): IR-parity R-key shuffle + stagger entry animation; fix(cleanup): min-dim Delete swallowed crypto.subtle TypeError on plain HTTP
**showcase R-key + entry animation**
Restores two behaviors lost during the FC-2 IR→Vue port. Operator-flagged
2026-05-27.
- ShowcaseView listens for keydown 'r'/'R' on window. Triggers
`store.shuffle()`. Skips when an input/textarea/contenteditable is
focused or a Vuetify overlay is open (the dialog/menu sets
`.v-overlay--active` on the body).
- MasonryGrid gains an opt-in `animateFromIndex` prop (default
`Number.POSITIVE_INFINITY` = off). When set, items with index ≥ the
threshold animate in with a stagger fade-in: 12px translateY,
0.25s ease, 60ms per item, capped by `prefers-reduced-motion`.
Stagger uses original-items-array index (resolved via an `idxById`
Map) so the reading order is preserved even after the masonry
distributes items across columns.
- ShowcaseView watches `store.images.length`: shrink-or-zero baseline
⇒ `animateFromIndex=0` (animate everything on initial load /
shuffle); grow ⇒ baseline=prevCount (animate only the appended
tail on infinite-scroll). Other MasonryGrid consumers (ArtistView's
Gallery tab) don't pass the prop, so they keep their current
no-animation behavior.
Direct port of IR's `app/static/js/showcase.js` keyboard handler +
`app/static/style.css` itemFadeIn keyframe.
**min-dim Delete: crypto.subtle TypeError fix**
The Delete button on the Cleanup → Minimum Dimensions card was
silently no-op'ing. Root cause: `crypto.subtle` is Secure-Context-gated
(undefined on plain-HTTP origins per the homelab posture). The card's
`onDeleteClick` computed the Tier-C confirm token via
`crypto.subtle.digest('SHA-256', ...)`, which threw TypeError before
`showModal.value = true`. The promise rejected, the click handler had
no `.catch`, the modal never opened — exactly the operator's reported
symptom.
Same shape as the v26.05.26.0 `navigator.clipboard` fix on the
ErrorDetailModal Copy button.
Fix: backend `/api/cleanup/min-dimension/preview` now returns
`confirm_token` (the canonical `delete-min-dim-<sha8>` string) in its
response. Frontend reads it from the preview response and feeds the
8-char suffix to DestructiveConfirmModal's `runId` prop — no
client-side crypto needed. Single source of truth.
Integration test `test_min_dimension_preview_returns_count` pinned to
also assert `body["confirm_token"]` matches the server-side compute.
This commit is contained in:
@@ -52,7 +52,7 @@
|
||||
v-model="showModal"
|
||||
action="delete"
|
||||
kind="min-dim"
|
||||
:run-id="tokenSha8"
|
||||
:run-id="tokenSuffix"
|
||||
tier="C"
|
||||
:projected-counts="projectedCounts"
|
||||
:description="`Width < ${minW} OR height < ${minH}`"
|
||||
@@ -62,7 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import DestructiveConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||
import { useCleanupStore } from '../../stores/cleanup.js'
|
||||
@@ -73,24 +73,29 @@ const minH = ref(0)
|
||||
const preview = ref(null)
|
||||
const busy = ref(false)
|
||||
const showModal = ref(false)
|
||||
const tokenSha8 = ref('')
|
||||
const projectedCounts = ref({})
|
||||
|
||||
// The backend hands the full Tier-C confirm token back with the
|
||||
// preview response (e.g. `delete-min-dim-1a2b3c4d`). We previously
|
||||
// reconstructed it client-side via Web Crypto's SHA-256, but
|
||||
// `crypto.subtle` is undefined on plain-HTTP origins (homelab posture)
|
||||
// and the Delete-button click handler was silently swallowing the
|
||||
// TypeError. Letting the backend own the token is also the single
|
||||
// source of truth — frontend just splits off the 8-char suffix to
|
||||
// feed DestructiveConfirmModal's `runId` slot (the modal templates
|
||||
// the prefix `delete-min-dim-` itself from action+kind).
|
||||
const tokenSuffix = computed(() => {
|
||||
const full = preview.value?.confirm_token ?? ''
|
||||
// `delete-min-dim-1a2b3c4d` → `1a2b3c4d`
|
||||
return full.startsWith('delete-min-dim-') ? full.slice('delete-min-dim-'.length) : ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadDefaults()
|
||||
minW.value = store.defaults.min_width
|
||||
minH.value = store.defaults.min_height
|
||||
})
|
||||
|
||||
// SHA-256 truncated to 8 hex chars — matches the backend's
|
||||
// _min_dim_token() exactly. Web Crypto rejects MD5 as insecure.
|
||||
async function sha8(canon) {
|
||||
const enc = new TextEncoder()
|
||||
const buf = await crypto.subtle.digest('SHA-256', enc.encode(canon))
|
||||
const hex = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
return hex.slice(0, 8)
|
||||
}
|
||||
|
||||
async function onPreview() {
|
||||
busy.value = true
|
||||
try {
|
||||
@@ -102,8 +107,7 @@ async function onPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeleteClick() {
|
||||
tokenSha8.value = await sha8(`${minW.value}x${minH.value}`)
|
||||
function onDeleteClick() {
|
||||
projectedCounts.value = { 'Images to delete': preview.value.count }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user