feat(SPA): media library indications for custom medial field type usage
This commit is contained in:
1 parent
3e7fc9a4a5
commit
0fc3511e2b
28 files changed
+2060
-131
No files matched your search
@@ -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,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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -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.':
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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([
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user