feat(SPA): media page T0

This commit is contained in:
fsylvain committed 2026-09-30 19:45:47 +02:00
1 parent 0d99544579
commit 67d456baf6
32 files changed
+4874 -40

No files matched your search

+473
View File
@@ -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>