feat: media page T1
This commit is contained in:
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>
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user