feat(SPA): media page T0
This commit is contained in:
1 parent
0d99544579
commit
67d456baf6
32 files changed
+4874
-40
No files matched your search
@@ -0,0 +1,473 @@
|
||||
<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>
|
||||
Reference in new issue
Block a user