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:
@@ -81,6 +81,13 @@ async def min_dim_preview():
|
||||
s, min_width=min_w, min_height=min_h,
|
||||
)
|
||||
)
|
||||
# Hand the canonical Tier-C delete token back with the preview so
|
||||
# the frontend doesn't have to recompute SHA-256 client-side.
|
||||
# window.crypto.subtle is Secure-Context-gated and undefined on
|
||||
# plain-HTTP origins (homelab posture); without this the Delete
|
||||
# button silently swallowed the TypeError and never opened the
|
||||
# confirm modal. Operator-flagged 2026-05-27.
|
||||
projection["confirm_token"] = _min_dim_token(min_w, min_h)
|
||||
return jsonify(projection)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
<div v-for="(col, ci) in columns" :key="ci" class="fc-masonry__col">
|
||||
<button
|
||||
v-for="item in col" :key="item.id"
|
||||
class="fc-masonry__item" type="button"
|
||||
class="fc-masonry__item"
|
||||
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
|
||||
:style="itemStyle(item)"
|
||||
type="button"
|
||||
@click="$emit('open', item.id)"
|
||||
>
|
||||
<img
|
||||
@@ -33,7 +36,13 @@ import { usePolyMasonry } from '../../composables/usePolyMasonry.js'
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
hasMore: { type: Boolean, default: false }
|
||||
hasMore: { type: Boolean, default: false },
|
||||
// Items at indices >= animateFromIndex get the stagger fade-in. Opt-in
|
||||
// — defaults to Infinity (no animation) so views that don't want it
|
||||
// (ArtistView, etc.) don't pay the layout-shift cost. ShowcaseView
|
||||
// uses 0 on initial load / shuffle and prevCount on infinite-scroll
|
||||
// appends.
|
||||
animateFromIndex: { type: Number, default: Number.POSITIVE_INFINITY },
|
||||
})
|
||||
const emit = defineEmits(['load-more', 'open'])
|
||||
|
||||
@@ -43,6 +52,25 @@ const { columnCount, distribute } = usePolyMasonry(containerEl)
|
||||
|
||||
const columns = computed(() => distribute(props.items, columnCount.value))
|
||||
|
||||
// id → index lookup so we can derive the stagger from natural reading
|
||||
// order even after the masonry distributes items across columns.
|
||||
const idxById = computed(() => {
|
||||
const m = new Map()
|
||||
props.items.forEach((it, i) => m.set(it.id, i))
|
||||
return m
|
||||
})
|
||||
|
||||
function shouldAnimate(item) {
|
||||
const idx = idxById.value.get(item.id)
|
||||
return idx !== undefined && idx >= props.animateFromIndex
|
||||
}
|
||||
|
||||
function itemStyle(item) {
|
||||
if (!shouldAnimate(item)) return {}
|
||||
const idx = idxById.value.get(item.id) - props.animateFromIndex
|
||||
return { '--stagger-index': idx }
|
||||
}
|
||||
|
||||
function aspectStyle(item) {
|
||||
const w = Number(item.width)
|
||||
const h = Number(item.height)
|
||||
@@ -81,4 +109,23 @@ onUnmounted(() => observer && observer.disconnect())
|
||||
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
||||
}
|
||||
.fc-masonry__end { text-align: center; padding: 32px 0; }
|
||||
|
||||
/* IR-parity stagger fade-in for showcase entry / shuffle. 60ms between
|
||||
items, 250ms each — matches IR's `itemFadeIn` keyframe (style.css
|
||||
~line 1834). Honors prefers-reduced-motion. */
|
||||
@keyframes fc-masonry-item-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fc-masonry__item--anim {
|
||||
animation: fc-masonry-item-in 0.25s ease forwards;
|
||||
animation-delay: calc(var(--stagger-index, 0) * 60ms);
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fc-masonry__item--anim {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
:items="store.images"
|
||||
:loading="store.loading"
|
||||
:has-more="store.hasMore"
|
||||
:animate-from-index="animateFromIndex"
|
||||
@load-more="store.fetchPage()"
|
||||
@open="openImage"
|
||||
/>
|
||||
@@ -25,17 +26,52 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useShowcaseStore } from '../stores/showcase.js'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import MasonryGrid from '../components/discovery/MasonryGrid.vue'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import { useShowcaseStore } from '../stores/showcase.js'
|
||||
|
||||
const store = useShowcaseStore()
|
||||
const modal = useModalStore()
|
||||
|
||||
onMounted(() => { if (store.images.length === 0) store.fetchPage() })
|
||||
// Track when items were appended vs replaced so MasonryGrid only animates
|
||||
// items new to the current batch (mirrors IR's behavior: animate on
|
||||
// initial load and on shuffle, but skip silent infinite-scroll appends).
|
||||
const animateFromIndex = ref(0)
|
||||
let prevCount = 0
|
||||
watch(() => store.images.length, (newCount) => {
|
||||
if (newCount < prevCount || prevCount === 0) {
|
||||
// Reset (shuffle) or initial load — animate everything from 0.
|
||||
animateFromIndex.value = 0
|
||||
} else {
|
||||
// Append — only animate the newly-added tail.
|
||||
animateFromIndex.value = prevCount
|
||||
}
|
||||
prevCount = newCount
|
||||
})
|
||||
|
||||
function openImage(id) {
|
||||
modal.open(id)
|
||||
}
|
||||
|
||||
// IR-parity keyboard shuffle: press R anywhere on the page (not inside a
|
||||
// text input, not while a Vuetify overlay is open) to reshuffle.
|
||||
function onKeydown(e) {
|
||||
const t = e.target
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return
|
||||
// Vuetify marks the active overlay (v-dialog, v-menu) on the body when
|
||||
// open. Skip shuffle when a modal is in the way.
|
||||
if (document.querySelector('.v-overlay--active')) return
|
||||
if (e.key === 'r' || e.key === 'R') {
|
||||
e.preventDefault()
|
||||
store.shuffle()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.images.length === 0) store.fetchPage()
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
@@ -66,6 +66,10 @@ async def test_min_dimension_preview_returns_count(client, db, tmp_path):
|
||||
assert resp.status_code == 200
|
||||
body = await resp.get_json()
|
||||
assert body["count"] == 1
|
||||
# Preview hands the canonical Tier-C confirm token back so the
|
||||
# frontend doesn't have to recompute SHA-256 client-side
|
||||
# (crypto.subtle is Secure-Context-gated; FC runs over plain HTTP).
|
||||
assert body["confirm_token"] == _sha256_min_dim_token(200, 200)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user