Files
RenewCMS/web/src/components/MediaDetailPanel.vue
T

463 lines
12 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
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 { MEDIA_SKELETON_DELAY_MS, type MediaItem, type UsageState } from '@/composables/useMedia'
import {
formatDimensions,
formatMediaDate,
formatSize,
formatType,
joinNames,
pluralArticles,
} from '@/lib/mediaFormat'
import { usageSplitOf } from '@/lib/mediaUsage'
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
const DESKTOP_QUERY = '(min-width: 48rem)'
const ACTION_BUTTON =
'h-11 justify-center border border-accented bg-default px-3 text-sm ring-0 max-md:flex-1 md:h-8'
const props = defineProps<{
item: MediaItem | null
usage: UsageState | null
copies: MediaItem[]
offline: boolean
}>()
const emit = defineEmits<{
'copy-url': []
rename: []
delete: []
'review-duplicates': []
'retry-usage': []
gone: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const canManageVariants = useCanManageVariants()
const panelTab = ref<'details' | 'variants'>('details')
const variantsVisited = ref(false)
const tabItems = computed(() => [
{
get label() {
return t('Details')
},
value: 'details',
},
{
get label() {
return t('Variants')
},
value: 'variants',
},
])
const tabModel = computed({
get: () => panelTab.value,
set: (value: string) => {
panelTab.value = value === 'variants' ? 'variants' : 'details'
if (panelTab.value === 'variants') {
variantsVisited.value = true
}
},
})
watch(
() => [open.value, props.item?.id],
() => {
panelTab.value = 'details'
variantsVisited.value = false
},
)
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,
icon: isDesktop.value ? 'i-lucide-x' : 'i-lucide-arrow-left',
class: 'text-muted',
}))
const metadata = computed(() => {
const item = props.item
if (!item) {
return []
}
return [
{
get label() {
return t('Type')
},
value: formatType(item.content_type),
},
{
get label() {
return t('Size')
},
value: formatSize(item.size),
},
{
get label() {
return t('Dimensions')
},
value: formatDimensions(item.width, item.height),
},
{
get label() {
return t('Uploaded')
},
value: formatMediaDate(item.created_at),
},
{
get label() {
return t('Uploaded by')
},
value: item.uploaded_by,
},
]
})
const displayUrl = computed(() => `${window.location.host}${props.item?.url ?? ''}`)
const duplicateAlert = computed(() => {
if (!props.item?.duplicate || props.copies.length === 0) {
return null
}
const names = props.copies.map((copy) => copy.filename)
return {
get title() {
return t('Identical to {0} other {1}', [
props.copies.length,
props.copies.length === 1 ? 'image' : 'images',
])
},
get description() {
return t('{0} {1} the same content.', [
joinNames(names),
names.length === 1 ? 'has' : 'have',
])
},
}
})
</script>
<template>
<USlideover
v-model:open="open"
side="right"
:title="item?.filename ?? ''"
:description="t('Image details')"
:close="closeButton"
:ui="{
content: 'w-full max-w-none md:max-w-115',
header: 'h-14 min-h-14 border-b border-default px-5 py-0 sm:px-5 max-md:px-1.5 max-md:sm:px-1.5',
wrapper: 'min-w-0 flex-1 max-md:ps-11 md:pe-9',
title: 'truncate text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-1.75 size-11 justify-center p-0 max-md:start-1.5 md:top-3.5 md:end-5 md:size-7',
body: 'p-4 md:p-5',
}"
>
<template #body>
<div v-if="item" class="flex flex-col gap-4">
<div
v-if="canManageVariants"
data-testid="panel-tabs"
class="-mx-4 -mt-4 h-11 border-b border-default px-4 md:-mx-5 md:-mt-5 md:mb-1 md:h-10 md:px-5"
>
<UTabs
v-model="tabModel"
:items="tabItems"
variant="link"
color="neutral"
:content="false"
class="-mb-px"
:ui="{
list: 'gap-5 border-b-0 p-0',
trigger: PANEL_TAB_TRIGGER,
}"
/>
</div>
<MediaVariantsTab
v-if="canManageVariants && variantsVisited"
v-show="panelTab === 'variants'"
:key="item.id"
:item="item"
@gone="emit('gone')"
/>
<div v-show="panelTab === 'details'" class="flex flex-col gap-5">
<MediaThumb
:item="item"
size="xl"
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">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-link"
:class="ACTION_BUTTON"
@click="emit('copy-url')"
>
{{ t('Copy URL') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
:disabled="offline"
:class="ACTION_BUTTON"
@click="emit('rename')"
>
{{ t('Rename') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash-2"
:disabled="offline"
:class="ACTION_BUTTON"
class="text-error"
@click="emit('delete')"
>
{{ t('Delete') }}
</UButton>
</div>
<UAlert
v-if="duplicateAlert"
data-testid="duplicate-alert"
color="info"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-copy"
:title="duplicateAlert.title"
:description="duplicateAlert.description"
:ui="{
actions: 'mt-2 w-full',
description: 'text-wrap',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-arrow-right"
block
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('review-duplicates')"
>
{{ t('Review duplicates') }}
</UButton>
</template>
</UAlert>
<dl class="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 gap-y-2 text-toned">
<template v-for="row in metadata" :key="row.label">
<dt class="text-muted">{{ row.label }}</dt>
<dd class="m-0 text-highlighted">
{{ row.value }}
</dd>
</template>
<dt class="text-muted">URL</dt>
<dd class="m-0 truncate font-mono text-xs/5 text-highlighted">
{{ displayUrl }}
</dd>
</dl>
<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="row in SKELETON_ROWS"
:key="row"
:class="[ROW_CLASS, { 'border-t border-muted': row > 0 }]"
>
<USkeleton class="h-3 w-full rounded-none bg-accented" />
</li>
</ul>
</template>
<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>
</template>
</USlideover>
</template>