474 lines
12 KiB
Vue
474 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useMediaQuery, useOnline } from '@vueuse/core'
|
|
import { useToast } from '@nuxt/ui/composables'
|
|
import DashboardShell from '@/components/DashboardShell.vue'
|
|
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
|
|
import MediaCardList from '@/components/MediaCardList.vue'
|
|
import MediaDetailPanel from '@/components/MediaDetailPanel.vue'
|
|
import MediaEmptyState from '@/components/MediaEmptyState.vue'
|
|
import MediaGrid from '@/components/MediaGrid.vue'
|
|
import MediaSkeletonGrid from '@/components/MediaSkeletonGrid.vue'
|
|
import MediaTable from '@/components/MediaTable.vue'
|
|
import MediaToolbar from '@/components/MediaToolbar.vue'
|
|
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
|
|
import {
|
|
classifyMediaError,
|
|
useMedia,
|
|
type MediaItem,
|
|
type MediaUsageItem,
|
|
} from '@/composables/useMedia'
|
|
import {
|
|
parseMediaControls,
|
|
toRequestQuery,
|
|
toRouteQuery,
|
|
type MediaControls,
|
|
} from '@/lib/mediaQuery'
|
|
|
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
|
const TOAST_DURATION_MS = 5000
|
|
const DESKTOP_SKELETON_CARDS = 10
|
|
const MOBILE_SKELETON_CARDS = 6
|
|
const DESKTOP_COLUMNS = 5
|
|
const MOBILE_COLUMNS = 2
|
|
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 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 = useToast()
|
|
const media = useMedia()
|
|
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 activeId = ref<number | null>(null)
|
|
const panelOpen = ref(false)
|
|
const renameOpen = ref(false)
|
|
const deleteOpen = ref(false)
|
|
const usage = ref<MediaUsageItem[]>([])
|
|
const copies = ref<MediaItem[]>([])
|
|
let opener: HTMLElement | null = null
|
|
let skipFocusRestore = 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 emptyLibrary = computed(
|
|
() =>
|
|
ready.value &&
|
|
media.items.value.length === 0 &&
|
|
media.counts.value.all === 0 &&
|
|
controls.value.q === '',
|
|
)
|
|
const noResults = computed(
|
|
() => ready.value && media.items.value.length === 0 && !emptyLibrary.value,
|
|
)
|
|
const showList = computed(() => ready.value && media.items.value.length > 0)
|
|
const showFab = computed(
|
|
() =>
|
|
!isDesktop.value &&
|
|
!panelOpen.value &&
|
|
!renameOpen.value &&
|
|
!deleteOpen.value &&
|
|
!loadFailed.value &&
|
|
!emptyLibrary.value,
|
|
)
|
|
const imageCount = computed(() => {
|
|
const total = media.items.value.length
|
|
return `${total} ${total === 1 ? 'image' : 'images'}`
|
|
})
|
|
|
|
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' })
|
|
}
|
|
|
|
canonicalizeUrl()
|
|
|
|
watch(activeItem, (item) => {
|
|
if (item) {
|
|
closingItem.value = item
|
|
}
|
|
})
|
|
|
|
watch(
|
|
requestQuery,
|
|
async () => {
|
|
await media.load(controls.value)
|
|
hasLoaded.value = true
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
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()
|
|
}, 0)
|
|
})
|
|
}
|
|
|
|
function restoreFocusToOpener(id: number | null) {
|
|
nextTick(() => {
|
|
setTimeout(() => {
|
|
if (opener?.isConnected) {
|
|
opener.focus()
|
|
} else if (id !== null) {
|
|
focusCard(id)
|
|
}
|
|
opener = null
|
|
}, 0)
|
|
})
|
|
}
|
|
|
|
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 removeAndRefocus(item: MediaItem) {
|
|
const index = media.items.value.findIndex((entry) => entry.id === item.id)
|
|
closeEverything()
|
|
media.removeLocal(item.id)
|
|
focusAfterRemoval(index)
|
|
refreshInBackground()
|
|
}
|
|
|
|
function handleGone(item: MediaItem) {
|
|
removeAndRefocus(item)
|
|
toast.add({
|
|
id: `media-not-found-${item.id}`,
|
|
color: 'info',
|
|
icon: 'i-lucide-info',
|
|
title: 'That image no longer exists',
|
|
description: 'It was deleted somewhere else, so we removed it from the library.',
|
|
})
|
|
}
|
|
|
|
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',
|
|
title: 'URL copied',
|
|
description: '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',
|
|
title: 'Image renamed',
|
|
description: `It's now "${renamed.filename}". The URL is the same.`,
|
|
duration: TOAST_DURATION_MS,
|
|
})
|
|
} catch (error) {
|
|
const failure = classifyMediaError(error)
|
|
if (failure === 'handled') {
|
|
return
|
|
}
|
|
if (failure === 'not-found') {
|
|
handleGone(item)
|
|
return
|
|
}
|
|
throw new Error(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(DELETE_FAILED_COPY)
|
|
}
|
|
closeEverything()
|
|
focusAfterRemoval(index)
|
|
toast.add({
|
|
id: `media-deleted-${item.id}`,
|
|
color: 'success',
|
|
icon: 'i-lucide-circle-check',
|
|
title: 'Image deleted',
|
|
description: `"${item.filename}" is gone from your library.`,
|
|
duration: TOAST_DURATION_MS,
|
|
})
|
|
refreshInBackground()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<DashboardShell
|
|
compact-padding
|
|
:offline-message="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"
|
|
>
|
|
Media
|
|
</h1>
|
|
<p class="text-muted">
|
|
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>
|
|
</div>
|
|
|
|
<template v-if="emptyLibrary">
|
|
<MediaEmptyState variant="first-run" />
|
|
<p class="self-center text-xs text-muted">PNG, JPEG, WEBP, SVG · max 10 MB each</p>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<UAlert
|
|
v-if="loadFailed"
|
|
role="alert"
|
|
color="error"
|
|
variant="soft"
|
|
orientation="vertical"
|
|
:close="false"
|
|
icon="i-lucide-circle-x"
|
|
title="Couldn't load your images"
|
|
description="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)"
|
|
>
|
|
Try again
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<MediaToolbar
|
|
:controls="controls"
|
|
:counts="counts"
|
|
@update:q="updateControls({ q: $event })"
|
|
@update:filter="updateControls({ filter: $event })"
|
|
@update:sort="updateControls({ sort: $event })"
|
|
@update:view="updateControls({ view: $event })"
|
|
/>
|
|
|
|
<MediaSkeletonGrid
|
|
v-if="loading && media.showSkeleton.value"
|
|
:count="isDesktop ? DESKTOP_SKELETON_CARDS : MOBILE_SKELETON_CARDS"
|
|
/>
|
|
|
|
<MediaEmptyState
|
|
v-else-if="noResults"
|
|
variant="no-results"
|
|
:query="controls.q"
|
|
:filter-label="controls.filter === 'unused' ? 'Unused' : 'All'"
|
|
@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" />
|
|
</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>
|
|
<MediaGrid
|
|
:items="media.items.value"
|
|
:columns="isDesktop ? DESKTOP_COLUMNS : MOBILE_COLUMNS"
|
|
@open="openPanel"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</template>
|
|
</div>
|
|
|
|
<UButton
|
|
v-if="showFab"
|
|
icon="i-lucide-upload"
|
|
disabled
|
|
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' }"
|
|
>
|
|
Upload
|
|
</UButton>
|
|
|
|
<MediaDetailPanel
|
|
:open="panelOpen"
|
|
:item="panelItem"
|
|
:usage="usage"
|
|
:copies="copies"
|
|
:offline="offline"
|
|
@update:open="onPanelOpenChange"
|
|
@copy-url="copyUrl"
|
|
@rename="renameOpen = true"
|
|
@delete="deleteOpen = true"
|
|
/>
|
|
|
|
<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"
|
|
/>
|
|
</DashboardShell>
|
|
</template>
|