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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user