feat(SPA): media library indications for custom medial field type usage

This commit is contained in:
Florian Sylvain committed 2026-10-21 16:17:09 +02:00
1 parent 3e7fc9a4a5
commit 0fc3511e2b
28 files changed
+2060 -131

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
@import '@nuxt/ui';
@theme static {
--aspect-panel-banner: 16 / 3.6;
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
--font-serif: 'Source Serif 4', ui-serif, Georgia, serif;
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
+6 -6
View File
@@ -6,9 +6,9 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaBadge from '@/components/MediaBadge.vue'
import VariantRemovalNote from '@/components/variants/VariantRemovalNote.vue'
import type { MediaItem } from '@/composables/useMedia'
import { pluralArticles } from '@/lib/mediaFormat'
import { pluralPlaces } from '@/lib/mediaFormat'
const BROKEN_IMAGE = 'which will show a broken image.'
const BROKEN_IMAGE = 'which will show a broken image or a missing-image notice.'
const FINAL_WARNING = "This can't be undone."
const props = defineProps<{
@@ -27,11 +27,11 @@ const variantCount = computed(() =>
const variantBytes = computed(() =>
props.items.reduce((sum, item) => sum + (item.variant_size ?? 0), 0),
)
const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
const placeCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
const description = computed(() => {
const usedCount = inUse.value.length
const unusedCount = total.value - usedCount
const articles = pluralArticles(articleCount.value)
const places = pluralPlaces(placeCount.value)
if (usedCount === 0) {
return total.value === 1
? t("It isn't used in any article. {0}", [t(FINAL_WARNING)])
@@ -39,7 +39,7 @@ const description = computed(() => {
}
if (unusedCount === 0) {
const subject = total.value === 1 ? t("It's") : t('All {0} are', [total.value])
return t('{0} used in {1}, {2} {3}', [subject, articles, t(BROKEN_IMAGE), t(FINAL_WARNING)])
return t('{0} used in {1}, {2} {3}', [subject, places, t(BROKEN_IMAGE), t(FINAL_WARNING)])
}
const rest =
unusedCount === 1
@@ -47,7 +47,7 @@ const description = computed(() => {
: t("The other {0} aren't used anywhere.", [unusedCount])
return t('{0} of them are used in {1}, {2} {3} {4}', [
usedCount,
articles,
places,
t(BROKEN_IMAGE),
rest,
t(FINAL_WARNING),
+41 -22
View File
@@ -5,38 +5,25 @@ import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import VariantRemovalNote from '@/components/variants/VariantRemovalNote.vue'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import { pluralArticles } from '@/lib/mediaFormat'
import type { MediaItem, UsageState } from '@/composables/useMedia'
import { deleteCopy } from '@/lib/mediaUsage'
const props = defineProps<{
item: MediaItem
usage: MediaUsageItem[]
usage: UsageState | null
action: () => Promise<void>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const inUse = computed(() => props.item.used_in > 0)
const title = computed(() =>
inUse.value ? t("Delete an image that's in use?") : t('Delete this image?'),
)
const description = computed(() =>
inUse.value
? t(
"\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.",
[props.item.filename, pluralArticles(props.item.used_in)],
)
: t(
"\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.",
[props.item.filename],
),
)
const copy = computed(() => deleteCopy(props.item.filename, props.item.used_in, props.usage))
const split = computed(() => copy.value.split)
</script>
<template>
<ConfirmDialog
:title="title"
:description="description"
:title="copy.title"
:description="copy.description"
:confirm-label="t('Delete image')"
:cancel-label="t('Cancel')"
:pending-label="t('Deleting…')"
@@ -45,9 +32,41 @@ const description = computed(() =>
@close="emit('close', $event)"
>
<template #body>
<ul v-if="inUse && usage.length > 0" class="border border-default">
<div v-if="split && split.entryCount > 0" class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">{{ t('Used in entries') }}</h3>
<ul data-testid="affected-entries" class="border border-default">
<li
v-for="(entry, index) in split.entries"
:key="entry.id"
data-testid="affected-entry"
class="flex h-12 items-center gap-2 px-3 sm:h-9"
:class="{ 'border-t border-muted': index > 0 }"
>
<span class="w-13 shrink-0 truncate text-xs text-muted">{{
entry.typeName
}}</span>
<span
:title="entry.title"
class="min-w-0 flex-1 truncate font-medium text-highlighted"
>{{ entry.title }}</span
>
</li>
<li
v-if="split.more > 0"
data-testid="affected-more"
class="border-t border-muted px-3 py-2 text-muted"
>
{{ t('and {0} more', [split.more]) }}
</li>
</ul>
</div>
<ul
v-if="split && split.articles.length > 0"
data-testid="affected-articles"
class="border border-default"
>
<li
v-for="(article, index) in usage"
v-for="(article, index) in split.articles"
:key="article.id"
data-testid="affected-article"
class="flex min-h-9 items-center gap-2 px-3"
+16 -2
View File
@@ -2,16 +2,30 @@
import { t } from '@/i18n'
import MediaBadge from '@/components/MediaBadge.vue'
import MediaUsageTooltip from '@/components/MediaUsageTooltip.vue'
defineProps<{
item: { duplicate: boolean; used_in: number; variant_count?: number }
item: { id?: number; duplicate: boolean; used_in: number; variant_count?: number }
variants?: boolean
tooltip?: boolean
focused?: boolean
}>()
</script>
<template>
<MediaBadge v-if="item.duplicate" kind="duplicate" :label="t('Duplicate')" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="t('Used in {0}', [item.used_in])" />
<MediaUsageTooltip
v-if="item.used_in > 0 && tooltip && item.id !== undefined"
:id="item.id"
:focused="focused"
>
<MediaBadge kind="used" :label="t('Used in {0}', [item.used_in])" />
</MediaUsageTooltip>
<MediaBadge
v-else-if="item.used_in > 0"
kind="used"
:label="t('Used in {0}', [item.used_in])"
/>
<MediaBadge v-else kind="unused" :label="t('Unused')" />
<MediaBadge
v-if="variants && (item.variant_count ?? 0) > 0"
+179 -45
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import MediaVariantsTab from '@/components/variants/MediaVariantsTab.vue'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import { MEDIA_SKELETON_DELAY_MS, type MediaItem, type UsageState } from '@/composables/useMedia'
import {
formatDimensions,
formatMediaDate,
@@ -15,6 +15,7 @@ import {
joinNames,
pluralArticles,
} from '@/lib/mediaFormat'
import { usageSplitOf } from '@/lib/mediaUsage'
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
@@ -24,7 +25,7 @@ const ACTION_BUTTON =
const props = defineProps<{
item: MediaItem | null
usage: MediaUsageItem[]
usage: UsageState | null
copies: MediaItem[]
offline: boolean
}>()
@@ -34,6 +35,7 @@ const emit = defineEmits<{
rename: []
delete: []
'review-duplicates': []
'retry-usage': []
gone: []
}>()
@@ -75,6 +77,66 @@ watch(
},
)
const SKELETON_ROWS = [0, 1, 2]
const ROW_CLASS = 'flex h-12 items-center gap-2 px-3 md:h-9'
const ENTRY_LINK_CLASS =
'min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const HEADING_CLASS = 'text-base font-semibold text-highlighted'
const split = computed(() => usageSplitOf(props.usage))
const usageLoading = computed(
() =>
(props.item?.used_in ?? 0) > 0 &&
(props.usage === null || props.usage.status === 'loading'),
)
const usageFailed = computed(
() =>
props.usage?.status === 'failed' &&
props.usage.failure !== 'handled' &&
props.usage.failure !== 'not-found',
)
const hasEntries = computed(() => (split.value?.entryCount ?? 0) > 0)
const articleCount = computed(() => split.value?.articles.length ?? 0)
const skeletonShown = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
watch(
[usageLoading, () => props.item?.id],
() => {
clearSkeletonTimer()
skeletonShown.value = false
if (usageLoading.value) {
skeletonTimer = setTimeout(() => {
skeletonShown.value = true
}, MEDIA_SKELETON_DELAY_MS)
}
},
{ immediate: true },
)
onBeforeUnmount(clearSkeletonTimer)
const articlesHeading = computed(() => {
if (usageLoading.value || props.usage?.status === 'failed') {
return t('Used in')
}
return articleCount.value > 0
? t('Used in {0}', [pluralArticles(articleCount.value)])
: t('Not used in any article')
})
const noArticleLine = computed(() =>
props.item?.used_in === 0
? t('This image is safe to delete. No article shows it.')
: t('No article shows it.'),
)
const closeButton = computed(() => ({
color: 'neutral' as const,
variant: 'ghost' as const,
@@ -195,7 +257,12 @@ const duplicateAlert = computed(() => {
<MediaThumb
:item="item"
size="xl"
class="aspect-4/3 w-full border border-default md:aspect-16/10"
class="w-full border border-default"
:class="
hasEntries
? 'aspect-panel-banner md:aspect-16/6'
: 'aspect-4/3 md:aspect-16/10'
"
/>
<div class="flex gap-2 max-md:flex-wrap">
@@ -274,52 +341,119 @@ const duplicateAlert = computed(() => {
</dd>
</dl>
<section class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">
{{
item.used_in > 0
? t('Used in {0}', [pluralArticles(item.used_in)])
: t('Not used in any article')
}}
</h3>
<template v-if="item.used_in > 0">
<ul v-if="usage.length > 0" class="border border-default">
<section class="flex flex-col gap-2" data-testid="usage-articles">
<h3 :class="HEADING_CLASS">{{ articlesHeading }}</h3>
<template v-if="item.used_in === 0">
<p class="text-muted">{{ noArticleLine }}</p>
</template>
<template v-else-if="usageLoading">
<ul
v-if="skeletonShown"
data-testid="usage-skeleton"
class="border border-default"
>
<li
v-for="(article, index) in usage"
:key="article.id"
data-testid="usage-row"
class="flex min-h-12 items-center gap-2 px-3 md:min-h-10"
:class="{
'border-t border-muted': index > 0,
}"
v-for="row in SKELETON_ROWS"
:key="row"
:class="[ROW_CLASS, { 'border-t border-muted': row > 0 }]"
>
<RouterLink
:to="`/article/${article.id}/edit`"
class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3"
>
{{ article.title }}
</RouterLink>
<MediaArticleStatusBadge
:published="article.published"
size="md"
/>
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
<USkeleton class="h-3 w-full rounded-none bg-accented" />
</li>
</ul>
<p class="text-xs text-muted">
{{
t(
'Counted from the text of your articles each time you open this page.',
)
}}
</p>
</template>
<p v-else class="text-muted">
{{ t('This image is safe to delete. No article shows it.') }}
</p>
<template v-else-if="usageFailed">
<div data-testid="usage-error" class="flex flex-col items-start gap-2">
<p class="text-muted">
{{ t("Couldn't load where this image is used.") }}
</p>
<UButton
color="neutral"
variant="ghost"
class="h-11 px-3 md:h-8"
@click="emit('retry-usage')"
>
{{ t('Try again') }}
</UButton>
</div>
</template>
<template v-else-if="split">
<template v-if="split.articles.length > 0">
<ul class="border border-default">
<li
v-for="(article, index) in split.articles"
:key="article.id"
data-testid="usage-row"
class="flex min-h-12 items-center gap-2 px-3 md:min-h-10"
:class="{
'border-t border-muted': index > 0,
}"
>
<RouterLink
:to="`/article/${article.id}/edit`"
class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3"
>
{{ article.title }}
</RouterLink>
<MediaArticleStatusBadge
:published="article.published"
size="md"
/>
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
</li>
</ul>
<p class="text-xs text-muted">
{{
t(
'Counted from the text of your articles each time you open this page.',
)
}}
</p>
</template>
<p v-else class="text-muted">{{ t('No article shows it.') }}</p>
</template>
</section>
<section
v-if="split && hasEntries"
data-testid="usage-entries"
class="flex flex-col gap-2"
>
<h3 :class="HEADING_CLASS">{{ t('Used in entries') }}</h3>
<ul class="border border-default">
<li
v-for="(entry, index) in split.entries"
:key="entry.id"
data-testid="usage-entry-row"
:class="[ROW_CLASS, { 'border-t border-muted': index > 0 }]"
>
<span class="w-13 shrink-0 truncate text-xs text-muted">{{
entry.typeName
}}</span>
<RouterLink
:to="`/content/${entry.key}/${entry.id}`"
:title="entry.title"
:aria-label="`${entry.title}, ${entry.typeName}`"
:class="ENTRY_LINK_CLASS"
>
{{ entry.title }}
</RouterLink>
<UIcon
name="i-lucide-arrow-up-right"
aria-hidden="true"
class="size-3.5 shrink-0 text-muted"
/>
</li>
<li
v-if="split.more > 0"
data-testid="usage-more"
class="border-t border-muted px-3 py-2 text-muted"
>
{{ t('and {0} more', [split.more]) }}
</li>
</ul>
</section>
</div>
</div>
+20 -1
View File
@@ -25,6 +25,7 @@ const emit = defineEmits<{
}>()
const grid = ref<HTMLElement | null>(null)
const focusedId = ref<number | null>(null)
function cards() {
return Array.from(grid.value?.querySelectorAll<HTMLElement>('[data-media-card]') ?? [])
@@ -66,6 +67,17 @@ function onKeydown(event: KeyboardEvent, item: MediaItem, index: number) {
cards()[target]?.focus()
}
function onFocus(event: FocusEvent, id: number) {
const target = event.currentTarget as HTMLElement
focusedId.value = target.matches(':focus-visible') ? id : null
}
function onBlur(id: number) {
if (focusedId.value === id) {
focusedId.value = null
}
}
function onKeyup(event: KeyboardEvent) {
if (event.key === ' ') {
event.preventDefault()
@@ -91,6 +103,8 @@ function onKeyup(event: KeyboardEvent) {
@click="emit('open', item.id, $event.currentTarget as HTMLElement)"
@keydown="onKeydown($event, item, index)"
@keyup="onKeyup"
@focus="onFocus($event, item.id)"
@blur="onBlur(item.id)"
>
<MediaThumb
:item="item"
@@ -117,7 +131,12 @@ function onKeyup(event: KeyboardEvent) {
>
</span>
<span class="flex flex-wrap gap-1">
<MediaCardBadges :item="item" variants />
<MediaCardBadges
:item="item"
variants
tooltip
:focused="focusedId === item.id"
/>
</span>
</span>
</button>
+2 -8
View File
@@ -6,13 +6,7 @@ import MediaCheckbox from '@/components/MediaCheckbox.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { SelectAllState } from '@/composables/useMediaSelection'
import {
formatDimensions,
formatMediaDate,
formatSize,
formatType,
pluralArticles,
} from '@/lib/mediaFormat'
import { formatDimensions, formatMediaDate, formatSize, formatType } from '@/lib/mediaFormat'
const ROW_GRID =
'grid grid-cols-[36px_minmax(0,1.7fr)_110px_76px_64px_120px_130px_100px] items-center pr-3'
@@ -109,7 +103,7 @@ function open(event: Event, id: number) {
class="relative z-10 text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="open($event, item.id)"
>
{{ pluralArticles(item.used_in) }}
{{ t('Used in {0}', [item.used_in]) }}
</button>
<MediaBadge v-else kind="unused" :label="t('Unused')" />
</span>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { useEventListener, useMediaQuery } from '@vueuse/core'
import { MEDIA_USAGE_KEY } from '@/lib/mediaUsageContext'
import { tooltipLines } from '@/lib/mediaUsage'
const DESKTOP_QUERY = '(min-width: 48rem)'
const TOOLTIP_UI = {
content:
'h-auto flex-col items-start gap-0 bg-inverted px-2 py-1.5 text-xs/4.5 text-inverted shadow-(--shadow-popover) ring-0',
}
const TOOLTIP_CONTENT = { side: 'bottom', align: 'start', sideOffset: 6 } as const
const props = defineProps<{
id: number
focused?: boolean
}>()
const usage = inject(MEDIA_USAGE_KEY, null)
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const hovered = ref(false)
const dismissed = ref(false)
const engaged = computed(() => hovered.value || props.focused === true)
const lines = computed(() => {
const state = usage?.stateOf(props.id)
return state?.status === 'ready' ? tooltipLines(state.items) : []
})
const open = computed(
() => isDesktop.value && engaged.value && !dismissed.value && lines.value.length > 0,
)
watch(engaged, (value) => {
if (!value) {
dismissed.value = false
return
}
if (isDesktop.value) {
usage?.ensure(props.id)
}
})
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'Escape' && open.value) {
dismissed.value = true
}
})
</script>
<template>
<span
class="inline-flex"
data-testid="media-usage-trigger"
@pointerenter="hovered = true"
@pointerleave="hovered = false"
>
<UTooltip
v-if="open"
open
:content="TOOLTIP_CONTENT"
:ui="TOOLTIP_UI"
@update:open="() => undefined"
>
<span class="inline-flex"><slot /></span>
<template #content>
<span
v-for="line in lines"
:key="line"
data-testid="media-usage-line"
class="whitespace-nowrap"
>{{ line }}</span
>
</template>
</UTooltip>
<slot v-else />
</span>
</template>
+4 -4
View File
@@ -6,7 +6,7 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { mergeArticleCount } from '@/lib/mediaDuplicates'
import { pluralArticles } from '@/lib/mediaFormat'
import { pluralPlaces } from '@/lib/mediaFormat'
const props = defineProps<{
keep: MediaItem
@@ -20,11 +20,11 @@ const otherCount = computed(() => props.copies.length - 1)
const others = computed(() =>
otherCount.value === 1 ? t('copy') : t('{0} copies', [otherCount.value]),
)
const articles = computed(() => mergeArticleCount(props.copies, props.keep.id))
const places = computed(() => mergeArticleCount(props.copies, props.keep.id))
const description = computed(() =>
t(
"Articles that use the other {0} will be switched to {1}, and the other {2} will be deleted. {3} will be updated. This can't be undone.",
[others.value, props.keep.filename, others.value, pluralArticles(articles.value)],
[others.value, props.keep.filename, others.value, pluralPlaces(places.value)],
),
)
</script>
@@ -48,7 +48,7 @@ const description = computed(() =>
<div class="flex min-w-0 flex-col">
<span class="truncate font-medium text-highlighted">{{ keep.filename }}</span>
<span class="text-xs/5 text-muted"
>{{ t('Kept · used in') }} {{ pluralArticles(keep.used_in) }}
>{{ t('Kept · used in') }} {{ pluralPlaces(keep.used_in) }}
{{ t('now') }}</span
>
</div>
@@ -209,8 +209,8 @@ describe('Load more is hidden on the last page', () => {
describe("A tile's accessible name", () => {
it.each([
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles'],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article'],
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 places'],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 place'],
['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused'],
])('names %s', async (filename, name) => {
await openLibrary()
+75 -5
View File
@@ -1,4 +1,4 @@
import { ref } from 'vue'
import { reactive, ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core'
import { ApiError, useApi } from '@/composables/useApi'
import {
@@ -34,12 +34,28 @@ export interface MediaCounts {
duplicates: number
}
export interface MediaUsageItem {
id: number
title: string
published: boolean
export interface UsageFirstField {
key: string
type: string
value: unknown
}
export interface MediaUsageItem {
type?: 'article' | 'author' | 'site' | 'entry'
id: number
title?: string
published?: boolean
fields?: string[]
content_type_key?: string
content_type_name?: string
first_field?: UsageFirstField
}
export type UsageState =
| { status: 'loading' }
| { status: 'ready'; items: MediaUsageItem[] }
| { status: 'failed'; failure: MediaFailure }
export interface MediaMergeResult {
articles_updated: number
merged: number
@@ -105,6 +121,9 @@ export function useMedia() {
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let latestLoad = 0
const usageStates = reactive<Record<number, UsageState>>({})
const usageRequests = new Map<number, Promise<void>>()
let usageGeneration = 0
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
@@ -179,6 +198,7 @@ export function useMedia() {
return { kind: 'stale' }
}
items.value = response.items
clearUsage()
total.value = response.total
counts.value = response.counts
status.value = 'ready'
@@ -204,6 +224,54 @@ export function useMedia() {
return request<MediaUsageItem[]>(`/v1/media/${id}/usage`)
}
function clearUsage() {
usageGeneration += 1
usageRequests.clear()
for (const id of Object.keys(usageStates)) {
delete usageStates[Number(id)]
}
}
function usageOf(id: number): UsageState | null {
return usageStates[id] ?? null
}
function ensureUsage(id: number, retryFailed = false) {
const current = usageStates[id]
const pending = usageRequests.get(id)
if (pending && current?.status !== 'failed') {
return pending
}
if (current && !(retryFailed && current.status === 'failed')) {
return Promise.resolve()
}
const generation = usageGeneration
usageStates[id] = { status: 'loading' }
const requested = fetchUsage(id)
.then(
(rows) => {
if (generation === usageGeneration) {
usageStates[id] = {
status: 'ready',
items: Array.isArray(rows) ? rows : [],
}
}
},
(error: unknown) => {
if (generation === usageGeneration) {
usageStates[id] = { status: 'failed', failure: classifyMediaError(error) }
}
},
)
.finally(() => {
if (usageRequests.get(id) === requested) {
usageRequests.delete(id)
}
})
usageRequests.set(id, requested)
return requested
}
async function findCopies(item: MediaItem) {
const response = await request<MediaListResponse>(
`/v1/media/?${toHashRequestQuery(item.sha256)}`,
@@ -294,6 +362,8 @@ export function useMedia() {
showSkeleton,
load,
fetchUsage,
usageOf,
ensureUsage,
findCopies,
renameItem,
deleteItem,
+49
View File
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { messages } from './messages'
const SOURCES = [
'components/MediaDetailPanel.vue',
'components/DeleteMediaDialog.vue',
'components/MediaCardBadges.vue',
'components/BulkDeleteMediaDialog.vue',
'components/MergeMediaDialog.vue',
'lib/mediaUsage.ts',
'lib/mediaFormat.ts',
]
const SAME_IN_FRENCH = new Set<string>(['Type', 'Dimensions', '{0} copies'])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\u0022/g, '"').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Media usage messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of the usage UI', () => {
expect(keys.length).toBeGreaterThan(30)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has its own French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(key as string)) {
expect(french).not.toBe(key)
}
},
)
})
+16 -1
View File
@@ -508,7 +508,22 @@ export const messages = {
"This can't be undone.": 'Cette action est irréversible.',
'None of them are used in any article. {0}': 'Aucune n’est utilisée dans un article. {0}',
"It isn't used in any article. {0}": 'Elle n’est utilisée dans aucun article. {0}',
'which will show a broken image.': 'qui afficheront une image manquante.',
'which will show a broken image or a missing-image notice.':
'qui afficheront une image manquante ou un avis d’image absente.',
'Used in articles: {0}': 'Utilisée dans les articles : {0}',
'Used in entries: {0}': 'Utilisée dans les entrées : {0}',
'Used in entries': 'Utilisée dans les entrées',
'and {0} more': 'et {0} autres',
'1 place': '1 emplacement',
'{0} places': '{0} emplacements',
'No article shows it.': 'Aucun article ne l’affiche.',
"Couldn't load where this image is used.": 'Impossible de charger où cette image est utilisée.',
'"{0}" is used in {1} and {2}. Those entry fields will show "This image is no longer in the library", and the articles will show a broken image. This can\'t be undone.':
'« {0} » est utilisée dans {1} et {2}. Ces champs d’entrée afficheront « Cette image n’est plus dans la bibliothèque », et les articles afficheront une image manquante. Cette action est irréversible.',
'"{0}" is used in {1}. Those entry fields will show "This image is no longer in the library". This can\'t be undone.':
'« {0} » est utilisée dans {1}. Ces champs d’entrée afficheront « Cette image n’est plus dans la bibliothèque ». Cette action est irréversible.',
'"{0}" is in use. Articles will show a broken image and entry fields will show "This image is no longer in the library". This can\'t be undone.':
'« {0} » est utilisée. Les articles afficheront une image manquante et les champs d’entrée afficheront « Cette image n’est plus dans la bibliothèque ». Cette action est irréversible.',
'{0} of them are used in {1}, {2} {3} {4}': '{0} sont utilisées dans {1}, {2} {3} {4}',
'{0} deleted': '{0} supprimées',
'{0} deleted. {1} {2} still in your library. Try again.':
+4 -4
View File
@@ -59,15 +59,15 @@ describe('formatType', () => {
describe('usedInLabel and accessibleName', () => {
it('pluralises the article count', () => {
expect(usedInLabel(1)).toBe('used in 1 article')
expect(usedInLabel(2)).toBe('used in 2 articles')
expect(usedInLabel(1)).toBe('used in 1 place')
expect(usedInLabel(2)).toBe('used in 2 places')
expect(usedInLabel(0)).toBe('unused')
})
it('builds the card name from filename, size and usage', () => {
expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 articles')
expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 places')
expect(accessibleName({ ...base, used_in: 1 })).toBe(
'north-wall.jpg, 3.9 MB, used in 1 article',
'north-wall.jpg, 3.9 MB, used in 1 place',
)
expect(accessibleName({ ...base, used_in: 0 })).toBe('north-wall.jpg, 3.9 MB, unused')
})
+200
View File
@@ -0,0 +1,200 @@
import { describe, expect, it } from 'vitest'
import type { MediaUsageItem } from '@/composables/useMedia'
import { deleteCopy, tooltipLines, pluralEntries, splitUsage, usageEntryTitle } from '../mediaUsage'
import { pluralPlaces } from '../mediaFormat'
function article(id: number, published = true): MediaUsageItem {
return { type: 'article', id, title: `Article ${id}`, published, fields: [] }
}
function entry(id: number, firstField?: { type: string; value: unknown }): MediaUsageItem {
return {
type: 'entry',
id,
fields: ['thumbnail'],
content_type_key: 'events',
content_type_name: 'Events',
first_field: firstField ? { key: 'title', ...firstField } : undefined,
}
}
function entries(count: number) {
return Array.from({ length: count }, (_, index) =>
entry(index + 1, { type: 'text', value: `Entry title ${index + 1}` }),
)
}
describe('splitUsage', () => {
it('counts articles and entries and ignores authors and sites', () => {
const split = splitUsage([
article(1),
article(2, false),
{ type: 'author', id: 3, fields: [] },
{ type: 'site', id: 4, fields: [] },
...entries(2),
])
expect(split.articles.map((row) => row.id)).toEqual([1, 2])
expect(split.entryCount).toBe(2)
expect(split.entries).toHaveLength(2)
expect(split.more).toBe(0)
})
it.each([
[1, 1, 0],
[3, 3, 0],
[4, 3, 1],
[5, 3, 2],
[120, 3, 117],
])('with %i entries keeps %i rows and %i more', (count, rows, more) => {
const split = splitUsage(entries(count))
expect(split.entryCount).toBe(count)
expect(split.entries).toHaveLength(rows)
expect(split.more).toBe(more)
})
it('keeps the order of the API', () => {
const split = splitUsage([entry(9), entry(2), entry(5), entry(1)])
expect(split.entries.map((row) => row.id)).toEqual([9, 2, 5])
})
it('gives zeros for an empty usage', () => {
expect(splitUsage([])).toMatchObject({ articles: [], entryCount: 0, more: 0 })
})
it('reads a row without a type as an article', () => {
expect(splitUsage([{ id: 7, title: 'Old', published: true }]).articles).toHaveLength(1)
})
it('carries the content type name, key and the derived title of an entry', () => {
const [row] = splitUsage([
entry(42, { type: 'text', value: 'Lime plaster workshop' }),
]).entries
expect(row).toEqual({
id: 42,
key: 'events',
typeName: 'Events',
title: 'Lime plaster workshop',
})
})
})
describe('usageEntryTitle', () => {
it.each([
['text', 'Lime plaster workshop', 'Lime plaster workshop'],
['text', '', 'Entry 42'],
['text', null, 'Entry 42'],
['richtext', '## Heritage walk\n\nMeet at 9', 'Heritage walk'],
['richtext', '', 'Entry 42'],
['number', 1500, '1,500'],
['number', 0, '0'],
['boolean', true, 'Yes'],
['boolean', false, 'No'],
['select', 'Workshop', 'Workshop'],
['media', 7, 'Entry 42'],
['media', null, 'Entry 42'],
['mystery', 'Odd value', 'Odd value'],
])('%s %j reads %s', (type, value, title) => {
expect(usageEntryTitle(entry(42, { type, value }))).toBe(title)
})
it('formats a date as the entry list does', () => {
expect(usageEntryTitle(entry(42, { type: 'date', value: '2026-11-21' }))).toBe(
'21 Nov 2026',
)
})
it('falls back to the id when there is no first field', () => {
expect(usageEntryTitle(entry(42))).toBe('Entry 42')
})
})
describe('wording', () => {
it('pluralises entries and places', () => {
expect(pluralEntries(1)).toBe('1 entry')
expect(pluralEntries(5)).toBe('5 entries')
expect(pluralPlaces(1)).toBe('1 place')
expect(pluralPlaces(7)).toBe('7 places')
})
})
describe('tooltipLines', () => {
it.each([
[[...entries(3)], ['Used in entries: 3']],
[
[article(1), article(2), ...entries(5)],
['Used in articles: 2', 'Used in entries: 5'],
],
[[article(1), article(2)], ['Used in articles: 2']],
[[{ type: 'author', id: 1, fields: [] }], []],
])('reads %#', (items, lines) => {
expect(tooltipLines(items as MediaUsageItem[])).toEqual(lines)
})
})
describe('deleteCopy', () => {
const NOTICE = 'This image is no longer in the library'
it.each([
[
[...Array(2).keys()].map((id) => article(id + 1)).concat(entries(5)),
"Delete an image that's in use?",
`"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`,
],
[
[article(1), entry(1)],
"Delete an image that's in use?",
`"lime-plaster.jpg" is used in 1 entry and 1 article. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`,
],
[
[article(1), article(2)],
"Delete an image that's in use?",
`"lime-plaster.jpg" is used in 2 articles. If you delete it, they will show a broken image. This can't be undone.`,
],
[
entries(3),
"Delete an image that's in use?",
`"lime-plaster.jpg" is used in 3 entries. Those entry fields will show "${NOTICE}". This can't be undone.`,
],
])('case %#', (items, title, description) => {
const copy = deleteCopy('lime-plaster.jpg', items.length, {
status: 'ready',
items: items as MediaUsageItem[],
})
expect(copy.title).toBe(title)
expect(copy.description).toBe(description)
expect(copy.split).not.toBeNull()
})
it('is the unused dialog at used_in 0', () => {
const copy = deleteCopy('lime-plaster.jpg', 0, null)
expect(copy.title).toBe('Delete this image?')
expect(copy.description).toBe(
`"lime-plaster.jpg" will be deleted from your library. No article uses it. This can't be undone.`,
)
expect(copy.split).toBeNull()
})
it.each([
['loading', { status: 'loading' } as const],
['failed', { status: 'failed', failure: 'failed' } as const],
['absent', null],
[
'authors only',
{ status: 'ready', items: [{ type: 'author', id: 1, fields: [] }] } as const,
],
])('uses the generic copy when the usage is %s', (_name, state) => {
const copy = deleteCopy('lime-plaster.jpg', 2, state as never)
expect(copy.title).toBe("Delete an image that's in use?")
expect(copy.description).toBe(
`"lime-plaster.jpg" is in use. Articles will show a broken image and entry fields will show "${NOTICE}". This can't be undone.`,
)
expect(copy.split).toBeNull()
})
})
+5 -1
View File
@@ -71,8 +71,12 @@ export function pluralArticles(count: number) {
return t(count === 1 ? '1 article' : '{0} articles', [count])
}
export function pluralPlaces(count: number) {
return t(count === 1 ? '1 place' : '{0} places', [count])
}
export function usedInLabel(count: number) {
return count > 0 ? t('used in {0}', [pluralArticles(count)]) : t('unused')
return count > 0 ? t('used in {0}', [pluralPlaces(count)]) : t('unused')
}
export function accessibleName(item: MediaNameSource) {
+140
View File
@@ -0,0 +1,140 @@
import { t } from '@/i18n'
import type { MediaUsageItem, UsageState } from '@/composables/useMedia'
import { describeCell, plainCellText } from '@/lib/entryCells'
import { pluralArticles } from '@/lib/mediaFormat'
const ENTRY_ROWS_SHOWN = 3
const ARTICLE_TYPE = 'article'
const ENTRY_TYPE = 'entry'
export interface UsageArticle {
id: number
title: string
published: boolean
}
export interface UsageEntry {
id: number
key: string
typeName: string
title: string
}
export interface UsageSplit {
articles: UsageArticle[]
entries: UsageEntry[]
entryCount: number
more: number
}
export function pluralEntries(count: number) {
return t(count === 1 ? '1 entry' : '{0} entries', [count])
}
export function usageEntryTitle(item: MediaUsageItem) {
const fallback = t('Entry {0}', [item.id])
const first = item.first_field
if (!first) {
return fallback
}
const cell = describeCell(
{
key: first.key,
label: first.key,
type: first.type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
},
first.value,
)
return cell.kind === 'empty' || cell.kind === 'thumbnail' ? fallback : plainCellText(cell)
}
function toEntry(item: MediaUsageItem): UsageEntry {
return {
id: item.id,
key: item.content_type_key ?? '',
typeName: item.content_type_name ?? '',
title: usageEntryTitle(item),
}
}
export function splitUsage(items: MediaUsageItem[]): UsageSplit {
const articles = items
.filter((item) => item.type === undefined || item.type === ARTICLE_TYPE)
.map((item) => ({
id: item.id,
title: item.title ?? '',
published: item.published === true,
}))
const entryItems = items.filter((item) => item.type === ENTRY_TYPE)
return {
articles,
entries: entryItems.slice(0, ENTRY_ROWS_SHOWN).map(toEntry),
entryCount: entryItems.length,
more: Math.max(0, entryItems.length - ENTRY_ROWS_SHOWN),
}
}
export function tooltipLines(items: MediaUsageItem[]) {
const split = splitUsage(items)
return [
split.articles.length > 0 ? t('Used in articles: {0}', [split.articles.length]) : '',
split.entryCount > 0 ? t('Used in entries: {0}', [split.entryCount]) : '',
].filter(Boolean)
}
export function usageSplitOf(state: UsageState | null) {
return state?.status === 'ready' ? splitUsage(state.items) : null
}
function inUseDescription(filename: string, split: UsageSplit | null) {
const articles = split?.articles.length ?? 0
const entries = split?.entryCount ?? 0
if (articles > 0 && entries > 0) {
return t(
"\u0022{0}\u0022 is used in {1} and {2}. Those entry fields will show \u0022This image is no longer in the library\u0022, and the articles will show a broken image. This can't be undone.",
[filename, pluralEntries(entries), pluralArticles(articles)],
)
}
if (articles > 0) {
return t(
"\u0022{0}\u0022 is used in {1}. If you delete it, they will show a broken image. This can't be undone.",
[filename, pluralArticles(articles)],
)
}
if (entries > 0) {
return t(
"\u0022{0}\u0022 is used in {1}. Those entry fields will show \u0022This image is no longer in the library\u0022. This can't be undone.",
[filename, pluralEntries(entries)],
)
}
return t(
"\u0022{0}\u0022 is in use. Articles will show a broken image and entry fields will show \u0022This image is no longer in the library\u0022. This can't be undone.",
[filename],
)
}
export function deleteCopy(filename: string, usedIn: number, state: UsageState | null) {
if (usedIn <= 0) {
return {
title: t('Delete this image?'),
description: t(
"\u0022{0}\u0022 will be deleted from your library. No article uses it. This can't be undone.",
[filename],
),
split: null,
}
}
const split = usageSplitOf(state)
const known = split !== null && (split.articles.length > 0 || split.entryCount > 0)
return {
title: t("Delete an image that's in use?"),
description: inUseDescription(filename, known ? split : null),
split: known ? split : null,
}
}
+9
View File
@@ -0,0 +1,9 @@
import type { InjectionKey } from 'vue'
import type { UsageState } from '@/composables/useMedia'
export interface MediaUsageContext {
stateOf: (id: number) => UsageState | null
ensure: (id: number) => void
}
export const MEDIA_USAGE_KEY: InjectionKey<MediaUsageContext> = Symbol('media-usage')
+25 -15
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useRoute, type LocationQuery } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
@@ -30,7 +30,6 @@ import {
useMedia,
type BulkDeleteResult,
type MediaItem,
type MediaUsageItem,
} from '@/composables/useMedia'
import { useMediaSelection } from '@/composables/useMediaSelection'
import { useMediaUpload } from '@/composables/useMediaUpload'
@@ -39,6 +38,7 @@ import type { PurgeResult } from '@/composables/useMediaPurge'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat'
import { MEDIA_USAGE_KEY } from '@/lib/mediaUsageContext'
import {
isFullSet,
parseMediaControls,
@@ -149,7 +149,6 @@ const purgeOpen = ref(false)
const dragDepth = ref(0)
const announcementSource = ref<() => string>(() => '')
const announcement = computed(() => announcementSource.value())
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
let purgeChangedList = false
@@ -160,9 +159,25 @@ let bulkFailureRevision = 0
let bulkFailureToastId = BULK_FAILED_TOAST_ID
let failureToastShown = false
provide(MEDIA_USAGE_KEY, {
stateOf: media.usageOf,
ensure: (id) => {
void media.ensureUsage(id)
},
})
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
)
watch(
() =>
panelOpen.value && activeItem.value !== null && media.usageOf(activeItem.value.id) === null,
(missing) => {
if (missing && activeItem.value && activeItem.value.used_in > 0) {
loadUsage(activeItem.value)
}
},
)
const closingItem = ref<MediaItem | null>(null)
const panelItem = computed(() => activeItem.value ?? closingItem.value)
const counts = computed(() => (hasLoaded.value ? media.counts.value : null))
@@ -365,15 +380,10 @@ function restoreFocusToOpener(id: number | null) {
}
async function loadUsage(item: MediaItem) {
try {
const rows = await media.fetchUsage(item.id)
if (activeId.value === item.id) {
usage.value = rows
}
} catch (error) {
if (activeId.value === item.id && classifyMediaError(error) === 'not-found') {
handleGone(item)
}
await media.ensureUsage(item.id, true)
const state = media.usageOf(item.id)
if (activeId.value === item.id && state?.status === 'failed' && state.failure === 'not-found') {
handleGone(item)
}
}
@@ -395,7 +405,6 @@ function openPanel(id: number, element: HTMLElement) {
}
opener = element
activeId.value = id
usage.value = []
copies.value = []
skipFocusRestore = false
panelOpen.value = true
@@ -1150,7 +1159,7 @@ async function mergeAction() {
<MediaDetailPanel
:open="panelOpen"
:item="panelItem"
:usage="usage"
:usage="panelItem ? media.usageOf(panelItem.id) : null"
:copies="copies"
:offline="offline"
@update:open="onPanelOpenChange"
@@ -1158,6 +1167,7 @@ async function mergeAction() {
@rename="renameOpen = true"
@delete="deleteOpen = true"
@review-duplicates="reviewDuplicates"
@retry-usage="activeItem && loadUsage(activeItem)"
@gone="activeItem && handleGone(activeItem)"
/>
@@ -1171,7 +1181,7 @@ async function mergeAction() {
<DeleteMediaDialog
v-if="deleteOpen && activeItem"
:item="activeItem"
:usage="usage"
:usage="media.usageOf(activeItem.id)"
:action="deleteAction"
@close="deleteOpen = false"
/>
@@ -264,9 +264,9 @@ describe('Merge', () => {
const box = dialog()!
expect(textOf(box)).toContain('Merge into floorboards.jpg?')
expect(textOf(box)).toContain(
"Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone.",
"Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 places will be updated. This can't be undone.",
)
expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 articles now')
expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 places now')
expect(Array.from(box.querySelectorAll('button')).map(textOf)).toEqual([
'Cancel',
'Merge 2 copies',
@@ -278,7 +278,7 @@ describe('Merge', () => {
await openMerge('IMG_2208.jpg')
expect(textOf(dialog())).toContain('Merge into IMG_2208.jpg?')
expect(textOf(dialog())).toContain('7 articles will be updated.')
expect(textOf(dialog())).toContain('7 places will be updated.')
})
it('uses the singular for a group of two copies', async () => {
@@ -357,7 +357,7 @@ describe('Bulk delete', () => {
const box = dialog()!
expect(textOf(box)).toContain('Delete 3 images?')
expect(textOf(box)).toContain(
"2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone.",
"2 of them are used in 5 places, which will show a broken image or a missing-image notice. The other one isn't used anywhere. This can't be undone.",
)
const rows = Array.from(box.querySelectorAll('[data-testid="bulk-delete-row"]')).map(textOf)
expect(rows).toEqual([
+662
View File
@@ -0,0 +1,662 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import type { MediaUsageItem } from '@/composables/useMedia'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
cardFor,
cards,
deferred,
dialog,
fixture,
findButton,
listBody,
mountPage,
panel,
settle,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const USED_IN: Record<number, number> = { 1: 2, 8: 0, 10: 3, 11: 7, 4: 2 }
const NOTICE = 'This image is no longer in the library'
function article(id: number, title: string, published: boolean): MediaUsageItem {
return { type: 'article', id, title, published, fields: [] }
}
function eventEntry(id: number, title: unknown, type = 'text'): MediaUsageItem {
return {
type: 'entry',
id,
fields: ['thumbnail'],
content_type_key: 'events',
content_type_name: 'Events',
first_field: { key: 'title', type, value: title },
}
}
const LIME_TITLES = ['Lime plaster workshop', 'Heritage stone walk', 'Winter open day']
function entriesOf(count: number): MediaUsageItem[] {
return Array.from({ length: count }, (_, index) =>
eventEntry(index + 1, LIME_TITLES[index] ?? `Event ${index + 1}`),
)
}
const ARTICLES: MediaUsageItem[] = [
article(20, 'Open day at the workshop', true),
article(21, 'Restoring lime plaster', false),
]
const USAGE: Record<number, MediaUsageItem[]> = {
1: ARTICLES,
4: [
{ type: 'author', id: 1, fields: ['avatar'] },
{ type: 'site', id: 2, fields: ['logo'] },
],
10: entriesOf(3),
11: [...ARTICLES, ...entriesOf(5)],
}
function library(): Handlers {
return {
list: () =>
listBody(
fixture().map((item) =>
item.id in USED_IN ? { ...item, used_in: USED_IN[item.id]! } : item,
),
),
usage: (id) => USAGE[id] ?? [],
}
}
async function open(filename: string) {
cardFor(filename).click()
await settle()
}
function usageCalls() {
return callsTo('GET', /\/usage$/)
}
function badgeTexts(root: Element) {
return Array.from(root.querySelectorAll('[data-kind]')).map(textOf)
}
function tooltipLines() {
return Array.from(document.body.querySelectorAll('[data-testid="media-usage-line"]')).map(
textOf,
)
}
function triggerOf(filename: string) {
return cardFor(filename).querySelector<HTMLElement>('[data-testid="media-usage-trigger"]')!
}
async function hover(filename: string) {
triggerOf(filename).dispatchEvent(new MouseEvent('pointerenter'))
await settle()
}
afterEach(unmountPage)
describe('The label of the usage badge', () => {
it.each([
['window-frame.jpg', 'Unused'],
['kitchen-before.webp', 'Used in 1'],
['bathroom-tiles.png', 'Used in 3'],
['lime-plaster.jpg', 'Used in 7'],
])('%s reads %s in the grid', async (filename, label) => {
await mountPage(library(), '/media', undefined, { app: true })
const badges = badgeTexts(cardFor(filename))
expect(badges).toContain(label)
expect(
cardFor(filename).querySelector('[data-kind="used"] svg, [data-kind="used"] i'),
).toBeNull()
})
it('reads the same in the list row and as a button', async () => {
await mountPage(library(), '/media?view=list', undefined, { app: true })
const rows = Array.from(document.body.querySelectorAll('[data-testid="media-row"]'))
const lime = rows.find((row) => textOf(row).includes('lime-plaster.jpg'))!
const unused = rows.find((row) => textOf(row).includes('window-frame.jpg'))!
const button = lime.querySelector<HTMLElement>('[data-testid="media-used-in"]')!
expect(textOf(button)).toBe('Used in 7')
expect(button.className).toContain('underline')
expect(textOf(unused.querySelector('[data-kind="unused"]'))).toBe('Unused')
expect(textOf(lime)).not.toMatch(/\d+ articles?/)
expect(textOf(unused)).not.toMatch(/\d+ articles?/)
})
it('reads the same in the mobile card', async () => {
await mountPage(library(), '/media?view=list', MOBILE_WIDTH, { app: true })
const row = Array.from(
document.body.querySelectorAll('[data-testid="media-card-row"]'),
).find((entry) => textOf(entry).includes('bathroom-tiles.png'))!
expect(badgeTexts(row)).toContain('Used in 3')
})
it.each([
['window-frame.jpg', 'unused'],
['kitchen-before.webp', 'used in 1 place'],
['lime-plaster.jpg', 'used in 7 places'],
])('names the card of %s with "%s"', async (filename, usage) => {
await mountPage(library(), '/media', undefined, { app: true })
expect(cardFor(filename).getAttribute('aria-label')).toMatch(new RegExp(`, ${usage}$`))
})
})
describe('The usage tooltip', () => {
it('opens on hover with the entries line only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('bathroom-tiles.png')
expect(callsTo('GET', /\/v1\/media\/10\/usage$/)).toHaveLength(1)
expect(tooltipLines()).toEqual(['Used in entries: 3'])
})
it('lists articles first then entries', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('lime-plaster.jpg')
expect(tooltipLines()).toEqual(['Used in articles: 2', 'Used in entries: 5'])
})
it('shows articles only without an entries line', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('north-wall.jpg')
expect(tooltipLines()).toEqual(['Used in articles: 2'])
})
it('closes when the pointer leaves', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('bathroom-tiles.png')
triggerOf('bathroom-tiles.png').dispatchEvent(new MouseEvent('pointerleave'))
await settle()
expect(tooltipLines()).toEqual([])
})
it('closes on Escape without closing anything else', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('bathroom-tiles.png')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await settle()
expect(tooltipLines()).toEqual([])
expect(cards().length).toBeGreaterThan(0)
})
it('opens when the card takes the keyboard focus', async () => {
await mountPage(library(), '/media', undefined, { app: true })
cardFor('bathroom-tiles.png').focus()
await settle()
expect(tooltipLines()).toEqual(['Used in entries: 3'])
cardFor('bathroom-tiles.png').blur()
await settle()
expect(tooltipLines()).toEqual([])
})
it('fetches once per image until the list is reloaded', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('bathroom-tiles.png')
await hover('bathroom-tiles.png')
await open('bathroom-tiles.png')
expect(callsTo('GET', /\/v1\/media\/10\/usage$/)).toHaveLength(1)
})
it('waits for the usage then opens if the pointer is still there', async () => {
const pending = deferred<MediaUsageItem[]>()
await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, {
app: true,
})
await hover('bathroom-tiles.png')
expect(tooltipLines()).toEqual([])
expect(textOf(triggerOf('bathroom-tiles.png'))).toBe('Used in 3')
pending.resolve(USAGE[10]!)
await settle()
expect(tooltipLines()).toEqual(['Used in entries: 3'])
})
it.each([
['answers 500', () => Promise.reject(new ApiError(500))],
['fails on the network', () => Promise.reject(new NetworkError())],
['answers 404', () => Promise.reject(new ApiError(404))],
['answers only authors and sites', () => USAGE[4]],
])('shows nothing when the usage %s', async (_name, usage) => {
await mountPage({ ...library(), usage }, '/media', undefined, { app: true })
await hover('kitchen-after.webp')
await hover('bathroom-tiles.png')
expect(tooltipLines()).toEqual([])
expect(cards()).toHaveLength(12)
})
it('does not open on mobile and sends no request', async () => {
await mountPage(library(), '/media', MOBILE_WIDTH, { app: true })
await hover('bathroom-tiles.png')
expect(tooltipLines()).toEqual([])
expect(usageCalls()).toHaveLength(0)
expect(textOf(cardFor('bathroom-tiles.png'))).toContain('Used in 3')
})
it('does not send a request for an unused image', async () => {
await mountPage(library(), '/media', undefined, { app: true })
cardFor('window-frame.jpg').focus()
await settle()
expect(tooltipLines()).toEqual([])
expect(usageCalls()).toHaveLength(0)
expect(
cardFor('window-frame.jpg').querySelector('[data-testid="media-usage-trigger"]'),
).toBeNull()
})
})
describe('The detail panel', () => {
function rows(selector: string) {
return Array.from(panel()!.querySelectorAll(selector))
}
it('lists the articles and the first three entries then "and 2 more"', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('lime-plaster.jpg')
const content = panel()!
const headings = Array.from(content.querySelectorAll('h3')).map(textOf)
expect(headings).toEqual(['Used in 2 articles', 'Used in entries'])
expect(rows('[data-testid="usage-row"]').map(textOf)).toEqual([
'Open day at the workshop Online',
'Restoring lime plaster Draft',
])
const entries = rows('[data-testid="usage-entry-row"]')
expect(entries.map(textOf)).toEqual([
'Events Lime plaster workshop',
'Events Heritage stone walk',
'Events Winter open day',
])
const link = entries[0]!.querySelector('a')!
expect(link.getAttribute('href')).toBe('/content/events/1')
expect(link.getAttribute('title')).toBe('Lime plaster workshop')
expect(link.getAttribute('aria-label')).toBe('Lime plaster workshop, Events')
expect(link.className).toContain('underline')
expect(link.className).toContain('truncate')
expect(entries[0]!.className).toContain('h-12')
expect(entries[0]!.className).toContain('md:h-9')
expect(entries[0]!.querySelector('[aria-hidden="true"]')).not.toBeNull()
const more = content.querySelector('[data-testid="usage-more"]')!
expect(textOf(more)).toBe('and 2 more')
expect(more.querySelector('a, button')).toBeNull()
expect(more.hasAttribute('tabindex')).toBe(false)
expect(link.className).toContain('focus-visible:outline-2')
expect(link.className).toContain('focus-visible:outline-primary')
const focusables = Array.from(
content.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]'),
)
const entryLinks = Array.from(content.querySelectorAll('[data-testid="usage-entry-row"] a'))
const order = focusables.filter((element) => entryLinks.includes(element))
expect(order).toEqual(entryLinks)
expect(focusables.some((element) => more.contains(element))).toBe(false)
expect(textOf(content)).toContain(
'Counted from the text of your articles each time you open this page.',
)
})
it.each([
[1, 1, null],
[3, 3, null],
[4, 3, 'and 1 more'],
[5, 3, 'and 2 more'],
[120, 3, 'and 117 more'],
])('with %i entries shows %i rows', async (count, shown, more) => {
await mountPage({ ...library(), usage: () => entriesOf(count) }, '/media', undefined, {
app: true,
})
await open('bathroom-tiles.png')
expect(rows('[data-testid="usage-entry-row"]')).toHaveLength(shown)
const moreRow = panel()!.querySelector('[data-testid="usage-more"]')
expect(moreRow ? textOf(moreRow) : null).toBe(more)
})
it('says no article shows an image used by entries only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('bathroom-tiles.png')
const content = panel()!
expect(textOf(content.querySelector('h3'))).toBe('Not used in any article')
expect(textOf(content)).toContain('No article shows it.')
expect(textOf(content)).not.toContain('safe to delete')
expect(textOf(content)).toContain('Used in entries')
})
it('is unchanged for an image used by articles only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('north-wall.jpg')
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
expect(panel()!.querySelector('[data-testid="usage-entries"]')).toBeNull()
})
it('says nothing is safe to claim for authors and sites only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('logo-renew.svg')
const content = panel()!
expect(textOf(content.querySelector('h3'))).toBe('Not used in any article')
expect(textOf(content)).toContain('No article shows it.')
expect(textOf(content)).not.toContain('safe to delete')
expect(content.querySelector('[data-testid="usage-row"]')).toBeNull()
expect(content.querySelector('[data-testid="usage-entry-row"]')).toBeNull()
})
it('sends no request and claims it is safe for an unused image', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('window-frame.jpg')
expect(usageCalls()).toHaveLength(0)
expect(textOf(panel())).toContain('This image is safe to delete. No article shows it.')
})
it('draws a taller banner preview only when entries use the image', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('lime-plaster.jpg')
const preview = panel()!.querySelector('[data-testid="media-thumb"]')!
expect(preview.className).toContain('aspect-panel-banner')
expect(preview.className).toContain('md:aspect-16/6')
})
it('keeps the default preview without entries', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('north-wall.jpg')
const preview = panel()!.querySelector('[data-testid="media-thumb"]')!
expect(preview.className).toContain('aspect-4/3')
expect(preview.className).toContain('md:aspect-16/10')
})
it('shows the skeleton after 150 ms, never before, and the sections after the answer', async () => {
const pending = deferred<MediaUsageItem[]>()
await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, {
app: true,
})
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
cardFor('lime-plaster.jpg').click()
await flushPromises()
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in')
expect(panel()!.querySelector('[data-testid="usage-skeleton"]')).toBeNull()
expect(findButton('Delete', panel()!)!.disabled).toBe(false)
vi.advanceTimersByTime(150)
await flushPromises()
expect(panel()!.querySelectorAll('[data-testid="usage-skeleton"] li')).toHaveLength(3)
vi.useRealTimers()
pending.resolve(USAGE[11]!)
await settle()
expect(panel()!.querySelector('[data-testid="usage-skeleton"]')).toBeNull()
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
})
it.each([
['answers 500', () => Promise.reject(new ApiError(500))],
['fails on the network', () => Promise.reject(new NetworkError())],
['answers 429', () => Promise.reject(new ApiError(429))],
])('shows the failure and Try again when the usage %s', async (_name, failure) => {
let calls = 0
await mountPage(
{
...library(),
usage: (id) => (++calls === 1 ? failure() : USAGE[id]),
},
'/media',
undefined,
{ app: true },
)
await open('lime-plaster.jpg')
expect(textOf(panel()!.querySelector('[data-testid="usage-error"]'))).toContain(
"Couldn't load where this image is used.",
)
expect(findButton('Delete', panel()!)!.disabled).toBe(false)
const listCalls = callsTo('GET', /^\/v1\/media\/\?/).length
findButton('Try again', panel()!)!.click()
await settle()
expect(usageCalls()).toHaveLength(2)
expect(callsTo('GET', /^\/v1\/media\/\?/)).toHaveLength(listCalls)
expect(panel()!.querySelector('[data-testid="usage-error"]')).toBeNull()
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
})
it('closes the panel and says the image is gone on a 404', async () => {
await mountPage(
{
...library(),
usage: () => Promise.reject(new ApiError(404)),
},
'/media',
undefined,
{ app: true },
)
await open('lime-plaster.jpg')
expect(panel()).toBeNull()
expect(textOf(document.body)).toContain('That image no longer exists')
})
it('ignores a late answer of the previous image', async () => {
const slow = deferred<MediaUsageItem[]>()
await mountPage(
{ ...library(), usage: (id) => (id === 10 ? slow.promise : USAGE[id]) },
'/media',
undefined,
{ app: true },
)
await open('bathroom-tiles.png')
panel()!.querySelector<HTMLElement>('[data-slot="close"]')?.click()
await settle()
await open('north-wall.jpg')
slow.resolve(USAGE[10]!)
await settle()
const content = panel()!
expect(textOf(content.querySelector('h3'))).toBe('Used in 2 articles')
expect(content.querySelector('[data-testid="usage-entry-row"]')).toBeNull()
})
})
describe('The delete dialog', () => {
async function openDelete(filename: string) {
await open(filename)
findButton('Delete', panel()!)!.click()
await settle()
}
it('lists entries then articles for an image used by both', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('lime-plaster.jpg')
const box = dialog()!
expect(textOf(box.querySelector('[data-slot="title"]'))).toBe(
"Delete an image that's in use?",
)
expect(textOf(box.querySelector('[data-slot="description"]'))).toBe(
`"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`,
)
const lists = Array.from(box.querySelectorAll('ul'))
expect(lists.map((list) => list.getAttribute('data-testid'))).toEqual([
'affected-entries',
'affected-articles',
])
expect(textOf(box.querySelector('h3'))).toBe('Used in entries')
expect(
Array.from(box.querySelectorAll('[data-testid="affected-entry"]')).map(textOf),
).toEqual([
'Events Lime plaster workshop',
'Events Heritage stone walk',
'Events Winter open day',
])
expect(textOf(box.querySelector('[data-testid="affected-more"]'))).toBe('and 2 more')
expect(box.querySelector('[data-testid="affected-entry"] a')).toBeNull()
expect(
Array.from(box.querySelectorAll('[data-testid="affected-article"]')).map(textOf),
).toEqual(['Open day at the workshop Online', 'Restoring lime plaster Draft'])
expect(findButton('Cancel', box)).toBeTruthy()
expect(findButton('Delete image', box)).toBeTruthy()
})
it('words an image used by entries only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('bathroom-tiles.png')
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toBe(
`"bathroom-tiles.png" is used in 3 entries. Those entry fields will show "${NOTICE}". This can't be undone.`,
)
expect(dialog()!.querySelector('[data-testid="affected-articles"]')).toBeNull()
})
it('keeps the unused copy and sends no request', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('window-frame.jpg')
expect(textOf(dialog()!.querySelector('[data-slot="title"]'))).toBe('Delete this image?')
expect(dialog()!.querySelector('ul')).toBeNull()
expect(usageCalls()).toHaveLength(0)
})
it('uses the generic copy for authors and sites only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('logo-renew.svg')
expect(textOf(dialog()!.querySelector('[data-slot="title"]'))).toBe(
"Delete an image that's in use?",
)
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toBe(
`"logo-renew.svg" is in use. Articles will show a broken image and entry fields will show "${NOTICE}". This can't be undone.`,
)
expect(dialog()!.querySelector('ul')).toBeNull()
})
it('opens at once with the generic copy then switches when the usage answers', async () => {
const pending = deferred<MediaUsageItem[]>()
await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, {
app: true,
})
await openDelete('lime-plaster.jpg')
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain(
'"lime-plaster.jpg" is in use.',
)
expect(dialog()!.querySelector('ul')).toBeNull()
expect(findButton('Delete image', dialog()!)!.disabled).toBe(false)
const cancel = findButton('Cancel', dialog()!)!
const focused = document.activeElement
pending.resolve(USAGE[11]!)
await settle()
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain(
'is used in 5 entries and 2 articles.',
)
expect(dialog()!.querySelectorAll('ul')).toHaveLength(2)
expect(document.activeElement).toBe(focused)
expect(cancel.isConnected).toBe(true)
})
it('uses the generic copy after a failure', async () => {
await mountPage(
{ ...library(), usage: () => Promise.reject(new ApiError(500)) },
'/media',
undefined,
{ app: true },
)
await openDelete('lime-plaster.jpg')
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain(
'"lime-plaster.jpg" is in use.',
)
expect(findButton('Delete image', dialog()!)!.disabled).toBe(false)
})
it('returns the focus to the Delete button of the panel on Cancel', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('lime-plaster.jpg')
const trigger = findButton('Delete', panel()!)!
trigger.focus()
trigger.click()
await settle()
findButton('Cancel', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(document.activeElement).toBe(trigger)
})
it('deletes with the usual request', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('lime-plaster.jpg')
findButton('Delete image', dialog()!)!.click()
await settle()
expect(callsTo('DELETE', /\/v1\/media\/11$/)).toHaveLength(1)
})
})
@@ -114,7 +114,7 @@ describe('The library badge', () => {
await mountPage()
expect(cardFor('north-wall.jpg').getAttribute('aria-label')).toBe(
'north-wall.jpg, 3.9 MB, used in 2 articles',
'north-wall.jpg, 3.9 MB, used in 2 places',
)
})
+13 -9
View File
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
@@ -319,7 +321,10 @@ async function mountPage(handlers: Handlers = {}, url = '/media', width = DESKTO
request: requestMock,
})
await router.push(url)
wrapper = mount(MediaView, {
const root = defineComponent({
render: () => h(UApp, null, { default: () => h(MediaView) }),
})
wrapper = mount(root, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
@@ -495,15 +500,15 @@ describe('Default, many images', () => {
describe('Badges and accessible names', () => {
it.each([
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles', ['Used in 2']],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article', ['Used in 1']],
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 places', ['Used in 2']],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 place', ['Used in 1']],
['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused', ['Unused']],
[
'floorboards.jpg',
'floorboards.jpg, 2.4 MB, used in 4 articles',
'floorboards.jpg, 2.4 MB, used in 4 places',
['Duplicate', 'Used in 4'],
],
['logo-renew.svg', 'logo-renew.svg, 8 KB, used in 5 articles', ['Used in 5']],
['logo-renew.svg', 'logo-renew.svg, 8 KB, used in 5 places', ['Used in 5']],
])('card %s reads %s with badges %j', async (filename, name, badges) => {
await mountPage()
@@ -557,7 +562,7 @@ describe('List view', () => {
fixture().map((item) => String(item.id)),
)
expect(textOf(rows[0])).toBe(
'north-wall.jpg 4032 × 3024 3.9 MB JPEG 2 articles Marta Alves 12 Sep 2026',
'north-wall.jpg 4032 × 3024 3.9 MB JPEG Used in 2 Marta Alves 12 Sep 2026',
)
expect(textOf(rows[4])).toContain('Duplicate')
expect(rows[0]!.querySelector('[data-testid="media-used-in"]')!.className).toContain(
@@ -918,7 +923,6 @@ describe('Hover, focus and keyboard', () => {
)
await flushPromises()
expect(panel()).toBeNull()
expect(requestMock.mock.calls.filter(([path]) => /usage/.test(path))).toHaveLength(0)
press(cards()[1]!, 'Enter')
await flushPromises()
@@ -1001,7 +1005,7 @@ describe('Detail panel', () => {
await clickCard('north-wall.jpg')
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in')
expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(0)
pending.resolve(USAGE)
await flushPromises()
@@ -1014,7 +1018,7 @@ describe('Detail panel', () => {
await clickCard('north-wall.jpg')
expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(0)
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in')
})
it('unused: sends no usage request and says it is safe to delete', async () => {
+11
View File
@@ -463,6 +463,17 @@ const extraSources: Record<string, Source[]> = {
states: ['Duplicates tab with the button', 'Duplicates tab: offline'],
alias: {},
},
{
board: schemaBoard,
group: '11 Media library: usage',
states: [
'Grid: usage badge tooltip',
'List view: usage badge',
'Detail panel: used in entries',
'Delete: image used by entries',
],
alias: {},
},
],
}
+101 -1
View File
@@ -1697,6 +1697,58 @@ const mediaFloorboardsUsage = [
{ id: 12, title: 'Before and after: the Rue Verte kitchen', published: true },
{ id: 13, title: 'Why we reuse original floorboards', published: true },
]
const mediaDesktopWidth = 768
const mediaBathroomTilesId = 10
const mediaLimePlasterId = 11
const mediaEntryUsedIn: Record<number, number> = {
[mediaBathroomTilesId]: 3,
[mediaLimePlasterId]: 7,
}
const mediaEventTitles = ['Lime plaster workshop', 'Heritage stone walk', 'Winter open day']
const mediaLimeArticles = [
{ type: 'article', id: 10, title: 'How we price a bathroom renovation', published: true },
{ type: 'article', id: 11, title: 'Winter checklist for timber windows', published: false },
].map((article) => ({ ...article, fields: [] }))
function mediaEventEntries(count: number) {
return Array.from({ length: count }, (_, index) => ({
type: 'entry',
id: index + 1,
fields: ['thumbnail'],
content_type_key: 'events',
content_type_name: 'Events',
first_field: {
key: 'title',
type: 'text',
value: mediaEventTitles[index] ?? `Event ${index + 1}`,
},
}))
}
function mediaUsageApi(overrides: Record<string, ApiMock> = {}) {
return mediaApi({
'GET /v1/media/': {
status: 200,
bodyFor: () =>
mediaList(
mediaItems().map((item) => ({
...item,
used_in: mediaEntryUsedIn[item.id] ?? item.used_in,
})),
),
},
[`GET /v1/media/${mediaBathroomTilesId}/usage`]: {
status: 200,
body: mediaEventEntries(3),
},
[`GET /v1/media/${mediaLimePlasterId}/usage`]: {
status: 200,
body: [...mediaLimeArticles, ...mediaEventEntries(5)],
},
...overrides,
})
}
const mediaDuplicateIds = [12, 5, 6]
const mediaDuplicateGroups = [mediaDuplicateIds, [7, 9]]
@@ -2218,6 +2270,25 @@ const mediaStates: Record<string, VisualState> = {
await page.mouse.move(0, 0)
},
),
'Grid: usage badge tooltip': mediaState(mediaUsageApi(), async (page) => {
const badge = mediaCard(page, mediaBathroomTilesId).getByText('Used in 3', {
exact: true,
})
const desktop = (page.viewportSize()?.width ?? 0) >= mediaDesktopWidth
if (desktop) {
await badge.hover()
await page.getByText('Used in entries: 3', { exact: true }).first().waitFor()
}
}),
'List view: usage badge': mediaState(mediaUsageApi(), undefined, '/media?view=list'),
'Detail panel: used in entries': mediaState(mediaUsageApi(), async (page) => {
await openMediaPanel(page, mediaLimePlasterId)
await page.getByText('and 2 more', { exact: true }).waitFor()
}),
'Delete: image used by entries': mediaState(mediaUsageApi(), async (page) => {
await openMediaDialog(page, mediaLimePlasterId, 'Delete')
await page.getByRole('alertdialog').getByText('and 2 more', { exact: true }).waitFor()
}),
'Delete: unused image': mediaState(mediaApi(), (page) => openMediaDialog(page, 8, 'Delete')),
'Delete: image in use': mediaState(mediaApi(), (page) => openMediaDialog(page, 1, 'Delete')),
'Delete: in progress': mediaState(
@@ -4706,6 +4777,35 @@ function withVariantsRowException(
const MEDIA_DESKTOP_BUDGET = 10
const usageCeilings: Record<string, { desktop: number; mobile: number }> = {
'Grid: usage badge tooltip': { desktop: MEDIA_DESKTOP_BUDGET, mobile: 32 },
'List view: usage badge': { desktop: 15, mobile: 24 },
'Detail panel: used in entries': { desktop: 17, mobile: 28 },
'Delete: image used by entries': { desktop: MEDIA_DESKTOP_BUDGET, mobile: 29 },
}
function withUsageException(states: Record<string, VisualState>): Record<string, VisualState> {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const maxPercent = usageCeilings[name]
if (maxPercent === undefined) {
return [name, state]
}
return [
name,
{
...state,
exception: {
maxPercent,
reason: 'mobile: variants filter row not drawn on the Media board; desktop: stray miniature in the board capture and the Details | Variants tab row not drawn on the board',
approvedBy: 'user (Florian Sylvain), 2026-10-21',
},
},
]
}),
)
}
function withDetailTabsException(states: Record<string, VisualState>): Record<string, VisualState> {
const name = 'Detail panel: duplicate'
const state = states[name]
@@ -9468,7 +9568,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
...withVariantExceptions(variantsEditorStates),
},
media: withDetailTabsException(
withVariantsRowException({ ...mediaStates, ...mediaPurgeStates }),
withUsageException(withVariantsRowException({ ...mediaStates, ...mediaPurgeStates })),
),
'media-picker': pickerStates,
}