feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
+538
-37
@@ -1,40 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMediaQuery, useOnline } from '@vueuse/core'
|
||||
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
|
||||
import { useToast } from '@nuxt/ui/composables'
|
||||
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 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 { 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 { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
|
||||
|
||||
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."
|
||||
|
||||
@@ -42,6 +70,8 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const media = useMedia()
|
||||
const selection = useMediaSelection(media.items)
|
||||
const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const online = useOnline()
|
||||
|
||||
@@ -51,14 +81,23 @@ 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 dragDepth = ref(0)
|
||||
const announcement = ref('')
|
||||
const usage = ref<MediaUsageItem[]>([])
|
||||
const copies = ref<MediaItem[]>([])
|
||||
let opener: HTMLElement | null = null
|
||||
let skipFocusRestore = false
|
||||
let pendingSelectId: number | null = null
|
||||
let bulkDeletedTotal = 0
|
||||
let failureToastShown = false
|
||||
|
||||
const activeItem = computed(
|
||||
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
|
||||
@@ -69,6 +108,8 @@ 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 &&
|
||||
@@ -76,23 +117,49 @@ const emptyLibrary = computed(
|
||||
media.counts.value.all === 0 &&
|
||||
controls.value.q === '',
|
||||
)
|
||||
const noResults = computed(
|
||||
() => ready.value && media.items.value.length === 0 && !emptyLibrary.value,
|
||||
const nothingToShow = computed(
|
||||
() =>
|
||||
ready.value &&
|
||||
!emptyLibrary.value &&
|
||||
(isDuplicates.value ? groups.value.length === 0 : media.items.value.length === 0),
|
||||
)
|
||||
const showList = computed(() => ready.value && media.items.value.length > 0)
|
||||
const emptyVariant = computed(() =>
|
||||
isDuplicates.value && controls.value.q === '' ? 'no-duplicates' : 'no-results',
|
||||
)
|
||||
const filterLabel = computed(
|
||||
() => ({ all: 'All', unused: 'Unused', duplicates: 'Duplicates' })[controls.value.filter],
|
||||
)
|
||||
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
|
||||
const dragging = computed(() => dragDepth.value > 0)
|
||||
const uploadBlocked = computed(() => offline.value || upload.limited.value)
|
||||
const uploadLabel = computed(() =>
|
||||
upload.limited.value ? `Try again in ${upload.countdown.value}` : 'Upload',
|
||||
)
|
||||
const uploadIcon = computed(() => (upload.limited.value ? 'i-lucide-timer' : 'i-lucide-upload'))
|
||||
const dialogOpen = computed(
|
||||
() =>
|
||||
panelOpen.value ||
|
||||
renameOpen.value ||
|
||||
deleteOpen.value ||
|
||||
bulkDeleteOpen.value ||
|
||||
mergeTarget.value !== null,
|
||||
)
|
||||
const hasSelection = computed(() => selection.count.value > 0)
|
||||
const showFab = computed(
|
||||
() =>
|
||||
!isDesktop.value &&
|
||||
!panelOpen.value &&
|
||||
!renameOpen.value &&
|
||||
!deleteOpen.value &&
|
||||
!dialogOpen.value &&
|
||||
!hasSelection.value &&
|
||||
!dragging.value &&
|
||||
!loadFailed.value &&
|
||||
!emptyLibrary.value,
|
||||
)
|
||||
const imageCount = computed(() => {
|
||||
const total = media.items.value.length
|
||||
return `${total} ${total === 1 ? 'image' : 'images'}`
|
||||
})
|
||||
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)
|
||||
@@ -115,6 +182,10 @@ function clearFilters() {
|
||||
updateControls({ q: '', filter: 'all' })
|
||||
}
|
||||
|
||||
function announce(text: string) {
|
||||
announcement.value = text
|
||||
}
|
||||
|
||||
canonicalizeUrl()
|
||||
|
||||
watch(activeItem, (item) => {
|
||||
@@ -126,12 +197,24 @@ watch(activeItem, (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(next.summary ?? `${next.title}. ${next.sub}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function refreshInBackground() {
|
||||
media.load(controls.value, { silent: true })
|
||||
}
|
||||
@@ -150,7 +233,20 @@ function focusAfterRemoval(index: number) {
|
||||
return
|
||||
}
|
||||
heading.value?.focus()
|
||||
}, 0)
|
||||
}, 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -163,7 +259,7 @@ function restoreFocusToOpener(id: number | null) {
|
||||
focusCard(id)
|
||||
}
|
||||
opener = null
|
||||
}, 0)
|
||||
}, FOCUS_DELAY_MS)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -229,6 +325,11 @@ function closeEverything() {
|
||||
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()
|
||||
@@ -237,10 +338,9 @@ function removeAndRefocus(item: MediaItem) {
|
||||
refreshInBackground()
|
||||
}
|
||||
|
||||
function handleGone(item: MediaItem) {
|
||||
removeAndRefocus(item)
|
||||
function notFoundToast(id: number) {
|
||||
toast.add({
|
||||
id: `media-not-found-${item.id}`,
|
||||
id: `media-not-found-${id}`,
|
||||
color: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
title: 'That image no longer exists',
|
||||
@@ -248,6 +348,11 @@ function handleGone(item: MediaItem) {
|
||||
})
|
||||
}
|
||||
|
||||
function handleGone(item: MediaItem) {
|
||||
removeAndRefocus(item)
|
||||
notFoundToast(item.id)
|
||||
}
|
||||
|
||||
async function copyUrl() {
|
||||
const item = activeItem.value
|
||||
if (!item) {
|
||||
@@ -327,6 +432,292 @@ async function deleteAction() {
|
||||
})
|
||||
refreshInBackground()
|
||||
}
|
||||
|
||||
function imageCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'image' : 'images'}`
|
||||
}
|
||||
|
||||
function announceSelection(filename: string, selected: boolean) {
|
||||
if (!selected && selection.count.value === 0) {
|
||||
announce(SELECTION_CLEARED)
|
||||
return
|
||||
}
|
||||
announce(
|
||||
`${filename} ${selected ? 'selected' : 'unselected'}, ${selection.count.value} selected`,
|
||||
)
|
||||
}
|
||||
|
||||
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 ? `${selection.count.value} selected` : SELECTION_CLEARED)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
if (!hasSelection.value) {
|
||||
return
|
||||
}
|
||||
selection.clear()
|
||||
announce(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('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 ? '1 image was' : `${bulkDeletedTotal} images were`
|
||||
return `${deleted} deleted. ${joinNames(names)} ${names.length === 1 ? 'is' : 'are'} still in your library. Try again.`
|
||||
}
|
||||
|
||||
function bulkFailedToast(failed: MediaItem[]) {
|
||||
const content = {
|
||||
color: 'error' as const,
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: `Couldn't delete ${imageCount(failed.length)}`,
|
||||
description: bulkFailedDescription(failed),
|
||||
duration: PERSISTENT_TOAST,
|
||||
actions: [
|
||||
{
|
||||
label: '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(BULK_FAILED_TOAST_ID, content)
|
||||
return
|
||||
}
|
||||
failureToastShown = true
|
||||
toast.add({ id: BULK_FAILED_TOAST_ID, ...content })
|
||||
}
|
||||
|
||||
function dismissBulkFailedToast() {
|
||||
if (failureToastShown) {
|
||||
failureToastShown = false
|
||||
toast.remove(BULK_FAILED_TOAST_ID)
|
||||
}
|
||||
}
|
||||
|
||||
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(`${imageCount(bulkDeletedTotal)} deleted`)
|
||||
toast.add({
|
||||
id: 'media-bulk-deleted',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: `${imageCount(bulkDeletedTotal)} deleted`,
|
||||
description: "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(BULK_DELETE_FAILED_COPY)
|
||||
}
|
||||
bulkDeleteOpen.value = false
|
||||
finishBulkDelete(result, firstIndex)
|
||||
}
|
||||
|
||||
async function retryBulkDelete(failed: MediaItem[]) {
|
||||
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 mergeText(merged: number, articlesUpdated: number) {
|
||||
const copiesText = merged === 1 ? '1 copy' : `${merged} copies`
|
||||
const articlesText = articlesUpdated === 1 ? '1 article' : `${articlesUpdated} articles`
|
||||
return `${copiesText} merged. ${articlesText} updated.`
|
||||
}
|
||||
|
||||
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))
|
||||
const text = mergeText(result.merged, result.articles_updated)
|
||||
announce(text)
|
||||
toast.add({
|
||||
id: `media-merged-${keep.id}`,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Copies merged',
|
||||
description: text,
|
||||
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(MERGE_CONFLICT_COPY)
|
||||
}
|
||||
throw new Error(MERGE_FAILED_COPY)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -350,17 +741,50 @@ async function deleteAction() {
|
||||
Every image you've uploaded, ready to use in any article.
|
||||
</p>
|
||||
</div>
|
||||
<UButton v-if="isDesktop" icon="i-lucide-upload" disabled class="h-8 px-3">
|
||||
Upload
|
||||
<UButton
|
||||
v-if="isDesktop"
|
||||
:icon="uploadIcon"
|
||||
:disabled="uploadBlocked"
|
||||
class="h-8 px-3"
|
||||
@click="openChooser"
|
||||
>
|
||||
{{ uploadLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<template v-if="emptyLibrary">
|
||||
<MediaEmptyState variant="first-run" />
|
||||
<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">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="RATE_LIMITED_TITLE"
|
||||
:description="RATE_LIMITED_TEXT"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
v-if="loadFailed"
|
||||
role="alert"
|
||||
@@ -396,53 +820,114 @@ async function deleteAction() {
|
||||
@update:view="updateControls({ view: $event })"
|
||||
/>
|
||||
|
||||
<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="noResults"
|
||||
variant="no-results"
|
||||
v-else-if="nothingToShow"
|
||||
:variant="emptyVariant"
|
||||
:query="controls.q"
|
||||
:filter-label="controls.filter === 'unused' ? 'Unused' : 'All'"
|
||||
:filter-label="filterLabel"
|
||||
@action="clearFilters"
|
||||
/>
|
||||
|
||||
<template v-else-if="showList">
|
||||
<template v-if="controls.view === 'list'">
|
||||
<MediaTable v-if="isDesktop" :items="media.items.value" @open="openPanel" />
|
||||
<MediaCardList v-else :items="media.items.value" @open="openPanel" />
|
||||
<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>
|
||||
<div class="flex items-center gap-2.5 text-muted">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="-ml-2.75 size-11 shrink-0 md:ml-0 md:size-4.5"
|
||||
/>
|
||||
<span aria-hidden="true" class="w-13.75 shrink-0" />
|
||||
<span data-testid="image-count">{{ imageCount }}</span>
|
||||
</div>
|
||||
<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"
|
||||
@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="i-lucide-upload"
|
||||
disabled
|
||||
: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"
|
||||
>
|
||||
Upload
|
||||
{{ uploadLabel }}
|
||||
</UButton>
|
||||
|
||||
<MediaBulkBar
|
||||
v-if="showBulkBar"
|
||||
:count="selection.count.value"
|
||||
:offline="offline"
|
||||
:obscured="dialogOpen"
|
||||
@delete="openBulkDelete"
|
||||
@clear="clearSelection"
|
||||
/>
|
||||
|
||||
<MediaDetailPanel
|
||||
:open="panelOpen"
|
||||
:item="panelItem"
|
||||
@@ -453,6 +938,7 @@ async function deleteAction() {
|
||||
@copy-url="copyUrl"
|
||||
@rename="renameOpen = true"
|
||||
@delete="deleteOpen = true"
|
||||
@review-duplicates="reviewDuplicates"
|
||||
/>
|
||||
|
||||
<RenameMediaDialog
|
||||
@@ -469,5 +955,20 @@ async function deleteAction() {
|
||||
:action="deleteAction"
|
||||
@close="deleteOpen = false"
|
||||
/>
|
||||
|
||||
<BulkDeleteMediaDialog
|
||||
v-if="bulkDeleteOpen"
|
||||
:items="bulkTargets"
|
||||
:action="bulkDeleteAction"
|
||||
@close="bulkDeleteOpen = false"
|
||||
/>
|
||||
|
||||
<MergeMediaDialog
|
||||
v-if="mergeTarget"
|
||||
:keep="mergeTarget.keep"
|
||||
:copies="mergeTarget.group.copies"
|
||||
:action="mergeAction"
|
||||
@close="mergeTarget = null"
|
||||
/>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
Reference in new issue
Block a user