feat(SPA): media page T0

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

No files matched your search

+18 -13
View File
@@ -9,7 +9,7 @@ const props = withDefaults(
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
action: () => Promise<void | boolean>
dismissResolvesTo?: 'cancel' | 'confirm'
}>(),
{
@@ -55,8 +55,10 @@ async function onConfirm() {
pending.value = true
error.value = ''
try {
await props.action()
settle(true)
const outcome = await props.action()
if (outcome !== false) {
settle(true)
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
} finally {
@@ -94,16 +96,19 @@ function onCancel() {
}"
>
<template #body>
<UAlert
v-if="error"
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
/>
<div class="flex flex-col gap-4">
<UAlert
v-if="error"
color="error"
icon="i-lucide-circle-x"
:description="error"
variant="soft"
:close="false"
role="alert"
:ui="{ root: 'gap-2 px-3 py-2.5', description: 'text-sm text-toned' }"
/>
<slot name="body" :pending="pending" :confirm="onConfirm" />
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
+21 -6
View File
@@ -15,20 +15,30 @@ const props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
compactPadding?: boolean
renderWhileChecking?: boolean
offlineMessage?: string
offlineIcon?: string
}>(),
{
mobileTitle: undefined,
offlineMessage: undefined,
offlineIcon: undefined,
fullBleed: false,
compactPadding: false,
renderWhileChecking: false,
},
)
const bodyUi = computed(() =>
props.fullBleed
? 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8',
)
const COMPACT_BODY = 'gap-0 px-4 pt-4 pb-22 sm:gap-0 sm:px-4 sm:pt-4 sm:pb-22 lg:p-6'
const DEFAULT_BODY = 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8'
const bodyUi = computed(() => {
if (props.fullBleed) {
return 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
}
return props.compactPadding ? COMPACT_BODY : DEFAULT_BODY
})
const route = useRoute()
const router = useRouter()
@@ -39,6 +49,7 @@ const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
@@ -203,7 +214,11 @@ async function onLogout() {
id="dashboard-content"
:class="fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''"
>
<OfflineBanner />
<OfflineBanner
:message="offlineMessage"
:icon="offlineIcon"
:bleed="compactPadding"
/>
<AppBoot v-if="showBoot" />
<ErrorView
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import { pluralArticles } from '@/lib/mediaFormat'
const props = defineProps<{
item: MediaItem
usage: MediaUsageItem[]
action: () => Promise<void>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const inUse = computed(() => props.item.used_in > 0)
const title = computed(() =>
inUse.value ? "Delete an image that's in use?" : 'Delete this image?',
)
const description = computed(() =>
inUse.value
? `"${props.item.filename}" is used in ${pluralArticles(props.item.used_in)}. If you delete it, they will show a broken image. This can't be undone.`
: `"${props.item.filename}" will be deleted from your library. No article uses it. This can't be undone.`,
)
</script>
<template>
<ConfirmDialog
:title="title"
:description="description"
confirm-label="Delete image"
cancel-label="Cancel"
pending-label="Deleting…"
tone="danger"
:action="action"
@close="emit('close', $event)"
>
<template #body>
<ul v-if="inUse && usage.length > 0" class="border border-default">
<li
v-for="(article, index) in usage"
:key="article.id"
data-testid="affected-article"
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">{{
article.title
}}</span>
<MediaArticleStatusBadge :published="article.published" />
</li>
</ul>
</template>
</ConfirmDialog>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
published: boolean
size?: 'sm' | 'md'
}>()
</script>
<template>
<MediaBadge
:kind="published ? 'online' : 'draft'"
:label="published ? 'Online' : 'Draft'"
:size="size"
/>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
kind: 'unused' | 'duplicate' | 'used' | 'online' | 'draft'
label: string
size?: 'sm' | 'md'
}>(),
{ size: 'sm' },
)
const OUTLINE = 'border border-accented bg-transparent text-toned'
const badges = {
unused: {
icon: 'i-lucide-unlink',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
duplicate: {
icon: 'i-lucide-copy',
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
},
used: { icon: undefined, class: OUTLINE },
online: {
icon: 'i-lucide-globe',
class: 'border border-inverted bg-inverted text-inverted',
},
draft: { icon: 'i-lucide-pencil-line', class: OUTLINE },
} as const
const badge = computed(() => badges[props.kind])
const sizeClass = computed(() => (props.size === 'md' ? 'h-5.5 px-1.5' : 'h-5 px-1.5'))
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:icon="badge.icon"
:class="[badge.class, sizeClass]"
:data-kind="kind"
:ui="{ base: 'gap-1 py-0 text-xs whitespace-nowrap', leadingIcon: 'size-3' }"
>
{{ label }}
</UBadge>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import MediaBadge from '@/components/MediaBadge.vue'
defineProps<{
item: { duplicate: boolean; used_in: number }
}>()
</script>
<template>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
<MediaBadge v-if="item.used_in > 0" kind="used" :label="`Used in ${item.used_in}`" />
<MediaBadge v-else kind="unused" label="Unused" />
</template>
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { formatDimensions, formatMediaDate, formatSize, formatType } from '@/lib/mediaFormat'
defineProps<{
items: MediaItem[]
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
}>()
</script>
<template>
<ul class="-mx-4 border-y border-default bg-default">
<li
v-for="item in items"
:key="item.id"
data-testid="media-card-row"
class="border-b border-default last:border-b-0"
>
<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"
@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="text-xs/5"
>{{ formatSize(item.size) }} ·
{{ formatDimensions(item.width, item.height) }} ·
{{ formatType(item.content_type) }}</span
>
<span class="flex flex-wrap items-center gap-1 text-xs/5">
<MediaCardBadges :item="item" />
<span>{{ item.uploaded_by }} · {{ formatMediaDate(item.created_at) }}</span>
</span>
</span>
<UIcon name="i-lucide-chevron-right" class="ml-2 size-4 shrink-0" />
</button>
</li>
</ul>
</template>
+214
View File
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaArticleStatusBadge from '@/components/MediaArticleStatusBadge.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import {
formatDimensions,
formatMediaDate,
formatSize,
formatType,
pluralArticles,
} from '@/lib/mediaFormat'
const DESKTOP_QUERY = '(min-width: 48rem)'
const ACTION_BUTTON =
'h-11 justify-center border border-accented bg-default px-3 text-sm ring-0 max-md:flex-1 md:h-8'
const props = defineProps<{
item: MediaItem | null
usage: MediaUsageItem[]
copies: MediaItem[]
offline: boolean
}>()
const emit = defineEmits<{
'copy-url': []
rename: []
delete: []
}>()
const open = defineModel<boolean>('open', { default: false })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const closeButton = computed(() => ({
color: 'neutral' as const,
variant: 'ghost' as const,
icon: isDesktop.value ? 'i-lucide-x' : 'i-lucide-arrow-left',
class: 'text-muted',
}))
const metadata = computed(() => {
const item = props.item
if (!item) {
return []
}
return [
{ label: 'Type', value: formatType(item.content_type) },
{ label: 'Size', value: formatSize(item.size) },
{ label: 'Dimensions', value: formatDimensions(item.width, item.height) },
{ label: 'Uploaded', value: formatMediaDate(item.created_at) },
{ label: 'Uploaded by', value: item.uploaded_by },
]
})
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
}
const names = props.copies.map((copy) => copy.filename)
return {
title: `Identical to ${props.copies.length} other ${props.copies.length === 1 ? 'image' : 'images'}`,
description: `${joinNames(names)} ${names.length === 1 ? 'has' : 'have'} the same content.`,
}
})
</script>
<template>
<USlideover
v-model:open="open"
side="right"
:title="item?.filename ?? ''"
description="Image details"
:close="closeButton"
:ui="{
content: 'w-full max-w-none md:max-w-115',
header: 'h-14 min-h-14 border-b border-default px-5 py-0 sm:px-5 max-md:px-1.5 max-md:sm:px-1.5',
wrapper: 'min-w-0 flex-1 max-md:ps-11 md:pe-9',
title: 'truncate text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-1.75 size-11 justify-center p-0 max-md:start-1.5 md:top-3.5 md:end-5 md:size-7',
body: 'p-4 md:p-5',
}"
>
<template #body>
<div v-if="item" class="flex flex-col gap-5">
<MediaThumb
:item="item"
size="xl"
class="aspect-4/3 w-full border border-default md:aspect-16/10"
/>
<div class="flex gap-2 max-md:flex-wrap">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-link"
:class="ACTION_BUTTON"
@click="emit('copy-url')"
>
Copy URL
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
:disabled="offline"
:class="ACTION_BUTTON"
@click="emit('rename')"
>
Rename
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-trash-2"
:disabled="offline"
:class="ACTION_BUTTON"
class="text-error"
@click="emit('delete')"
>
Delete
</UButton>
</div>
<UAlert
v-if="duplicateAlert"
data-testid="duplicate-alert"
color="info"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-copy"
:title="duplicateAlert.title"
:description="duplicateAlert.description"
:ui="{ actions: 'mt-2 w-full', description: 'text-wrap' }"
>
<template #actions>
<UButton
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' }"
>
Review duplicates
</UButton>
</template>
</UAlert>
<dl class="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 gap-y-2 text-toned">
<template v-for="row in metadata" :key="row.label">
<dt class="text-muted">{{ row.label }}</dt>
<dd class="m-0 text-highlighted">{{ row.value }}</dd>
</template>
<dt class="text-muted">URL</dt>
<dd class="m-0 truncate font-mono text-xs/5 text-highlighted">
{{ displayUrl }}
</dd>
</dl>
<section class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">
{{
item.used_in > 0
? `Used in ${pluralArticles(item.used_in)}`
: 'Not used in any article'
}}
</h3>
<template v-if="item.used_in > 0">
<ul v-if="usage.length > 0" class="border border-default">
<li
v-for="(article, index) in usage"
:key="article.id"
data-testid="usage-row"
class="flex min-h-12 items-center gap-2 px-3 md:min-h-10"
:class="{ 'border-t border-muted': index > 0 }"
>
<RouterLink
:to="`/article/${article.id}/edit`"
class="min-w-0 flex-1 truncate font-medium text-highlighted underline underline-offset-3"
>
{{ article.title }}
</RouterLink>
<MediaArticleStatusBadge :published="article.published" size="md" />
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
</li>
</ul>
<p class="text-xs text-muted">
Counted from the text of your articles each time you open this page.
</p>
</template>
<p v-else class="text-muted">
This image is safe to delete. No article shows it.
</p>
</section>
</div>
</template>
</USlideover>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'first-run' | 'no-results'
query?: string
filterLabel?: string
}>()
const emit = defineEmits<{ action: [] }>()
const truncatedQuery = computed(() => (props.query ?? '').slice(0, QUERY_MAX_LENGTH))
const current = computed(() => {
if (props.variant === 'first-run') {
return {
icon: 'i-lucide-images',
title: 'No images yet',
description: 'Upload your first image. You can use it in any article.',
action: 'Upload an image',
}
}
return {
icon: 'i-lucide-search',
title: truncatedQuery.value
? `No images match "${truncatedQuery.value}"`
: 'No images match this filter',
description: `Nothing matches in ${props.filterLabel ?? 'All'}. Check the spelling, or clear the filters.`,
action: 'Clear filters',
}
})
</script>
<template>
<UEmpty
variant="naked"
:icon="current.icon"
:title="current.title"
:description="current.description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-[0.9375rem] font-semibold',
description: 'max-w-80 text-muted text-pretty',
}"
>
<template #actions>
<UButton
v-if="variant === 'first-run'"
icon="i-lucide-upload"
disabled
class="h-11 px-4 md:h-8"
>
{{ current.action }}
</UButton>
<UButton
v-else
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="emit('action')"
>
{{ current.action }}
</UButton>
</template>
</UEmpty>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref } from 'vue'
import MediaCardBadges from '@/components/MediaCardBadges.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
const props = defineProps<{
items: MediaItem[]
columns: number
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
}>()
const grid = ref<HTMLElement | null>(null)
function cards() {
return Array.from(grid.value?.querySelectorAll<HTMLElement>('[data-media-card]') ?? [])
}
function targetIndex(key: string, index: number) {
const steps: Record<string, number> = {
ArrowLeft: -1,
ArrowRight: 1,
ArrowUp: -props.columns,
ArrowDown: props.columns,
}
const step = steps[key]
if (step === undefined) {
return null
}
const next = index + step
return next >= 0 && next < props.items.length ? next : index
}
function onKeydown(event: KeyboardEvent, item: MediaItem, index: number) {
if (event.key === ' ') {
event.preventDefault()
return
}
if (event.key === 'Enter') {
event.preventDefault()
emit('open', item.id, event.currentTarget as HTMLElement)
return
}
const target = targetIndex(event.key, index)
if (target === null) {
return
}
event.preventDefault()
cards()[target]?.focus()
}
function onKeyup(event: KeyboardEvent) {
if (event.key === ' ') {
event.preventDefault()
}
}
</script>
<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
>
</span>
<span class="flex flex-wrap gap-1">
<MediaCardBadges :item="item" />
</span>
</span>
</button>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
count: number
}>()
</script>
<template>
<div
aria-busy="true"
data-testid="media-skeleton"
class="grid grid-cols-2 gap-2.5 md:grid-cols-5 md:gap-4"
>
<span role="status" class="sr-only">Loading images</span>
<div
v-for="n in count"
:key="n"
data-testid="media-skeleton-card"
class="border border-default bg-default"
>
<USkeleton class="aspect-4/3 w-full rounded-none bg-accented" />
<div class="flex flex-col gap-2 px-3 py-2.5">
<USkeleton class="h-3.25 w-[70%] rounded-none bg-accented" />
<USkeleton class="h-2.75 w-[45%] rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
</div>
</div>
</div>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import MediaBadge from '@/components/MediaBadge.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import {
formatDimensions,
formatMediaDate,
formatSize,
formatType,
pluralArticles,
} from '@/lib/mediaFormat'
const ROW_GRID =
'grid grid-cols-[36px_minmax(0,1.7fr)_110px_76px_64px_120px_130px_100px] items-center pr-3'
defineProps<{
items: MediaItem[]
}>()
const emit = defineEmits<{
open: [id: number, opener: HTMLElement]
}>()
function open(event: Event, id: number) {
emit('open', id, event.currentTarget as HTMLElement)
}
</script>
<template>
<div class="border border-default bg-default">
<div :class="ROW_GRID" class="h-9 bg-elevated font-medium text-muted">
<span></span>
<span>Filename</span>
<span>Dimensions</span>
<span>Size</span>
<span>Type</span>
<span>Used in</span>
<span>Uploaded by</span>
<span class="flex items-center gap-1 text-highlighted">
Uploaded
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
</div>
<div
v-for="item in items"
: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"
>
<span></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"
@click="open($event, item.id)"
>
{{ item.filename }}
</button>
<MediaBadge v-if="item.duplicate" kind="duplicate" label="Duplicate" />
</span>
<span>{{ formatDimensions(item.width, item.height) }}</span>
<span>{{ formatSize(item.size) }}</span>
<span>{{ formatType(item.content_type) }}</span>
<span>
<button
v-if="item.used_in > 0"
type="button"
data-testid="media-used-in"
class="relative z-10 text-highlighted underline underline-offset-3 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="open($event, item.id)"
>
{{ pluralArticles(item.used_in) }}
</button>
<MediaBadge v-else kind="unused" label="Unused" />
</span>
<span class="truncate pr-2">{{ item.uploaded_by }}</span>
<span>{{ formatMediaDate(item.created_at) }}</span>
</div>
</div>
</template>
+87
View File
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import AppLogo from '@/components/AppLogo.vue'
const TONE_COUNT = 4
const HEX_RADIX = 16
const ALPHA_TYPES = ['image/png', 'image/svg+xml']
const SVG_TYPE = 'image/svg+xml'
const props = withDefaults(
defineProps<{
item: { url: string; content_type: string; sha256: string }
size?: 'sm' | 'md' | 'lg' | 'xl'
}>(),
{ size: 'lg' },
)
const loaded = ref(false)
const failed = ref(false)
const hasAlpha = computed(() => ALPHA_TYPES.includes(props.item.content_type))
const isSvg = computed(() => props.item.content_type === SVG_TYPE)
const tone = computed(() => {
const digit = Number.parseInt(props.item.sha256.charAt(0), HEX_RADIX)
return Number.isNaN(digit) ? 0 : digit % TONE_COUNT
})
const iconClass = computed(
() => ({ sm: 'size-4', md: 'size-5', lg: 'size-6', xl: 'size-10' })[props.size],
)
const logoSize = computed(() => ({ sm: 16, md: 20, lg: 28, xl: 40 })[props.size])
</script>
<template>
<div
data-testid="media-thumb"
class="relative flex shrink-0 items-center justify-center overflow-hidden text-muted"
:class="hasAlpha ? 'media-thumb-checker' : ['media-thumb-tone', `media-thumb-tone-${tone}`]"
>
<span v-if="!loaded" aria-hidden="true" class="absolute flex">
<AppLogo v-if="isSvg" :size="logoSize" aria-hidden="true" />
<UIcon v-else name="i-lucide-image" class="opacity-55" :class="iconClass" />
</span>
<img
v-if="!failed"
:src="item.url"
alt=""
loading="lazy"
class="absolute inset-0 size-full object-cover"
@load="loaded = true"
@error="failed = true"
/>
</div>
</template>
<style scoped>
.media-thumb-checker {
background: conic-gradient(
var(--ui-bg-accented) 25%,
var(--ui-bg) 0 50%,
var(--ui-bg-accented) 0 75%,
var(--ui-bg) 0
)
0 0 / 16px 16px;
}
.media-thumb-tone {
background-image: repeating-linear-gradient(
135deg,
color-mix(in srgb, var(--ui-text-muted) 12%, transparent) 0 1px,
transparent 1px 12px
);
}
.media-thumb-tone-0 {
background-color: var(--ui-color-secondary-200);
}
.dark .media-thumb-tone-0 {
background-color: var(--ui-color-secondary-300);
}
.media-thumb-tone-1 {
background-color: var(--ui-bg-accented);
}
.media-thumb-tone-2 {
background-color: color-mix(in srgb, var(--ui-border-accented) 35%, var(--ui-bg));
}
.media-thumb-tone-3 {
background-color: var(--ui-bg-elevated);
}
</style>
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
const SEARCH_DEBOUNCE_MS = 120
const TAB_TRIGGER =
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
const SORT_LABELS: Record<MediaSort, string> = {
newest: 'Newest',
oldest: 'Oldest',
name: 'Name',
largest: 'Largest',
}
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
{ value: 'grid', icon: 'i-lucide-layout-grid', label: 'Grid view' },
{ value: 'list', icon: 'i-lucide-list', label: 'List view' },
]
const props = defineProps<{
controls: MediaControls
counts: MediaCounts | null
}>()
const emit = defineEmits<{
'update:q': [value: string]
'update:filter': [value: MediaFilter]
'update:sort': [value: MediaSort]
'update:view': [value: MediaView]
}>()
const searchText = ref(props.controls.q)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function clearDebounce() {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
watch(
() => props.controls.q,
(value) => {
if (value !== searchText.value.trim()) {
clearDebounce()
searchText.value = value
}
},
)
watch(searchText, (value) => {
clearDebounce()
debounceTimer = setTimeout(() => {
debounceTimer = null
const trimmed = value.trim()
if (trimmed !== props.controls.q) {
emit('update:q', trimmed)
}
}, SEARCH_DEBOUNCE_MS)
})
function clearSearch() {
searchText.value = ''
}
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 },
])
const tab = computed({
get: () => props.controls.filter,
set: (value: string) => {
if (value === 'all' || value === 'unused') {
emit('update:filter', value)
}
},
})
const sortItems = computed(() =>
(Object.keys(SORT_LABELS) as MediaSort[]).map((value) => ({
label: SORT_LABELS[value],
type: 'checkbox' as const,
checked: props.controls.sort === value,
onUpdateChecked: () => emit('update:sort', value),
})),
)
</script>
<template>
<div
class="flex flex-col gap-3 md:flex-row md:items-end md:gap-3 md:border-b md:border-default"
>
<UTabs
v-model="tab"
:items="tabItems"
variant="link"
color="neutral"
:content="false"
class="order-2 md:order-none md:-mb-px md:flex-1"
:ui="{
list: 'gap-5 border-b-0 p-0 max-md:border-b',
trigger: TAB_TRIGGER,
}"
>
<template #default="{ item }">
<span class="flex items-center gap-1.5">
<span>{{ item.label }}</span>
<span
v-if="item.value === 'duplicates' && item.count"
data-testid="duplicates-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-(--rc-info-soft) px-1.25 font-mono text-[0.6875rem] font-medium text-info"
>{{ item.count }}</span
>
<span
v-else-if="item.value !== 'duplicates' && item.count !== undefined"
class="font-mono text-[0.6875rem] font-medium text-muted"
>{{ item.count }}</span
>
</span>
</template>
</UTabs>
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="Search by filename"
class="order-1 w-full md:order-none md:mb-1.5 md:w-60"
:ui="{ base: 'h-11 text-lg md:h-8 md:text-sm', leadingIcon: 'size-3.5' }"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
aria-label="Clear search"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
<UButton
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-down"
aria-label="Sort"
class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
>
{{ SORT_LABELS[controls.sort] }}
</UButton>
</UDropdownMenu>
<div role="group" aria-label="View" class="flex shrink-0 border border-accented">
<UButton
v-for="option in VIEW_OPTIONS"
:key="option.value"
color="neutral"
variant="ghost"
:icon="option.icon"
:aria-label="option.label"
:aria-pressed="controls.view === option.value"
class="h-10.5 w-11 justify-center md:h-7.5 md:w-8"
:class="
controls.view === option.value
? 'bg-secondary-200 text-primary-900 hover:bg-secondary-200 dark:bg-secondary-300 dark:hover:bg-secondary-300'
: 'bg-default text-toned'
"
@click="emit('update:view', option.value)"
/>
</div>
</div>
</div>
</template>
+23 -1
View File
@@ -1,6 +1,26 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const DEFAULT_MESSAGE =
"You're offline. You can keep writing, but saving and publishing will wait until you're back online."
const BLEED_UI = {
root: 'w-auto -mx-4 -mt-4 mb-4 lg:-mx-6 lg:-mt-6 lg:mb-6',
container: 'h-auto min-h-10 max-w-none justify-start gap-2.5 px-4 py-2.5 sm:px-4 lg:px-4',
left: 'hidden lg:hidden',
icon: 'size-4',
title: 'whitespace-normal font-normal text-wrap',
}
withDefaults(
defineProps<{
message?: string
icon?: string
bleed?: boolean
}>(),
{ message: DEFAULT_MESSAGE, icon: undefined, bleed: false },
)
const isOffline = ref(!navigator.onLine)
function updateOnlineStatus() {
@@ -22,6 +42,8 @@ onUnmounted(() => {
<UBanner
v-if="isOffline"
color="neutral"
title="You're offline. You can keep writing, but saving and publishing will wait until you're back online."
:icon="icon"
:title="message"
:ui="bleed ? BLEED_UI : undefined"
/>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { MediaItem } from '@/composables/useMedia'
import { isValidFilenameStem, splitFilename } from '@/lib/mediaFormat'
const INVALID_FILENAME_MESSAGE = 'Enter a filename.'
const props = defineProps<{
item: MediaItem
action: (stem: string) => Promise<void>
}>()
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const parts = splitFilename(props.item.filename)
const stem = ref(parts.stem)
const touched = ref(false)
const error = computed(() =>
touched.value && !isValidFilenameStem(stem.value) ? INVALID_FILENAME_MESSAGE : undefined,
)
const help = computed(
() => `Letters, numbers, dashes and underscores. The ${parts.extension} ending stays.`,
)
async function submit() {
touched.value = true
if (!isValidFilenameStem(stem.value)) {
return false
}
await props.action(stem.value.trim())
}
</script>
<template>
<ConfirmDialog
title="Rename image"
description="Only the filename changes. The URL stays the same, so articles that use this image keep working."
confirm-label="Rename"
cancel-label="Cancel"
pending-label="Renaming…"
tone="neutral"
:action="submit"
@close="emit('close', $event)"
>
<template #body="{ pending, confirm }">
<form class="flex flex-col" novalidate @submit.prevent="confirm">
<UFormField
label="Filename"
name="filename"
:error="error"
:help="help"
:ui="{ error: 'mt-1.5 text-xs/5', help: 'mt-1.5 text-xs/5' }"
>
<template v-if="error" #error="{ error: message }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ message }}
</span>
</template>
<UInput
v-model="stem"
autocomplete="off"
:disabled="pending"
class="w-full"
:highlight="!!error"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
@blur="touched = true"
>
<template #trailing>
<span class="font-mono text-base text-muted">{{
parts.extension
}}</span>
</template>
</UInput>
</UFormField>
</form>
</template>
</ConfirmDialog>
</template>
@@ -58,14 +58,21 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the five navigation destinations', () => {
it('lists Home, Articles, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the six navigation destinations', () => {
it('lists Home, Articles, Media, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
.findAll('nav[aria-label="Main"] a')
.map((link) => link.attributes('href'))
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration', '/settings'])
expect(hrefs).toEqual([
'/home',
'/article',
'/media',
'/users',
'/integration',
'/settings',
])
const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]')
@@ -78,7 +85,7 @@ describe('DashboardShell renders the five navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same five destinations in the mobile drawer and Settings current', async () => {
it('lists the same six destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
@@ -91,12 +98,36 @@ describe('DashboardShell renders the five navigation destinations', () => {
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/media',
'/users',
'/integration',
'/settings',
])
expect(links[4].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[4].getAttribute('aria-current')).toBe('page')
expect(links[5].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[5].getAttribute('aria-current')).toBe('page')
})
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
const wrapper = await mountShell('/media')
const media = wrapper.find('a[href="/media"]')
expect(media.text()).toContain('Media')
expect(media.attributes('aria-current')).toBe('page')
expect(media.find('svg, [class*="lucide"]').exists()).toBe(true)
expect(wrapper.find('a[href="/article"]').attributes('aria-current')).toBeUndefined()
expect(wrapper.find('a[href="/users"]').attributes('aria-current')).toBeUndefined()
wrapper.unmount()
mountedShell = null
document.body.innerHTML = ''
await mountShell('/media', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const drawerMedia = document.body.querySelector(
'[role="dialog"] nav[aria-label="Main"] a[href="/media"]',
)
expect(drawerMedia?.getAttribute('aria-current')).toBe('page')
})
it('marks Users with aria-current on /users', async () => {
@@ -232,11 +263,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the five navigation items current on /profile', async () => {
it('marks none of the six navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(5)
expect(links).toHaveLength(6)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { classifyMediaError, useMedia, type MediaItem } from '../useMedia'
import { DEFAULT_CONTROLS } from '@/lib/mediaQuery'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
function item(id: number, overrides: Partial<MediaItem> = {}): MediaItem {
return {
id,
filename: `image-${id}.jpg`,
url: `/media/image-${id}.jpg`,
size: 2097152,
content_type: 'image/jpeg',
width: 3600,
height: 2400,
sha256: `hash-${id}`,
uploaded_by: 'jonas',
created_at: '2026-09-12T12:00:00Z',
used_in: 0,
duplicate: false,
...overrides,
}
}
function listBody(items: MediaItem[]) {
return {
items,
next_cursor: null,
counts: { all: items.length, unused: 1, duplicates: 0 },
}
}
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 }
}
let request: ReturnType<typeof vi.fn>
beforeEach(() => {
request = vi.fn()
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request })
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
describe('useMedia list state', () => {
it('requests the list with the controls and limit=100 and stores items and counts', async () => {
request.mockResolvedValue(listBody([item(1), item(2)]))
const media = useMedia()
await media.load({ ...DEFAULT_CONTROLS, q: 'wall', filter: 'unused' })
expect(request).toHaveBeenCalledWith(
'/v1/media/?q=wall&filter=unused&sort=newest&limit=100',
)
expect(media.items.value.map((entry) => entry.id)).toEqual([1, 2])
expect(media.counts.value).toEqual({ all: 2, unused: 1, duplicates: 0 })
expect(media.status.value).toBe('ready')
})
it('shows the skeleton only after 150 ms of loading', async () => {
const pending = deferred<unknown>()
request.mockReturnValue(pending.promise)
const media = useMedia()
const loading = media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('loading')
expect(media.showSkeleton.value).toBe(false)
vi.advanceTimersByTime(149)
expect(media.showSkeleton.value).toBe(false)
vi.advanceTimersByTime(1)
expect(media.showSkeleton.value).toBe(true)
pending.resolve(listBody([item(1)]))
await loading
expect(media.showSkeleton.value).toBe(false)
expect(media.status.value).toBe('ready')
})
it.each([
['a 500 response', () => new ApiError(500)],
['a network failure', () => new NetworkError()],
])('moves to error on %s and recovers on the next load', async (_name, failure) => {
request.mockRejectedValueOnce(failure())
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('error')
request.mockResolvedValueOnce(listBody([item(1)]))
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('ready')
expect(media.items.value).toHaveLength(1)
})
it.each([401, 403])(
'marks a %i as handled by useApi, not as a local failure',
async (status) => {
request.mockRejectedValueOnce(new ApiError(status))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
expect(media.status.value).toBe('handled')
},
)
it('a silent reload keeps the status and ignores failures', async () => {
request.mockResolvedValueOnce(listBody([item(1)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
const pending = deferred<unknown>()
request.mockReturnValueOnce(pending.promise)
const silent = media.load(DEFAULT_CONTROLS, { silent: true })
expect(media.status.value).toBe('ready')
vi.advanceTimersByTime(500)
expect(media.showSkeleton.value).toBe(false)
pending.reject(new ApiError(500))
await silent
expect(media.status.value).toBe('ready')
expect(media.items.value).toHaveLength(1)
})
it('a silent reload refreshes items and counts', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce({
items: [item(1)],
next_cursor: null,
counts: { all: 1, unused: 0, duplicates: 0 },
})
await media.load(DEFAULT_CONTROLS, { silent: true })
expect(media.items.value).toHaveLength(1)
expect(media.counts.value.all).toBe(1)
})
it('ignores a response that a newer load has replaced', async () => {
const first = deferred<unknown>()
const second = deferred<unknown>()
request.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const media = useMedia()
const older = media.load(DEFAULT_CONTROLS)
const newer = media.load({ ...DEFAULT_CONTROLS, q: 'wall' })
second.resolve(listBody([item(2)]))
await newer
first.resolve(listBody([item(1)]))
await older
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
})
})
describe('useMedia requests', () => {
it('fetches the usage of an image', async () => {
const usage = [{ id: 10, title: 'How we price a bathroom renovation', published: true }]
request.mockResolvedValue(usage)
const media = useMedia()
await expect(media.fetchUsage(1)).resolves.toEqual(usage)
expect(request).toHaveBeenCalledWith('/v1/media/1/usage')
})
it('finds the other copies by hash and excludes the image itself', async () => {
request.mockResolvedValue(listBody([item(12), item(5), item(6)]))
const media = useMedia()
const others = await media.findCopies(item(12, { sha256: 'abc123' }))
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123')
expect(others.map((entry) => entry.id)).toEqual([5, 6])
})
it('renames with the filename only and replaces the item in place', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2), item(3)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce(item(2, { filename: 'kept-name.jpg' }))
const renamed = await media.renameItem(2, 'kept-name')
expect(request).toHaveBeenLastCalledWith('/v1/media/2', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: 'kept-name' }),
})
expect(renamed.filename).toBe('kept-name.jpg')
expect(media.items.value.map((entry) => entry.filename)).toEqual([
'image-1.jpg',
'kept-name.jpg',
'image-3.jpg',
])
})
it('deletes and removes the item locally', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockResolvedValueOnce(undefined)
await media.deleteItem(1)
expect(request).toHaveBeenLastCalledWith('/v1/media/1', { method: 'DELETE' })
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
})
it('removeLocal drops an item without a request', async () => {
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
const media = useMedia()
await media.load(DEFAULT_CONTROLS)
request.mockClear()
media.removeLocal(2)
expect(media.items.value.map((entry) => entry.id)).toEqual([1])
expect(request).not.toHaveBeenCalled()
})
it('copies the absolute address of the image', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
const media = useMedia()
await media.copyUrl(item(1, { url: '/media/north-wall.jpg' }))
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/media/north-wall.jpg`)
})
})
describe('classifyMediaError', () => {
it.each([
[new ApiError(401), 'handled'],
[new ApiError(403), 'handled'],
[new ApiError(404), 'not-found'],
[new ApiError(400), 'failed'],
[new ApiError(500), 'failed'],
[new NetworkError(), 'failed'],
[new Error('x'), 'failed'],
])('classifies %s', (error, expected) => {
expect(classifyMediaError(error)).toBe(expected)
})
})
+164
View File
@@ -0,0 +1,164 @@
import { ref } from 'vue'
import { tryOnBeforeUnmount } from '@vueuse/core'
import { ApiError, useApi } from '@/composables/useApi'
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
export interface MediaItem {
id: number
filename: string
url: string
size: number
content_type: string
width: number | null
height: number | null
sha256: string
uploaded_by: string
created_at: string
used_in: number
duplicate: boolean
}
export interface MediaCounts {
all: number
unused: number
duplicates: number
}
export interface MediaUsageItem {
id: number
title: string
published: boolean
}
interface MediaListResponse {
items: MediaItem[]
next_cursor: string | null
counts: MediaCounts
}
export type MediaLoadStatus = 'loading' | 'ready' | 'error' | 'handled'
export type MediaFailure = 'handled' | 'not-found' | 'failed'
export const MEDIA_SKELETON_DELAY_MS = 150
const AUTH_HANDLED_STATUSES = [401, 403]
const NOT_FOUND_STATUS = 404
export function classifyMediaError(error: unknown): MediaFailure {
if (error instanceof ApiError && AUTH_HANDLED_STATUSES.includes(error.status)) {
return 'handled'
}
if (error instanceof ApiError && error.status === NOT_FOUND_STATUS) {
return 'not-found'
}
return 'failed'
}
export function useMedia() {
const { request } = useApi()
const items = ref<MediaItem[]>([])
const counts = ref<MediaCounts>({ all: 0, unused: 0, duplicates: 0 })
const status = ref<MediaLoadStatus>('loading')
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let latestLoad = 0
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function startLoading() {
status.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, MEDIA_SKELETON_DELAY_MS)
}
async function load(controls: MediaControls, options: { silent?: boolean } = {}) {
const silent = options.silent === true
if (!silent) {
latestLoad += 1
startLoading()
}
const ticket = latestLoad
try {
const response = await request<MediaListResponse>(
`/v1/media/?${toRequestQuery(controls)}`,
)
if (ticket !== latestLoad) {
return
}
items.value = response.items
counts.value = response.counts
status.value = 'ready'
} catch (error) {
if (silent || ticket !== latestLoad) {
return
}
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
} finally {
if (!silent && ticket === latestLoad) {
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
async function fetchUsage(id: number) {
return request<MediaUsageItem[]>(`/v1/media/${id}/usage`)
}
async function findCopies(item: MediaItem) {
const response = await request<MediaListResponse>(`/v1/media/?hash=${item.sha256}`)
return response.items.filter((copy) => copy.id !== item.id)
}
function replaceLocal(updated: MediaItem) {
items.value = items.value.map((entry) => (entry.id === updated.id ? updated : entry))
}
function removeLocal(id: number) {
items.value = items.value.filter((entry) => entry.id !== id)
}
async function renameItem(id: number, filename: string) {
const updated = await request<MediaItem>(`/v1/media/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename }),
})
replaceLocal(updated)
return updated
}
async function deleteItem(id: number) {
await request<void>(`/v1/media/${id}`, { method: 'DELETE' })
removeLocal(id)
}
function copyUrl(item: MediaItem) {
return navigator.clipboard.writeText(`${window.location.origin}${item.url}`)
}
tryOnBeforeUnmount(clearSkeletonTimer)
return {
items,
counts,
status,
showSkeleton,
load,
fetchUsage,
findCopies,
renameItem,
deleteItem,
removeLocal,
copyUrl,
}
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
accessibleName,
formatDimensions,
formatSize,
formatType,
isValidFilenameStem,
splitFilename,
usedInLabel,
} from '../mediaFormat'
const base = {
id: 1,
filename: 'north-wall.jpg',
url: '/media/north-wall.jpg',
size: 4089446,
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'a',
uploaded_by: 'Marta Alves',
created_at: '2026-09-12T12:00:00Z',
used_in: 2,
duplicate: false,
}
describe('formatSize', () => {
it.each([
[8192, '8 KB'],
[880640, '860 KB'],
[4089446, '3.9 MB'],
[1048576, '1.0 MB'],
[1048575, '1024 KB'],
])('reads %i bytes as %s', (bytes, expected) => {
expect(formatSize(bytes)).toBe(expected)
})
})
describe('formatDimensions', () => {
it('reads width and height with a multiplication sign', () => {
expect(formatDimensions(4032, 3024)).toBe('4032 × 3024')
})
it('reads Vector when width and height are null', () => {
expect(formatDimensions(null, null)).toBe('Vector')
})
})
describe('formatType', () => {
it.each([
['image/jpeg', 'JPEG'],
['image/png', 'PNG'],
['image/webp', 'WEBP'],
['image/svg+xml', 'SVG'],
])('reads %s as %s', (contentType, expected) => {
expect(formatType(contentType)).toBe(expected)
})
})
describe('usedInLabel and accessibleName', () => {
it('pluralises the article count', () => {
expect(usedInLabel(1)).toBe('used in 1 article')
expect(usedInLabel(2)).toBe('used in 2 articles')
expect(usedInLabel(0)).toBe('unused')
})
it('builds the card name from filename, size and usage', () => {
expect(accessibleName(base)).toBe('north-wall.jpg, 3.9 MB, used in 2 articles')
expect(accessibleName({ ...base, used_in: 1 })).toBe(
'north-wall.jpg, 3.9 MB, used in 1 article',
)
expect(accessibleName({ ...base, used_in: 0 })).toBe('north-wall.jpg, 3.9 MB, unused')
})
})
describe('splitFilename', () => {
it('separates the stem from the extension', () => {
expect(splitFilename('north-wall.jpg')).toEqual({ stem: 'north-wall', extension: '.jpg' })
expect(splitFilename('a.b.webp')).toEqual({ stem: 'a.b', extension: '.webp' })
expect(splitFilename('noextension')).toEqual({ stem: 'noextension', extension: '' })
})
})
describe('isValidFilenameStem', () => {
it.each(['north-wall', 'north-wall-01', 'Été_2026', ' padded '])('accepts %j', (value) => {
expect(isValidFilenameStem(value)).toBe(true)
})
it.each(['', ' ', '---', '!!!', 'a b', 'a.b', '___'])('rejects %j', (value) => {
expect(isValidFilenameStem(value)).toBe(false)
})
it('rejects more than 100 characters', () => {
expect(isValidFilenameStem('a'.repeat(100))).toBe(true)
expect(isValidFilenameStem('a'.repeat(101))).toBe(false)
})
})
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_CONTROLS, parseMediaControls, toRequestQuery, toRouteQuery } from '../mediaQuery'
describe('parseMediaControls', () => {
it('uses the defaults for an empty query', () => {
expect(parseMediaControls({})).toEqual(DEFAULT_CONTROLS)
})
it.each([
[{ filter: 'unused' }, { filter: 'unused' }],
[{ sort: 'largest' }, { sort: 'largest' }],
[{ sort: 'name' }, { sort: 'name' }],
[{ sort: 'oldest' }, { sort: 'oldest' }],
[{ q: 'wall' }, { q: 'wall' }],
[{ view: 'list' }, { view: 'list' }],
[{ filter: 'bogus', sort: 'zzz' }, {}],
[{ filter: 'duplicates' }, {}],
[{ view: 'grid' }, {}],
[{ sort: 'newest' }, {}],
])('maps %j', (query, expected) => {
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
})
it('takes the first value of a repeated parameter and trims the search', () => {
expect(parseMediaControls({ q: [' wall ', 'x'] }).q).toBe('wall')
})
})
describe('toRouteQuery', () => {
it('drops every default value', () => {
expect(toRouteQuery(DEFAULT_CONTROLS)).toEqual({})
})
it('keeps only the non-default values', () => {
expect(
toRouteQuery({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' }),
).toEqual({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' })
})
})
describe('toRequestQuery', () => {
it.each([
[{}, 'filter=all&sort=newest&limit=100'],
[{ filter: 'unused' }, 'filter=unused&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'],
])('builds the request for %j', (overrides, expected) => {
expect(toRequestQuery({ ...DEFAULT_CONTROLS, ...overrides })).toBe(expected)
})
})
+71
View File
@@ -0,0 +1,71 @@
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
const BYTES_PER_KB = 1024
const BYTES_PER_MB = BYTES_PER_KB * 1024
const FILENAME_MAX_LENGTH = 100
const FILENAME_PATTERN = /^[\p{L}\p{N}_-]+$/u
const LETTER_OR_NUMBER = /[\p{L}\p{N}]/u
const VECTOR_DIMENSIONS = 'Vector'
const TYPE_LABELS: Record<string, string> = {
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/webp': 'WEBP',
'image/svg+xml': 'SVG',
}
export interface MediaNameSource {
filename: string
size: number
used_in: number
}
export function formatSize(bytes: number) {
if (bytes < BYTES_PER_MB) {
return `${Math.round(bytes / BYTES_PER_KB)} KB`
}
return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`
}
export function formatDimensions(width: number | null, height: number | null) {
if (width === null || height === null) {
return VECTOR_DIMENSIONS
}
return `${width} × ${height}`
}
export function formatType(contentType: string) {
return TYPE_LABELS[contentType] ?? (contentType.split('/')[1] ?? contentType).toUpperCase()
}
export const formatMediaDate = formatAbsoluteDateFromIso
export function pluralArticles(count: number) {
return `${count} ${count === 1 ? 'article' : 'articles'}`
}
export function usedInLabel(count: number) {
return count > 0 ? `used in ${pluralArticles(count)}` : 'unused'
}
export function accessibleName(item: MediaNameSource) {
return `${item.filename}, ${formatSize(item.size)}, ${usedInLabel(item.used_in)}`
}
export function splitFilename(filename: string) {
const dot = filename.lastIndexOf('.')
if (dot <= 0) {
return { stem: filename, extension: '' }
}
return { stem: filename.slice(0, dot), extension: filename.slice(dot) }
}
export function isValidFilenameStem(value: string) {
const stem = value.trim()
return (
stem.length > 0 &&
stem.length <= FILENAME_MAX_LENGTH &&
FILENAME_PATTERN.test(stem) &&
LETTER_OR_NUMBER.test(stem)
)
}
+69
View File
@@ -0,0 +1,69 @@
import type { LocationQuery } from 'vue-router'
export type MediaFilter = 'all' | 'unused'
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
export type MediaView = 'grid' | 'list'
export interface MediaControls {
q: string
filter: MediaFilter
sort: MediaSort
view: MediaView
}
export const MEDIA_PAGE_LIMIT = 100
export const DEFAULT_CONTROLS: MediaControls = {
q: '',
filter: 'all',
sort: 'newest',
view: 'grid',
}
const FILTERS: MediaFilter[] = ['all', 'unused']
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
const VIEWS: MediaView[] = ['grid', 'list']
function firstValue(value: LocationQuery[string] | undefined) {
return (Array.isArray(value) ? value[0] : value) ?? ''
}
function pick<T extends string>(allowed: T[], value: string, fallback: T) {
return (allowed as string[]).includes(value) ? (value as T) : fallback
}
export function parseMediaControls(query: LocationQuery): MediaControls {
return {
q: firstValue(query.q).trim(),
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
}
}
export function toRouteQuery(controls: MediaControls) {
const query: Record<string, string> = {}
if (controls.q) {
query.q = controls.q
}
if (controls.filter !== DEFAULT_CONTROLS.filter) {
query.filter = controls.filter
}
if (controls.sort !== DEFAULT_CONTROLS.sort) {
query.sort = controls.sort
}
if (controls.view !== DEFAULT_CONTROLS.view) {
query.view = controls.view
}
return query
}
export function toRequestQuery(controls: MediaControls) {
const parts = [
...(controls.q ? [`q=${encodeURIComponent(controls.q)}`] : []),
`filter=${controls.filter}`,
`sort=${controls.sort}`,
`limit=${MEDIA_PAGE_LIMIT}`,
]
return parts.join('&')
}
@@ -248,4 +248,38 @@ describe('Nuxt UI shell foundation and router guards', () => {
expect(activeLink.exists()).toBe(true)
},
)
it('The /media route is verified, titled Media and renders MediaView inside DashboardShell', async () => {
const route = router.getRoutes().find((candidate) => candidate.path === '/media')
expect(route?.meta).toEqual({ auth: 'verified', title: 'Media' })
const getMe = vi.fn().mockResolvedValue({ verified: true })
const request = vi.fn().mockResolvedValue({
items: [],
next_cursor: null,
counts: { all: 0, unused: 0, duplicates: 0 },
})
vi.mocked(useApi).mockReturnValue({ getMe, request })
setUpSession()
await router.push('/media')
const app = mountApp()
await flushPromises()
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe('Media')
expect(app.find('a[href="/media"][aria-current="page"]').exists()).toBe(true)
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&limit=100')
})
it('An unverified session on /media is sent to /register/pending', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe, request: vi.fn() })
setUpSession()
await router.push('/media')
mountApp()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
})
+5
View File
@@ -71,6 +71,11 @@ const router = createRouter({
component: () => import('@/views/EditorView.vue'),
meta: { auth: 'verified', title: 'Editor' },
},
{
path: '/media',
component: () => import('@/views/MediaView.vue'),
meta: { auth: 'verified', title: 'Media' },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
+473
View File
@@ -0,0 +1,473 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery, useOnline } from '@vueuse/core'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
import MediaCardList from '@/components/MediaCardList.vue'
import MediaDetailPanel from '@/components/MediaDetailPanel.vue'
import MediaEmptyState from '@/components/MediaEmptyState.vue'
import MediaGrid from '@/components/MediaGrid.vue'
import MediaSkeletonGrid from '@/components/MediaSkeletonGrid.vue'
import MediaTable from '@/components/MediaTable.vue'
import MediaToolbar from '@/components/MediaToolbar.vue'
import RenameMediaDialog from '@/components/RenameMediaDialog.vue'
import {
classifyMediaError,
useMedia,
type MediaItem,
type MediaUsageItem,
} from '@/composables/useMedia'
import {
parseMediaControls,
toRequestQuery,
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
const DESKTOP_QUERY = '(min-width: 48rem)'
const TOAST_DURATION_MS = 5000
const DESKTOP_SKELETON_CARDS = 10
const MOBILE_SKELETON_CARDS = 6
const DESKTOP_COLUMNS = 5
const MOBILE_COLUMNS = 2
const RENAME_FAILED_COPY =
"Couldn't rename the image. Your text is still here. Try again in a moment."
const DELETE_FAILED_COPY = "Couldn't delete the image, so it's still there. Try again in a moment."
const OFFLINE_COPY =
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
const route = useRoute()
const router = useRouter()
const toast = useToast()
const media = useMedia()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline()
const controls = computed(() => parseMediaControls(route.query))
const requestQuery = computed(() => toRequestQuery(controls.value))
const offline = computed(() => !online.value)
const hasLoaded = ref(false)
const heading = ref<HTMLElement | null>(null)
const activeId = ref<number | null>(null)
const panelOpen = ref(false)
const renameOpen = ref(false)
const deleteOpen = ref(false)
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
let skipFocusRestore = false
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
)
const closingItem = ref<MediaItem | null>(null)
const panelItem = computed(() => activeItem.value ?? closingItem.value)
const counts = computed(() => (hasLoaded.value ? media.counts.value : null))
const loadFailed = computed(() => media.status.value === 'error')
const loading = computed(() => media.status.value === 'loading')
const ready = computed(() => media.status.value === 'ready')
const emptyLibrary = computed(
() =>
ready.value &&
media.items.value.length === 0 &&
media.counts.value.all === 0 &&
controls.value.q === '',
)
const noResults = computed(
() => ready.value && media.items.value.length === 0 && !emptyLibrary.value,
)
const showList = computed(() => ready.value && media.items.value.length > 0)
const showFab = computed(
() =>
!isDesktop.value &&
!panelOpen.value &&
!renameOpen.value &&
!deleteOpen.value &&
!loadFailed.value &&
!emptyLibrary.value,
)
const imageCount = computed(() => {
const total = media.items.value.length
return `${total} ${total === 1 ? 'image' : 'images'}`
})
function canonicalizeUrl() {
const canonical = toRouteQuery(controls.value)
const current = Object.fromEntries(
Object.entries(route.query).map(([key, value]) => [
key,
Array.isArray(value) ? (value[0] ?? '') : (value ?? ''),
]),
)
if (JSON.stringify(canonical) !== JSON.stringify(current)) {
router.replace({ query: canonical })
}
}
function updateControls(patch: Partial<MediaControls>) {
router.replace({ query: toRouteQuery({ ...controls.value, ...patch }) })
}
function clearFilters() {
updateControls({ q: '', filter: 'all' })
}
canonicalizeUrl()
watch(activeItem, (item) => {
if (item) {
closingItem.value = item
}
})
watch(
requestQuery,
async () => {
await media.load(controls.value)
hasLoaded.value = true
},
{ immediate: true },
)
function refreshInBackground() {
media.load(controls.value, { silent: true })
}
function focusCard(id: number) {
document.querySelector<HTMLElement>(`[data-media-card][data-media-id="${id}"]`)?.focus()
}
function focusAfterRemoval(index: number) {
nextTick(() => {
setTimeout(() => {
const cards = document.querySelectorAll<HTMLElement>('[data-media-card]')
const target = cards[index]
if (target) {
target.focus()
return
}
heading.value?.focus()
}, 0)
})
}
function restoreFocusToOpener(id: number | null) {
nextTick(() => {
setTimeout(() => {
if (opener?.isConnected) {
opener.focus()
} else if (id !== null) {
focusCard(id)
}
opener = null
}, 0)
})
}
async function loadUsage(item: MediaItem) {
try {
const rows = await media.fetchUsage(item.id)
if (activeId.value === item.id) {
usage.value = rows
}
} catch (error) {
if (activeId.value === item.id && classifyMediaError(error) === 'not-found') {
handleGone(item)
}
}
}
async function loadCopies(item: MediaItem) {
try {
const others = await media.findCopies(item)
if (activeId.value === item.id) {
copies.value = others
}
} catch {
copies.value = []
}
}
function openPanel(id: number, element: HTMLElement) {
const item = media.items.value.find((entry) => entry.id === id)
if (!item) {
return
}
opener = element
activeId.value = id
usage.value = []
copies.value = []
skipFocusRestore = false
panelOpen.value = true
if (item.used_in > 0) {
loadUsage(item)
}
if (item.duplicate) {
loadCopies(item)
}
}
function onPanelOpenChange(isOpen: boolean) {
panelOpen.value = isOpen
if (isOpen) {
return
}
const id = activeId.value
if (!skipFocusRestore) {
restoreFocusToOpener(id)
}
skipFocusRestore = false
}
function closeEverything() {
renameOpen.value = false
deleteOpen.value = false
skipFocusRestore = true
panelOpen.value = false
}
function removeAndRefocus(item: MediaItem) {
const index = media.items.value.findIndex((entry) => entry.id === item.id)
closeEverything()
media.removeLocal(item.id)
focusAfterRemoval(index)
refreshInBackground()
}
function handleGone(item: MediaItem) {
removeAndRefocus(item)
toast.add({
id: `media-not-found-${item.id}`,
color: 'info',
icon: 'i-lucide-info',
title: 'That image no longer exists',
description: 'It was deleted somewhere else, so we removed it from the library.',
})
}
async function copyUrl() {
const item = activeItem.value
if (!item) {
return
}
try {
await media.copyUrl(item)
} catch {
return
}
toast.add({
id: `media-url-copied-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'URL copied',
description: 'Paste it wherever you need the image address.',
duration: TOAST_DURATION_MS,
})
}
async function renameAction(stem: string) {
const item = activeItem.value
if (!item) {
return
}
try {
const renamed = await media.renameItem(item.id, stem)
toast.add({
id: `media-renamed-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image renamed',
description: `It's now "${renamed.filename}". The URL is the same.`,
duration: TOAST_DURATION_MS,
})
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
return
}
if (failure === 'not-found') {
handleGone(item)
return
}
throw new Error(RENAME_FAILED_COPY)
}
}
async function deleteAction() {
const item = activeItem.value
if (!item) {
return
}
const index = media.items.value.findIndex((entry) => entry.id === item.id)
try {
await media.deleteItem(item.id)
} catch (error) {
const failure = classifyMediaError(error)
if (failure === 'handled') {
return
}
if (failure === 'not-found') {
handleGone(item)
return
}
throw new Error(DELETE_FAILED_COPY)
}
closeEverything()
focusAfterRemoval(index)
toast.add({
id: `media-deleted-${item.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Image deleted',
description: `"${item.filename}" is gone from your library.`,
duration: TOAST_DURATION_MS,
})
refreshInBackground()
}
</script>
<template>
<DashboardShell
compact-padding
:offline-message="OFFLINE_COPY"
offline-icon="i-lucide-wifi-off"
>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1
ref="heading"
data-testid="media-heading"
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
Media
</h1>
<p class="text-muted">
Every image you've uploaded, ready to use in any article.
</p>
</div>
<UButton v-if="isDesktop" icon="i-lucide-upload" disabled class="h-8 px-3">
Upload
</UButton>
</div>
<template v-if="emptyLibrary">
<MediaEmptyState variant="first-run" />
<p class="self-center text-xs text-muted">PNG, JPEG, WEBP, SVG · max 10 MB each</p>
</template>
<template v-else>
<UAlert
v-if="loadFailed"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
title="Couldn't load your images"
description="Check your connection and try again."
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="media.load(controls)"
>
Try again
</UButton>
</template>
</UAlert>
<MediaToolbar
:controls="controls"
:counts="counts"
@update:q="updateControls({ q: $event })"
@update:filter="updateControls({ filter: $event })"
@update:sort="updateControls({ sort: $event })"
@update:view="updateControls({ view: $event })"
/>
<MediaSkeletonGrid
v-if="loading && media.showSkeleton.value"
:count="isDesktop ? DESKTOP_SKELETON_CARDS : MOBILE_SKELETON_CARDS"
/>
<MediaEmptyState
v-else-if="noResults"
variant="no-results"
:query="controls.q"
:filter-label="controls.filter === 'unused' ? 'Unused' : 'All'"
@action="clearFilters"
/>
<template v-else-if="showList">
<template v-if="controls.view === 'list'">
<MediaTable v-if="isDesktop" :items="media.items.value" @open="openPanel" />
<MediaCardList v-else :items="media.items.value" @open="openPanel" />
</template>
<template v-else>
<div class="flex items-center gap-2.5 text-muted">
<span
aria-hidden="true"
class="-ml-2.75 size-11 shrink-0 md:ml-0 md:size-4.5"
/>
<span aria-hidden="true" class="w-13.75 shrink-0" />
<span data-testid="image-count">{{ imageCount }}</span>
</div>
<MediaGrid
:items="media.items.value"
:columns="isDesktop ? DESKTOP_COLUMNS : MOBILE_COLUMNS"
@open="openPanel"
/>
</template>
</template>
</template>
</div>
<UButton
v-if="showFab"
icon="i-lucide-upload"
disabled
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover)"
:ui="{ base: 'gap-2', leadingIcon: 'size-4.5' }"
>
Upload
</UButton>
<MediaDetailPanel
:open="panelOpen"
:item="panelItem"
:usage="usage"
:copies="copies"
:offline="offline"
@update:open="onPanelOpenChange"
@copy-url="copyUrl"
@rename="renameOpen = true"
@delete="deleteOpen = true"
/>
<RenameMediaDialog
v-if="renameOpen && activeItem"
:item="activeItem"
:action="renameAction"
@close="renameOpen = false"
/>
<DeleteMediaDialog
v-if="deleteOpen && activeItem"
:item="activeItem"
:usage="usage"
:action="deleteAction"
@close="deleteOpen = false"
/>
</DashboardShell>
</template>
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the five navigation items as the current page', async () => {
it('marks none of the six navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(5)
expect(links).toHaveLength(6)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})