feat: media page T1
This commit is contained in:
1 parent
67d456baf6
commit
008b0dfbb9
46 files changed
+5742
-204
No files matched your search
+9
-1
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
|
||||
const BULK_BAR_TOAST_OFFSET = 'max-sm:bottom-20'
|
||||
|
||||
const toaster = computed(() => ({
|
||||
ui: { viewport: toastsAboveBulkBar.value ? BULK_BAR_TOAST_OFFSET : '' },
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp class="isolate">
|
||||
<UApp class="isolate" :toaster="toaster">
|
||||
<RouterView />
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import MediaBadge from '@/components/MediaBadge.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { pluralArticles } from '@/lib/mediaFormat'
|
||||
|
||||
const BROKEN_IMAGE = 'which will show a broken image.'
|
||||
const FINAL_WARNING = "This can't be undone."
|
||||
|
||||
const props = defineProps<{
|
||||
items: MediaItem[]
|
||||
action: () => Promise<void | boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
||||
|
||||
const total = computed(() => props.items.length)
|
||||
const imageLabel = computed(() => `${total.value} ${total.value === 1 ? 'image' : 'images'}`)
|
||||
const inUse = computed(() => props.items.filter((item) => item.used_in > 0))
|
||||
const articleCount = computed(() => inUse.value.reduce((sum, item) => sum + item.used_in, 0))
|
||||
const description = computed(() => {
|
||||
const usedCount = inUse.value.length
|
||||
const unusedCount = total.value - usedCount
|
||||
const articles = pluralArticles(articleCount.value)
|
||||
if (usedCount === 0) {
|
||||
return total.value === 1
|
||||
? `It isn't used in any article. ${FINAL_WARNING}`
|
||||
: `None of them are used in any article. ${FINAL_WARNING}`
|
||||
}
|
||||
if (unusedCount === 0) {
|
||||
const subject = total.value === 1 ? "It's" : `All ${total.value} are`
|
||||
return `${subject} used in ${articles}, ${BROKEN_IMAGE} ${FINAL_WARNING}`
|
||||
}
|
||||
const rest =
|
||||
unusedCount === 1
|
||||
? "The other one isn't used anywhere."
|
||||
: `The other ${unusedCount} aren't used anywhere.`
|
||||
return `${usedCount} of them are used in ${articles}, ${BROKEN_IMAGE} ${rest} ${FINAL_WARNING}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog
|
||||
:title="`Delete ${imageLabel}?`"
|
||||
:description="description"
|
||||
:confirm-label="`Delete ${imageLabel}`"
|
||||
cancel-label="Cancel"
|
||||
pending-label="Deleting…"
|
||||
tone="danger"
|
||||
:action="action"
|
||||
@close="emit('close', $event)"
|
||||
>
|
||||
<template #body>
|
||||
<ul class="border border-default">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
data-testid="bulk-delete-row"
|
||||
class="flex min-h-9 items-center gap-2 px-3"
|
||||
:class="{ 'border-t border-muted': index > 0 }"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-highlighted">{{
|
||||
item.filename
|
||||
}}</span>
|
||||
<MediaBadge
|
||||
v-if="item.used_in > 0"
|
||||
kind="used"
|
||||
:label="`Used in ${item.used_in}`"
|
||||
/>
|
||||
<MediaBadge v-else kind="unused" label="Unused" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { DuplicateGroup } from '@/lib/mediaDuplicates'
|
||||
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
|
||||
|
||||
const FOOTER =
|
||||
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.'
|
||||
|
||||
defineProps<{
|
||||
groups: DuplicateGroup[]
|
||||
offline: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
merge: [group: DuplicateGroup, keep: MediaItem]
|
||||
open: [id: number, opener: HTMLElement]
|
||||
}>()
|
||||
|
||||
function uploaded(copy: MediaItem) {
|
||||
const by = copy.uploaded_by ? ` by ${copy.uploaded_by}` : ''
|
||||
return `Uploaded ${formatMediaDate(copy.created_at)}${by}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<section
|
||||
v-for="(group, index) in groups"
|
||||
:key="group.hash"
|
||||
data-testid="duplicate-group"
|
||||
class="border border-default bg-default"
|
||||
>
|
||||
<header class="flex flex-col gap-0.5 border-b border-default px-3.5 py-3">
|
||||
<h3 class="font-semibold text-highlighted">Group {{ index + 1 }}</h3>
|
||||
<span class="text-muted">{{ group.header }}</span>
|
||||
</header>
|
||||
<div
|
||||
class="grid md:grid-cols-(--columns) md:gap-4 md:p-4"
|
||||
:style="{ '--columns': `repeat(${group.copies.length}, minmax(0, 1fr))` }"
|
||||
>
|
||||
<div
|
||||
v-for="copy in group.copies"
|
||||
:key="copy.id"
|
||||
data-testid="duplicate-copy"
|
||||
:data-suggested="copy.id === group.suggested.id || undefined"
|
||||
class="flex flex-col gap-3 max-md:border-b max-md:border-muted max-md:p-3.5 max-md:last:border-b-0 md:border md:p-3"
|
||||
:class="
|
||||
copy.id === group.suggested.id
|
||||
? 'bg-secondary-200 text-primary-900 md:border-primary dark:bg-secondary-300'
|
||||
: 'bg-default text-default md:border-default'
|
||||
"
|
||||
>
|
||||
<div class="flex gap-3 max-md:items-start md:flex-col">
|
||||
<MediaThumb
|
||||
:item="copy"
|
||||
class="border border-default max-md:h-18 max-md:w-24 md:aspect-4/3 md:w-full"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
class="truncate font-medium"
|
||||
:class="
|
||||
copy.id === group.suggested.id
|
||||
? 'text-primary-900'
|
||||
: 'text-highlighted'
|
||||
"
|
||||
>{{ copy.filename }}</span
|
||||
>
|
||||
<span
|
||||
v-if="copy.id === group.suggested.id"
|
||||
class="inline-flex h-5 items-center bg-primary px-1.5 text-xs font-medium text-inverted"
|
||||
>Suggested</span
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs/5"
|
||||
:class="
|
||||
copy.id === group.suggested.id
|
||||
? 'text-primary-600'
|
||||
: 'text-muted'
|
||||
"
|
||||
>{{ uploaded(copy) }}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="duplicate-used-in"
|
||||
class="self-start font-medium underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
@click="emit('open', copy.id, $event.currentTarget as HTMLElement)"
|
||||
>
|
||||
Used in {{ pluralArticles(copy.used_in) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
icon="i-lucide-merge"
|
||||
:color="copy.id === group.suggested.id ? 'primary' : 'neutral'"
|
||||
:variant="copy.id === group.suggested.id ? 'solid' : 'outline'"
|
||||
:disabled="offline"
|
||||
class="h-11 justify-center px-3 md:h-8"
|
||||
:class="
|
||||
copy.id === group.suggested.id
|
||||
? ''
|
||||
: 'border border-accented bg-default text-highlighted ring-0'
|
||||
"
|
||||
@click="emit('merge', group, copy)"
|
||||
>
|
||||
Merge into this one
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="border-t border-default px-3.5 py-2.5 text-xs/5 text-muted">
|
||||
{{ FOOTER }}
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useElementBounding, useMediaQuery } from '@vueuse/core'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const CONTENT_SELECTOR = '#dashboard-content'
|
||||
|
||||
defineProps<{
|
||||
count: number
|
||||
offline: boolean
|
||||
obscured: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: []
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const content = useElementBounding(() => document.querySelector<HTMLElement>(CONTENT_SELECTOR))
|
||||
const centre = computed(() => `${content.left.value + content.width.value / 2}px`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="region"
|
||||
aria-label="Selection"
|
||||
data-testid="media-bulk-bar"
|
||||
class="fixed bottom-4 z-(--z-banner) flex items-center gap-2 bg-inverted py-2 pr-2 pl-4 text-inverted shadow-(--shadow-popover) max-md:inset-x-4 md:bottom-6 md:left-(--bulk-centre) md:w-100 md:-translate-x-1/2"
|
||||
:class="{ invisible: obscured }"
|
||||
:inert="obscured || undefined"
|
||||
:style="isDesktop ? { '--bulk-centre': centre } : undefined"
|
||||
>
|
||||
<span class="flex-1 font-medium">{{ count }} selected</span>
|
||||
<UButton
|
||||
color="error"
|
||||
icon="i-lucide-trash-2"
|
||||
:disabled="offline"
|
||||
data-testid="bulk-delete"
|
||||
class="h-11 px-3 md:h-8"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
Delete
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
data-testid="bulk-clear"
|
||||
class="h-11 border border-(--ui-text-inverted) bg-transparent px-3 text-inverted ring-0 hover:bg-transparent md:h-8"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
Clear
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import MediaCardBadges from '@/components/MediaCardBadges.vue'
|
||||
import MediaCheckbox from '@/components/MediaCheckbox.vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { formatDimensions, formatMediaDate, formatSize, formatType } from '@/lib/mediaFormat'
|
||||
|
||||
defineProps<{
|
||||
items: MediaItem[]
|
||||
selected: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [id: number, opener: HTMLElement]
|
||||
toggle: [id: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -19,20 +22,31 @@ const emit = defineEmits<{
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
data-testid="media-card-row"
|
||||
class="border-b border-default last:border-b-0"
|
||||
:data-selected="selected.includes(item.id) || undefined"
|
||||
class="relative border-b border-default last:border-b-0"
|
||||
:class="
|
||||
selected.includes(item.id)
|
||||
? 'bg-secondary-200 text-primary-900 dark:bg-secondary-300'
|
||||
: 'bg-default'
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-media-card
|
||||
:data-media-id="item.id"
|
||||
class="flex min-h-20 w-full items-center py-2 pr-3 pl-11 text-start text-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
||||
class="flex min-h-20 w-full items-center py-2 pr-3 pl-11 text-start focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
||||
:class="selected.includes(item.id) ? 'text-primary-600' : 'text-muted'"
|
||||
@click="emit('open', item.id, $event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<MediaThumb :item="item" size="md" class="size-14 border border-default" />
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.75 pl-3">
|
||||
<span class="truncate text-[0.9375rem] font-medium text-highlighted">{{
|
||||
item.filename
|
||||
}}</span>
|
||||
<span
|
||||
class="truncate text-[0.9375rem] font-medium"
|
||||
:class="
|
||||
selected.includes(item.id) ? 'text-primary-900' : 'text-highlighted'
|
||||
"
|
||||
>{{ item.filename }}</span
|
||||
>
|
||||
<span class="text-xs/5"
|
||||
>{{ formatSize(item.size) }} ·
|
||||
{{ formatDimensions(item.width, item.height) }} ·
|
||||
@@ -45,6 +59,17 @@ const emit = defineEmits<{
|
||||
</span>
|
||||
<UIcon name="i-lucide-chevron-right" class="ml-2 size-4 shrink-0" />
|
||||
</button>
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 flex w-11 items-center justify-center"
|
||||
@click.self="emit('toggle', item.id)"
|
||||
>
|
||||
<MediaCheckbox
|
||||
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
|
||||
:label="`Select ${item.filename}`"
|
||||
:tabbable="false"
|
||||
@toggle="emit('toggle', item.id)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state: 'checked' | 'unchecked' | 'partial'
|
||||
label: string
|
||||
size?: 'sm' | 'md'
|
||||
tabbable?: boolean
|
||||
}>(),
|
||||
{ size: 'md', tabbable: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
||||
const modelValue = computed(() => {
|
||||
if (props.state === 'partial') {
|
||||
return 'indeterminate' as const
|
||||
}
|
||||
return props.state === 'checked'
|
||||
})
|
||||
const boxClass = computed(() =>
|
||||
props.size === 'sm' ? 'size-4.5 bg-default' : 'size-5 bg-default max-md:size-5.5',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCheckbox
|
||||
:model-value="modelValue"
|
||||
:aria-label="label"
|
||||
:tabindex="tabbable ? undefined : -1"
|
||||
color="primary"
|
||||
:ui="{
|
||||
root: 'items-center',
|
||||
container: 'h-auto items-center',
|
||||
base: boxClass,
|
||||
icon: 'size-3.5',
|
||||
}"
|
||||
@update:model-value="emit('toggle')"
|
||||
/>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
formatMediaDate,
|
||||
formatSize,
|
||||
formatType,
|
||||
joinNames,
|
||||
pluralArticles,
|
||||
} from '@/lib/mediaFormat'
|
||||
|
||||
@@ -27,6 +28,7 @@ const emit = defineEmits<{
|
||||
'copy-url': []
|
||||
rename: []
|
||||
delete: []
|
||||
'review-duplicates': []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
@@ -55,13 +57,6 @@ const metadata = computed(() => {
|
||||
|
||||
const displayUrl = computed(() => `${window.location.host}${props.item?.url ?? ''}`)
|
||||
|
||||
function joinNames(names: string[]) {
|
||||
if (names.length <= 1) {
|
||||
return names.join('')
|
||||
}
|
||||
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
|
||||
}
|
||||
|
||||
const duplicateAlert = computed(() => {
|
||||
if (!props.item?.duplicate || props.copies.length === 0) {
|
||||
return null
|
||||
@@ -149,10 +144,10 @@ const duplicateAlert = computed(() => {
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-arrow-right"
|
||||
disabled
|
||||
block
|
||||
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="emit('review-duplicates')"
|
||||
>
|
||||
Review duplicates
|
||||
</UButton>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { DROP_TEXT, DROP_TITLE } from '@/lib/mediaUploadRows'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="media-drop-area"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none fixed inset-2 top-16 z-(--z-popover) flex flex-col items-center justify-center gap-2 border-2 border-dashed border-primary bg-secondary-200 text-center text-primary-900 md:inset-3 md:top-15 dark:bg-secondary-300"
|
||||
>
|
||||
<UIcon name="i-lucide-upload" class="size-7" />
|
||||
<span class="text-lg font-semibold">{{ DROP_TITLE }}</span>
|
||||
<span class="text-primary-600">{{ DROP_TEXT }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,9 +4,11 @@ import { computed } from 'vue'
|
||||
const QUERY_MAX_LENGTH = 40
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'first-run' | 'no-results'
|
||||
variant: 'first-run' | 'no-results' | 'no-duplicates'
|
||||
query?: string
|
||||
filterLabel?: string
|
||||
offline?: boolean
|
||||
limited?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
@@ -22,6 +24,14 @@ const current = computed(() => {
|
||||
action: 'Upload an image',
|
||||
}
|
||||
}
|
||||
if (props.variant === 'no-duplicates') {
|
||||
return {
|
||||
icon: 'i-lucide-copy',
|
||||
title: 'No duplicates',
|
||||
description: 'Every image in your library is different.',
|
||||
action: 'Back to all images',
|
||||
}
|
||||
}
|
||||
return {
|
||||
icon: 'i-lucide-search',
|
||||
title: truncatedQuery.value
|
||||
@@ -51,8 +61,9 @@ const current = computed(() => {
|
||||
<UButton
|
||||
v-if="variant === 'first-run'"
|
||||
icon="i-lucide-upload"
|
||||
disabled
|
||||
:disabled="offline || limited"
|
||||
class="h-11 px-4 md:h-8"
|
||||
@click="emit('action')"
|
||||
>
|
||||
{{ current.action }}
|
||||
</UButton>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import MediaCardBadges from '@/components/MediaCardBadges.vue'
|
||||
import MediaCheckbox from '@/components/MediaCheckbox.vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||
|
||||
const SELECTED_CARD =
|
||||
'border-primary bg-secondary-200 text-primary-900 inset-ring-1 inset-ring-primary dark:bg-secondary-300'
|
||||
const IDLE_CARD = 'border-default bg-default text-default'
|
||||
|
||||
const props = defineProps<{
|
||||
items: MediaItem[]
|
||||
columns: number
|
||||
selected: number[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [id: number, opener: HTMLElement]
|
||||
toggle: [id: number]
|
||||
}>()
|
||||
|
||||
const grid = ref<HTMLElement | null>(null)
|
||||
@@ -38,6 +45,9 @@ function targetIndex(key: string, index: number) {
|
||||
function onKeydown(event: KeyboardEvent, item: MediaItem, index: number) {
|
||||
if (event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (!event.repeat) {
|
||||
emit('toggle', item.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
@@ -62,31 +72,66 @@ function onKeyup(event: KeyboardEvent) {
|
||||
|
||||
<template>
|
||||
<div ref="grid" class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
data-media-card
|
||||
:data-media-id="item.id"
|
||||
:aria-label="accessibleName(item)"
|
||||
class="flex min-w-0 flex-col border border-default bg-default text-start text-default transition-shadow hover:border-accented hover:shadow-(--shadow-popover) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
@click="emit('open', item.id, $event.currentTarget as HTMLElement)"
|
||||
@keydown="onKeydown($event, item, index)"
|
||||
@keyup="onKeyup"
|
||||
>
|
||||
<MediaThumb :item="item" class="aspect-4/3 w-full border-b border-default" />
|
||||
<span class="flex min-w-0 flex-col gap-1.5 px-3 py-2.5">
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate font-medium text-highlighted">{{ item.filename }}</span>
|
||||
<span class="truncate text-xs/5 text-muted"
|
||||
>{{ formatSize(item.size) }} ·
|
||||
{{ formatDimensions(item.width, item.height) }}</span
|
||||
>
|
||||
<div v-for="(item, index) in items" :key="item.id" class="group/card relative min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
data-media-card
|
||||
:data-media-id="item.id"
|
||||
:data-selected="selected.includes(item.id) || undefined"
|
||||
:aria-label="accessibleName(item)"
|
||||
class="flex w-full min-w-0 flex-col border text-start transition-shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
:class="
|
||||
selected.includes(item.id)
|
||||
? SELECTED_CARD
|
||||
: [IDLE_CARD, 'hover:border-accented hover:shadow-(--shadow-popover)']
|
||||
"
|
||||
@click="emit('open', item.id, $event.currentTarget as HTMLElement)"
|
||||
@keydown="onKeydown($event, item, index)"
|
||||
@keyup="onKeyup"
|
||||
>
|
||||
<MediaThumb
|
||||
:item="item"
|
||||
class="aspect-4/3 w-full border-b"
|
||||
:class="selected.includes(item.id) ? 'border-primary' : 'border-default'"
|
||||
/>
|
||||
<span class="flex min-w-0 flex-col gap-1.5 px-3 py-2.5">
|
||||
<span class="flex min-w-0 flex-col gap-0.5">
|
||||
<span
|
||||
class="truncate font-medium"
|
||||
:class="
|
||||
selected.includes(item.id) ? 'text-primary-900' : 'text-highlighted'
|
||||
"
|
||||
>{{ item.filename }}</span
|
||||
>
|
||||
<span
|
||||
class="truncate text-xs/5"
|
||||
:class="selected.includes(item.id) ? 'text-primary-600' : 'text-muted'"
|
||||
>{{ formatSize(item.size) }} ·
|
||||
{{ formatDimensions(item.width, item.height) }}</span
|
||||
>
|
||||
</span>
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<MediaCardBadges :item="item" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex flex-wrap gap-1">
|
||||
<MediaCardBadges :item="item" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
<div
|
||||
data-testid="card-checkbox"
|
||||
class="absolute top-0 left-0 flex size-11 items-center justify-center md:size-9"
|
||||
:class="
|
||||
selected.length > 0
|
||||
? 'opacity-100'
|
||||
: 'max-md:opacity-100 md:opacity-0 md:group-hover/card:opacity-100 md:group-has-[:focus-visible]/card:opacity-100'
|
||||
"
|
||||
@click.self="emit('toggle', item.id)"
|
||||
>
|
||||
<MediaCheckbox
|
||||
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
|
||||
:label="`Select ${item.filename}`"
|
||||
:tabbable="false"
|
||||
@toggle="emit('toggle', item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { SelectAllState } from '@/composables/useMediaSelection'
|
||||
|
||||
const props = defineProps<{
|
||||
state: SelectAllState
|
||||
count: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
||||
const modelValue = computed(() => {
|
||||
if (props.state === 'partial') {
|
||||
return 'indeterminate' as const
|
||||
}
|
||||
return props.state === 'all'
|
||||
})
|
||||
const imageCount = computed(() => `${props.count} ${props.count === 1 ? 'image' : 'images'}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2.5 text-muted">
|
||||
<UCheckbox
|
||||
:model-value="modelValue"
|
||||
label="Select all"
|
||||
color="primary"
|
||||
class="-ml-2.75 md:ml-0"
|
||||
:ui="{
|
||||
root: 'items-center',
|
||||
container: 'size-11 shrink-0 items-center justify-center md:size-4.5',
|
||||
base: 'size-5.5 bg-default md:size-4.5',
|
||||
wrapper: 'ms-2.5',
|
||||
label: 'font-medium text-highlighted',
|
||||
}"
|
||||
@update:model-value="emit('toggle')"
|
||||
/>
|
||||
<span data-testid="image-count">{{ imageCount }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import MediaBadge from '@/components/MediaBadge.vue'
|
||||
import MediaCheckbox from '@/components/MediaCheckbox.vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { SelectAllState } from '@/composables/useMediaSelection'
|
||||
import {
|
||||
formatDimensions,
|
||||
formatMediaDate,
|
||||
@@ -15,12 +17,22 @@ const ROW_GRID =
|
||||
|
||||
defineProps<{
|
||||
items: MediaItem[]
|
||||
selected: number[]
|
||||
allState: SelectAllState
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [id: number, opener: HTMLElement]
|
||||
toggle: [id: number]
|
||||
'toggle-all': []
|
||||
}>()
|
||||
|
||||
const ALL_STATES = {
|
||||
none: 'unchecked',
|
||||
partial: 'partial',
|
||||
all: 'checked',
|
||||
} as const
|
||||
|
||||
function open(event: Event, id: number) {
|
||||
emit('open', id, event.currentTarget as HTMLElement)
|
||||
}
|
||||
@@ -29,7 +41,14 @@ function open(event: Event, id: number) {
|
||||
<template>
|
||||
<div class="border border-default bg-default">
|
||||
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
|
||||
<span></span>
|
||||
<span class="flex justify-center">
|
||||
<MediaCheckbox
|
||||
size="sm"
|
||||
label="Select all"
|
||||
:state="ALL_STATES[allState]"
|
||||
@toggle="emit('toggle-all')"
|
||||
/>
|
||||
</span>
|
||||
<span>Filename</span>
|
||||
<span>Dimensions</span>
|
||||
<span>Size</span>
|
||||
@@ -46,17 +65,31 @@ function open(event: Event, id: number) {
|
||||
:key="item.id"
|
||||
data-testid="media-row"
|
||||
:data-media-id="item.id"
|
||||
:class="ROW_GRID"
|
||||
class="relative h-14 border-t border-default bg-default text-muted hover:bg-elevated"
|
||||
:data-selected="selected.includes(item.id) || undefined"
|
||||
class="relative h-14 border-t border-default"
|
||||
:class="[
|
||||
ROW_GRID,
|
||||
selected.includes(item.id)
|
||||
? 'bg-secondary-200 text-primary-900 dark:bg-secondary-300'
|
||||
: 'bg-default text-muted hover:bg-elevated',
|
||||
]"
|
||||
>
|
||||
<span></span>
|
||||
<span class="relative z-10 flex justify-center">
|
||||
<MediaCheckbox
|
||||
size="sm"
|
||||
:label="`Select ${item.filename}`"
|
||||
:state="selected.includes(item.id) ? 'checked' : 'unchecked'"
|
||||
@toggle="emit('toggle', item.id)"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex min-w-0 items-center gap-3 pr-3">
|
||||
<MediaThumb :item="item" size="sm" class="size-10 border border-default" />
|
||||
<button
|
||||
type="button"
|
||||
data-media-card
|
||||
:data-media-id="item.id"
|
||||
class="truncate font-medium text-highlighted after:absolute after:inset-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
||||
class="truncate font-medium after:absolute after:inset-0 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
||||
:class="selected.includes(item.id) ? 'text-primary-900' : 'text-highlighted'"
|
||||
@click="open($event, item.id)"
|
||||
>
|
||||
{{ item.filename }}
|
||||
|
||||
@@ -41,7 +41,7 @@ const logoSize = computed(() => ({ sm: 16, md: 20, lg: 28, xl: 40 })[props.size]
|
||||
<UIcon v-else name="i-lucide-image" class="opacity-55" :class="iconClass" />
|
||||
</span>
|
||||
<img
|
||||
v-if="!failed"
|
||||
v-if="!failed && item.url"
|
||||
:src="item.url"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
|
||||
@@ -72,13 +72,13 @@ onBeforeUnmount(clearDebounce)
|
||||
const tabItems = computed(() => [
|
||||
{ label: 'All', value: 'all', count: props.counts?.all },
|
||||
{ label: 'Unused', value: 'unused', count: props.counts?.unused },
|
||||
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates, disabled: true },
|
||||
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates },
|
||||
])
|
||||
|
||||
const tab = computed({
|
||||
get: () => props.controls.filter,
|
||||
set: (value: string) => {
|
||||
if (value === 'all' || value === 'unused') {
|
||||
if (value === 'all' || value === 'unused' || value === 'duplicates') {
|
||||
emit('update:filter', value)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { UploadRow } from '@/composables/useMediaUpload'
|
||||
import { formatUploadProgress } from '@/lib/imageUpload'
|
||||
import { formatMediaDate, formatSize } from '@/lib/mediaFormat'
|
||||
import {
|
||||
ADDED_TEXT,
|
||||
DUPLICATE_TITLE,
|
||||
FAILED_TEXT,
|
||||
FAILED_TITLE,
|
||||
duplicateText,
|
||||
rejectionText,
|
||||
rejectionTitle,
|
||||
type UploadHeader,
|
||||
} from '@/lib/mediaUploadRows'
|
||||
|
||||
const BUTTON =
|
||||
'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 max-md:flex-1 md:h-7'
|
||||
const PERCENT = 100
|
||||
|
||||
defineProps<{
|
||||
rows: UploadRow[]
|
||||
header: UploadHeader
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'use-existing': [key: number]
|
||||
'upload-anyway': [key: number]
|
||||
retry: [key: number]
|
||||
'cancel-row': [key: number]
|
||||
'cancel-all': []
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
function thumbItem(row: UploadRow) {
|
||||
return {
|
||||
url: row.previewUrl ?? '',
|
||||
content_type: row.file.type,
|
||||
sha256: String(row.tone),
|
||||
}
|
||||
}
|
||||
|
||||
function percent(row: UploadRow) {
|
||||
return row.total > 0 ? Math.round((row.loaded / row.total) * PERCENT) : 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-testid="media-upload-panel"
|
||||
aria-label="Uploads"
|
||||
class="flex flex-col border border-default bg-default"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-3.5 py-3">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span data-testid="upload-title" class="font-semibold text-highlighted">{{
|
||||
header.title
|
||||
}}</span>
|
||||
<span data-testid="upload-sub" class="text-muted">{{ header.sub }}</span>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
|
||||
@click="header.phase === 'working' ? emit('cancel-all') : emit('dismiss')"
|
||||
>
|
||||
{{ header.phase === 'working' ? 'Cancel all' : 'Dismiss' }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.key"
|
||||
data-testid="upload-row"
|
||||
:data-status="row.status"
|
||||
class="flex items-start gap-3 border-t border-muted px-3.5 py-3"
|
||||
>
|
||||
<MediaThumb :item="thumbItem(row)" size="sm" class="size-10 border border-default" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div class="flex justify-between gap-2">
|
||||
<span class="truncate font-medium text-highlighted">{{ row.name }}</span>
|
||||
<span class="whitespace-nowrap text-muted">{{ formatSize(row.size) }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="row.status === 'waiting'">
|
||||
<span class="text-muted">Waiting</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="row.status === 'uploading'">
|
||||
<span>{{ formatUploadProgress(row.loaded, row.total) }}</span>
|
||||
<UProgress
|
||||
:model-value="percent(row)"
|
||||
size="sm"
|
||||
:ui="{ base: 'rounded-none', indicator: 'rounded-none' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="row.status === 'added'">
|
||||
<span class="flex items-center gap-1.5 text-success">
|
||||
<UIcon name="i-lucide-circle-check" class="size-3.5 shrink-0" />
|
||||
{{ ADDED_TEXT }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="row.status === 'duplicate' && row.existing">
|
||||
<span class="font-semibold text-highlighted">{{ DUPLICATE_TITLE }}</span>
|
||||
<span class="text-xs/5 text-toned">{{
|
||||
duplicateText(row.existing, formatMediaDate)
|
||||
}}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:class="BUTTON"
|
||||
@click="emit('use-existing', row.key)"
|
||||
>
|
||||
Use existing
|
||||
</UButton>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:class="BUTTON"
|
||||
@click="emit('upload-anyway', row.key)"
|
||||
>
|
||||
Upload anyway
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="row.status === 'rejected' && row.rejection">
|
||||
<span class="flex items-center gap-1.5 font-semibold text-error">
|
||||
<UIcon name="i-lucide-file-x" class="size-3.5 shrink-0" />
|
||||
{{ rejectionTitle(row.rejection, row.name, row.size) }}
|
||||
</span>
|
||||
<span class="text-xs/5 text-toned">{{ rejectionText(row.rejection) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="row.status === 'failed'">
|
||||
<span class="flex items-center gap-1.5 font-semibold text-error">
|
||||
<UIcon name="i-lucide-circle-x" class="size-3.5 shrink-0" />
|
||||
{{ FAILED_TITLE }}
|
||||
</span>
|
||||
<span class="text-xs/5 text-toned">{{ FAILED_TEXT }}</span>
|
||||
<div class="flex">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-rotate-cw"
|
||||
:class="BUTTON"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('retry', row.key)"
|
||||
>
|
||||
Try again
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="row.status === 'uploading'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
:aria-label="`Cancel upload of ${row.name}`"
|
||||
class="size-6 justify-center text-muted"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('cancel-row', row.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="header.summary"
|
||||
data-testid="upload-summary"
|
||||
class="border-t border-default bg-elevated px-3.5 py-3 font-medium text-toned"
|
||||
>
|
||||
{{ header.summary }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { mergeArticleCount } from '@/lib/mediaDuplicates'
|
||||
import { pluralArticles } from '@/lib/mediaFormat'
|
||||
|
||||
const props = defineProps<{
|
||||
keep: MediaItem
|
||||
copies: MediaItem[]
|
||||
action: () => Promise<void | boolean>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
||||
|
||||
const otherCount = computed(() => props.copies.length - 1)
|
||||
const others = computed(() => (otherCount.value === 1 ? 'copy' : `${otherCount.value} copies`))
|
||||
const articles = computed(() => mergeArticleCount(props.copies, props.keep.id))
|
||||
const description = computed(
|
||||
() =>
|
||||
`Articles that use the other ${others.value} will be switched to ${props.keep.filename}, and the other ${others.value} will be deleted. ${pluralArticles(articles.value)} will be updated. This can't be undone.`,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog
|
||||
:title="`Merge into ${keep.filename}?`"
|
||||
:description="description"
|
||||
:confirm-label="`Merge ${otherCount === 1 ? '1 copy' : `${otherCount} copies`}`"
|
||||
cancel-label="Cancel"
|
||||
pending-label="Merging…"
|
||||
tone="danger"
|
||||
:action="action"
|
||||
@close="emit('close', $event)"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex items-center gap-3 border border-default px-3 py-2.5">
|
||||
<MediaThumb :item="keep" size="sm" class="h-10.5 w-14 border border-default" />
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-medium text-highlighted">{{ keep.filename }}</span>
|
||||
<span class="text-xs/5 text-muted"
|
||||
>Kept · used in {{ pluralArticles(keep.used_in) }} now</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</template>
|
||||
@@ -79,6 +79,27 @@ if (typeof window !== 'undefined') {
|
||||
})
|
||||
}
|
||||
|
||||
export function redirectToLogin() {
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
useSessionStore().clear()
|
||||
authRedirectPending.value = true
|
||||
router
|
||||
.push({
|
||||
path: '/login',
|
||||
query: { redirect: currentPath, reason: 'expired' },
|
||||
})
|
||||
.finally(() => {
|
||||
authRedirectPending.value = false
|
||||
})
|
||||
}
|
||||
|
||||
export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
|
||||
const session = useSessionStore()
|
||||
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
|
||||
router.push('/register/pending')
|
||||
}
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
|
||||
@@ -95,25 +116,12 @@ export function useApi() {
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && onUnauthorized === 'redirect') {
|
||||
const currentPath = router.currentRoute.value.fullPath
|
||||
useSessionStore().clear()
|
||||
authRedirectPending.value = true
|
||||
router
|
||||
.push({
|
||||
path: '/login',
|
||||
query: { redirect: currentPath, reason: 'expired' },
|
||||
})
|
||||
.finally(() => {
|
||||
authRedirectPending.value = false
|
||||
})
|
||||
redirectToLogin()
|
||||
throw new ApiError(401)
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
const session = useSessionStore()
|
||||
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
|
||||
router.push('/register/pending')
|
||||
}
|
||||
redirectUnverified(onForbidden)
|
||||
throw new ApiError(403)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,17 @@ export interface MediaUsageItem {
|
||||
published: boolean
|
||||
}
|
||||
|
||||
export interface MediaMergeResult {
|
||||
articles_updated: number
|
||||
merged: number
|
||||
}
|
||||
|
||||
export interface BulkDeleteResult {
|
||||
deleted: MediaItem[]
|
||||
failed: MediaItem[]
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
interface MediaListResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
@@ -142,6 +153,40 @@ export function useMedia() {
|
||||
removeLocal(id)
|
||||
}
|
||||
|
||||
async function deleteMany(targets: MediaItem[]): Promise<BulkDeleteResult> {
|
||||
const outcomes = await Promise.all(
|
||||
targets.map(async (item) => {
|
||||
try {
|
||||
await request<void>(`/v1/media/${item.id}`, { method: 'DELETE' })
|
||||
return { item, outcome: 'deleted' as const }
|
||||
} catch (error) {
|
||||
const failure = classifyMediaError(error)
|
||||
return {
|
||||
item,
|
||||
outcome: failure === 'not-found' ? ('deleted' as const) : failure,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
const pick = (outcome: string) =>
|
||||
outcomes.filter((entry) => entry.outcome === outcome).map((entry) => entry.item)
|
||||
const deleted = pick('deleted')
|
||||
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
|
||||
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
|
||||
}
|
||||
|
||||
function mergeCopies(keepId: number, mergeIds: number[]) {
|
||||
return request<MediaMergeResult>('/v1/media/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_id: keepId, merge_ids: mergeIds }),
|
||||
})
|
||||
}
|
||||
|
||||
function removeLocalMany(ids: number[]) {
|
||||
items.value = items.value.filter((entry) => !ids.includes(entry.id))
|
||||
}
|
||||
|
||||
function copyUrl(item: MediaItem) {
|
||||
return navigator.clipboard.writeText(`${window.location.origin}${item.url}`)
|
||||
}
|
||||
@@ -159,6 +204,9 @@ export function useMedia() {
|
||||
renameItem,
|
||||
deleteItem,
|
||||
removeLocal,
|
||||
removeLocalMany,
|
||||
deleteMany,
|
||||
mergeCopies,
|
||||
copyUrl,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
|
||||
export type SelectAllState = 'none' | 'partial' | 'all'
|
||||
|
||||
export function useMediaSelection(items: Ref<MediaItem[]>) {
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const count = computed(() => selectedIds.value.length)
|
||||
const selectedItems = computed(() =>
|
||||
items.value.filter((item) => selectedIds.value.includes(item.id)),
|
||||
)
|
||||
const allState = computed<SelectAllState>(() => {
|
||||
if (count.value === 0) {
|
||||
return 'none'
|
||||
}
|
||||
return count.value >= items.value.length ? 'all' : 'partial'
|
||||
})
|
||||
|
||||
function isSelected(id: number) {
|
||||
return selectedIds.value.includes(id)
|
||||
}
|
||||
|
||||
function select(id: number) {
|
||||
if (!isSelected(id)) {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: number) {
|
||||
selectedIds.value = isSelected(id)
|
||||
? selectedIds.value.filter((entry) => entry !== id)
|
||||
: [...selectedIds.value, id]
|
||||
}
|
||||
|
||||
function replace(ids: number[]) {
|
||||
selectedIds.value = ids
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedIds.value = []
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
selectedIds.value = allState.value === 'all' ? [] : items.value.map((item) => item.id)
|
||||
}
|
||||
|
||||
watch(items, (loaded) => {
|
||||
const kept = selectedIds.value.filter((id) => loaded.some((item) => item.id === id))
|
||||
if (kept.length !== selectedIds.value.length) {
|
||||
selectedIds.value = kept
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
selectedItems,
|
||||
count,
|
||||
allState,
|
||||
isSelected,
|
||||
select,
|
||||
toggle,
|
||||
replace,
|
||||
clear,
|
||||
toggleAll,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi'
|
||||
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { hashFile } from '@/lib/fileHash'
|
||||
import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload'
|
||||
import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows'
|
||||
import type { UploadRejection, UploadRowStatus } from '@/lib/mediaUploadRows'
|
||||
|
||||
const MAX_CONCURRENT_UPLOADS = 2
|
||||
const UPLOAD_URL = '/v1/media/'
|
||||
const HTTP_CREATED = 201
|
||||
const HTTP_BAD_REQUEST = 400
|
||||
const HTTP_FORBIDDEN = 403
|
||||
const HTTP_UNAUTHORIZED = 401
|
||||
const HTTP_TOO_LARGE = 413
|
||||
const HTTP_UNSUPPORTED = 415
|
||||
const HTTP_RATE_LIMITED = 429
|
||||
const TONE_COUNT = 4
|
||||
|
||||
export interface UploadRow {
|
||||
key: number
|
||||
file: File
|
||||
name: string
|
||||
size: number
|
||||
status: UploadRowStatus
|
||||
loaded: number
|
||||
total: number
|
||||
rejection: UploadRejection | null
|
||||
existing: MediaItem | null
|
||||
previewUrl: string | null
|
||||
skipLookup: boolean
|
||||
tone: number
|
||||
}
|
||||
|
||||
interface UploadOptions {
|
||||
onAdded?: () => void
|
||||
}
|
||||
|
||||
function toneOf(name: string) {
|
||||
return Array.from(name).reduce((sum, letter) => sum + letter.charCodeAt(0), 0) % TONE_COUNT
|
||||
}
|
||||
|
||||
function oldestOf(items: MediaItem[]) {
|
||||
return [...items].sort(
|
||||
(first, second) =>
|
||||
Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id,
|
||||
)[0]
|
||||
}
|
||||
|
||||
function mismatchRejection(file: File): UploadRejection | null {
|
||||
return extensionMatchesType(file) ? null : 'type'
|
||||
}
|
||||
|
||||
function createPreview(file: File) {
|
||||
return typeof URL.createObjectURL === 'function' ? URL.createObjectURL(file) : null
|
||||
}
|
||||
|
||||
function releasePreview(row: UploadRow) {
|
||||
if (row.previewUrl && typeof URL.revokeObjectURL === 'function') {
|
||||
URL.revokeObjectURL(row.previewUrl)
|
||||
}
|
||||
}
|
||||
|
||||
export function useMediaUpload(options: UploadOptions = {}) {
|
||||
const { request } = useApi()
|
||||
const retry = useRetryAfter()
|
||||
const rows = ref<UploadRow[]>([])
|
||||
const slots = new Set<number>()
|
||||
const requests = new Map<number, XMLHttpRequest>()
|
||||
let nextKey = 1
|
||||
let halted = false
|
||||
|
||||
const header = computed(() => describeUpload(rows.value))
|
||||
const limited = computed(() => retry.active.value)
|
||||
const countdown = computed(() => retry.formatted.value)
|
||||
|
||||
function createRow(file: File): UploadRow {
|
||||
const verdict = validateImageFile(file)
|
||||
const rejection = verdict.ok ? mismatchRejection(file) : verdict.kind
|
||||
return {
|
||||
key: nextKey++,
|
||||
file,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: rejection ? 'rejected' : 'waiting',
|
||||
loaded: 0,
|
||||
total: file.size,
|
||||
rejection,
|
||||
existing: null,
|
||||
previewUrl: rejection ? null : createPreview(file),
|
||||
skipLookup: false,
|
||||
tone: toneOf(file.name),
|
||||
}
|
||||
}
|
||||
|
||||
function find(key: number) {
|
||||
return rows.value.find((row) => row.key === key)
|
||||
}
|
||||
|
||||
function release(key: number) {
|
||||
slots.delete(key)
|
||||
requests.delete(key)
|
||||
}
|
||||
|
||||
function settle(key: number) {
|
||||
release(key)
|
||||
pump()
|
||||
}
|
||||
|
||||
function halt(row: UploadRow) {
|
||||
row.status = 'waiting'
|
||||
release(row.key)
|
||||
halted = true
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (halted || limited.value) {
|
||||
return
|
||||
}
|
||||
for (const row of rows.value) {
|
||||
if (slots.size >= MAX_CONCURRENT_UPLOADS) {
|
||||
return
|
||||
}
|
||||
if (row.status === 'waiting' && !slots.has(row.key)) {
|
||||
slots.add(row.key)
|
||||
void process(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findExisting(file: File): Promise<MediaItem | null | 'halted'> {
|
||||
const hash = await hashFile(file)
|
||||
if (hash === null) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
||||
return oldestOf(response.items) ?? null
|
||||
} catch (error) {
|
||||
return classifyMediaError(error) === 'handled' ? 'halted' : null
|
||||
}
|
||||
}
|
||||
|
||||
async function process(row: UploadRow) {
|
||||
if (!row.skipLookup) {
|
||||
const match = await findExisting(row.file)
|
||||
if (!find(row.key)) {
|
||||
settle(row.key)
|
||||
return
|
||||
}
|
||||
if (match === 'halted') {
|
||||
halt(row)
|
||||
return
|
||||
}
|
||||
if (match) {
|
||||
row.status = 'duplicate'
|
||||
row.existing = match
|
||||
settle(row.key)
|
||||
return
|
||||
}
|
||||
}
|
||||
send(row)
|
||||
}
|
||||
|
||||
function reject(row: UploadRow, kind: UploadRejection) {
|
||||
row.status = 'rejected'
|
||||
row.rejection = kind
|
||||
settle(row.key)
|
||||
}
|
||||
|
||||
function pause(row: UploadRow, header: string | null) {
|
||||
row.status = 'waiting'
|
||||
release(row.key)
|
||||
retry.start(parseRetryAfter(header))
|
||||
}
|
||||
|
||||
function onLoaded(row: UploadRow, xhr: XMLHttpRequest) {
|
||||
requests.delete(row.key)
|
||||
switch (xhr.status) {
|
||||
case HTTP_CREATED:
|
||||
row.status = 'added'
|
||||
options.onAdded?.()
|
||||
settle(row.key)
|
||||
return
|
||||
case HTTP_BAD_REQUEST:
|
||||
reject(row, 'unreadable')
|
||||
return
|
||||
case HTTP_TOO_LARGE:
|
||||
reject(row, 'size')
|
||||
return
|
||||
case HTTP_UNSUPPORTED:
|
||||
reject(row, 'type')
|
||||
return
|
||||
case HTTP_UNAUTHORIZED:
|
||||
redirectToLogin()
|
||||
halt(row)
|
||||
return
|
||||
case HTTP_FORBIDDEN:
|
||||
redirectUnverified()
|
||||
halt(row)
|
||||
return
|
||||
case HTTP_RATE_LIMITED:
|
||||
pause(row, xhr.getResponseHeader('Retry-After'))
|
||||
return
|
||||
default:
|
||||
row.status = 'failed'
|
||||
settle(row.key)
|
||||
}
|
||||
}
|
||||
|
||||
function send(row: UploadRow) {
|
||||
row.status = 'uploading'
|
||||
row.loaded = 0
|
||||
row.total = row.size
|
||||
const xhr = new XMLHttpRequest()
|
||||
requests.set(row.key, xhr)
|
||||
xhr.open('POST', UPLOAD_URL)
|
||||
xhr.withCredentials = true
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
row.loaded = event.loaded
|
||||
row.total = event.total || row.size
|
||||
})
|
||||
xhr.addEventListener('load', () => onLoaded(row, xhr))
|
||||
xhr.addEventListener('error', () => {
|
||||
row.status = 'failed'
|
||||
settle(row.key)
|
||||
})
|
||||
const form = new FormData()
|
||||
form.append('file', row.file)
|
||||
xhr.send(form)
|
||||
}
|
||||
|
||||
function addFiles(files: File[]) {
|
||||
if (limited.value || files.length === 0) {
|
||||
return
|
||||
}
|
||||
rows.value.push(...files.map(createRow))
|
||||
pump()
|
||||
}
|
||||
|
||||
function remove(key: number) {
|
||||
const row = find(key)
|
||||
if (row) {
|
||||
releasePreview(row)
|
||||
}
|
||||
rows.value = rows.value.filter((entry) => entry.key !== key)
|
||||
}
|
||||
|
||||
function cancel(key: number) {
|
||||
requests.get(key)?.abort()
|
||||
release(key)
|
||||
remove(key)
|
||||
pump()
|
||||
}
|
||||
|
||||
function cancelAll() {
|
||||
for (const row of [...rows.value]) {
|
||||
if (row.status === 'uploading' || row.status === 'waiting') {
|
||||
requests.get(row.key)?.abort()
|
||||
release(row.key)
|
||||
remove(row.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retryRow(key: number) {
|
||||
const row = find(key)
|
||||
if (!row || (row.status !== 'failed' && row.status !== 'duplicate')) {
|
||||
return
|
||||
}
|
||||
row.status = 'waiting'
|
||||
row.skipLookup = true
|
||||
row.existing = null
|
||||
pump()
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
rows.value.forEach(releasePreview)
|
||||
rows.value = []
|
||||
}
|
||||
|
||||
watch(limited, (active) => {
|
||||
if (!active) {
|
||||
pump()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requests.forEach((xhr) => xhr.abort())
|
||||
requests.clear()
|
||||
rows.value.forEach(releasePreview)
|
||||
})
|
||||
|
||||
return {
|
||||
rows,
|
||||
header,
|
||||
limited,
|
||||
countdown,
|
||||
addFiles,
|
||||
remove,
|
||||
cancel,
|
||||
cancelAll,
|
||||
retryRow,
|
||||
dismiss,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const toastsAboveBulkBar = ref(false)
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { hashFile } from '../fileHash'
|
||||
|
||||
const SHA256_OF_ABC = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'
|
||||
|
||||
function fileOf(bytes: number[]) {
|
||||
return new File([new Uint8Array(bytes)], 'a.png', { type: 'image/png' })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('hashFile', () => {
|
||||
it('resolves the lowercase 64-character hex of the digest', async () => {
|
||||
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
|
||||
expect(Array.from(new Uint8Array(data))).toEqual([97, 98, 99])
|
||||
return new Uint8Array(
|
||||
SHA256_OF_ABC.match(/../g)!.map((pair) => Number.parseInt(pair, 16)),
|
||||
).buffer
|
||||
})
|
||||
vi.stubGlobal('crypto', { subtle: { digest } })
|
||||
|
||||
const hex = await hashFile(fileOf([97, 98, 99]))
|
||||
|
||||
expect(digest).toHaveBeenCalledWith('SHA-256', expect.anything())
|
||||
expect(hex).toBe(SHA256_OF_ABC)
|
||||
expect(hex).toMatch(/^[0-9a-f]{64}$/)
|
||||
})
|
||||
|
||||
it('resolves null when crypto.subtle is unavailable', async () => {
|
||||
vi.stubGlobal('crypto', { subtle: undefined })
|
||||
|
||||
expect(await hashFile(fileOf([1]))).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves null when the digest rejects', async () => {
|
||||
vi.stubGlobal('crypto', { subtle: { digest: vi.fn().mockRejectedValue(new Error('no')) } })
|
||||
|
||||
expect(await hashFile(fileOf([1]))).toBeNull()
|
||||
})
|
||||
|
||||
it('imports nothing from the Media page', async () => {
|
||||
const source = (await import('../fileHash?raw')).default
|
||||
expect(source).not.toMatch(/import .*Media/)
|
||||
expect(source).not.toMatch(/from '@\/(components|views|composables)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extensionMatchesType, validateImageFile } from '../imageUpload'
|
||||
|
||||
function fileOf(name: string, type: string, size = 100) {
|
||||
const file = new File(['x'], name, { type })
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
return file
|
||||
}
|
||||
|
||||
describe('validateImageFile', () => {
|
||||
it('checks the size first, 10 MB allowed and one byte more too large', () => {
|
||||
expect(validateImageFile(fileOf('a.png', 'image/png', 10485760))).toEqual({ ok: true })
|
||||
expect(validateImageFile(fileOf('a.png', 'image/png', 10485761))).toEqual({
|
||||
ok: false,
|
||||
kind: 'size',
|
||||
})
|
||||
expect(validateImageFile(fileOf('a.tif', 'image/tiff', 10485761))).toEqual({
|
||||
ok: false,
|
||||
kind: 'size',
|
||||
})
|
||||
})
|
||||
|
||||
it('needs an allowed MIME type and an allowed extension', () => {
|
||||
expect(validateImageFile(fileOf('a.gif', 'image/gif'))).toEqual({ ok: false, kind: 'type' })
|
||||
expect(validateImageFile(fileOf('a.jpg', 'text/plain'))).toEqual({
|
||||
ok: false,
|
||||
kind: 'type',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('extensionMatchesType', () => {
|
||||
it.each([
|
||||
['a.png', 'image/png', true],
|
||||
['a.JPG', 'image/jpeg', true],
|
||||
['a.jpeg', 'image/jpeg', true],
|
||||
['a.webp', 'image/webp', true],
|
||||
['a.svg', 'image/svg+xml', true],
|
||||
['pic.jpg', 'image/png', false],
|
||||
['a.svg', 'image/jpeg', false],
|
||||
['a.tif', 'image/tiff', false],
|
||||
])('%s as %s is %s', (name, type, expected) => {
|
||||
expect(extensionMatchesType(fileOf(name, type))).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { groupDuplicates, mergeArticleCount } from '../mediaDuplicates'
|
||||
|
||||
function copy(id: number, usedIn: number, createdAt: string, overrides: Partial<MediaItem> = {}) {
|
||||
return {
|
||||
id,
|
||||
filename: `copy-${id}.jpg`,
|
||||
url: `/media/copy-${id}.jpg`,
|
||||
size: 2516582,
|
||||
content_type: 'image/jpeg',
|
||||
width: 3600,
|
||||
height: 2400,
|
||||
sha256: 'aa',
|
||||
uploaded_by: 'Inès Duarte',
|
||||
created_at: createdAt,
|
||||
used_in: usedIn,
|
||||
duplicate: true,
|
||||
...overrides,
|
||||
} satisfies MediaItem
|
||||
}
|
||||
|
||||
describe('groupDuplicates', () => {
|
||||
it.each([
|
||||
[
|
||||
'the most used copy',
|
||||
[
|
||||
copy(1, 4, '2026-06-14T12:00:00Z'),
|
||||
copy(2, 3, '2026-09-02T12:00:00Z'),
|
||||
copy(3, 2, '2026-08-30T12:00:00Z'),
|
||||
],
|
||||
1,
|
||||
],
|
||||
[
|
||||
'the oldest copy at equal use',
|
||||
[copy(1, 2, '2026-08-29T12:00:00Z'), copy(2, 2, '2026-08-21T12:00:00Z')],
|
||||
2,
|
||||
],
|
||||
[
|
||||
'the oldest unused copy',
|
||||
[copy(1, 0, '2026-09-03T12:00:00Z'), copy(2, 0, '2026-09-01T12:00:00Z')],
|
||||
2,
|
||||
],
|
||||
[
|
||||
'the lowest id at equal use and date',
|
||||
[copy(9, 1, '2026-09-03T12:00:00Z'), copy(4, 1, '2026-09-03T12:00:00Z')],
|
||||
4,
|
||||
],
|
||||
])('suggests %s', (_name, copies, suggestedId) => {
|
||||
const [group] = groupDuplicates(copies)
|
||||
|
||||
expect(group!.suggested.id).toBe(suggestedId)
|
||||
expect(group!.copies[0]!.id).toBe(suggestedId)
|
||||
})
|
||||
|
||||
it('groups by sha256, orders copies and puts the oldest suggested copy first', () => {
|
||||
const groups = groupDuplicates([
|
||||
copy(7, 2, '2026-08-29T12:00:00Z', { sha256: 'bb', size: 3250585 }),
|
||||
copy(5, 3, '2026-09-02T12:00:00Z'),
|
||||
copy(9, 1, '2026-08-21T12:00:00Z', { sha256: 'bb', size: 3250585 }),
|
||||
copy(12, 4, '2026-06-14T12:00:00Z'),
|
||||
copy(6, 2, '2026-08-30T12:00:00Z'),
|
||||
])
|
||||
|
||||
expect(groups.map((group) => group.copies.map((item) => item.id))).toEqual([
|
||||
[12, 5, 6],
|
||||
[7, 9],
|
||||
])
|
||||
expect(groups.map((group) => group.header)).toEqual([
|
||||
'3 identical images · 2.4 MB each · 3600 × 2400',
|
||||
'2 identical images · 3.1 MB each · 3600 × 2400',
|
||||
])
|
||||
})
|
||||
|
||||
it('reads Vector for SVG groups and ignores single copies', () => {
|
||||
const groups = groupDuplicates([
|
||||
copy(1, 0, '2026-09-01T12:00:00Z', { width: null, height: null, size: 8192 }),
|
||||
copy(2, 0, '2026-09-02T12:00:00Z', { width: null, height: null, size: 8192 }),
|
||||
copy(3, 0, '2026-09-02T12:00:00Z', { sha256: 'zz' }),
|
||||
])
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]!.header).toBe('2 identical images · 8 KB each · Vector')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeArticleCount', () => {
|
||||
it('sums the use of every copy except the kept one', () => {
|
||||
const copies = [
|
||||
copy(12, 4, '2026-06-14T12:00:00Z'),
|
||||
copy(5, 3, '2026-09-02T12:00:00Z'),
|
||||
copy(6, 2, '2026-08-30T12:00:00Z'),
|
||||
]
|
||||
|
||||
expect(mergeArticleCount(copies, 12)).toBe(5)
|
||||
expect(mergeArticleCount(copies, 6)).toBe(7)
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ describe('parseMediaControls', () => {
|
||||
[{ q: 'wall' }, { q: 'wall' }],
|
||||
[{ view: 'list' }, { view: 'list' }],
|
||||
[{ filter: 'bogus', sort: 'zzz' }, {}],
|
||||
[{ filter: 'duplicates' }, {}],
|
||||
[{ filter: 'duplicates' }, { filter: 'duplicates' }],
|
||||
[{ view: 'grid' }, {}],
|
||||
[{ sort: 'newest' }, {}],
|
||||
])('maps %j', (query, expected) => {
|
||||
@@ -42,6 +42,7 @@ describe('toRequestQuery', () => {
|
||||
it.each([
|
||||
[{}, 'filter=all&sort=newest&limit=100'],
|
||||
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
|
||||
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&limit=100'],
|
||||
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'],
|
||||
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'],
|
||||
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&limit=100'],
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
describeUpload,
|
||||
parseRetryAfter,
|
||||
rejectionText,
|
||||
rejectionTitle,
|
||||
summaryLine,
|
||||
type UploadRowStatus,
|
||||
} from '../mediaUploadRows'
|
||||
|
||||
function rows(...statuses: UploadRowStatus[]) {
|
||||
return statuses.map((status) => ({ status }))
|
||||
}
|
||||
|
||||
describe('summaryLine', () => {
|
||||
it.each([
|
||||
[2, 1, 2, '2 uploaded · 1 already in your library · 2 need attention'],
|
||||
[3, 0, 0, '3 uploaded'],
|
||||
[0, 1, 0, '1 already in your library'],
|
||||
[1, 0, 1, '1 uploaded · 1 need attention'],
|
||||
])('%i added, %i duplicate, %i attention reads "%s"', (added, duplicate, attention, line) => {
|
||||
expect(summaryLine({ added, duplicate, attention })).toBe(line)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeUpload', () => {
|
||||
it('counts settled rows while working', () => {
|
||||
const header = describeUpload(
|
||||
rows('added', 'duplicate', 'rejected', 'failed', 'uploading', 'waiting'),
|
||||
)
|
||||
|
||||
expect(header).toEqual({
|
||||
phase: 'working',
|
||||
title: 'Uploading 6 images',
|
||||
sub: '4 of 6 done',
|
||||
summary: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses singular forms', () => {
|
||||
expect(describeUpload(rows('uploading')).title).toBe('Uploading 1 image')
|
||||
expect(describeUpload(rows('added')).sub).toBe('1 image')
|
||||
})
|
||||
|
||||
it('settles with a summary', () => {
|
||||
const header = describeUpload(rows('added', 'added', 'duplicate', 'rejected', 'failed'))
|
||||
|
||||
expect(header).toEqual({
|
||||
phase: 'settled',
|
||||
title: 'Upload finished',
|
||||
sub: '5 images',
|
||||
summary: '2 uploaded · 1 already in your library · 2 need attention',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads Nothing was uploaded without a summary when nothing was added', () => {
|
||||
expect(describeUpload(rows('rejected', 'rejected', 'rejected'))).toEqual({
|
||||
phase: 'settled',
|
||||
title: 'Nothing was uploaded',
|
||||
sub: '3 files were rejected',
|
||||
summary: null,
|
||||
})
|
||||
expect(describeUpload(rows('rejected', 'failed'))).toMatchObject({
|
||||
title: 'Nothing was uploaded',
|
||||
sub: '2 files need attention',
|
||||
summary: null,
|
||||
})
|
||||
expect(describeUpload(rows('failed'))).toMatchObject({ sub: '1 file needs attention' })
|
||||
expect(describeUpload(rows('rejected'))).toMatchObject({ sub: '1 file was rejected' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rejection copy', () => {
|
||||
it.each([
|
||||
['size', 'scan.tif', 14.2 * 1024 * 1024, 'scan.tif is 14.2 MB'],
|
||||
['size', 'photo.png', 10.1 * 1024 * 1024, 'photo.png is 10.1 MB'],
|
||||
['type', 'notes.pdf', 215040, "notes.pdf isn't an image"],
|
||||
['unreadable', 'broken.png', 3145728, "broken.png couldn't be read"],
|
||||
] as const)('titles a %s rejection of %s', (kind, name, bytes, title) => {
|
||||
expect(rejectionTitle(kind, name, bytes)).toBe(title)
|
||||
})
|
||||
|
||||
it('picks the text per kind', () => {
|
||||
expect(rejectionText('size')).toBe(
|
||||
'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.',
|
||||
)
|
||||
expect(rejectionText('type')).toBe(rejectionText('size'))
|
||||
expect(rejectionText('unreadable')).toBe(
|
||||
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRetryAfter', () => {
|
||||
it('reads whole seconds and falls back to 60', () => {
|
||||
expect(parseRetryAfter('42')).toBe(42)
|
||||
expect(parseRetryAfter(null)).toBe(60)
|
||||
expect(parseRetryAfter('soon')).toBe(60)
|
||||
expect(parseRetryAfter('')).toBe(60)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
const HASH_ALGORITHM = 'SHA-256'
|
||||
const HEX_RADIX = 16
|
||||
const HEX_PAIR_WIDTH = 2
|
||||
|
||||
function readBytes(file: File): Promise<ArrayBuffer> {
|
||||
if (typeof file.arrayBuffer === 'function') {
|
||||
return file.arrayBuffer()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
function toHex(digest: ArrayBuffer) {
|
||||
return Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(HEX_RADIX).padStart(HEX_PAIR_WIDTH, '0'),
|
||||
).join('')
|
||||
}
|
||||
|
||||
export async function hashFile(file: File): Promise<string | null> {
|
||||
const subtle = globalThis.crypto?.subtle
|
||||
if (!subtle) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return toHex(await subtle.digest(HASH_ALGORITHM, await readBytes(file)))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,17 @@ export function validateImageFile(file: File): ImageValidationResult {
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const EXTENSIONS_BY_MIME_TYPE: Record<string, string[]> = {
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/webp': ['.webp'],
|
||||
'image/svg+xml': ['.svg'],
|
||||
}
|
||||
|
||||
export function extensionMatchesType(file: File) {
|
||||
return EXTENSIONS_BY_MIME_TYPE[file.type]?.includes(fileExtension(file.name)) ?? false
|
||||
}
|
||||
|
||||
export function formatMegabytes(bytes: number) {
|
||||
return (bytes / (1024 * 1024)).toFixed(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { formatDimensions, formatSize } from '@/lib/mediaFormat'
|
||||
|
||||
export interface DuplicateGroup {
|
||||
hash: string
|
||||
copies: MediaItem[]
|
||||
suggested: MediaItem
|
||||
header: string
|
||||
}
|
||||
|
||||
function byAge(first: MediaItem, second: MediaItem) {
|
||||
return Date.parse(first.created_at) - Date.parse(second.created_at) || first.id - second.id
|
||||
}
|
||||
|
||||
function byPreference(first: MediaItem, second: MediaItem) {
|
||||
return second.used_in - first.used_in || byAge(first, second)
|
||||
}
|
||||
|
||||
export function groupHeader(copies: MediaItem[]) {
|
||||
const [first] = copies
|
||||
return `${copies.length} identical images · ${formatSize(first!.size)} each · ${formatDimensions(first!.width, first!.height)}`
|
||||
}
|
||||
|
||||
export function groupDuplicates(items: MediaItem[]): DuplicateGroup[] {
|
||||
const byHash = new Map<string, MediaItem[]>()
|
||||
for (const item of items) {
|
||||
byHash.set(item.sha256, [...(byHash.get(item.sha256) ?? []), item])
|
||||
}
|
||||
return [...byHash.entries()]
|
||||
.filter(([, copies]) => copies.length > 1)
|
||||
.map(([hash, copies]) => {
|
||||
const sorted = [...copies].sort(byPreference)
|
||||
return { hash, copies: sorted, suggested: sorted[0]!, header: groupHeader(sorted) }
|
||||
})
|
||||
.sort((first, second) => byAge(first.suggested, second.suggested))
|
||||
}
|
||||
|
||||
export function mergeArticleCount(copies: MediaItem[], keepId: number) {
|
||||
return copies
|
||||
.filter((copy) => copy.id !== keepId)
|
||||
.reduce((total, copy) => total + copy.used_in, 0)
|
||||
}
|
||||
@@ -69,3 +69,10 @@ export function isValidFilenameStem(value: string) {
|
||||
LETTER_OR_NUMBER.test(stem)
|
||||
)
|
||||
}
|
||||
|
||||
export function joinNames(names: string[]) {
|
||||
if (names.length <= 1) {
|
||||
return names.join('')
|
||||
}
|
||||
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
export type MediaFilter = 'all' | 'unused'
|
||||
export type MediaFilter = 'all' | 'unused' | 'duplicates'
|
||||
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
|
||||
export type MediaView = 'grid' | 'list'
|
||||
|
||||
@@ -20,7 +20,7 @@ export const DEFAULT_CONTROLS: MediaControls = {
|
||||
view: 'grid',
|
||||
}
|
||||
|
||||
const FILTERS: MediaFilter[] = ['all', 'unused']
|
||||
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
|
||||
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
|
||||
const VIEWS: MediaView[] = ['grid', 'list']
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { formatMegabytes } from '@/lib/imageUpload'
|
||||
|
||||
export type UploadRowStatus =
|
||||
| 'waiting'
|
||||
| 'uploading'
|
||||
| 'added'
|
||||
| 'duplicate'
|
||||
| 'rejected'
|
||||
| 'failed'
|
||||
export type UploadRejection = 'size' | 'type' | 'unreadable'
|
||||
export type UploadPhase = 'working' | 'settled'
|
||||
|
||||
export interface UploadRowLike {
|
||||
status: UploadRowStatus
|
||||
}
|
||||
|
||||
export interface UploadHeader {
|
||||
phase: UploadPhase
|
||||
title: string
|
||||
sub: string
|
||||
summary: string | null
|
||||
}
|
||||
|
||||
export const REJECTION_TEXT = 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.'
|
||||
export const UNREADABLE_TEXT =
|
||||
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
|
||||
export const FAILED_TITLE = 'Upload failed'
|
||||
export const FAILED_TEXT = "The server didn't accept the file. Nothing was added to your library."
|
||||
export const ADDED_TEXT = 'Added to your library'
|
||||
export const DUPLICATE_TITLE = 'Already in your library'
|
||||
export const DROP_TITLE = 'Drop images to upload'
|
||||
export const DROP_TEXT = 'PNG, JPEG, WEBP, SVG · max 10 MB each'
|
||||
export const RATE_LIMITED_TITLE = 'Too many uploads'
|
||||
export const RATE_LIMITED_TEXT = 'For your security, wait a moment before uploading again.'
|
||||
export const DEFAULT_RETRY_AFTER_SECONDS = 60
|
||||
export const UPLOAD_ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml'
|
||||
|
||||
const SETTLED: UploadRowStatus[] = ['added', 'duplicate', 'rejected', 'failed']
|
||||
|
||||
export function isSettled(status: UploadRowStatus) {
|
||||
return SETTLED.includes(status)
|
||||
}
|
||||
|
||||
function imageCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'image' : 'images'}`
|
||||
}
|
||||
|
||||
function fileCount(total: number) {
|
||||
return `${total} ${total === 1 ? 'file' : 'files'}`
|
||||
}
|
||||
|
||||
export function rejectionTitle(kind: UploadRejection, filename: string, bytes: number) {
|
||||
if (kind === 'size') {
|
||||
return `${filename} is ${formatMegabytes(bytes)} MB`
|
||||
}
|
||||
if (kind === 'unreadable') {
|
||||
return `${filename} couldn't be read`
|
||||
}
|
||||
return `${filename} isn't an image`
|
||||
}
|
||||
|
||||
export function rejectionText(kind: UploadRejection) {
|
||||
return kind === 'unreadable' ? UNREADABLE_TEXT : REJECTION_TEXT
|
||||
}
|
||||
|
||||
export function duplicateText(
|
||||
existing: { filename: string; created_at: string; uploaded_by: string },
|
||||
formatDate: (iso: string) => string,
|
||||
) {
|
||||
const by = existing.uploaded_by ? ` by ${existing.uploaded_by}` : ''
|
||||
return `Same as "${existing.filename}", uploaded ${formatDate(existing.created_at)}${by}.`
|
||||
}
|
||||
|
||||
export function summaryLine(counts: { added: number; duplicate: number; attention: number }) {
|
||||
const parts = [
|
||||
...(counts.added > 0 ? [`${counts.added} uploaded`] : []),
|
||||
...(counts.duplicate > 0 ? [`${counts.duplicate} already in your library`] : []),
|
||||
...(counts.attention > 0 ? [`${counts.attention} need attention`] : []),
|
||||
]
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
export function describeUpload(rows: UploadRowLike[]): UploadHeader {
|
||||
const total = rows.length
|
||||
const count = (status: UploadRowStatus) => rows.filter((row) => row.status === status).length
|
||||
const settled = rows.filter((row) => isSettled(row.status)).length
|
||||
if (settled < total) {
|
||||
return {
|
||||
phase: 'working',
|
||||
title: `Uploading ${imageCount(total)}`,
|
||||
sub: `${settled} of ${total} done`,
|
||||
summary: null,
|
||||
}
|
||||
}
|
||||
const added = count('added')
|
||||
const duplicate = count('duplicate')
|
||||
const attention = count('rejected') + count('failed')
|
||||
if (added === 0 && duplicate === 0) {
|
||||
const sub =
|
||||
count('rejected') === total
|
||||
? `${fileCount(total)} ${total === 1 ? 'was' : 'were'} rejected`
|
||||
: `${fileCount(total)} ${total === 1 ? 'needs' : 'need'} attention`
|
||||
return { phase: 'settled', title: 'Nothing was uploaded', sub, summary: null }
|
||||
}
|
||||
return {
|
||||
phase: 'settled',
|
||||
title: 'Upload finished',
|
||||
sub: imageCount(total),
|
||||
summary: summaryLine({ added, duplicate, attention }),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRetryAfter(header: string | null) {
|
||||
if (header === null || !/^\d+$/.test(header.trim())) {
|
||||
return DEFAULT_RETRY_AFTER_SECONDS
|
||||
}
|
||||
return Math.max(1, Number.parseInt(header, 10))
|
||||
}
|
||||
+538
-37
@@ -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>
|
||||
@@ -0,0 +1,443 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
announcer,
|
||||
callsTo,
|
||||
deferred,
|
||||
dialog,
|
||||
findButton,
|
||||
fixture,
|
||||
lastListPath,
|
||||
listBody,
|
||||
listCalls,
|
||||
mountPage,
|
||||
panel,
|
||||
press,
|
||||
settle,
|
||||
tab,
|
||||
tabs,
|
||||
textOf,
|
||||
unmountPage,
|
||||
type Handlers,
|
||||
} from './mediaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const DUPLICATE_IDS = [5, 6, 7, 9, 12]
|
||||
const DUPLICATE_COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
||||
|
||||
function duplicatesList(ids = DUPLICATE_IDS): Handlers['list'] {
|
||||
return (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(ids), DUPLICATE_COUNTS)
|
||||
: listBody(fixture())
|
||||
}
|
||||
|
||||
function groups() {
|
||||
return Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[data-testid="duplicate-group"]'),
|
||||
)
|
||||
}
|
||||
|
||||
function copiesOf(group: HTMLElement) {
|
||||
return Array.from(group.querySelectorAll<HTMLElement>('[data-testid="duplicate-copy"]'))
|
||||
}
|
||||
|
||||
function mergeButton(filename: string) {
|
||||
const copy = copiesOf(groups()[0]!)
|
||||
.concat(copiesOf(groups()[1] ?? groups()[0]!))
|
||||
.find((entry) => textOf(entry).includes(filename))!
|
||||
return findButton('Merge into this one', copy)!
|
||||
}
|
||||
|
||||
async function openMerge(filename: string, handlers: Handlers = {}) {
|
||||
await mountPage({ list: duplicatesList(), ...handlers }, '/media?filter=duplicates')
|
||||
mergeButton(filename).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
toasts.remove.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('The Duplicates tab', () => {
|
||||
it('is enabled, writes the URL, requests filter=duplicates with limit=100 and shows sort and view', async () => {
|
||||
await mountPage({ list: duplicatesList() })
|
||||
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
|
||||
|
||||
const duplicates = tab('Duplicates 2')
|
||||
duplicates.focus()
|
||||
press(duplicates, 'Enter')
|
||||
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
|
||||
expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe(
|
||||
'2',
|
||||
)
|
||||
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
|
||||
expect(document.body.querySelector('[aria-label="Grid view"]')).not.toBeNull()
|
||||
expect(
|
||||
document.body.querySelector('input[placeholder="Search by filename"]'),
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens from a deep link on the same view', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(groups()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('applies the search to the duplicates request', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
|
||||
const input = document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="Search by filename"]',
|
||||
)!
|
||||
input.value = 'floor'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
vi.advanceTimersByTime(120)
|
||||
await flushPromises()
|
||||
|
||||
expect(lastListPath()).toBe('/v1/media/?q=floor&filter=duplicates&sort=newest&limit=100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duplicates, groups', () => {
|
||||
it('groups the images by content into bordered cards in the suggested order', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
|
||||
expect(groups()).toHaveLength(2)
|
||||
const [first, second] = groups()
|
||||
expect(textOf(first!.querySelector('header'))).toBe(
|
||||
'Group 1 3 identical images · 2.4 MB each · 3600 × 2400',
|
||||
)
|
||||
expect(textOf(second!.querySelector('header'))).toBe(
|
||||
'Group 2 2 identical images · 3.1 MB each · 3600 × 2400',
|
||||
)
|
||||
const names = (group: HTMLElement) =>
|
||||
copiesOf(group).map((copy) => textOf(copy).split(' ')[0])
|
||||
expect(names(first!)).toEqual(['floorboards.jpg', 'floorboards-copy.jpg', 'IMG_2208.jpg'])
|
||||
expect(names(second!)).toEqual(['IMG_4471.jpg', 'stone-wall-detail.jpg'])
|
||||
expect(first!.className).toContain('border')
|
||||
expect(first!.parentElement!.className).toContain('gap-4')
|
||||
expect(textOf(first!.querySelector('footer'))).toBe(
|
||||
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows each copy with its uploader, use, merge button and the suggested styling', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
|
||||
const [suggested, other] = copiesOf(groups()[0]!)
|
||||
expect(textOf(suggested)).toContain('Uploaded 14 Jun 2026 by Inès Duarte')
|
||||
expect(textOf(suggested)).toContain('Used in 4 articles')
|
||||
expect(textOf(suggested)).toContain('Suggested')
|
||||
expect(suggested!.hasAttribute('data-suggested')).toBe(true)
|
||||
expect(suggested!.className).toContain('bg-secondary-200')
|
||||
expect(suggested!.className).toContain('md:border-primary')
|
||||
expect(findButton('Merge into this one', suggested)!.className).toContain('bg-primary')
|
||||
expect(textOf(other)).not.toContain('Suggested')
|
||||
expect(other!.hasAttribute('data-suggested')).toBe(false)
|
||||
expect(findButton('Merge into this one', other)!.className).toContain('border-accented')
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
findButton('Merge into this one', suggested)!.querySelector('svg.iconify--lucide'),
|
||||
).toBeTruthy(),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens the detail panel from the underlined "Used in" count', async () => {
|
||||
await mountPage(
|
||||
{
|
||||
list: duplicatesList(),
|
||||
usage: () => [{ id: 10, title: 'How we price', published: true }],
|
||||
},
|
||||
'/media?filter=duplicates',
|
||||
)
|
||||
|
||||
copiesOf(groups()[0]!)[0]!
|
||||
.querySelector<HTMLButtonElement>('[data-testid="duplicate-used-in"]')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(panel()).not.toBeNull()
|
||||
expect(textOf(panel())).toContain('floorboards.jpg')
|
||||
})
|
||||
|
||||
it('stacks the columns below md', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates', MOBILE_WIDTH)
|
||||
|
||||
const grid = copiesOf(groups()[0]!)[0]!.parentElement!
|
||||
expect(grid.className).toContain('md:grid-cols-(--columns)')
|
||||
expect(grid.className).not.toMatch(/(^| )grid-cols-/)
|
||||
})
|
||||
|
||||
it('reads the vector size and omits "by" when the uploader is unknown', async () => {
|
||||
const svg = (id: number, createdAt: string): MediaItem => ({
|
||||
...fixture([4])[0]!,
|
||||
id,
|
||||
filename: `mark-${id}.svg`,
|
||||
sha256: 'svg',
|
||||
uploaded_by: '',
|
||||
created_at: createdAt,
|
||||
used_in: 0,
|
||||
duplicate: true,
|
||||
})
|
||||
await mountPage(
|
||||
{
|
||||
list: () =>
|
||||
listBody(
|
||||
[svg(20, '2026-09-01T12:00:00Z'), svg(21, '2026-09-02T12:00:00Z')],
|
||||
DUPLICATE_COUNTS,
|
||||
),
|
||||
},
|
||||
'/media?filter=duplicates',
|
||||
)
|
||||
|
||||
expect(textOf(groups()[0]!.querySelector('header'))).toBe(
|
||||
'Group 1 2 identical images · 8 KB each · Vector',
|
||||
)
|
||||
expect(textOf(copiesOf(groups()[0]!)[0])).toContain('Uploaded 1 Sep 2026')
|
||||
expect(textOf(copiesOf(groups()[0]!)[0])).not.toContain(' by ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duplicates, empty', () => {
|
||||
const noDuplicates = () => listBody([], { all: 12, unused: 2, duplicates: 0 })
|
||||
|
||||
it('has no count badge and shows the panel, and Back to all images returns to All', async () => {
|
||||
await mountPage(
|
||||
{
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates') ? noDuplicates() : listBody(fixture()),
|
||||
},
|
||||
'/media?filter=duplicates',
|
||||
)
|
||||
|
||||
expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull()
|
||||
expect(textOf(tab('Duplicates'))).toBe('Duplicates')
|
||||
expect(document.body.textContent).toContain('No duplicates')
|
||||
expect(document.body.textContent).toContain('Every image in your library is different.')
|
||||
|
||||
findButton('Back to all images')!.click()
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({})
|
||||
expect(tab('All').getAttribute('aria-selected')).toBe('true')
|
||||
expect(document.body.querySelectorAll('[data-media-card]')).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Merge', () => {
|
||||
it('confirms for the suggested copy with the kept image and the article count', async () => {
|
||||
await openMerge('floorboards.jpg')
|
||||
|
||||
const box = dialog()!
|
||||
expect(textOf(box)).toContain('Merge into floorboards.jpg?')
|
||||
expect(textOf(box)).toContain(
|
||||
"Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone.",
|
||||
)
|
||||
expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 articles now')
|
||||
expect(Array.from(box.querySelectorAll('button')).map(textOf)).toEqual([
|
||||
'Cancel',
|
||||
'Merge 2 copies',
|
||||
])
|
||||
expect(callsTo('POST', /merge/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps another copy and sums the use of the others', async () => {
|
||||
await openMerge('IMG_2208.jpg')
|
||||
|
||||
expect(textOf(dialog())).toContain('Merge into IMG_2208.jpg?')
|
||||
expect(textOf(dialog())).toContain('7 articles will be updated.')
|
||||
})
|
||||
|
||||
it('uses the singular for a group of two copies', async () => {
|
||||
await openMerge('IMG_4471.jpg')
|
||||
|
||||
expect(textOf(dialog())).toContain('Articles that use the other copy will be switched to')
|
||||
expect(textOf(dialog())).toContain('the other copy will be deleted.')
|
||||
expect(findButton('Merge 1 copy', dialog()!)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends the ids and locks the dialog while the request is in flight', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
await openMerge('floorboards.jpg', { merge: () => pending.promise })
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
const [call] = callsTo('POST', /merge/)
|
||||
expect(call![0]).toBe('/v1/media/merge')
|
||||
expect(JSON.parse(call![1].body)).toEqual({ keep_id: 12, merge_ids: [5, 6] })
|
||||
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
|
||||
const confirm = findButton('Merging…', dialog()!)!
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
dialog()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
pending.resolve({ articles_updated: 5, merged: 2 })
|
||||
await settle()
|
||||
})
|
||||
|
||||
it('done: the group leaves, a toast and the live region say so, and the list reloads silently', async () => {
|
||||
let merged = false
|
||||
await openMerge('floorboards.jpg', {
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(merged ? [7, 9] : DUPLICATE_IDS), {
|
||||
all: merged ? 10 : 12,
|
||||
unused: 2,
|
||||
duplicates: merged ? 1 : 2,
|
||||
})
|
||||
: listBody(fixture()),
|
||||
merge: () => {
|
||||
merged = true
|
||||
return { articles_updated: 5, merged: 2 }
|
||||
},
|
||||
})
|
||||
const reloads = listCalls().length
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(groups()).toHaveLength(1)
|
||||
expect(textOf(groups()[0]!.querySelector('header'))).toContain('Group 1')
|
||||
expect(textOf(copiesOf(groups()[0]!)[0])).toContain('IMG_4471.jpg')
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'media-merged-12',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: 'Copies merged',
|
||||
description: '2 copies merged. 5 articles updated.',
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(announcer()).toBe('2 copies merged. 5 articles updated.')
|
||||
expect(listCalls().length).toBe(reloads + 1)
|
||||
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
it('done with the last group shows "No duplicates" and drops the count badge', async () => {
|
||||
let merged = false
|
||||
await openMerge('IMG_4471.jpg', {
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture(merged ? [] : [7, 9]), {
|
||||
all: merged ? 10 : 12,
|
||||
unused: 2,
|
||||
duplicates: merged ? 0 : 1,
|
||||
})
|
||||
: listBody(fixture()),
|
||||
merge: () => {
|
||||
merged = true
|
||||
return { articles_updated: 1, merged: 1 }
|
||||
},
|
||||
})
|
||||
|
||||
findButton('Merge 1 copy', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(document.body.textContent).toContain('No duplicates')
|
||||
expect(document.body.querySelector('[data-testid="duplicates-count"]')).toBeNull()
|
||||
expect(announcer()).toBe('1 copy merged. 1 article updated.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a 500 response', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])(
|
||||
'%s keeps the dialog open with the error alert and enables both buttons',
|
||||
async (_name, merge) => {
|
||||
await openMerge('floorboards.jpg', { merge })
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
const box = dialog()!
|
||||
expect(textOf(box.querySelector('[role="alert"]'))).toBe(
|
||||
"Couldn't merge the copies. Nothing was changed. Try again in a moment.",
|
||||
)
|
||||
expect(findButton('Cancel', box)!.disabled).toBe(false)
|
||||
expect(findButton('Merge 2 copies', box)!.disabled).toBe(false)
|
||||
expect(groups()).toHaveLength(2)
|
||||
},
|
||||
)
|
||||
|
||||
it('a 409 keeps the dialog open with the conflict alert and reloads the list', async () => {
|
||||
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(409)) })
|
||||
const before = listCalls().length
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toBe(
|
||||
'One of these images changed, so nothing was merged. The list is up to date now. Check it and try again.',
|
||||
)
|
||||
expect(listCalls().length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('a 404 closes the dialog, shows the info toast and reloads the list', async () => {
|
||||
await openMerge('floorboards.jpg', { merge: () => Promise.reject(new ApiError(404)) })
|
||||
const before = listCalls().length
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'media-not-found-12',
|
||||
color: 'info',
|
||||
title: 'That image no longer exists',
|
||||
}),
|
||||
)
|
||||
expect(listCalls().length).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('disables every Merge into this one while offline', async () => {
|
||||
await mountPage({ list: duplicatesList() }, '/media?filter=duplicates')
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
|
||||
(button) => textOf(button) === 'Merge into this one',
|
||||
)
|
||||
expect(buttons).toHaveLength(5)
|
||||
expect(buttons.every((button) => button.disabled)).toBe(true)
|
||||
expect(callsTo('POST', /merge/)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,572 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
announcer,
|
||||
bulkBar,
|
||||
callsTo,
|
||||
cardFor,
|
||||
cards,
|
||||
checkboxFor,
|
||||
deferred,
|
||||
dialog,
|
||||
findButton,
|
||||
fixture,
|
||||
libraryHandlers,
|
||||
listBody,
|
||||
listCalls,
|
||||
mountPage,
|
||||
panel,
|
||||
press,
|
||||
pressOnWindow,
|
||||
selectAllCheckbox,
|
||||
settle,
|
||||
tab,
|
||||
tableHeaderCheckbox,
|
||||
textOf,
|
||||
unmountPage,
|
||||
} from './mediaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const NAMES = ['north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg']
|
||||
|
||||
function select(...names: string[]) {
|
||||
for (const name of names) {
|
||||
checkboxFor(name).click()
|
||||
}
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
function state(element: Element) {
|
||||
return element.getAttribute('data-state')
|
||||
}
|
||||
|
||||
function selectedNames() {
|
||||
return cards()
|
||||
.filter((card) => card.hasAttribute('data-selected'))
|
||||
.map((card) => card.getAttribute('aria-label')!.split(',')[0])
|
||||
}
|
||||
|
||||
function checkboxWrapper(filename: string) {
|
||||
return checkboxFor(filename).closest<HTMLElement>('[data-testid="card-checkbox"]')!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
toasts.remove.mockClear()
|
||||
toasts.update.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('Selecting images', () => {
|
||||
it('shows no checkbox until the pointer or keyboard focus reaches a card, then a 36 px one', async () => {
|
||||
await mountPage()
|
||||
|
||||
const wrapper = checkboxWrapper('north-wall.jpg')
|
||||
expect(wrapper.classList.contains('opacity-100')).toBe(false)
|
||||
expect(wrapper.classList.contains('md:opacity-0')).toBe(true)
|
||||
expect(wrapper.className).toContain('md:group-hover/card:opacity-100')
|
||||
expect(wrapper.className).toContain('md:group-has-[:focus-visible]/card:opacity-100')
|
||||
expect(wrapper.className).toContain('md:size-9')
|
||||
expect(checkboxFor('north-wall.jpg').getAttribute('tabindex')).toBe('-1')
|
||||
})
|
||||
|
||||
it('ticking a checkbox selects the card, shows every checkbox and announces it', async () => {
|
||||
await mountPage()
|
||||
|
||||
await select('north-wall.jpg')
|
||||
|
||||
const card = cardFor('north-wall.jpg')
|
||||
expect(card.hasAttribute('data-selected')).toBe(true)
|
||||
expect(card.className).toContain('bg-secondary-200')
|
||||
expect(card.className).toContain('border-primary')
|
||||
expect(card.className).toContain('inset-ring-primary')
|
||||
expect(state(checkboxFor('north-wall.jpg'))).toBe('checked')
|
||||
expect(checkboxWrapper('kitchen-after.webp').classList.contains('opacity-100')).toBe(true)
|
||||
expect(announcer()).toBe('north-wall.jpg selected, 1 selected')
|
||||
expect(panel()).toBeNull()
|
||||
})
|
||||
|
||||
it('Space on the card toggles it and Enter still opens the detail panel', async () => {
|
||||
await mountPage()
|
||||
const card = cardFor('north-wall.jpg')
|
||||
|
||||
card.focus()
|
||||
press(card, ' ')
|
||||
await flushPromises()
|
||||
expect(card.hasAttribute('data-selected')).toBe(true)
|
||||
expect(announcer()).toBe('north-wall.jpg selected, 1 selected')
|
||||
expect(panel()).toBeNull()
|
||||
|
||||
press(card, ' ')
|
||||
await flushPromises()
|
||||
expect(card.hasAttribute('data-selected')).toBe(false)
|
||||
|
||||
press(card, 'Enter')
|
||||
await flushPromises()
|
||||
expect(panel()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('announces the new count when unselecting, and "Selection cleared" for the last one', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
|
||||
|
||||
await select('north-wall.jpg')
|
||||
expect(announcer()).toBe('north-wall.jpg unselected, 2 selected')
|
||||
await select('kitchen-after.webp')
|
||||
await select('window-frame.jpg')
|
||||
|
||||
expect(announcer()).toBe('Selection cleared')
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a 44 px checkbox on every card below md, and tapping it does not open the panel', async () => {
|
||||
await mountPage({}, '/media', MOBILE_WIDTH)
|
||||
|
||||
const wrapper = checkboxWrapper('north-wall.jpg')
|
||||
expect(wrapper.className).toContain('max-md:opacity-100')
|
||||
expect(wrapper.className).toContain('size-11')
|
||||
|
||||
await select('north-wall.jpg')
|
||||
expect(cardFor('north-wall.jpg').hasAttribute('data-selected')).toBe(true)
|
||||
expect(panel()).toBeNull()
|
||||
|
||||
cardFor('kitchen-after.webp').click()
|
||||
await flushPromises()
|
||||
expect(panel()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('puts a 44 px checkbox on every row of the card list in list view', async () => {
|
||||
await mountPage({}, '/media?view=list', MOBILE_WIDTH)
|
||||
|
||||
expect(document.body.querySelectorAll('[data-testid="media-card-row"]')).toHaveLength(12)
|
||||
expect(document.body.querySelectorAll('[role="checkbox"]')).toHaveLength(12)
|
||||
await select('north-wall.jpg')
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="media-card-row"][data-selected]')!.className,
|
||||
).toContain('bg-secondary-200')
|
||||
expect(document.body.querySelector('[data-testid="image-count"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Select all and the partial state', () => {
|
||||
it('shows Select all with the image count, partial with some selected, then all, then none', async () => {
|
||||
await mountPage()
|
||||
|
||||
const row = document.body.querySelector('[data-testid="image-count"]')!.parentElement!
|
||||
expect(textOf(row)).toBe('Select all 12 images')
|
||||
expect(state(selectAllCheckbox())).toBe('unchecked')
|
||||
|
||||
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
|
||||
expect(state(selectAllCheckbox())).toBe('indeterminate')
|
||||
|
||||
selectAllCheckbox().click()
|
||||
await flushPromises()
|
||||
expect(state(selectAllCheckbox())).toBe('checked')
|
||||
expect(selectedNames()).toHaveLength(12)
|
||||
expect(textOf(bulkBar())).toContain('12 selected')
|
||||
|
||||
selectAllCheckbox().click()
|
||||
await flushPromises()
|
||||
expect(state(selectAllCheckbox())).toBe('unchecked')
|
||||
expect(bulkBar()).toBeNull()
|
||||
expect(announcer()).toBe('Selection cleared')
|
||||
})
|
||||
|
||||
it('the table header checkbox does the same in list view at md width or more', async () => {
|
||||
await mountPage({}, '/media?view=list')
|
||||
|
||||
expect(state(tableHeaderCheckbox())).toBe('unchecked')
|
||||
await select('north-wall.jpg')
|
||||
expect(state(tableHeaderCheckbox())).toBe('indeterminate')
|
||||
tableHeaderCheckbox().click()
|
||||
await flushPromises()
|
||||
expect(state(tableHeaderCheckbox())).toBe('checked')
|
||||
expect(textOf(bulkBar())).toContain('12 selected')
|
||||
tableHeaderCheckbox().click()
|
||||
await flushPromises()
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('has no Select all row in the mobile card list', async () => {
|
||||
await mountPage({}, '/media?view=list', MOBILE_WIDTH)
|
||||
|
||||
expect(document.body.textContent).not.toContain('Select all')
|
||||
await select('north-wall.jpg', 'kitchen-after.webp')
|
||||
expect(textOf(bulkBar())).toContain('2 selected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Escape and Clear', () => {
|
||||
it('Escape and Clear empty the selection when the panel is closed', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg', 'kitchen-after.webp', 'window-frame.jpg')
|
||||
|
||||
pressOnWindow('Escape')
|
||||
await flushPromises()
|
||||
expect(bulkBar()).toBeNull()
|
||||
expect(selectedNames()).toEqual([])
|
||||
|
||||
await select('north-wall.jpg', 'kitchen-after.webp')
|
||||
findButton('Clear', bulkBar()!)!.click()
|
||||
await flushPromises()
|
||||
expect(bulkBar()).toBeNull()
|
||||
expect(announcer()).toBe('Selection cleared')
|
||||
})
|
||||
|
||||
it('Escape closes only the panel while it is open over a selection', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg', 'kitchen-after.webp')
|
||||
cardFor('window-frame.jpg').click()
|
||||
await flushPromises()
|
||||
expect(panel()).not.toBeNull()
|
||||
|
||||
panel()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await settle()
|
||||
|
||||
expect(panel()).toBeNull()
|
||||
expect(selectedNames()).toEqual(['north-wall.jpg', 'kitchen-after.webp'])
|
||||
pressOnWindow('Escape')
|
||||
await flushPromises()
|
||||
expect(selectedNames()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('The selection follows the query', () => {
|
||||
it('clears when the tab changes', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg')
|
||||
|
||||
tab('Unused').focus()
|
||||
press(tab('Unused'), 'Enter')
|
||||
await settle()
|
||||
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears when the sort changes and stays when the view changes', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg', 'kitchen-after.webp')
|
||||
|
||||
await router.replace({ query: { view: 'list' } })
|
||||
await settle()
|
||||
expect(textOf(bulkBar())).toContain('2 selected')
|
||||
|
||||
await router.replace({ query: { view: 'list', sort: 'oldest' } })
|
||||
await settle()
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears when the search text changes', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg')
|
||||
|
||||
await router.replace({ query: { q: 'wall' } })
|
||||
await settle()
|
||||
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The bulk bar', () => {
|
||||
it('shows the count, a danger Delete and an inverted Clear, placed per breakpoint', async () => {
|
||||
await mountPage()
|
||||
|
||||
await select(...NAMES)
|
||||
|
||||
const bar = bulkBar()!
|
||||
expect(textOf(bar)).toContain('3 selected')
|
||||
expect(bar.className).toContain('bg-inverted')
|
||||
expect(bar.className).toContain('text-inverted')
|
||||
expect(bar.className).toContain('fixed')
|
||||
expect(bar.className).toContain('md:w-100')
|
||||
expect(bar.className).toContain('md:bottom-6')
|
||||
expect(bar.className).toContain('bottom-4')
|
||||
expect(bar.className).toContain('max-md:inset-x-4')
|
||||
const remove = findButton('Delete', bar)!
|
||||
expect(remove.className).toContain('bg-error')
|
||||
expect(findButton('Clear', bar)!.className).toContain('border-(--ui-text-inverted)')
|
||||
await vi.waitFor(() => expect(remove.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('replaces the mobile pinned Upload button', async () => {
|
||||
await mountPage({}, '/media', MOBILE_WIDTH)
|
||||
expect(findButton('Upload')).toBeTruthy()
|
||||
|
||||
await select('north-wall.jpg')
|
||||
|
||||
expect(findButton('Upload')).toBeUndefined()
|
||||
expect(textOf(bulkBar())).toContain('1 selected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bulk delete', () => {
|
||||
async function openBulkDialog(names = NAMES) {
|
||||
await select(...names)
|
||||
findButton('Delete', bulkBar()!)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('confirms with the summed article count and one row per image, without a usage request', async () => {
|
||||
await mountPage()
|
||||
|
||||
await openBulkDialog()
|
||||
|
||||
const box = dialog()!
|
||||
expect(textOf(box)).toContain('Delete 3 images?')
|
||||
expect(textOf(box)).toContain(
|
||||
"2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone.",
|
||||
)
|
||||
const rows = Array.from(box.querySelectorAll('[data-testid="bulk-delete-row"]')).map(textOf)
|
||||
expect(rows).toEqual([
|
||||
'north-wall.jpg Used in 2',
|
||||
'kitchen-after.webp Used in 3',
|
||||
'window-frame.jpg Unused',
|
||||
])
|
||||
const buttons = Array.from(box.querySelectorAll('button')).map(textOf)
|
||||
expect(buttons).toEqual(['Cancel', 'Delete 3 images'])
|
||||
expect(callsTo('GET', /usage/)).toHaveLength(0)
|
||||
expect(callsTo('DELETE', /media/)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the singular for one image', async () => {
|
||||
await mountPage()
|
||||
|
||||
await openBulkDialog(['window-frame.jpg'])
|
||||
|
||||
expect(textOf(dialog())).toContain('Delete 1 image?')
|
||||
expect(findButton('Delete 1 image', dialog()!)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends one DELETE per image and locks the dialog while they are in flight', async () => {
|
||||
const pending = deferred<undefined>()
|
||||
await mountPage({ remove: () => pending.promise })
|
||||
await openBulkDialog()
|
||||
|
||||
findButton('Delete 3 images', dialog()!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(callsTo('DELETE', /^\/v1\/media\/(1|2|8)$/).map(([path]) => path)).toEqual([
|
||||
'/v1/media/1',
|
||||
'/v1/media/2',
|
||||
'/v1/media/8',
|
||||
])
|
||||
expect(findButton('Cancel', dialog()!)!.disabled).toBe(true)
|
||||
const confirm = findButton('Deleting…', dialog()!)!
|
||||
expect(confirm.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(confirm.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
dialog()!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(dialog()).not.toBeNull()
|
||||
pending.resolve(undefined)
|
||||
await settle()
|
||||
})
|
||||
|
||||
it('done: the cards leave, the selection empties, focus moves on and a toast shows', async () => {
|
||||
let calls = 0
|
||||
await mountPage({
|
||||
list: () =>
|
||||
++calls === 1
|
||||
? listBody(fixture())
|
||||
: listBody(
|
||||
fixture().filter((item) => ![1, 2, 8].includes(item.id)),
|
||||
{ all: 9, unused: 1, duplicates: 2 },
|
||||
),
|
||||
})
|
||||
await openBulkDialog()
|
||||
|
||||
findButton('Delete 3 images', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(cards()).toHaveLength(9)
|
||||
expect(bulkBar()).toBeNull()
|
||||
expect(document.activeElement).toBe(cardFor('kitchen-before.webp'))
|
||||
expect(toasts.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'media-bulk-deleted',
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: '3 images deleted',
|
||||
description: "They're gone from your library.",
|
||||
duration: 5000,
|
||||
}),
|
||||
)
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
it('done with nothing left moves focus to the page heading', async () => {
|
||||
await mountPage(libraryHandlers(() => undefined, [1]))
|
||||
await openBulkDialog(['north-wall.jpg'])
|
||||
|
||||
findButton('Delete 1 image', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
document.body.querySelector('[data-testid="media-heading"]'),
|
||||
)
|
||||
})
|
||||
|
||||
it('partly failed: deleted cards leave, the failed one stays selected and an error toast offers Retry', async () => {
|
||||
await mountPage(
|
||||
libraryHandlers((id) => (id === 8 ? Promise.reject(new ApiError(500)) : undefined)),
|
||||
)
|
||||
await openBulkDialog()
|
||||
|
||||
findButton('Delete 3 images', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(dialog()).toBeNull()
|
||||
expect(cardFor('north-wall.jpg')).toBeUndefined()
|
||||
expect(cardFor('kitchen-after.webp')).toBeUndefined()
|
||||
expect(selectedNames()).toEqual(['window-frame.jpg'])
|
||||
expect(textOf(bulkBar())).toContain('1 selected')
|
||||
const toast = toasts.add.mock.calls.at(-1)![0]
|
||||
expect(toast).toMatchObject({
|
||||
id: 'media-bulk-delete-failed',
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: "Couldn't delete 1 image",
|
||||
description:
|
||||
'2 images were deleted. window-frame.jpg is still in your library. Try again.',
|
||||
duration: 0,
|
||||
})
|
||||
expect(toast.actions).toHaveLength(1)
|
||||
expect(toast.actions[0]).toMatchObject({ label: 'Retry', icon: 'i-lucide-rotate-cw' })
|
||||
})
|
||||
|
||||
it('Retry sends only the failed DELETE with no dialog and replaces the toast on success', async () => {
|
||||
let eightAttempts = 0
|
||||
await mountPage(
|
||||
libraryHandlers((id) => {
|
||||
if (id === 8 && ++eightAttempts === 1) {
|
||||
return Promise.reject(new NetworkError())
|
||||
}
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
await openBulkDialog()
|
||||
findButton('Delete 3 images', dialog()!)!.click()
|
||||
await settle()
|
||||
const failed = toasts.add.mock.calls.at(-1)![0]
|
||||
const deletesBefore = callsTo('DELETE', /media/).length
|
||||
|
||||
failed.actions[0].onClick()
|
||||
await settle()
|
||||
|
||||
expect(callsTo('DELETE', /media/).length).toBe(deletesBefore + 1)
|
||||
expect(callsTo('DELETE', /media\/8$/)).toHaveLength(2)
|
||||
expect(dialog()).toBeNull()
|
||||
expect(cardFor('window-frame.jpg')).toBeUndefined()
|
||||
expect(toasts.remove).toHaveBeenCalledWith('media-bulk-delete-failed')
|
||||
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
|
||||
id: 'media-bulk-deleted',
|
||||
color: 'success',
|
||||
title: '3 images deleted',
|
||||
})
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('several failures name every file and pluralise the title', async () => {
|
||||
await mountPage({
|
||||
remove: (id) => (id === 2 || id === 3 ? Promise.reject(new ApiError(500)) : undefined),
|
||||
})
|
||||
await openBulkDialog([
|
||||
'north-wall.jpg',
|
||||
'kitchen-after.webp',
|
||||
'kitchen-before.webp',
|
||||
'window-frame.jpg',
|
||||
])
|
||||
|
||||
findButton('Delete 4 images', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
|
||||
title: "Couldn't delete 2 images",
|
||||
description:
|
||||
'2 images were deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.',
|
||||
})
|
||||
})
|
||||
|
||||
it('an image that no longer exists counts as deleted', async () => {
|
||||
await mountPage(
|
||||
libraryHandlers((id) => (id === 2 ? Promise.reject(new ApiError(404)) : undefined)),
|
||||
)
|
||||
await openBulkDialog()
|
||||
|
||||
findButton('Delete 3 images', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
expect(cardFor('kitchen-after.webp')).toBeUndefined()
|
||||
expect(toasts.add.mock.calls.at(-1)![0]).toMatchObject({
|
||||
id: 'media-bulk-deleted',
|
||||
title: '3 images deleted',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['500', () => Promise.reject(new ApiError(500))],
|
||||
['a network failure', () => Promise.reject(new NetworkError())],
|
||||
])(
|
||||
'every request failing with %s keeps the dialog open with an error alert',
|
||||
async (_name, remove) => {
|
||||
await mountPage({ remove })
|
||||
await openBulkDialog()
|
||||
|
||||
findButton('Delete 3 images', dialog()!)!.click()
|
||||
await settle()
|
||||
|
||||
const box = dialog()!
|
||||
expect(textOf(box.querySelector('[role="alert"]'))).toBe(
|
||||
"Couldn't delete the images, so they're still there. Try again in a moment.",
|
||||
)
|
||||
expect(findButton('Cancel', box)!.disabled).toBe(false)
|
||||
expect(findButton('Delete 3 images', box)!.disabled).toBe(false)
|
||||
expect(cards()).toHaveLength(12)
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('disables Delete in the bulk bar while offline but keeps Clear working', async () => {
|
||||
await mountPage()
|
||||
await select('north-wall.jpg')
|
||||
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
|
||||
expect(findButton('Delete', bulkBar()!)!.disabled).toBe(true)
|
||||
await select('kitchen-after.webp')
|
||||
expect(textOf(bulkBar())).toContain('2 selected')
|
||||
findButton('Clear', bulkBar()!)!.click()
|
||||
await flushPromises()
|
||||
expect(bulkBar()).toBeNull()
|
||||
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
window.dispatchEvent(new Event('online'))
|
||||
await select('north-wall.jpg')
|
||||
expect(findButton('Delete', bulkBar()!)!.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
FakeXhr,
|
||||
chooseFiles,
|
||||
dialog,
|
||||
findButton,
|
||||
checkboxFor,
|
||||
bulkBar,
|
||||
fileWith,
|
||||
fixture,
|
||||
listBody,
|
||||
mountPage,
|
||||
settle,
|
||||
stubDigest,
|
||||
stubXhr,
|
||||
textOf,
|
||||
unmountPage,
|
||||
type Handlers,
|
||||
} from './mediaTestKit'
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const LOGIN = { path: '/login', query: { redirect: '/media', reason: 'expired' } }
|
||||
const DUPLICATES_LOGIN = {
|
||||
path: '/login',
|
||||
query: { redirect: '/media?filter=duplicates', reason: 'expired' },
|
||||
}
|
||||
const PENDING = { path: '/register/pending', query: {} }
|
||||
|
||||
async function mountUnverified(handlers: Handlers, url = '/media') {
|
||||
await mountPage(handlers, url, undefined, { realApi: true })
|
||||
useSessionStore().session = {
|
||||
verified: false,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: 'owner',
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRedirect(destination: { path: string; query: Record<string, string> }) {
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination.path))
|
||||
expect(router.currentRoute.value.query).toEqual(destination.query)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
stubDigest()
|
||||
stubXhr()
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('Session errors are global for the new calls', () => {
|
||||
it.each([
|
||||
['POST /v1/media/merge', 401, DUPLICATES_LOGIN],
|
||||
['POST /v1/media/merge', 403, PENDING],
|
||||
])('%s resolving %i redirects and shows no local copy', async (_call, status, destination) => {
|
||||
await mountUnverified(
|
||||
{
|
||||
list: (path) =>
|
||||
path.includes('filter=duplicates')
|
||||
? listBody(fixture([5, 6, 12]))
|
||||
: listBody(fixture()),
|
||||
merge: () => Promise.reject(new ApiError(status)),
|
||||
},
|
||||
'/media?filter=duplicates',
|
||||
)
|
||||
document.body
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="duplicate-copy"] button:not([data-testid])',
|
||||
)!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
findButton('Merge 2 copies', dialog()!)!.click()
|
||||
await expectRedirect(destination)
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['DELETE /v1/media/{id}', 403, PENDING],
|
||||
['DELETE /v1/media/{id}', 401, LOGIN],
|
||||
])('%s resolving %i redirects and shows no local copy', async (_call, status, destination) => {
|
||||
await mountUnverified({ remove: () => Promise.reject(new ApiError(status)) })
|
||||
checkboxFor('north-wall.jpg').click()
|
||||
await flushPromises()
|
||||
findButton('Delete', bulkBar()!)!.click()
|
||||
await flushPromises()
|
||||
|
||||
findButton('Delete 1 image', dialog()!)!.click()
|
||||
await expectRedirect(destination)
|
||||
await settle()
|
||||
|
||||
expect(dialog()!.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('GET /v1/media/?hash= resolving 401 redirects to the login page and sends nothing', async () => {
|
||||
await mountUnverified({ hash: () => Promise.reject(new ApiError(401)) })
|
||||
|
||||
await chooseFiles([fileWith('a.jpg', 'image/jpeg', 1000)])
|
||||
await expectRedirect(LOGIN)
|
||||
|
||||
expect(FakeXhr.instances).toHaveLength(0)
|
||||
expect(document.body.textContent).not.toContain('Upload failed')
|
||||
})
|
||||
|
||||
it('GET /v1/media/?hash= resolving 403 redirects to the pending page', async () => {
|
||||
await mountUnverified({ hash: () => Promise.reject(new ApiError(403)) })
|
||||
|
||||
await chooseFiles([fileWith('a.jpg', 'image/jpeg', 1000)])
|
||||
await expectRedirect(PENDING)
|
||||
|
||||
expect(FakeXhr.instances).toHaveLength(0)
|
||||
expect(textOf(document.body.querySelector('[data-testid="upload-row"]'))).not.toContain(
|
||||
'Upload failed',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,844 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { ApiError, NetworkError } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
FakeXhr,
|
||||
MEGABYTE,
|
||||
announcer,
|
||||
bulkBar,
|
||||
callsTo,
|
||||
cards,
|
||||
chooseFiles,
|
||||
dragEvent,
|
||||
deferred,
|
||||
fileInput,
|
||||
fileWith,
|
||||
findButton,
|
||||
fixture,
|
||||
hexOf,
|
||||
inFlight,
|
||||
listBody,
|
||||
listCalls,
|
||||
mountPage,
|
||||
rowTexts,
|
||||
settle,
|
||||
stubDigest,
|
||||
stubXhr,
|
||||
tab,
|
||||
textOf,
|
||||
unmountPage,
|
||||
uploadPanel,
|
||||
uploadRows,
|
||||
xhrFor,
|
||||
type Handlers,
|
||||
} from './mediaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => toasts }
|
||||
})
|
||||
|
||||
const REJECTION_TEXT = 'Images can be up to 10 MB. Use a PNG, JPEG, WEBP or SVG image.'
|
||||
const UNREADABLE_TEXT =
|
||||
"The server couldn't read this image. Save it again as a PNG or JPEG, then try again."
|
||||
const FAILED_TEXT = "The server didn't accept the file. Nothing was added to your library."
|
||||
|
||||
function photo(name: string, size = 1000) {
|
||||
return fileWith(name, 'image/jpeg', size)
|
||||
}
|
||||
|
||||
function hashLookups() {
|
||||
return callsTo('GET', /\?hash=/)
|
||||
}
|
||||
|
||||
function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] {
|
||||
return (path) =>
|
||||
path === `/v1/media/?hash=${hexOf(name)}` ? listBody(fixture(existing)) : listBody([])
|
||||
}
|
||||
|
||||
function headerButton(label: string) {
|
||||
return findButton(label)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
toasts.add.mockClear()
|
||||
toasts.remove.mockClear()
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
stubDigest()
|
||||
stubXhr()
|
||||
})
|
||||
|
||||
afterEach(unmountPage)
|
||||
|
||||
describe('Upload entry points', () => {
|
||||
it.each([
|
||||
['at md width or more', 'Upload', 1440],
|
||||
['below md width', 'Upload', MOBILE_WIDTH],
|
||||
])(
|
||||
'the Default state loaded %s: "%s" opens the file chooser',
|
||||
async (_context, label, width) => {
|
||||
await mountPage({}, '/media', width)
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
|
||||
|
||||
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
|
||||
(button) => textOf(button) === label,
|
||||
)
|
||||
expect(buttons).toHaveLength(1)
|
||||
buttons[0]!.click()
|
||||
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
expect(click.mock.contexts[0]).toBe(fileInput())
|
||||
},
|
||||
)
|
||||
|
||||
it('and the library has no image: "Upload an image" opens the file chooser', async () => {
|
||||
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
|
||||
const click = vi.spyOn(HTMLInputElement.prototype, 'click')
|
||||
|
||||
findButton('Upload an image')!.click()
|
||||
|
||||
expect(click.mock.contexts[0]).toBe(fileInput())
|
||||
})
|
||||
|
||||
it('the chooser accepts several files and offers the four image types', async () => {
|
||||
await mountPage()
|
||||
|
||||
expect(fileInput().multiple).toBe(true)
|
||||
expect(fileInput().getAttribute('accept')).toBe(
|
||||
'image/png,image/jpeg,image/webp,image/svg+xml',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dropping files anywhere on the page', () => {
|
||||
it('shows the drop area while files are dragged over, then uploads the drop with no crop step', async () => {
|
||||
await mountPage()
|
||||
|
||||
dragEvent('dragenter', ['Files'])
|
||||
await flushPromises()
|
||||
const area = document.body.querySelector('[data-testid="media-drop-area"]')!
|
||||
expect(textOf(area)).toBe('Drop images to upload PNG, JPEG, WEBP, SVG · max 10 MB each')
|
||||
expect(area.className).toContain('border-dashed')
|
||||
expect(area.className).toContain('border-primary')
|
||||
|
||||
dragEvent('dragleave', ['Files'])
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
|
||||
expect(FakeXhr.instances).toHaveLength(0)
|
||||
expect(uploadPanel()).toBeNull()
|
||||
|
||||
dragEvent('dragenter', ['Files'])
|
||||
dragEvent('drop', ['Files'], [photo('a.jpg'), photo('b.jpg')])
|
||||
await settle()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
|
||||
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg', 'b.jpg'])
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the mobile pinned Upload button while the area shows', async () => {
|
||||
await mountPage({}, '/media', MOBILE_WIDTH)
|
||||
expect(headerButton('Upload')).toBeTruthy()
|
||||
|
||||
dragEvent('dragenter', ['Files'])
|
||||
await flushPromises()
|
||||
expect(headerButton('Upload')).toBeUndefined()
|
||||
|
||||
dragEvent('dragleave', ['Files'])
|
||||
await flushPromises()
|
||||
expect(headerButton('Upload')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does nothing when something that is not a file is dragged over', async () => {
|
||||
await mountPage()
|
||||
|
||||
const event = dragEvent('dragenter', ['text/plain'])
|
||||
await flushPromises()
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Browser checks, nothing is sent', () => {
|
||||
it.each([
|
||||
['scan.tif', 14.2 * MEGABYTE, 'image/tiff', 'scan.tif is 14.2 MB'],
|
||||
['notes.pdf', 210 * 1024, 'application/pdf', "notes.pdf isn't an image"],
|
||||
['photo.png', 10.1 * MEGABYTE, 'image/png', 'photo.png is 10.1 MB'],
|
||||
['fake.jpg', 40 * 1024, 'text/plain', "fake.jpg isn't an image"],
|
||||
['logo.gif', 40 * 1024, 'image/gif', "logo.gif isn't an image"],
|
||||
['pic.jpg', 40 * 1024, 'image/png', "pic.jpg isn't an image"],
|
||||
])('%s becomes a rejected row', async (name, size, mime, title) => {
|
||||
await mountPage()
|
||||
const digest = globalThis.crypto.subtle.digest
|
||||
|
||||
await chooseFiles([fileWith(name, mime, size)])
|
||||
|
||||
const [row] = uploadRows()
|
||||
expect(row!.getAttribute('data-status')).toBe('rejected')
|
||||
expect(textOf(row)).toContain(title)
|
||||
expect(textOf(row)).toContain(REJECTION_TEXT)
|
||||
await vi.waitFor(() => expect(row!.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
expect(digest).not.toHaveBeenCalled()
|
||||
expect(hashLookups()).toHaveLength(0)
|
||||
expect(FakeXhr.instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts exactly 10485760 bytes and rejects one byte more', async () => {
|
||||
await mountPage()
|
||||
|
||||
await chooseFiles([
|
||||
fileWith('edge.png', 'image/png', 10485760),
|
||||
fileWith('over.png', 'image/png', 10485761),
|
||||
])
|
||||
|
||||
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
|
||||
'uploading',
|
||||
'rejected',
|
||||
])
|
||||
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['edge.png'])
|
||||
})
|
||||
|
||||
it('checks the size before the type', async () => {
|
||||
await mountPage()
|
||||
|
||||
await chooseFiles([fileWith('huge.tif', 'image/tiff', 11 * MEGABYTE)])
|
||||
|
||||
expect(textOf(uploadRows()[0])).toContain('huge.tif is 11.0 MB')
|
||||
})
|
||||
|
||||
it('leaves the panel with "Nothing was uploaded" when every file is rejected', async () => {
|
||||
await mountPage()
|
||||
|
||||
await chooseFiles([
|
||||
fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE),
|
||||
fileWith('notes.pdf', 'application/pdf', 210 * 1024),
|
||||
fileWith('broken.png', 'image/png', 3 * MEGABYTE),
|
||||
])
|
||||
xhrFor('broken.png').respond(400)
|
||||
await settle()
|
||||
|
||||
const panel = uploadPanel()!
|
||||
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe(
|
||||
'Nothing was uploaded',
|
||||
)
|
||||
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe(
|
||||
'3 files were rejected',
|
||||
)
|
||||
expect(findButton('Dismiss', panel)).toBeTruthy()
|
||||
expect(panel.querySelector('[data-testid="upload-summary"]')).toBeNull()
|
||||
const [scan, notes, broken] = rowTexts()
|
||||
expect(scan).toContain(`scan.tif is 14.2 MB ${REJECTION_TEXT}`)
|
||||
expect(notes).toContain(`notes.pdf isn't an image ${REJECTION_TEXT}`)
|
||||
expect(broken).toContain(`broken.png couldn't be read ${UNREADABLE_TEXT}`)
|
||||
})
|
||||
|
||||
it('reads "2 files need attention" for a rejected and a failed file, with no summary', async () => {
|
||||
await mountPage()
|
||||
|
||||
await chooseFiles([fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE), photo('roof.jpg')])
|
||||
xhrFor('roof.jpg').respond(500)
|
||||
await settle()
|
||||
|
||||
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
|
||||
'Nothing was uploaded',
|
||||
)
|
||||
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-sub"]'))).toBe(
|
||||
'2 files need attention',
|
||||
)
|
||||
expect(uploadPanel()!.querySelector('[data-testid="upload-summary"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The upload panel and its rows', () => {
|
||||
function sixImages() {
|
||||
return Array.from({ length: 6 }, (_, index) =>
|
||||
photo(`img-${index + 1}.jpg`, 2.6 * MEGABYTE),
|
||||
)
|
||||
}
|
||||
|
||||
it('uploads two at a time, in order, with progress', async () => {
|
||||
await mountPage()
|
||||
|
||||
await chooseFiles(sixImages())
|
||||
|
||||
const panel = uploadPanel()!
|
||||
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe(
|
||||
'Uploading 6 images',
|
||||
)
|
||||
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('0 of 6 done')
|
||||
expect(findButton('Cancel all', panel)).toBeTruthy()
|
||||
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
|
||||
'uploading',
|
||||
'uploading',
|
||||
'waiting',
|
||||
'waiting',
|
||||
'waiting',
|
||||
'waiting',
|
||||
])
|
||||
expect(textOf(uploadRows()[2])).toContain('Waiting')
|
||||
expect(textOf(uploadRows()[2])).toContain('2.6 MB')
|
||||
expect(inFlight()).toHaveLength(2)
|
||||
|
||||
xhrFor('img-1.jpg').progress(1.1 * MEGABYTE, 2.6 * MEGABYTE)
|
||||
await flushPromises()
|
||||
expect(textOf(uploadRows()[0])).toContain('Uploading 1.1 of 2.6 MB')
|
||||
expect(
|
||||
uploadRows()[0]!.querySelector('[role="progressbar"]')!.getAttribute('aria-valuenow'),
|
||||
).toBe('42')
|
||||
expect(
|
||||
uploadRows()[0]!.querySelector(`[aria-label="Cancel upload of img-1.jpg"]`),
|
||||
).toBeTruthy()
|
||||
|
||||
xhrFor('img-1.jpg').respond(201)
|
||||
await settle()
|
||||
|
||||
expect(textOf(uploadRows()[0])).toContain('Added to your library')
|
||||
await vi.waitFor(() =>
|
||||
expect(uploadRows()[0]!.querySelector('svg.iconify--lucide')).toBeTruthy(),
|
||||
)
|
||||
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('1 of 6 done')
|
||||
expect(uploadRows()[2]!.getAttribute('data-status')).toBe('uploading')
|
||||
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
|
||||
'img-1.jpg',
|
||||
'img-2.jpg',
|
||||
'img-3.jpg',
|
||||
])
|
||||
expect(inFlight()).toHaveLength(2)
|
||||
expect(FakeXhr.instances[0]!.url).toBe('/v1/media/')
|
||||
expect(FakeXhr.instances[0]!.method).toBe('POST')
|
||||
expect(FakeXhr.instances[0]!.withCredentials).toBe(true)
|
||||
})
|
||||
|
||||
it('never has more than two requests in flight while draining the queue', async () => {
|
||||
await mountPage()
|
||||
await chooseFiles(sixImages())
|
||||
let peak = inFlight().length
|
||||
|
||||
for (let guard = 0; guard < 6 && inFlight().length > 0; guard++) {
|
||||
inFlight()[0]!.respond(201)
|
||||
await settle()
|
||||
peak = Math.max(peak, inFlight().length)
|
||||
}
|
||||
|
||||
expect(peak).toBe(2)
|
||||
expect(FakeXhr.instances).toHaveLength(6)
|
||||
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
|
||||
'Upload finished',
|
||||
)
|
||||
})
|
||||
|
||||
it('counts settled rows in the header', async () => {
|
||||
await mountPage({ hash: hashHandlerFor('dup.jpg') })
|
||||
|
||||
await chooseFiles([
|
||||
photo('added.jpg'),
|
||||
photo('dup.jpg'),
|
||||
fileWith('rej.tif', 'image/tiff', 1000),
|
||||
photo('fail.jpg'),
|
||||
photo('up-1.jpg'),
|
||||
photo('up-2.jpg'),
|
||||
])
|
||||
xhrFor('added.jpg').respond(201)
|
||||
await settle()
|
||||
xhrFor('fail.jpg').respond(500)
|
||||
await settle()
|
||||
|
||||
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
|
||||
'added',
|
||||
'duplicate',
|
||||
'rejected',
|
||||
'failed',
|
||||
'uploading',
|
||||
'uploading',
|
||||
])
|
||||
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-sub"]'))).toBe(
|
||||
'4 of 6 done',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the singular for one image', async () => {
|
||||
await mountPage()
|
||||
|
||||
await chooseFiles([photo('one.jpg')])
|
||||
|
||||
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
|
||||
'Uploading 1 image',
|
||||
)
|
||||
})
|
||||
|
||||
it('appends files added while the panel is open', async () => {
|
||||
await mountPage()
|
||||
await chooseFiles([photo('a.jpg'), photo('b.jpg')])
|
||||
|
||||
await chooseFiles([photo('c.jpg')])
|
||||
|
||||
expect(uploadRows()).toHaveLength(3)
|
||||
expect(textOf(uploadPanel()!.querySelector('[data-testid="upload-title"]'))).toBe(
|
||||
'Uploading 3 images',
|
||||
)
|
||||
expect(textOf(uploadRows()[2])).toContain('c.jpg')
|
||||
})
|
||||
|
||||
it('refreshes the list in the background after a 201', async () => {
|
||||
let calls = 0
|
||||
await mountPage({
|
||||
list: () =>
|
||||
++calls === 1
|
||||
? listBody(fixture())
|
||||
: listBody(fixture(), { all: 13, unused: 2, duplicates: 2 }),
|
||||
})
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
|
||||
await chooseFiles([photo('a.jpg')])
|
||||
xhrFor('a.jpg').respond(201)
|
||||
await settle()
|
||||
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(document.body.querySelectorAll('[data-testid="media-skeleton-card"]')).toHaveLength(
|
||||
0,
|
||||
)
|
||||
expect(tab('All').textContent).toContain('13')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A row whose content already exists', () => {
|
||||
const existingText = 'Same as "floorboards.jpg", uploaded 14 Jun 2026 by Inès Duarte.'
|
||||
|
||||
async function mountWithDuplicate(handlers: Handlers = {}) {
|
||||
await mountPage({ hash: hashHandlerFor('floorboards-2.jpg'), ...handlers })
|
||||
await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)])
|
||||
}
|
||||
|
||||
it('looks up the hash before any POST and shows the duplicate row, naming the oldest match', async () => {
|
||||
await mountWithDuplicate()
|
||||
|
||||
expect(hashLookups().map(([path]) => path)).toEqual([
|
||||
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}`,
|
||||
])
|
||||
expect(FakeXhr.instances).toHaveLength(0)
|
||||
const row = uploadRows()[0]!
|
||||
expect(row.getAttribute('data-status')).toBe('duplicate')
|
||||
expect(textOf(row)).toContain('Already in your library')
|
||||
expect(textOf(row)).toContain(existingText)
|
||||
expect(findButton('Use existing', row)).toBeTruthy()
|
||||
expect(findButton('Upload anyway', row)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hashes inside the concurrency slot while the row reads Waiting', async () => {
|
||||
const first = deferred<unknown>()
|
||||
const second = deferred<unknown>()
|
||||
const answers = [first, second]
|
||||
await mountPage({ hash: () => (answers.shift() ?? deferred<unknown>()).promise })
|
||||
|
||||
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
|
||||
|
||||
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
|
||||
'waiting',
|
||||
'waiting',
|
||||
'waiting',
|
||||
])
|
||||
expect(hashLookups()).toHaveLength(2)
|
||||
expect(FakeXhr.instances).toHaveLength(0)
|
||||
|
||||
first.resolve(listBody([]))
|
||||
await settle()
|
||||
|
||||
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg'])
|
||||
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
|
||||
expect(hashLookups()).toHaveLength(2)
|
||||
|
||||
xhrFor('a.jpg').respond(201)
|
||||
await settle()
|
||||
|
||||
expect(hashLookups()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('releases the concurrency slot so the next file starts', async () => {
|
||||
await mountPage({ hash: hashHandlerFor('dup.jpg') })
|
||||
|
||||
await chooseFiles([photo('dup.jpg'), photo('b.jpg'), photo('c.jpg'), photo('d.jpg')])
|
||||
|
||||
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
|
||||
'duplicate',
|
||||
'uploading',
|
||||
'uploading',
|
||||
'waiting',
|
||||
])
|
||||
})
|
||||
|
||||
it('Use existing drops the row and selects the image', async () => {
|
||||
await mountWithDuplicate()
|
||||
|
||||
findButton('Use existing')!.click()
|
||||
await settle()
|
||||
|
||||
expect(uploadPanel()).toBeNull()
|
||||
expect(textOf(bulkBar())).toContain('1 selected')
|
||||
expect(announcer()).toBe('floorboards.jpg selected, 1 selected')
|
||||
})
|
||||
|
||||
it('Use existing resets the search and the tab when the image is not loaded', async () => {
|
||||
await mountPage(
|
||||
{
|
||||
hash: hashHandlerFor('floorboards-2.jpg'),
|
||||
list: (path) =>
|
||||
path.includes('filter=unused')
|
||||
? listBody(fixture([8, 10]))
|
||||
: listBody(fixture()),
|
||||
},
|
||||
'/media?filter=unused',
|
||||
)
|
||||
await chooseFiles([fileWith('floorboards-2.jpg', 'image/jpeg', 2.4 * MEGABYTE)])
|
||||
|
||||
findButton('Use existing')!.click()
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.query).toEqual({})
|
||||
expect(tab('All').getAttribute('aria-selected')).toBe('true')
|
||||
expect(textOf(bulkBar())).toContain('1 selected')
|
||||
expect(announcer()).toBe('floorboards.jpg selected, 1 selected')
|
||||
})
|
||||
|
||||
it('Use existing drops the row without selecting when the image is still not loaded', async () => {
|
||||
await mountWithDuplicate({
|
||||
list: () => listBody(fixture().filter((item) => item.id !== 12)),
|
||||
})
|
||||
|
||||
findButton('Use existing')!.click()
|
||||
await settle()
|
||||
|
||||
expect(uploadPanel()).toBeNull()
|
||||
expect(bulkBar()).toBeNull()
|
||||
})
|
||||
|
||||
it('Upload anyway stores a second copy and the Duplicates count follows', async () => {
|
||||
let calls = 0
|
||||
await mountWithDuplicate({
|
||||
list: () =>
|
||||
++calls === 1
|
||||
? listBody(fixture())
|
||||
: listBody(fixture(), { all: 13, unused: 2, duplicates: 3 }),
|
||||
})
|
||||
|
||||
findButton('Upload anyway')!.click()
|
||||
await settle()
|
||||
|
||||
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['floorboards-2.jpg'])
|
||||
expect(hashLookups()).toHaveLength(1)
|
||||
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
|
||||
FakeXhr.instances[0]!.respond(201)
|
||||
await settle()
|
||||
|
||||
expect(textOf(uploadRows()[0])).toContain('Added to your library')
|
||||
expect(tab('Duplicates').textContent).toContain('3')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'crypto.subtle is undefined',
|
||||
() => vi.stubGlobal('crypto', { subtle: undefined }),
|
||||
undefined,
|
||||
],
|
||||
['the lookup fails with 500', () => undefined, () => Promise.reject(new ApiError(500))],
|
||||
[
|
||||
'the lookup never reaches the server',
|
||||
() => undefined,
|
||||
() => Promise.reject(new NetworkError()),
|
||||
],
|
||||
])('skips the duplicate check when %s', async (_name, prepare, hash) => {
|
||||
prepare()
|
||||
await mountPage({ hash })
|
||||
|
||||
await chooseFiles([photo('a.jpg')])
|
||||
|
||||
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual(['a.jpg'])
|
||||
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Server answers for an upload that passed the checks', () => {
|
||||
it.each([
|
||||
['roof.png', 1.4 * MEGABYTE, 201, 'Added to your library'],
|
||||
['scan.png', 9.9 * MEGABYTE, 413, `scan.png is 9.9 MB ${REJECTION_TEXT}`],
|
||||
['notes.png', 210 * 1024, 415, `notes.png isn't an image ${REJECTION_TEXT}`],
|
||||
['broken.png', 3 * MEGABYTE, 400, `broken.png couldn't be read ${UNREADABLE_TEXT}`],
|
||||
['roof.png', 1.4 * MEGABYTE, 500, `Upload failed ${FAILED_TEXT} Try again`],
|
||||
['roof.png', 1.4 * MEGABYTE, 0, `Upload failed ${FAILED_TEXT} Try again`],
|
||||
])('%s of %i bytes resolving %i reads the right row', async (name, size, status, text) => {
|
||||
await mountPage()
|
||||
await chooseFiles([fileWith(name, 'image/png', size)])
|
||||
|
||||
if (status === 0) {
|
||||
FakeXhr.instances[0]!.fail()
|
||||
} else {
|
||||
FakeXhr.instances[0]!.respond(status)
|
||||
}
|
||||
await settle()
|
||||
|
||||
const row = uploadRows()[0]!
|
||||
expect(textOf(row)).toContain(text)
|
||||
await vi.waitFor(() => expect(row.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('Try again retries one file with no new duplicate lookup', async () => {
|
||||
await mountPage()
|
||||
await chooseFiles([photo('roof.jpg'), photo('other.jpg')])
|
||||
xhrFor('roof.jpg').respond(500)
|
||||
await settle()
|
||||
expect(hashLookups()).toHaveLength(2)
|
||||
|
||||
findButton('Try again')!.click()
|
||||
await settle()
|
||||
|
||||
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
|
||||
'roof.jpg',
|
||||
'other.jpg',
|
||||
'roof.jpg',
|
||||
])
|
||||
expect(hashLookups()).toHaveLength(2)
|
||||
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('uploading')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancelling one upload or all of them', () => {
|
||||
it('the row x aborts that request, removes the row and starts the next waiting file', async () => {
|
||||
await mountPage()
|
||||
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
|
||||
|
||||
document.body
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Cancel upload of a.jpg"]')!
|
||||
.click()
|
||||
await settle()
|
||||
|
||||
expect(xhrFor('a.jpg').aborted).toBe(true)
|
||||
expect(uploadRows().map((row) => textOf(row).split(' ')[0])).toEqual(['b.jpg', 'c.jpg'])
|
||||
expect(inFlight().map((xhr) => xhr.fileName)).toEqual(['b.jpg', 'c.jpg'])
|
||||
})
|
||||
|
||||
it('Cancel all aborts what is in flight, removes waiting rows and keeps settled ones', async () => {
|
||||
await mountPage()
|
||||
await chooseFiles([
|
||||
photo('a.jpg'),
|
||||
photo('b.jpg'),
|
||||
photo('c.jpg'),
|
||||
fileWith('bad.tif', 'image/tiff', 10),
|
||||
])
|
||||
xhrFor('a.jpg').respond(201)
|
||||
await settle()
|
||||
|
||||
findButton('Cancel all')!.click()
|
||||
await settle()
|
||||
|
||||
expect(xhrFor('b.jpg').aborted).toBe(true)
|
||||
expect(xhrFor('c.jpg').aborted).toBe(true)
|
||||
expect(uploadRows().map((row) => row.getAttribute('data-status'))).toEqual([
|
||||
'added',
|
||||
'rejected',
|
||||
])
|
||||
})
|
||||
|
||||
it('closes the panel when no row is left', async () => {
|
||||
await mountPage()
|
||||
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg')])
|
||||
|
||||
findButton('Cancel all')!.click()
|
||||
await settle()
|
||||
|
||||
expect(uploadPanel()).toBeNull()
|
||||
expect(FakeXhr.instances.every((xhr) => xhr.aborted)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The panel settles with a summary', () => {
|
||||
it('settles with the counts, announces the summary and Dismiss removes every row', async () => {
|
||||
await mountPage({ hash: hashHandlerFor('dup.jpg') })
|
||||
await chooseFiles([
|
||||
photo('one.jpg'),
|
||||
photo('two.jpg'),
|
||||
photo('dup.jpg'),
|
||||
fileWith('scan.tif', 'image/tiff', 14.2 * MEGABYTE),
|
||||
photo('roof.jpg'),
|
||||
])
|
||||
xhrFor('one.jpg').respond(201)
|
||||
xhrFor('two.jpg').respond(201)
|
||||
await settle()
|
||||
xhrFor('roof.jpg').respond(500)
|
||||
await settle()
|
||||
|
||||
const panel = uploadPanel()!
|
||||
expect(textOf(panel.querySelector('[data-testid="upload-title"]'))).toBe('Upload finished')
|
||||
expect(textOf(panel.querySelector('[data-testid="upload-sub"]'))).toBe('5 images')
|
||||
expect(findButton('Dismiss', panel)).toBeTruthy()
|
||||
expect(findButton('Cancel all', panel)).toBeUndefined()
|
||||
expect(textOf(panel.querySelector('[data-testid="upload-summary"]'))).toBe(
|
||||
'2 uploaded · 1 already in your library · 2 need attention',
|
||||
)
|
||||
expect(announcer()).toBe('2 uploaded · 1 already in your library · 2 need attention')
|
||||
|
||||
findButton('Dismiss', panel)!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadPanel()).toBeNull()
|
||||
expect(uploadRows()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Too many uploads (429)', () => {
|
||||
async function rateLimit(retryAfter?: string, width = 1440) {
|
||||
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
||||
await mountPage({}, '/media', width)
|
||||
await chooseFiles([photo('a.jpg'), photo('b.jpg'), photo('c.jpg'), photo('d.jpg')])
|
||||
xhrFor('a.jpg').respond(429, retryAfter === undefined ? {} : { 'Retry-After': retryAfter })
|
||||
await settle()
|
||||
}
|
||||
|
||||
it('shows the alert, counts down in a disabled button and pauses the queue', async () => {
|
||||
await rateLimit('42')
|
||||
|
||||
const alert = Array.from(document.body.querySelectorAll('[role="status"]')).find(
|
||||
(element) => textOf(element).includes('Too many uploads'),
|
||||
)
|
||||
expect(textOf(alert)).toBe(
|
||||
'Too many uploads For your security, wait a moment before uploading again.',
|
||||
)
|
||||
const button = findButton('Try again in 0:42')!
|
||||
expect(button.disabled).toBe(true)
|
||||
await vi.waitFor(() => expect(button.querySelector('svg.iconify--lucide')).toBeTruthy())
|
||||
expect(findButton('Upload')).toBeUndefined()
|
||||
expect(uploadRows()[0]!.getAttribute('data-status')).toBe('waiting')
|
||||
expect(inFlight().map((xhr) => xhr.fileName)).toEqual(['b.jpg'])
|
||||
expect(FakeXhr.instances).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resumes by itself when the countdown reaches 0:00, keeping Added rows', async () => {
|
||||
await rateLimit('42')
|
||||
xhrFor('b.jpg').respond(201)
|
||||
await settle()
|
||||
expect(FakeXhr.instances).toHaveLength(2)
|
||||
|
||||
vi.advanceTimersByTime(1000)
|
||||
await flushPromises()
|
||||
expect(findButton('Try again in 0:41')).toBeTruthy()
|
||||
vi.advanceTimersByTime(41000)
|
||||
await settle()
|
||||
|
||||
expect(document.body.textContent).not.toContain('Too many uploads')
|
||||
expect(findButton('Upload')!.disabled).toBe(false)
|
||||
expect(uploadRows()[1]!.getAttribute('data-status')).toBe('added')
|
||||
expect(FakeXhr.instances.map((xhr) => xhr.fileName)).toEqual([
|
||||
'a.jpg',
|
||||
'b.jpg',
|
||||
'a.jpg',
|
||||
'c.jpg',
|
||||
])
|
||||
})
|
||||
|
||||
it('counts 60 seconds when Retry-After is missing or unreadable', async () => {
|
||||
await rateLimit(undefined)
|
||||
expect(findButton('Try again in 1:00')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables the mobile pinned button too', async () => {
|
||||
await rateLimit('42', MOBILE_WIDTH)
|
||||
|
||||
const pinned = findButton('Try again in 0:42')!
|
||||
expect(pinned.disabled).toBe(true)
|
||||
expect(pinned.className).toContain('h-12')
|
||||
})
|
||||
|
||||
it('ignores drops and the drop area while counting', async () => {
|
||||
await rateLimit('42')
|
||||
const before = FakeXhr.instances.length
|
||||
|
||||
dragEvent('dragenter', ['Files'])
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
|
||||
dragEvent('drop', ['Files'], [photo('z.jpg')])
|
||||
await settle()
|
||||
|
||||
expect(FakeXhr.instances).toHaveLength(before)
|
||||
expect(uploadRows()).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Offline', () => {
|
||||
async function goOffline() {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('disables the Upload controls and ignores drops, then enables them again', async () => {
|
||||
await mountPage()
|
||||
|
||||
await goOffline()
|
||||
|
||||
expect(document.body.textContent).toContain("You're offline.")
|
||||
expect(findButton('Upload')!.disabled).toBe(true)
|
||||
dragEvent('dragenter', ['Files'])
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-testid="media-drop-area"]')).toBeNull()
|
||||
dragEvent('drop', ['Files'], [photo('z.jpg')])
|
||||
await settle()
|
||||
expect(FakeXhr.instances).toHaveLength(0)
|
||||
expect(cards()).toHaveLength(12)
|
||||
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true)
|
||||
window.dispatchEvent(new Event('online'))
|
||||
await flushPromises()
|
||||
expect(findButton('Upload')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('disables the mobile pinned button and "Upload an image"', async () => {
|
||||
await mountPage({}, '/media', MOBILE_WIDTH)
|
||||
await goOffline()
|
||||
expect(findButton('Upload')!.disabled).toBe(true)
|
||||
await unmountPage()
|
||||
stubDigest()
|
||||
stubXhr()
|
||||
|
||||
await mountPage({ list: () => listBody([], { all: 0, unused: 0, duplicates: 0 }) })
|
||||
await goOffline()
|
||||
expect(findButton('Upload an image')!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session errors on the upload calls are global', () => {
|
||||
it.each([
|
||||
[401, '/login', { redirect: '/media', reason: 'expired' }],
|
||||
[403, '/register/pending', {}],
|
||||
])(
|
||||
'an XHR upload resolving %i sends the user to %s with no local copy',
|
||||
async (status, path, query) => {
|
||||
await mountPage()
|
||||
useSessionStore().session = {
|
||||
verified: false,
|
||||
username: 'marta',
|
||||
id: 1,
|
||||
displayName: '',
|
||||
role: 'owner',
|
||||
}
|
||||
await chooseFiles([photo('a.jpg')])
|
||||
|
||||
FakeXhr.instances[0]!.respond(status)
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(path))
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe(path)
|
||||
expect(router.currentRoute.value.query).toEqual(query)
|
||||
expect(document.body.textContent).not.toContain('Upload failed')
|
||||
expect(toasts.add).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -18,7 +18,7 @@ let toastAdd: ReturnType<typeof vi.fn>
|
||||
|
||||
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
|
||||
return { ...actual, useToast: () => ({ add: toastAdd }) }
|
||||
return { ...actual, useToast: () => ({ add: toastAdd, remove: vi.fn() }) }
|
||||
})
|
||||
|
||||
type Row = [
|
||||
@@ -455,32 +455,28 @@ describe('Default, many images', () => {
|
||||
expect(document.body.querySelector('[data-testid="image-count"]')?.textContent).toBe(
|
||||
'12 images',
|
||||
)
|
||||
expect(document.body.textContent).not.toContain('Select all')
|
||||
expect(document.body.querySelector('input[type="checkbox"], [role="checkbox"]')).toBeNull()
|
||||
expect(document.body.textContent).toContain('Select all')
|
||||
expect(document.body.querySelectorAll('[role="checkbox"]')).toHaveLength(13)
|
||||
expect(searchInput()).toBeTruthy()
|
||||
expect(textOf(ariaLabel('Sort'))).toBe('Newest')
|
||||
expect(ariaLabel('Grid view').getAttribute('aria-pressed')).toBe('true')
|
||||
expect(ariaLabel('List view').getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
|
||||
it('renders the header Upload button disabled and inert', async () => {
|
||||
it('renders the header Upload button enabled', async () => {
|
||||
await mountPage()
|
||||
|
||||
const upload = findButton('Upload')!
|
||||
expect(upload.disabled).toBe(true)
|
||||
upload.click()
|
||||
await flushPromises()
|
||||
expect(panel()).toBeNull()
|
||||
expect(findButton('Upload')!.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the mobile pinned Upload button disabled, full width, 48 px', async () => {
|
||||
it('renders the mobile pinned Upload button enabled, full width, 48 px', async () => {
|
||||
await mountPage({}, '/media', MOBILE_WIDTH)
|
||||
|
||||
const buttons = Array.from(document.body.querySelectorAll('button')).filter(
|
||||
(button) => textOf(button) === 'Upload',
|
||||
)
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.disabled).toBe(true)
|
||||
expect(buttons[0]!.disabled).toBe(false)
|
||||
expect(buttons[0]!.className).toContain('h-12')
|
||||
expect(buttons[0]!.className).toContain('inset-x-4')
|
||||
expect(document.body.querySelector('nav[aria-label="breadcrumb"]')?.className).toContain(
|
||||
@@ -614,7 +610,7 @@ describe('The query string is the source of truth', () => {
|
||||
['?q=wall', 'q=wall&filter=all&sort=newest&limit=100'],
|
||||
['?view=list', 'filter=all&sort=newest&limit=100'],
|
||||
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&limit=100'],
|
||||
['?filter=duplicates', 'filter=all&sort=newest&limit=100'],
|
||||
['?filter=duplicates', 'filter=duplicates&sort=newest&limit=100'],
|
||||
])('opening /media%s requests %s', async (url, request) => {
|
||||
await mountPage({}, `/media${url}`)
|
||||
|
||||
@@ -638,7 +634,7 @@ describe('The query string is the source of truth', () => {
|
||||
document.body.innerHTML = ''
|
||||
|
||||
await mountPage({}, '/media?filter=duplicates')
|
||||
expect(tab('All').getAttribute('aria-selected')).toBe('true')
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('drops unknown values from the URL', async () => {
|
||||
@@ -703,23 +699,20 @@ describe('The query string is the source of truth', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('The disabled Duplicates tab', () => {
|
||||
it('does not become selected, does not navigate and sends no request', async () => {
|
||||
describe('The Duplicates tab', () => {
|
||||
it('becomes selected, writes the filter to the URL and requests the duplicates', async () => {
|
||||
await mountPage()
|
||||
const duplicates = tab('Duplicates')
|
||||
|
||||
expect(duplicates.hasAttribute('disabled')).toBe(true)
|
||||
expect(duplicates.hasAttribute('data-disabled')).toBe(true)
|
||||
expect(duplicates.hasAttribute('disabled')).toBe(false)
|
||||
duplicates.focus()
|
||||
duplicates.click()
|
||||
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
press(duplicates, 'Enter')
|
||||
duplicates.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 }))
|
||||
await flushPromises()
|
||||
|
||||
expect(duplicates.getAttribute('aria-selected')).toBe('false')
|
||||
expect(tab('All').getAttribute('aria-selected')).toBe('true')
|
||||
expect(router.currentRoute.value.query).toEqual({})
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -799,7 +792,7 @@ describe('Empty library and no results', () => {
|
||||
expect(document.body.textContent).toContain(
|
||||
'Upload your first image. You can use it in any article.',
|
||||
)
|
||||
expect(findButton('Upload an image')!.disabled).toBe(true)
|
||||
expect(findButton('Upload an image')!.disabled).toBe(false)
|
||||
expect(document.body.textContent).toContain('PNG, JPEG, WEBP, SVG · max 10 MB each')
|
||||
})
|
||||
|
||||
@@ -885,7 +878,7 @@ describe('Hover, focus and keyboard', () => {
|
||||
expect(document.activeElement).toBe(list[2])
|
||||
})
|
||||
|
||||
it('opens the panel on Enter and does nothing on Space', async () => {
|
||||
it('opens the panel on Enter and Space does not open it', async () => {
|
||||
await mountPage()
|
||||
|
||||
cards()[1]!.focus()
|
||||
@@ -1000,7 +993,7 @@ describe('Detail panel', () => {
|
||||
expect(textOf(panel())).toContain('This image is safe to delete. No article shows it.')
|
||||
})
|
||||
|
||||
it('duplicate: looks up the hash, excludes the image itself and disables Review duplicates', async () => {
|
||||
it('duplicate: looks up the hash, excludes the image itself and Review duplicates opens the tab', async () => {
|
||||
await mountPage()
|
||||
|
||||
await clickCard('floorboards.jpg')
|
||||
@@ -1012,10 +1005,12 @@ describe('Detail panel', () => {
|
||||
'floorboards-copy.jpg and IMG_2208.jpg have the same content.',
|
||||
)
|
||||
const review = findButton('Review duplicates', alert)!
|
||||
expect(review.disabled).toBe(true)
|
||||
expect(review.disabled).toBe(false)
|
||||
review.click()
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/media')
|
||||
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
|
||||
expect(panel()).toBeNull()
|
||||
})
|
||||
|
||||
it('duplicate: uses the singular for one other copy', async () => {
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
import { vi } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import MediaView from '../MediaView.vue'
|
||||
|
||||
type Row = [
|
||||
number,
|
||||
string,
|
||||
number,
|
||||
number | null,
|
||||
number | null,
|
||||
string,
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
boolean,
|
||||
string,
|
||||
]
|
||||
|
||||
const ROWS: Row[] = [
|
||||
[
|
||||
1,
|
||||
'north-wall.jpg',
|
||||
4089446,
|
||||
4032,
|
||||
3024,
|
||||
'image/jpeg',
|
||||
2,
|
||||
'Marta Alves',
|
||||
'2026-09-12T12:00:00Z',
|
||||
false,
|
||||
'0a',
|
||||
],
|
||||
[
|
||||
2,
|
||||
'kitchen-after.webp',
|
||||
1258291,
|
||||
2400,
|
||||
1600,
|
||||
'image/webp',
|
||||
3,
|
||||
'Inès Duarte',
|
||||
'2026-09-08T12:00:00Z',
|
||||
false,
|
||||
'1b',
|
||||
],
|
||||
[
|
||||
3,
|
||||
'kitchen-before.webp',
|
||||
1153434,
|
||||
2400,
|
||||
1600,
|
||||
'image/webp',
|
||||
1,
|
||||
'Inès Duarte',
|
||||
'2026-09-08T11:00:00Z',
|
||||
false,
|
||||
'2c',
|
||||
],
|
||||
[
|
||||
4,
|
||||
'logo-renew.svg',
|
||||
8192,
|
||||
null,
|
||||
null,
|
||||
'image/svg+xml',
|
||||
5,
|
||||
'jonas',
|
||||
'2026-09-05T12:00:00Z',
|
||||
false,
|
||||
'3d',
|
||||
],
|
||||
[
|
||||
5,
|
||||
'floorboards-copy.jpg',
|
||||
2516582,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
3,
|
||||
'Marta Alves',
|
||||
'2026-09-02T12:00:00Z',
|
||||
true,
|
||||
'0e',
|
||||
],
|
||||
[
|
||||
6,
|
||||
'IMG_2208.jpg',
|
||||
2516582,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
2,
|
||||
'jonas',
|
||||
'2026-08-30T12:00:00Z',
|
||||
true,
|
||||
'0e',
|
||||
],
|
||||
[
|
||||
7,
|
||||
'IMG_4471.jpg',
|
||||
3250585,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
2,
|
||||
'Marta Alves',
|
||||
'2026-08-29T12:00:00Z',
|
||||
true,
|
||||
'2f',
|
||||
],
|
||||
[
|
||||
8,
|
||||
'window-frame.jpg',
|
||||
1887437,
|
||||
3000,
|
||||
2000,
|
||||
'image/jpeg',
|
||||
0,
|
||||
'Inès Duarte',
|
||||
'2026-08-28T12:00:00Z',
|
||||
false,
|
||||
'3a',
|
||||
],
|
||||
[
|
||||
9,
|
||||
'stone-wall-detail.jpg',
|
||||
3250585,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
1,
|
||||
'jonas',
|
||||
'2026-08-21T12:00:00Z',
|
||||
true,
|
||||
'2f',
|
||||
],
|
||||
[
|
||||
10,
|
||||
'bathroom-tiles.png',
|
||||
880640,
|
||||
1800,
|
||||
1200,
|
||||
'image/png',
|
||||
0,
|
||||
'Marta Alves',
|
||||
'2026-08-04T12:00:00Z',
|
||||
false,
|
||||
'1b',
|
||||
],
|
||||
[
|
||||
11,
|
||||
'lime-plaster.jpg',
|
||||
2202009,
|
||||
3200,
|
||||
2133,
|
||||
'image/jpeg',
|
||||
2,
|
||||
'Inès Duarte',
|
||||
'2026-07-19T12:00:00Z',
|
||||
false,
|
||||
'1c',
|
||||
],
|
||||
[
|
||||
12,
|
||||
'floorboards.jpg',
|
||||
2516582,
|
||||
3600,
|
||||
2400,
|
||||
'image/jpeg',
|
||||
4,
|
||||
'Inès Duarte',
|
||||
'2026-06-14T12:00:00Z',
|
||||
true,
|
||||
'0e',
|
||||
],
|
||||
]
|
||||
|
||||
export const FULL_COUNTS = { all: 12, unused: 2, duplicates: 2 }
|
||||
export const MEGABYTE = 1024 * 1024
|
||||
|
||||
export function fixture(ids?: number[]): MediaItem[] {
|
||||
return ROWS.filter(([id]) => !ids || ids.includes(id)).map(
|
||||
([
|
||||
id,
|
||||
filename,
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
content_type,
|
||||
used_in,
|
||||
uploaded_by,
|
||||
created_at,
|
||||
duplicate,
|
||||
sha256,
|
||||
]) => ({
|
||||
id,
|
||||
filename,
|
||||
url: `/media/${filename}`,
|
||||
size,
|
||||
content_type,
|
||||
width,
|
||||
height,
|
||||
sha256,
|
||||
uploaded_by,
|
||||
created_at,
|
||||
used_in,
|
||||
duplicate,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function listBody(items: MediaItem[], counts = FULL_COUNTS) {
|
||||
return { items, next_cursor: null, counts }
|
||||
}
|
||||
|
||||
export function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
export interface Handlers {
|
||||
list?: (path: string) => unknown
|
||||
usage?: (id: number) => unknown
|
||||
hash?: (path: string) => unknown
|
||||
remove?: (id: number) => unknown
|
||||
merge?: (body: { keep_id: number; merge_ids: number[] }) => unknown
|
||||
}
|
||||
|
||||
export function libraryHandlers(
|
||||
outcome: (id: number) => unknown = () => undefined,
|
||||
ids?: number[],
|
||||
): Handlers {
|
||||
const gone = new Set<number>()
|
||||
return {
|
||||
list: () => listBody(fixture(ids).filter((item) => !gone.has(item.id))),
|
||||
remove: async (id) => {
|
||||
try {
|
||||
const result = await outcome(id)
|
||||
gone.add(id)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
gone.add(id)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const kit = {
|
||||
requestMock: vi.fn(),
|
||||
wrapper: null as VueWrapper | null,
|
||||
}
|
||||
|
||||
function dispatcher(handlers: Handlers) {
|
||||
return (path: string, options?: RequestInit) => {
|
||||
const method = options?.method ?? 'GET'
|
||||
if (method === 'GET' && path.startsWith('/v1/media/?hash=')) {
|
||||
return (handlers.hash ?? (() => listBody([], FULL_COUNTS)))(path)
|
||||
}
|
||||
if (method === 'GET' && path.startsWith('/v1/media/?')) {
|
||||
return (handlers.list ?? (() => listBody(fixture())))(path)
|
||||
}
|
||||
const usage = /^\/v1\/media\/(\d+)\/usage$/.exec(path)
|
||||
if (method === 'GET' && usage) {
|
||||
return (handlers.usage ?? (() => []))(Number(usage[1]))
|
||||
}
|
||||
if (method === 'POST' && path === '/v1/media/merge') {
|
||||
return (handlers.merge ?? (() => ({ articles_updated: 0, merged: 0 })))(
|
||||
JSON.parse(String(options?.body)),
|
||||
)
|
||||
}
|
||||
const single = /^\/v1\/media\/(\d+)$/.exec(path)
|
||||
if (method === 'DELETE' && single) {
|
||||
return (handlers.remove ?? (() => undefined))(Number(single[1]))
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method} ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFrom(dispatch: ReturnType<typeof dispatcher>) {
|
||||
return vi.fn(async (path: string, init?: RequestInit) => {
|
||||
const reply = (status: number, body?: unknown) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers(),
|
||||
json: async () => body,
|
||||
text: async () => '',
|
||||
})
|
||||
try {
|
||||
const body = await dispatch(path, init)
|
||||
return reply(body === undefined ? 204 : 200, body)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return reply(error.status)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function mountPage(
|
||||
handlers: Handlers = {},
|
||||
url = '/media',
|
||||
width = DESKTOP_WIDTH,
|
||||
options: { realApi?: boolean } = {},
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', id: 1, displayName: '', role: 'owner' }
|
||||
session.status = 'ready'
|
||||
const dispatch = dispatcher(handlers)
|
||||
if (options.realApi) {
|
||||
kit.requestMock = fetchFrom(dispatch)
|
||||
vi.stubGlobal('fetch', kit.requestMock)
|
||||
} else {
|
||||
kit.requestMock = vi.fn(async (path: string, init?: RequestInit) => dispatch(path, init))
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
request: kit.requestMock,
|
||||
})
|
||||
}
|
||||
await router.push(url)
|
||||
kit.wrapper = mount(MediaView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return kit.wrapper
|
||||
}
|
||||
|
||||
export async function unmountPage() {
|
||||
kit.wrapper?.unmount()
|
||||
kit.wrapper = null
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function textOf(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function cards() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-media-card]'))
|
||||
}
|
||||
|
||||
export function cardFor(filename: string) {
|
||||
return cards().find((card) => textOf(card).includes(filename))!
|
||||
}
|
||||
|
||||
export function tabs() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
|
||||
}
|
||||
|
||||
export function tab(label: string) {
|
||||
return tabs().find((element) => textOf(element).startsWith(label))!
|
||||
}
|
||||
|
||||
export function findButton(label: string, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(button) => textOf(button) === label,
|
||||
)
|
||||
}
|
||||
|
||||
export function panel() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"]')
|
||||
}
|
||||
|
||||
export function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
|
||||
}
|
||||
|
||||
export function listCalls() {
|
||||
return kit.requestMock.mock.calls.filter(
|
||||
([path]) =>
|
||||
typeof path === 'string' && path.startsWith('/v1/media/?') && !path.includes('hash='),
|
||||
)
|
||||
}
|
||||
|
||||
export function lastListPath() {
|
||||
return listCalls().at(-1)![0] as string
|
||||
}
|
||||
|
||||
export function callsTo(method: string, pattern: RegExp) {
|
||||
return kit.requestMock.mock.calls.filter(
|
||||
([path, options]) => (options?.method ?? 'GET') === method && pattern.test(path),
|
||||
)
|
||||
}
|
||||
|
||||
export function press(element: Element, key: string) {
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||
}
|
||||
|
||||
export function pressOnWindow(key: string) {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
|
||||
)
|
||||
}
|
||||
|
||||
export async function settle() {
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function announcer() {
|
||||
return textOf(document.body.querySelector('[data-testid="media-announcer"]'))
|
||||
}
|
||||
|
||||
export function checkboxFor(filename: string) {
|
||||
return document.body.querySelector<HTMLElement>(
|
||||
`[role="checkbox"][aria-label="Select ${filename}"]`,
|
||||
)!
|
||||
}
|
||||
|
||||
export function selectAllCheckbox() {
|
||||
return document.body
|
||||
.querySelector('[data-testid="image-count"]')!
|
||||
.parentElement!.querySelector<HTMLElement>('[role="checkbox"]')!
|
||||
}
|
||||
|
||||
export function tableHeaderCheckbox() {
|
||||
return document.body.querySelector<HTMLElement>('[role="checkbox"][aria-label="Select all"]')!
|
||||
}
|
||||
|
||||
export function bulkBar() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="media-bulk-bar"]')
|
||||
}
|
||||
|
||||
export function fileWith(name: string, type: string, size?: number, content = name) {
|
||||
const file = new File([content], name, { type })
|
||||
if (size !== undefined) {
|
||||
Object.defineProperty(file, 'size', { value: size })
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
export function hexOf(content: string) {
|
||||
return Array.from(new TextEncoder().encode(content), (byte) =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
)
|
||||
.join('')
|
||||
.padEnd(64, '0')
|
||||
.slice(0, 64)
|
||||
}
|
||||
|
||||
export function stubDigest() {
|
||||
const digest = vi.fn(async (_algorithm: string, data: ArrayBuffer) => {
|
||||
const hex = Array.from(new Uint8Array(data), (byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.padEnd(64, '0')
|
||||
.slice(0, 64)
|
||||
return new Uint8Array(hex.match(/../g)!.map((pair) => Number.parseInt(pair, 16))).buffer
|
||||
})
|
||||
vi.stubGlobal('crypto', { subtle: { digest } })
|
||||
return digest
|
||||
}
|
||||
|
||||
export class FakeXhr extends EventTarget {
|
||||
static instances: FakeXhr[] = []
|
||||
upload = new EventTarget()
|
||||
method = ''
|
||||
url = ''
|
||||
status = 0
|
||||
responseText = ''
|
||||
withCredentials = false
|
||||
body: FormData | null = null
|
||||
aborted = false
|
||||
finished = false
|
||||
headers: Record<string, string> = {}
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
|
||||
send(body: FormData) {
|
||||
this.body = body
|
||||
FakeXhr.instances.push(this)
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true
|
||||
this.finished = true
|
||||
this.dispatchEvent(new Event('abort'))
|
||||
}
|
||||
|
||||
getResponseHeader(name: string) {
|
||||
return this.headers[name.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
progress(loaded: number, total: number) {
|
||||
this.upload.dispatchEvent(Object.assign(new Event('progress'), { loaded, total }))
|
||||
}
|
||||
|
||||
respond(status: number, headers: Record<string, string> = {}, text = '') {
|
||||
this.status = status
|
||||
this.headers = Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
)
|
||||
this.responseText = text
|
||||
this.finished = true
|
||||
this.dispatchEvent(new Event('load'))
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.finished = true
|
||||
this.dispatchEvent(new Event('error'))
|
||||
}
|
||||
|
||||
get fileName() {
|
||||
return (this.body?.get('file') as File).name
|
||||
}
|
||||
}
|
||||
|
||||
export function stubXhr() {
|
||||
FakeXhr.instances = []
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr)
|
||||
}
|
||||
|
||||
export function inFlight() {
|
||||
return FakeXhr.instances.filter((xhr) => !xhr.finished)
|
||||
}
|
||||
|
||||
export function xhrFor(name: string) {
|
||||
return FakeXhr.instances.find((xhr) => xhr.fileName === name)!
|
||||
}
|
||||
|
||||
export function fileInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
}
|
||||
|
||||
export async function chooseFiles(files: File[]) {
|
||||
const input = fileInput()
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true })
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await settle()
|
||||
}
|
||||
|
||||
export function dragEvent(type: string, types: string[], files: File[] = []) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { types, files } })
|
||||
document.body.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
export function uploadPanel() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="media-upload-panel"]')
|
||||
}
|
||||
|
||||
export function uploadRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[data-testid="upload-row"]'))
|
||||
}
|
||||
|
||||
export function rowTexts() {
|
||||
return uploadRows().map(textOf)
|
||||
}
|
||||
Reference in new issue
Block a user