Files
FabledCurator/frontend/src/components/gallery/GalleryItem.vue
T
bvandeusen 1ac448d881
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 4m57s
refactor: four small cleanups from the review pass (#3072)
Items 2-5 of #3072. Item 1 (the per-row sweep inserts) is separate.

2. .fc-bad was not merely duplicated — it is .fc-weak under a second
   name. Both local definitions were `color: rgb(var(--v-theme-error))`,
   identical to the global .fc-weak, and GpuAgentCard was already using
   .fc-weak to colour exactly what GpuActivityPanel coloured .fc-bad (an
   errored count, red when non-zero). So rather than promoting a synonym
   to app.css, both call sites now use .fc-weak and the local defs are
   gone. app.css's status-colour comment records why there is no .fc-bad,
   next to the existing note on why .fc-ok is deliberately NOT global.

3. GalleryItem.vue's obsidian literals now use --v-theme-background,
   which IS obsidian (vuetify-theme.js maps background -> surfaces.
   obsidian). Preferred over --fc-chrome-rgb: same value, but that
   variable is named for the nav fade, not for the palette entry.

   The ticket said these were the only three real uses in the tree. They
   are not — GalleryItem itself had two more in the artist-label
   gradient (fixed here, so the file is now consistent), and ~13 more
   live in SeriesView, SeriesReaderView, ImageViewer, ArtistHeader,
   ExploreView and GalleryFilterBar. Those are a separate sweep, filed
   rather than folded in here.

4. The attachment download path had two hand-formatted copies. One
   definition now, `attachment_download_url`, next to the model both
   serializers already import. The test pins it by MATCHING the built
   path against the app's real URL map rather than comparing to a
   literal — a string-equality test would still pass after someone
   renamed the route, which is the drift the helper exists to prevent.

5. Extension API key now compares with hmac.compare_digest. Compared as
   BYTES, not str: compare_digest's str form raises TypeError on
   non-ASCII, and this value comes straight from an attacker-controlled
   header, so the str form would turn a junk key into a 500 instead of a
   403. Low stakes either way — the API is unauthenticated-by-design on
   a LAN — but it costs nothing.

Refs #3072
2026-08-27 07:48:18 -04:00

177 lines
6.0 KiB
Vue

<template>
<v-card
class="fc-gallery-item" elevation="0"
:class="{ 'fc-gallery-item--selected': selected }"
@click="onClick"
:aria-label="`Image ${image.id}`" tabindex="0"
@keydown.enter="onClick"
@keydown.space.prevent="onClick"
>
<div class="fc-gallery-item__media">
<img
v-if="!isVideo" ref="imgEl" :src="image.thumbnail_url"
:alt="`Image ${image.id}`" loading="lazy"
:class="{ 'is-loaded': loaded }"
@load="loaded = true" @error="onThumbError"
>
<div v-else class="fc-gallery-item__video-thumb">
<img
ref="imgEl" :src="image.thumbnail_url" :alt="`Video ${image.id}`"
loading="lazy" :class="{ 'is-loaded': loaded }" @load="loaded = true"
>
<v-icon class="fc-gallery-item__video-badge" icon="mdi-play-circle" />
</div>
<div v-if="thumbError" class="fc-gallery-item__placeholder">
<v-icon size="32" icon="mdi-image-broken-variant" />
</div>
<template v-if="sel.isSelectMode">
<div class="fc-gallery-item__checkbox" :class="{ on: selected }">
<v-icon v-if="selected" size="16" icon="mdi-check" />
</div>
<div v-if="selected" class="fc-gallery-item__order">{{ orderNum }}</div>
</template>
<div v-if="image.artist" class="fc-gallery-item__artist">
<RouterLink
v-if="!sel.isSelectMode"
:to="`/artist/${image.artist.slug}`"
@click.stop
>{{ image.artist.name }}</RouterLink>
<span v-else>{{ image.artist.name }}</span>
</div>
</div>
</v-card>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
const props = defineProps({ image: { type: Object, required: true } })
const emit = defineEmits(['open'])
const sel = useGallerySelectionStore()
const thumbError = ref(false)
// Reveal each tile when ITS OWN thumbnail finishes loading (`@load`), not
// when its metadata batch lands — so tiles fade/flip in individually in
// load order instead of popping in together (operator-flagged 2026-06-04).
const loaded = ref(false)
const imgEl = ref(null)
onMounted(() => {
// A cached thumbnail can already be complete before @load binds; reflect
// that so the tile reveals instead of sitting invisible at opacity 0.
const el = imgEl.value
if (el && el.complete && el.naturalWidth > 0) loaded.value = true
})
const isVideo = computed(
() => props.image.mime && props.image.mime.startsWith('video/')
)
const selected = computed(() => sel.isSelected(props.image.id))
const orderNum = computed(() => sel.order.indexOf(props.image.id) + 1)
function onClick() {
if (sel.isSelectMode) sel.toggle(props.image.id)
else emit('open', props.image.id)
}
function onThumbError() { thumbError.value = true }
</script>
<style scoped>
.fc-gallery-item {
cursor: pointer;
overflow: hidden;
background: rgb(var(--v-theme-surface));
transition: transform 120ms ease, box-shadow 120ms ease;
}
.fc-gallery-item:hover, .fc-gallery-item:focus-visible {
transform: translateY(-1px);
outline: 2px solid rgb(var(--v-theme-accent));
}
.fc-gallery-item--selected {
outline: 3px solid rgb(var(--v-theme-accent));
outline-offset: -3px;
}
.fc-gallery-item__media {
position: relative;
aspect-ratio: 1;
overflow: hidden;
background: rgb(var(--v-theme-surface-light));
}
.fc-gallery-item__media img {
width: 100%; height: 100%; object-fit: cover; display: block;
opacity: 0;
}
/* Entrance: each thumbnail flips up out of a slight backward tilt and
settles into place, played WHEN ITS OWN image finishes loading (the
`is-loaded` class) rather than on a batch/timer — so tiles cascade in
natural load order instead of popping in together. Mirrors the showcase
MasonryGrid entrance styling (operator-flagged 2026-06-04). */
.fc-gallery-item__media img.is-loaded {
animation: fc-gallery-item-in 0.5s cubic-bezier(0.34, 1.45, 0.64, 1) both;
}
@keyframes fc-gallery-item-in {
0% {
opacity: 0;
transform: perspective(1000px) rotateX(-22deg) translateY(20px) scale(0.96);
}
55% { opacity: 1; }
100% {
opacity: 1;
transform: perspective(1000px) rotateX(0) translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.fc-gallery-item__media img { opacity: 1; }
.fc-gallery-item__media img.is-loaded { animation: none; }
}
.fc-gallery-item__video-thumb { position: relative; height: 100%; }
.fc-gallery-item__video-badge {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: rgba(232, 228, 216, 0.85);
font-size: 36px;
pointer-events: none;
}
.fc-gallery-item__placeholder {
position: absolute; inset: 0; display: grid; place-items: center;
background: rgb(var(--v-theme-surface-light));
}
.fc-gallery-item__checkbox {
position: absolute; top: 8px; left: 8px;
width: 22px; height: 22px; border-radius: 4px;
border: 2px solid rgba(232, 228, 216, 0.8);
background: rgba(var(--v-theme-background), 0.45);
display: grid; place-items: center;
color: rgb(var(--v-theme-background)); z-index: 11;
}
.fc-gallery-item__checkbox.on {
background: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-gallery-item__order {
position: absolute; top: 6px; right: 6px;
min-width: 22px; height: 22px; padding: 0 5px;
border-radius: 11px;
background: rgb(var(--v-theme-accent));
color: rgb(var(--v-theme-background)); font-size: 12px; font-weight: 700;
display: grid; place-items: center; z-index: 11;
pointer-events: none;
}
.fc-gallery-item__artist {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 14px 8px 6px;
background: linear-gradient(
to top, rgba(var(--v-theme-background), 0.78),
rgba(var(--v-theme-background), 0)
);
font-size: 12px; line-height: 1.2;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
pointer-events: none;
}
.fc-gallery-item__artist a {
color: rgb(var(--v-theme-parchment, 232 228 216));
text-decoration: none;
pointer-events: auto;
}
.fc-gallery-item__artist span { color: rgba(232, 228, 216, 0.85); }
</style>