1095 lines
28 KiB
Vue
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>
|