12be188ada
**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.
132 lines
4.2 KiB
Vue
132 lines
4.2 KiB
Vue
<template>
|
|
<div class="fc-masonry" ref="containerEl">
|
|
<div class="fc-masonry__cols">
|
|
<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"
|
|
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
|
|
:style="itemStyle(item)"
|
|
type="button"
|
|
@click="$emit('open', item.id)"
|
|
>
|
|
<img
|
|
:src="item.thumbnail_url" :alt="`image ${item.id}`"
|
|
loading="lazy"
|
|
:style="aspectStyle(item)"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="fc-masonry__sentinel">
|
|
<v-progress-circular indeterminate color="accent" size="28" />
|
|
</div>
|
|
<div v-else-if="hasMore" ref="sentinelEl" class="fc-masonry__sentinel" />
|
|
<div v-else-if="items.length" class="fc-masonry__end text-caption">
|
|
End.
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
import { usePolyMasonry } from '../../composables/usePolyMasonry.js'
|
|
|
|
const props = defineProps({
|
|
items: { type: Array, default: () => [] },
|
|
loading: { 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'])
|
|
|
|
const containerEl = ref(null)
|
|
const sentinelEl = ref(null)
|
|
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)
|
|
if (!w || !h) return {}
|
|
return { aspectRatio: `${w} / ${h}` }
|
|
}
|
|
|
|
let observer = null
|
|
function attachObserver() {
|
|
if (observer) observer.disconnect()
|
|
if (!sentinelEl.value) return
|
|
observer = new IntersectionObserver(([entry]) => {
|
|
if (entry.isIntersecting && props.hasMore && !props.loading) {
|
|
emit('load-more')
|
|
}
|
|
}, { rootMargin: '600px' })
|
|
observer.observe(sentinelEl.value)
|
|
}
|
|
watch(sentinelEl, attachObserver)
|
|
onMounted(attachObserver)
|
|
onUnmounted(() => observer && observer.disconnect())
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-masonry__cols { display: flex; gap: 8px; align-items: flex-start; }
|
|
.fc-masonry__col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
.fc-masonry__item {
|
|
display: block; padding: 0; border: 0; background: none;
|
|
cursor: pointer; width: 100%;
|
|
}
|
|
.fc-masonry__item img {
|
|
width: 100%; height: auto; display: block; border-radius: 4px;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-masonry__sentinel {
|
|
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>
|