feat(SPA): media page T0
This commit is contained in:
1 parent
0d99544579
commit
67d456baf6
32 files changed
+4874
-40
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user