feat: media page T1

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

No files matched your search

@@ -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>
+117
View File
@@ -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>
+55
View File
@@ -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>
+30 -5
View File
@@ -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>
+41
View File
@@ -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>
+3 -8
View File
@@ -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>
+15
View File
@@ -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>
+13 -2
View File
@@ -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>
+70 -25
View File
@@ -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>
+39
View File
@@ -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>
+38 -5
View File
@@ -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 }}
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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)
}
},
+178
View File
@@ -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>
+49
View File
@@ -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>