feat(media): align visual checks and variant UI with merged design
This commit is contained in:
1 parent
e6746f2be7
commit
160c0a49ce
23 files changed
+606
-99
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user