Files
RenewCMS/web/src/views/MediaView.vue
T

1095 lines
28 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
import DuplicateGroups from '@/components/DuplicateGroups.vue'
import MediaBulkBar from '@/components/MediaBulkBar.vue'
import MediaCardList from '@/components/MediaCardList.vue'
import MediaDetailPanel from '@/components/MediaDetailPanel.vue'
import MediaDropArea from '@/components/MediaDropArea.vue'
import MediaEmptyState from '@/components/MediaEmptyState.vue'
import MediaGrid from '@/components/MediaGrid.vue'
import MediaSelectAllRow from '@/components/MediaSelectAllRow.vue'
import MediaSkeletonGrid from '@/components/MediaSkeletonGrid.vue'
import MediaTable from '@/components/MediaTable.vue'
import MediaToolbar from '@/components/MediaToolbar.vue'
import MediaUploadPanel from '@/components/MediaUploadPanel.vue'
import MergeMediaDialog from '@/components/MergeMediaDialog.vue'
import PurgeDuplicatesDialog from '@/components/PurgeDuplicatesDialog.vue'
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
import { ApiError } from '@/composables/useApi'
import {
classifyMediaError,
useMedia,
type BulkDeleteResult,
type MediaItem,
type MediaUsageItem,
} from '@/composables/useMedia'
import { useMediaSelection } from '@/composables/useMediaSelection'
import { useMediaUpload } from '@/composables/useMediaUpload'
import type { PurgeResult } from '@/composables/useMediaPurge'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat'
import {
parseMediaControls,
toRequestQuery,
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
import { purgedToast } from '@/lib/mediaPurge'
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const TOAST_DURATION_MS = 5000
const PERSISTENT_TOAST = 0
const BULK_FAILED_TOAST_ID = 'media-bulk-delete-failed'
const RETRY_BUTTON =
'h-11 w-full justify-center border border-accented bg-default text-xs text-highlighted ring-0 md:h-7'
const DESKTOP_SKELETON_CARDS = 10
const MOBILE_SKELETON_CARDS = 6
const DESKTOP_COLUMNS = 5
const MOBILE_COLUMNS = 2
const HTTP_CONFLICT = 409
const FOCUS_DELAY_MS = 0
const OVERLAY_SELECTOR = '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"]'
const SELECTION_CLEARED = 'Selection cleared'
const RENAME_FAILED_COPY =
"Couldn't rename the image. Your text is still here. Try again in a moment."
const DELETE_FAILED_COPY = "Couldn't delete the image, so it's still there. Try again in a moment."
const BULK_DELETE_FAILED_COPY =
"Couldn't delete the images, so they're still there. Try again in a moment."
const MERGE_FAILED_COPY = "Couldn't merge the copies. Nothing was changed. Try again in a moment."
const MERGE_CONFLICT_COPY =
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.'
const OFFLINE_COPY =
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
const route = useRoute()
const router = useRouter()
const toast = useLocalizedToast()
const session = useSessionStore()
const media = useMedia()
const selection = useMediaSelection(media.items)
const upload = useMediaUpload({ onAdded: refreshInBackground })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline()
const controls = computed(() => parseMediaControls(route.query))
const requestQuery = computed(() => toRequestQuery(controls.value))
const offline = computed(() => !online.value)
const hasLoaded = ref(false)
const heading = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const activeId = ref<number | null>(null)
const panelOpen = ref(false)
const renameOpen = ref(false)
const deleteOpen = ref(false)
const bulkDeleteOpen = ref(false)
const bulkTargets = ref<MediaItem[]>([])
const mergeTarget = ref<{ group: DuplicateGroup; keep: MediaItem } | null>(null)
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
let skipFocusRestore = false
let pendingSelectId: number | null = null
let bulkDeletedTotal = 0
let bulkFailureRevision = 0
let bulkFailureToastId = BULK_FAILED_TOAST_ID
let failureToastShown = false
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
)
const closingItem = ref<MediaItem | null>(null)
const panelItem = computed(() => activeItem.value ?? closingItem.value)
const counts = computed(() => (hasLoaded.value ? media.counts.value : null))
const loadFailed = computed(() => media.status.value === 'error')
const loading = computed(() => media.status.value === 'loading')
const ready = computed(() => media.status.value === 'ready')
const isDuplicates = computed(() => controls.value.filter === 'duplicates')
const groups = computed(() => (isDuplicates.value ? groupDuplicates(media.items.value) : []))
const emptyLibrary = computed(
() =>
ready.value &&
media.items.value.length === 0 &&
media.counts.value.all === 0 &&
controls.value.q === '',
)
const nothingToShow = computed(
() =>
ready.value &&
!emptyLibrary.value &&
(isDuplicates.value ? groups.value.length === 0 : media.items.value.length === 0),
)
const emptyVariant = computed(() => {
if (isDuplicates.value && controls.value.q === '') {
return 'no-duplicates'
}
if (controls.value.variants === 'with' && controls.value.q === '' && !isDuplicates.value) {
return controls.value.filter === 'all' ? 'no-variants' : 'no-results'
}
return 'no-results'
})
const filterLabel = computed(
() =>
({ all: t('All'), unused: t('Unused'), duplicates: t('Duplicates') })[
controls.value.filter
],
)
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
const canPurge = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const showPurge = computed(
() => canPurge.value && isDuplicates.value && showList.value && groups.value.length > 0,
)
const dragging = computed(() => dragDepth.value > 0)
const uploadBlocked = computed(() => offline.value || upload.limited.value)
const uploadLabel = computed(() =>
upload.limited.value ? t('Try again in {0}', [upload.countdown.value]) : t('Upload'),
)
const uploadIcon = computed(() => (upload.limited.value ? 'i-lucide-timer' : 'i-lucide-upload'))
const dialogOpen = computed(
() =>
panelOpen.value ||
renameOpen.value ||
deleteOpen.value ||
bulkDeleteOpen.value ||
purgeOpen.value ||
mergeTarget.value !== null,
)
const hasSelection = computed(() => selection.count.value > 0)
const showFab = computed(
() =>
!isDesktop.value &&
!dialogOpen.value &&
!hasSelection.value &&
!dragging.value &&
!loadFailed.value &&
!emptyLibrary.value,
)
const gridSelected = computed(() => selection.selectedIds.value)
const showBulkBar = computed(() => hasSelection.value && !emptyLibrary.value)
watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
immediate: true,
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function canonicalizeUrl() {
const canonical = toRouteQuery(controls.value)
const current = Object.fromEntries(
Object.entries(route.query).map(([key, value]) => [
key,
Array.isArray(value) ? (value[0] ?? '') : (value ?? ''),
]),
)
if (JSON.stringify(canonical) !== JSON.stringify(current)) {
router.replace({ query: canonical })
}
}
function updateControls(patch: Partial<MediaControls>) {
router.replace({ query: toRouteQuery({ ...controls.value, ...patch }) })
}
function clearFilters() {
updateControls({ q: '', filter: 'all', variants: 'all' })
}
function announce(source: () => string) {
announcementSource.value = source
}
canonicalizeUrl()
watch(activeItem, (item) => {
if (item) {
closingItem.value = item
}
})
watch(
requestQuery,
async () => {
selection.clear()
await media.load(controls.value)
hasLoaded.value = true
selectPending()
},
{ immediate: true },
)
watch(
() => upload.header.value,
(next, previous) => {
const settledNow = next.phase === 'settled' && previous?.phase === 'working'
if (settledNow && upload.rows.value.length > 0) {
announce(
() =>
upload.header.value.summary ??
`${upload.header.value.title}. ${upload.header.value.sub}`,
)
}
},
)
function refreshInBackground() {
media.load(controls.value, { silent: true })
}
function focusCard(id: number) {
document.querySelector<HTMLElement>(`[data-media-card][data-media-id="${id}"]`)?.focus()
}
function focusAfterRemoval(index: number) {
nextTick(() => {
setTimeout(() => {
const cards = document.querySelectorAll<HTMLElement>('[data-media-card]')
const target = cards[index]
if (target) {
target.focus()
return
}
heading.value?.focus()
}, FOCUS_DELAY_MS)
})
}
function focusElement(selector: string) {
nextTick(() => {
setTimeout(() => {
const target = document.querySelector<HTMLElement>(selector)
if (target) {
target.focus()
return
}
heading.value?.focus()
}, FOCUS_DELAY_MS)
})
}
function restoreFocusToOpener(id: number | null) {
nextTick(() => {
setTimeout(() => {
if (opener?.isConnected) {
opener.focus()
} else if (id !== null) {
focusCard(id)
}
opener = null
}, FOCUS_DELAY_MS)
})
}
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)
}
}
}
async function loadCopies(item: MediaItem) {
try {
const others = await media.findCopies(item)
if (activeId.value === item.id) {
copies.value = others
}
} catch {
copies.value = []
}
}
function openPanel(id: number, element: HTMLElement) {
const item = media.items.value.find((entry) => entry.id === id)
if (!item) {
return
}
opener = element
activeId.value = id
usage.value = []
copies.value = []
skipFocusRestore = false
panelOpen.value = true
if (item.used_in > 0) {
loadUsage(item)
}
if (item.duplicate) {
loadCopies(item)
}
}
function onPanelOpenChange(isOpen: boolean) {
panelOpen.value = isOpen
if (isOpen) {
return
}
const id = activeId.value
if (!skipFocusRestore) {
restoreFocusToOpener(id)
}
skipFocusRestore = false
}
function closeEverything() {
renameOpen.value = false
deleteOpen.value = false
skipFocusRestore = true
panelOpen.value = false
}
function reviewDuplicates() {
closeEverything()
updateControls({ filter: 'duplicates' })
}
function removeAndRefocus(item: MediaItem) {
const index = media.items.value.findIndex((entry) => entry.id === item.id)
closeEverything()
media.removeLocal(item.id)
focusAfterRemoval(index)
refreshInBackground()
}
function notFoundToast(id: number) {
toast.add(() => ({
id: `media-not-found-${id}`,
color: 'info',
icon: 'i-lucide-info',
get title() {
return t('That image no longer exists')
},
get description() {
return t('It was deleted somewhere else, so we removed it from the library.')
},
}))
}
function handleGone(item: MediaItem) {
removeAndRefocus(item)
notFoundToast(item.id)
}
async function copyUrl() {
const item = activeItem.value
if (!item) {
return
}
try {
await media.copyUrl(item)
} catch {
return
}
toast.add(() => ({
id: `media-url-copied-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('URL copied')
},
get description() {
return t('Paste it wherever you need the image address.')
},
duration: TOAST_DURATION_MS,
}))
}
async function renameAction(stem: string) {
const item = activeItem.value
if (!item) {
return
}
try {
const renamed = await media.renameItem(item.id, stem)
toast.add(() => ({
id: `media-renamed-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Image renamed')
},
get description() {
return t("It's now \u0022{0}\u0022. The URL is the same.", [renamed.filename])
},
duration: TOAST_DURATION_MS,
}))
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
return
}
if (failure === 'not-found') {
handleGone(item)
return
}
throw new Error(t(RENAME_FAILED_COPY))
}
}
async function deleteAction() {
const item = activeItem.value
if (!item) {
return
}
const index = media.items.value.findIndex((entry) => entry.id === item.id)
try {
await media.deleteItem(item.id)
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
return
}
if (failure === 'not-found') {
handleGone(item)
return
}
throw new Error(t(DELETE_FAILED_COPY))
}
closeEverything()
focusAfterRemoval(index)
toast.add(() => ({
id: `media-deleted-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Image deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone from your library.', [item.filename])
},
duration: TOAST_DURATION_MS,
}))
refreshInBackground()
}
function imageCount(total: number) {
return t(total === 1 ? '{0} image' : '{0} images', [total])
}
function announceSelection(filename: string, selected: boolean) {
if (!selected && selection.count.value === 0) {
announce(() => t(SELECTION_CLEARED))
return
}
announce(() =>
t('{0} {1}, {2} selected', [
filename,
selected ? t('selected') : t('unselected'),
selection.count.value,
]),
)
}
function toggleSelected(id: number) {
const item = media.items.value.find((entry) => entry.id === id)
if (!item) {
return
}
const selected = !selection.isSelected(id)
selection.toggle(id)
announceSelection(item.filename, selected)
}
function toggleAllSelected() {
selection.toggleAll()
announce(() =>
hasSelection.value ? t('{0} selected', [selection.count.value]) : t(SELECTION_CLEARED),
)
}
function clearSelection() {
if (!hasSelection.value) {
return
}
selection.clear()
announce(() => t(SELECTION_CLEARED))
}
function selectExisting(item: MediaItem) {
selection.select(item.id)
announceSelection(item.filename, true)
}
function selectPending() {
const id = pendingSelectId
pendingSelectId = null
const item = id === null ? undefined : media.items.value.find((entry) => entry.id === id)
if (item) {
selectExisting(item)
}
}
function useExisting(key: number) {
const existing = upload.rows.value.find((row) => row.key === key)?.existing
if (!existing) {
return
}
upload.remove(key)
if (media.items.value.some((item) => item.id === existing.id)) {
selectExisting(existing)
return
}
if (controls.value.q !== '' || controls.value.filter !== 'all') {
pendingSelectId = existing.id
clearFilters()
}
}
useEventListener(
window,
'keydown',
(event: KeyboardEvent) => {
if (event.key !== 'Escape' || !hasSelection.value) {
return
}
if (document.querySelector(OVERLAY_SELECTOR)) {
return
}
clearSelection()
},
{ capture: true },
)
function openChooser() {
if (!uploadBlocked.value) {
fileInput.value?.click()
}
}
function onFilesChosen(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (!uploadBlocked.value) {
upload.addFiles(files)
}
}
function carriesFiles(event: DragEvent) {
return Array.from(event.dataTransfer?.types ?? []).includes(t('Files'))
}
useEventListener(window, 'dragenter', (event: DragEvent) => {
if (!carriesFiles(event)) {
return
}
event.preventDefault()
if (!uploadBlocked.value) {
dragDepth.value += 1
}
})
useEventListener(window, 'dragover', (event: DragEvent) => {
if (carriesFiles(event)) {
event.preventDefault()
}
})
useEventListener(window, 'dragleave', (event: DragEvent) => {
if (carriesFiles(event)) {
dragDepth.value = Math.max(0, dragDepth.value - 1)
}
})
useEventListener(window, 'drop', (event: DragEvent) => {
if (!carriesFiles(event)) {
return
}
event.preventDefault()
dragDepth.value = 0
if (!uploadBlocked.value) {
upload.addFiles(Array.from(event.dataTransfer?.files ?? []))
}
})
function openBulkDelete() {
dismissBulkFailedToast()
bulkTargets.value = selection.selectedItems.value
bulkDeletedTotal = 0
bulkDeleteOpen.value = true
}
function bulkFailedDescription(failed: MediaItem[]) {
const names = failed.map((item) => item.filename)
const deleted =
bulkDeletedTotal === 1 ? t('1 image was') : t('{0} images were', [bulkDeletedTotal])
return t('{0} deleted. {1} {2} still in your library. Try again.', [
deleted,
joinNames(names),
t(names.length === 1 ? 'is' : 'are'),
])
}
function bulkFailedToast(failed: MediaItem[]) {
const content = {
color: 'error' as const,
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't delete {0}", [imageCount(failed.length)])
},
get description() {
return bulkFailedDescription(failed)
},
duration: PERSISTENT_TOAST,
actions: [
{
get label() {
return t('Retry')
},
icon: 'i-lucide-rotate-cw',
color: 'neutral' as const,
variant: 'outline' as const,
class: RETRY_BUTTON,
ui: { leadingIcon: 'size-3.5' },
onClick: () => retryBulkDelete(failed),
},
],
}
if (failureToastShown) {
toast.update(bulkFailureToastId, () => content)
return
}
failureToastShown = true
toast.add(() => ({ id: bulkFailureToastId, ...content }))
}
function dismissBulkFailedToast() {
if (failureToastShown) {
failureToastShown = false
toast.remove(bulkFailureToastId)
}
}
function finishBulkDelete(result: BulkDeleteResult, firstIndex: number) {
bulkDeletedTotal += result.deleted.length
if (result.failed.length > 0) {
selection.replace(result.failed.map((item) => item.id))
bulkFailedToast(result.failed)
focusElement('[data-testid="bulk-delete"]')
} else {
dismissBulkFailedToast()
selection.clear()
announce(() => t('{0} deleted', [imageCount(bulkDeletedTotal)]))
toast.add(() => ({
id: 'media-bulk-deleted',
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('{0} deleted', [imageCount(bulkDeletedTotal)])
},
get description() {
return t("They're gone from your library.")
},
duration: TOAST_DURATION_MS,
}))
focusAfterRemoval(firstIndex)
}
refreshInBackground()
}
function firstIndexOf(targets: MediaItem[]) {
const indexes = targets
.map((target) => media.items.value.findIndex((entry) => entry.id === target.id))
.filter((index) => index >= 0)
return indexes.length > 0 ? Math.min(...indexes) : 0
}
async function bulkDeleteAction() {
const targets = bulkTargets.value
const firstIndex = firstIndexOf(targets)
const result = await media.deleteMany(targets)
if (result.handled) {
return false
}
if (result.deleted.length === 0) {
throw new Error(t(BULK_DELETE_FAILED_COPY))
}
bulkDeleteOpen.value = false
finishBulkDelete(result, firstIndex)
}
async function retryBulkDelete(failed: MediaItem[]) {
dismissBulkFailedToast()
bulkFailureToastId = `${BULK_FAILED_TOAST_ID}-${++bulkFailureRevision}`
const firstIndex = firstIndexOf(failed)
const result = await media.deleteMany(failed)
if (result.handled) {
return
}
finishBulkDelete(result, firstIndex)
}
function openMerge(group: DuplicateGroup, keep: MediaItem) {
mergeTarget.value = { group, keep }
}
function openPurge(event?: Event) {
if (offline.value) {
return
}
opener = (event?.currentTarget as HTMLElement | null) ?? null
purgeChangedList = false
purgeOpen.value = true
}
function onPurged(result: PurgeResult) {
toast.add(purgedToast(result))
purgeChangedList = true
}
function onPurgePartial() {
purgeChangedList = true
}
function closePurge() {
purgeOpen.value = false
if (purgeChangedList) {
purgeChangedList = false
refreshInBackground()
}
restoreFocusToOpener(null)
}
function mergeText(merged: number, articlesUpdated: number) {
const copiesText = merged === 1 ? t('1 copy') : t('{0} copies', [merged])
const articlesText =
articlesUpdated === 1 ? t('1 article') : t('{0} articles', [articlesUpdated])
return t('{0} merged. {1} updated.', [copiesText, articlesText])
}
async function mergeAction() {
const target = mergeTarget.value
if (!target) {
return
}
const { group, keep } = target
const mergeIds = group.copies.filter((copy) => copy.id !== keep.id).map((copy) => copy.id)
try {
const result = await media.mergeCopies(keep.id, mergeIds)
mergeTarget.value = null
media.removeLocalMany(group.copies.map((copy) => copy.id))
announce(() => mergeText(result.merged, result.articles_updated))
toast.add(() => ({
id: `media-merged-${keep.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Copies merged')
},
get description() {
return mergeText(result.merged, result.articles_updated)
},
duration: TOAST_DURATION_MS,
}))
focusElement('[data-testid="media-heading"]')
refreshInBackground()
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
return false
}
if (failure === 'not-found') {
mergeTarget.value = null
notFoundToast(keep.id)
refreshInBackground()
return
}
if (error instanceof ApiError && error.status === HTTP_CONFLICT) {
refreshInBackground()
throw new Error(t(MERGE_CONFLICT_COPY))
}
throw new Error(t(MERGE_FAILED_COPY))
}
}
</script>
<template>
<DashboardShell
compact-padding
:offline-message="t(OFFLINE_COPY)"
offline-icon="i-lucide-wifi-off"
>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1
ref="heading"
data-testid="media-heading"
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
{{ t('Media') }}
</h1>
<p class="text-muted">
{{ t("Every image you've uploaded, ready to use in any article.") }}
</p>
</div>
<UButton
v-if="isDesktop"
:icon="uploadIcon"
:disabled="uploadBlocked"
class="h-8 px-3"
@click="openChooser"
>
{{ uploadLabel }}
</UButton>
</div>
<template v-if="emptyLibrary">
<MediaUploadPanel
v-if="upload.rows.value.length > 0"
:rows="upload.rows.value"
:header="upload.header.value"
@use-existing="useExisting"
@upload-anyway="upload.retryRow"
@retry="upload.retryRow"
@cancel-row="upload.cancel"
@cancel-all="upload.cancelAll"
@dismiss="upload.dismiss"
/>
<MediaEmptyState
variant="first-run"
:offline="offline"
:limited="upload.limited.value"
@action="openChooser"
/>
<p class="self-center text-xs text-muted">
{{ t('PNG, JPEG, WEBP, SVG · max 10 MB each') }}
</p>
</template>
<template v-else>
<UAlert
v-if="upload.limited.value"
role="status"
color="info"
variant="soft"
:close="false"
icon="i-lucide-timer"
:title="t(RATE_LIMITED_TITLE)"
:description="t(RATE_LIMITED_TEXT)"
/>
<UAlert
v-if="loadFailed"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load your images')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
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="media.load(controls)"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<MediaToolbar
:controls="controls"
:counts="counts"
:show-purge="showPurge"
:purge-disabled="offline"
@update:q="updateControls({ q: $event })"
@update:filter="updateControls({ filter: $event })"
@update:sort="updateControls({ sort: $event })"
@update:variants="updateControls({ variants: $event })"
@update:view="updateControls({ view: $event })"
@purge="openPurge"
/>
<MediaUploadPanel
v-if="upload.rows.value.length > 0"
:rows="upload.rows.value"
:header="upload.header.value"
@use-existing="useExisting"
@upload-anyway="upload.retryRow"
@retry="upload.retryRow"
@cancel-row="upload.cancel"
@cancel-all="upload.cancelAll"
@dismiss="upload.dismiss"
/>
<MediaSkeletonGrid
v-if="loading && media.showSkeleton.value"
:count="isDesktop ? DESKTOP_SKELETON_CARDS : MOBILE_SKELETON_CARDS"
/>
<MediaEmptyState
v-else-if="nothingToShow"
:variant="emptyVariant"
:query="controls.q"
:filter-label="filterLabel"
@action="clearFilters"
/>
<template v-else-if="showList">
<DuplicateGroups
v-if="isDuplicates"
:groups="groups"
:offline="offline"
@merge="openMerge"
@open="openPanel"
/>
<template v-else-if="controls.view === 'list'">
<MediaTable
v-if="isDesktop"
:items="media.items.value"
:selected="gridSelected"
:all-state="selection.allState.value"
@open="openPanel"
@toggle="toggleSelected"
@toggle-all="toggleAllSelected"
/>
<template v-else>
<MediaCardList
:items="media.items.value"
:selected="gridSelected"
@open="openPanel"
@toggle="toggleSelected"
/>
</template>
</template>
<template v-else>
<MediaSelectAllRow
:state="selection.allState.value"
:count="media.items.value.length"
@toggle="toggleAllSelected"
/>
<MediaGrid
:items="media.items.value"
:columns="isDesktop ? DESKTOP_COLUMNS : MOBILE_COLUMNS"
:selected="gridSelected"
:total-size="controls.sort === 'total_size'"
@open="openPanel"
@toggle="toggleSelected"
/>
</template>
</template>
</template>
</div>
<input
ref="fileInput"
type="file"
multiple
:accept="UPLOAD_ACCEPT"
data-testid="media-file-input"
class="hidden"
tabindex="-1"
aria-hidden="true"
@change="onFilesChosen"
/>
<p role="status" aria-live="polite" data-testid="media-announcer" class="sr-only">
{{ announcement }}
</p>
<MediaDropArea v-if="dragging" />
<UButton
v-if="showFab"
:icon="uploadIcon"
:disabled="uploadBlocked"
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover)"
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
@click="openChooser"
>
{{ uploadLabel }}
</UButton>
<MediaBulkBar
v-if="showBulkBar"
:count="selection.count.value"
:offline="offline"
:obscured="dialogOpen"
@delete="openBulkDelete"
@clear="clearSelection"
/>
<MediaDetailPanel
:open="panelOpen"
:item="panelItem"
:usage="usage"
:copies="copies"
:offline="offline"
@update:open="onPanelOpenChange"
@copy-url="copyUrl"
@rename="renameOpen = true"
@delete="deleteOpen = true"
@review-duplicates="reviewDuplicates"
@gone="activeItem && handleGone(activeItem)"
/>
<RenameMediaDialog
v-if="renameOpen && activeItem"
:item="activeItem"
:action="renameAction"
@close="renameOpen = false"
/>
<DeleteMediaDialog
v-if="deleteOpen && activeItem"
:item="activeItem"
:usage="usage"
:action="deleteAction"
@close="deleteOpen = false"
/>
<BulkDeleteMediaDialog
v-if="bulkDeleteOpen"
:items="bulkTargets"
:action="bulkDeleteAction"
@close="bulkDeleteOpen = false"
/>
<PurgeDuplicatesDialog
v-if="purgeOpen"
@close="closePurge"
@purged="onPurged"
@partial="onPurgePartial"
@review="purgeOpen = false"
/>
<MergeMediaDialog
v-if="mergeTarget"
:keep="mergeTarget.keep"
:copies="mergeTarget.group.copies"
:action="mergeAction"
@close="mergeTarget = null"
/>
</DashboardShell>
</template>