feat(media): align visual checks and variant UI with merged design

This commit is contained in:
Florian Sylvain committed 2026-10-13 02:35:20 +02:00
1 parent e6746f2be7
commit 160c0a49ce
23 files changed
+606 -99

No files matched your search

+11 -5
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t, translate } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, useSlots, watch } from 'vue'
import MediaThumb from '@/components/MediaThumb.vue'
const props = withDefaults(
@@ -23,6 +24,8 @@ const props = withDefaults(
const emit = defineEmits<{ close: [confirmed: boolean] }>()
const slots = useSlots()
const open = defineModel<boolean>('open', { default: true })
const pending = ref(false)
const error = ref('')
@@ -39,6 +42,10 @@ const previewThumb = computed(() => ({
content_type: '',
sha256: '',
}))
const hasBody = computed(() => Boolean(error.value || props.preview || slots.body))
const bodyClass = computed(() =>
hasBody.value ? 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0' : 'p-0 sm:p-0',
)
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
@@ -108,13 +115,12 @@ function onCancel() {
'aria-busy': pending ? 'true' : undefined,
}"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
body: bodyClass,
footer: SHEET_FOOTER_CLASS,
}"
>
<template #body>
+3 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
@@ -197,7 +198,7 @@ const modalDescription = computed(() => {
const modalContentClass = computed(
() =>
`divide-y-0 shadow-(--shadow-modal) ring-0 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 ${
`${SHEET_CONTENT_CLASS} ${
showTabs.value ? 'sm:top-10' : 'sm:top-35'
} ${wideModal.value ? 'sm:max-w-160' : 'sm:max-w-120'}`,
)
@@ -765,7 +766,7 @@ onUnmounted(() => {
close: 'top-5 end-6',
title: 'text-lg/6 font-semibold',
body: 'p-0 sm:p-0',
footer: 'px-6 py-5 sm:px-6',
footer: SHEET_FOOTER_CLASS,
}"
>
<template #body>
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import MediaLinksCard from './MediaLinksCard.vue'
describe('MediaLinksCard', () => {
it('links to the variants and cache pages', () => {
const wrapper = mount(MediaLinksCard, { global: { plugins: [router, uiPlugin] } })
const hrefs = wrapper.findAll('a').map((link) => link.attributes('href'))
expect(hrefs).toEqual(['/settings/media/variants', '/settings/media/cache'])
expect(wrapper.text()).toContain('Manage image variants')
expect(wrapper.text()).toContain('Generated images and disk space')
})
})
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const links = computed(() => [
{
to: '/settings/media/variants',
icon: 'i-lucide-layers',
label: t('Manage image variants'),
hint: t('Named sizes requested with ?v='),
},
{
to: '/settings/media/cache',
icon: 'i-lucide-database',
label: t('Cache usage'),
hint: t('Generated images and disk space'),
},
])
</script>
<template>
<section
aria-labelledby="media-links-h"
data-testid="media-links-card"
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
>
<div class="flex flex-col gap-0.5">
<h2 id="media-links-h" class="text-[0.9375rem]/6 font-semibold text-highlighted">
{{ t('Media') }}
</h2>
<p class="text-pretty text-muted">
{{ t('Image sizes, cache and cleanup for your media library.') }}
</p>
</div>
<div class="flex flex-col">
<RouterLink
v-for="link in links"
:key="link.to"
:to="link.to"
class="flex min-h-11 items-center gap-3 border-t border-default py-2 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
>
<UIcon :name="link.icon" class="size-4 shrink-0" />
<span class="flex min-w-0 flex-1 flex-col">
<span class="font-medium">{{ link.label }}</span>
<span class="text-muted">{{ link.hint }}</span>
</span>
<UIcon name="i-lucide-chevron-right" class="size-3.5 shrink-0" />
</RouterLink>
</div>
</section>
</template>
+6
View File
@@ -0,0 +1,6 @@
export const SHEET_MOBILE_CLASS =
'max-sm:!inset-x-0 max-sm:!top-auto max-sm:!bottom-0 max-sm:!left-0 max-sm:!w-full max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 max-sm:pt-3 max-sm:before:absolute max-sm:before:top-2 max-sm:before:left-1/2 max-sm:before:h-1 max-sm:before:w-9 max-sm:before:-translate-x-1/2 max-sm:before:bg-(--ui-border-accented)'
export const SHEET_CONTENT_CLASS = `divide-y-0 shadow-(--shadow-modal) ring-0 sm:translate-y-0 ${SHEET_MOBILE_CLASS}`
export const SHEET_FOOTER_CLASS = 'px-6 py-5 sm:px-6'
@@ -18,6 +18,7 @@ defineProps<{ files: number }>()
root: 'items-center rounded-none px-3.5 py-3',
icon: 'size-4 animate-spin',
title: 'font-semibold',
description: 'mt-2',
}"
>
<template #description>
+10 -1
View File
@@ -9,7 +9,8 @@ const VARIANT_ROWS = 5
const IMAGE_ROWS = 6
const BAR = 'rounded-none bg-accented'
const CARD_UI = {
root: 'min-w-0 rounded-none border border-default bg-default ring-0',
root: 'min-w-0 rounded-none border border-default bg-default ring-0 divide-y-0',
header: 'flex min-h-11 items-center border-b border-default px-4 py-0 sm:px-4 sm:py-0',
body: 'flex flex-col gap-5 p-4 sm:p-4',
}
</script>
@@ -24,12 +25,20 @@ const CARD_UI = {
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[460px_minmax(0,1fr)]">
<UCard :ui="CARD_UI">
<template #header>
<h2 class="font-semibold text-highlighted">{{ t('By variant') }}</h2>
</template>
<div v-for="row in VARIANT_ROWS" :key="row" class="flex flex-col gap-2">
<USkeleton class="h-3.25 w-[50%]" :class="BAR" />
<USkeleton class="h-2 w-full" :class="BAR" />
</div>
</UCard>
<UCard v-if="desktop" :ui="CARD_UI">
<template #header>
<h2 class="font-semibold text-highlighted">
{{ t('Top 10 images by variant disk usage') }}
</h2>
</template>
<USkeleton
v-for="row in IMAGE_ROWS"
:key="row"
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
@@ -55,13 +56,12 @@ function onConfirm() {
:close="false"
:content="{ role: 'alertdialog' }"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-115 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
footer: SHEET_FOOTER_CLASS,
}"
@update:open="open = $event"
>
@@ -41,6 +41,7 @@ const overlay = useLocalizedOverlay()
const { announceFailure } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const form = useVariantForm(props.mode, props.initial)
const side = computed(() => (isDesktop.value ? 'right' : 'bottom'))
const saving = ref(false)
const saveFailed = ref(false)
@@ -63,6 +64,29 @@ const discardDescription = computed(() =>
: t("You have unsaved changes. If you close now, they're lost."),
)
const closeButton = computed(() => ({
icon: 'i-lucide-x',
color: 'neutral' as const,
variant: 'ghost' as const,
'aria-label': t('Close'),
class: isDesktop.value
? 'size-7 justify-center text-muted'
: 'top-4.5 end-1.5 size-11 justify-center p-0 text-muted',
ui: { leadingIcon: isDesktop.value ? 'size-4' : 'size-5' },
}))
const overlayUi = computed(() => ({
content: isDesktop.value
? 'w-full max-w-none sm:max-w-240'
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'h-14 min-h-14 gap-3 px-5 py-0 max-sm:gap-1 max-sm:px-1.5',
wrapper: 'min-w-0 flex-1',
title: 'truncate text-lg font-semibold',
description: 'sr-only',
body: 'min-h-0 p-0 sm:overflow-hidden sm:p-0',
footer: 'h-18 gap-2 px-4 py-0 sm:h-16 sm:gap-3 sm:px-5',
}))
function focusField(field: FieldName) {
const target = document.querySelector<HTMLElement>(
`[data-field="${field}"] input:not([type="hidden"])`,
@@ -208,50 +232,19 @@ async function submit() {
<template>
<USlideover
:open="true"
side="right"
:side="side"
:title="title"
:description="t('Define a named size and format for generated images.')"
:close="
isDesktop
? {
icon: 'i-lucide-x',
color: 'neutral',
variant: 'ghost',
'aria-label': t('Close'),
class: 'size-7 justify-center text-muted',
}
: false
"
:close="closeButton"
:content="{ onOpenAutoFocus: focusOnOpen }"
:ui="{
content: 'w-full max-w-none sm:max-w-240',
header: 'h-14 min-h-14 gap-3 px-5 py-0 max-sm:gap-1 max-sm:px-1.5',
wrapper: 'min-w-0 flex-1',
title: 'truncate text-lg font-semibold',
description: 'sr-only',
body: 'min-h-0 p-0 sm:overflow-hidden sm:p-0',
footer: 'h-18 gap-2 px-4 py-0 sm:h-16 sm:gap-3 sm:px-5',
}"
:ui="overlayUi"
@update:open="onOpenChange"
>
<template #actions>
<UButton
v-if="!isDesktop"
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
:aria-label="t('Back')"
class="order-first size-11 shrink-0 justify-center"
:ui="{ leadingIcon: 'size-5' }"
@click="requestClose"
/>
</template>
<template #body>
<UForm
:state="form.values"
:validate-on="[]"
class="flex flex-col gap-6 p-4 max-sm:pb-25 sm:grid sm:h-full sm:grid-cols-[440px_minmax(0,1fr)] sm:gap-0 sm:p-0"
class="flex flex-col gap-6 p-4 sm:grid sm:h-full sm:grid-cols-[440px_minmax(0,1fr)] sm:gap-0 sm:p-0"
@submit="submit"
>
<div class="min-w-0 sm:overflow-y-auto sm:p-5">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed } from 'vue'
@@ -41,14 +42,13 @@ const originalTitle = computed(
:title="t('Preview: {0}', [variant.name])"
:description="t('Both images are shown at actual size. Scroll to pan.')"
:ui="{
content:
'sm:top-20 sm:translate-y-0 sm:max-w-220 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0 divide-y-0 shadow-(--shadow-modal) ring-0',
content: `${SHEET_CONTENT_CLASS} sm:top-20 sm:max-w-220`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
close: 'top-5 end-6',
close: 'top-5 end-6 max-sm:top-8 max-sm:size-11 max-sm:justify-center',
title: 'text-lg/6 font-semibold',
description: 'sr-only',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
footer: SHEET_FOOTER_CLASS,
}"
>
<template #body>