feat: media page T1

This commit is contained in:
fsylvain committed 2026-10-01 01:36:35 +02:00
1 parent 67d456baf6
commit 008b0dfbb9
46 files changed
+5742 -204

No files matched your search

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