feat: image variants settings screens

This commit is contained in:
fsylvain committed 2026-10-08 13:57:41 +02:00
1 parent f1d56ef50e
commit f567866161
42 files changed
+7192 -11

No files matched your search

+45 -5
View File
@@ -25,6 +25,7 @@ const props = withDefaults(
compactPadding?: boolean
bleedOfflineBanner?: boolean
renderWhileChecking?: boolean
noCurrentNav?: boolean
offlineMessage?: string
offlineIcon?: string
}>(),
@@ -39,6 +40,7 @@ const props = withDefaults(
compactPadding: false,
bleedOfflineBanner: false,
renderWhileChecking: false,
noCurrentNav: false,
},
)
@@ -62,6 +64,26 @@ const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const canManageMedia = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const settingsChildren = computed(() => [
{ label: t('Media'), type: 'label' as const },
{
label: t('Variants'),
to: '/settings/media/variants',
icon: 'i-lucide-layers',
active: !props.noCurrentNav && route.path === '/settings/media/variants',
},
{
label: t('Cache'),
icon: 'i-lucide-database',
disabled: true,
active: !props.noCurrentNav && route.path === '/settings/media/cache',
},
])
const navItems = computed(() =>
[
{
@@ -114,10 +136,23 @@ const navItems = computed(() =>
icon: 'i-lucide-settings',
slot: 'settings',
},
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
].map((item) => {
const isSettings = item.to === '/settings'
const parentActive = isSettings
? route.path === item.to
: route.path === item.to || route.path.startsWith(`${item.to}/`)
return {
...item,
active: !props.noCurrentNav && parentActive,
...(isSettings && canManageMedia.value
? {
children: settingsChildren.value,
defaultOpen: route.path.startsWith(item.to),
trailingIcon: 'i-lucide-chevron-right',
}
: {}),
}
}),
)
const routeTitle = computed(() => translate((route.meta.title as string) ?? ''))
@@ -135,8 +170,13 @@ const navUi = computed(() => ({
list: 'flex flex-col gap-0.5',
link: sidebarCollapsed.value
? 'h-8 justify-center gap-0 px-0 text-sm'
: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
: 'h-8 gap-2.5 px-2.5 py-0 text-sm [li[data-state=open]>&]:font-medium [li[data-state=open]>&]:text-highlighted',
linkLeadingIcon: 'size-4',
linkTrailingIcon: 'size-3.5 group-data-[state=open]:rotate-90',
label: 'px-2.5 pt-1.5 pb-0.5 pl-3 text-[11px] leading-4 font-semibold tracking-[.04em] text-muted uppercase',
childList: 'ms-[19px] gap-0.5 border-s border-default ps-0',
childLink: 'h-8 gap-2.5 py-0 pr-2.5 pl-3 text-sm',
childLinkIcon: 'size-3.5',
}))
const loggingOut = ref(false)
const sidebarOpen = ref(false)
@@ -20,6 +20,7 @@ interface ShellOptions {
username?: string
displayName?: string
email?: string
role?: string
width?: number
}
@@ -30,13 +31,14 @@ async function mountShell(path: string, options: ShellOptions = {}) {
username = 'marta',
displayName = '',
email = 'marta@renew.example',
role = undefined,
width = DESKTOP_WIDTH,
} = options
localStorage.clear()
setViewportWidth(width)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName, role }),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
@@ -382,3 +384,41 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('DashboardShell Settings expands to the Media subpages', () => {
it.each(['owner', 'admin'])(
'shows Media: Variants and Cache under Settings for %s on /settings',
async (role) => {
const wrapper = await mountShell('/settings', { role })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.text()).toContain('Media')
const variants = nav.find('a[href="/settings/media/variants"]')
expect(variants.text()).toContain('Variants')
expect(variants.attributes('aria-current')).toBeUndefined()
const cache = nav
.findAll('[aria-disabled="true"], [disabled]')
.find((el) => el.text().includes('Cache'))
expect(cache).toBeDefined()
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBe('page')
},
)
it('marks Variants current and Settings not current on the variants page', async () => {
const wrapper = await mountShell('/settings/media/variants', { role: 'admin' })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.find('a[href="/settings/media/variants"]').attributes('aria-current')).toBe(
'page',
)
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined()
})
it('shows no Media subpages without an owner or admin role', async () => {
const wrapper = await mountShell('/settings', { role: 'user' })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.find('a[href="/settings/media/variants"]').exists()).toBe(false)
expect(nav.text()).not.toContain('Cache')
})
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const MIN = 0
const MAX = 100
const STEP = 1
const LARGE_STEP = 10
const PATTERN =
'bg-[repeating-linear-gradient(135deg,color-mix(in_srgb,var(--ui-text-muted)_15%,transparent)_0_1px,transparent_1px_12px)]'
const VARIANT_PATTERN =
'bg-[repeating-linear-gradient(45deg,color-mix(in_srgb,var(--ui-text-muted)_25%,transparent)_0_2px,transparent_2px_6px)]'
const CHIP =
'absolute top-2 flex h-5 items-center border border-accented bg-default px-1.5 text-xs font-medium text-highlighted'
const props = defineProps<{
name: string
comparable: boolean
}>()
const position = defineModel<number>({ default: 50 })
const shownName = computed(() => props.name || t('name'))
function clamp(value: number) {
return Math.min(MAX, Math.max(MIN, value))
}
function onInput(event: Event) {
position.value = clamp(Number((event.target as HTMLInputElement).value))
}
function stepFor(event: KeyboardEvent) {
return event.shiftKey ? LARGE_STEP : STEP
}
const KEY_ACTIONS: Record<string, (event: KeyboardEvent) => number> = {
ArrowRight: (event) => position.value + stepFor(event),
ArrowUp: (event) => position.value + stepFor(event),
ArrowLeft: (event) => position.value - stepFor(event),
ArrowDown: (event) => position.value - stepFor(event),
Home: () => MIN,
End: () => MAX,
}
function onKeydown(event: KeyboardEvent) {
const action = KEY_ACTIONS[event.key]
if (!action) {
return
}
event.preventDefault()
position.value = clamp(action(event))
}
</script>
<template>
<div
data-testid="image-compare"
class="relative aspect-3/2 shrink-0 overflow-hidden border border-default"
>
<div
data-testid="compare-original"
class="absolute inset-0 flex items-center justify-center bg-(--ui-color-secondary-200) text-muted dark:bg-(--ui-color-secondary-300)"
:class="PATTERN"
>
<UIcon name="i-lucide-image" class="size-8 opacity-55" />
</div>
<template v-if="comparable">
<div
data-testid="compare-variant"
class="absolute inset-0 bg-accented"
:class="VARIANT_PATTERN"
:style="{ clipPath: `inset(0 0 0 ${position}%)` }"
/>
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
<span :class="[CHIP, 'right-2 font-mono']">{{ shownName }}</span>
<input
type="range"
:min="MIN"
:max="MAX"
:step="STEP"
:value="position"
:aria-label="t('Compare original and {0}', [shownName])"
:aria-valuenow="position"
:aria-valuetext="t('{0}% {1}', [position, shownName])"
class="peer absolute inset-0 z-10 size-full cursor-ew-resize opacity-0"
@input="onInput"
@keydown="onKeydown"
/>
<div
data-testid="compare-divider"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 -ml-px w-0.5 bg-default outline outline-accented"
:style="{ left: `${position}%` }"
/>
<span
data-testid="compare-handle"
aria-hidden="true"
class="pointer-events-none absolute top-1/2 flex size-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center border border-accented bg-default text-highlighted peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-primary max-sm:size-11"
:style="{ left: `${position}%` }"
>
<UIcon name="i-lucide-chevrons-left-right" class="size-4" />
</span>
</template>
<template v-else>
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
<p
class="absolute right-3 bottom-3 left-3 border border-default bg-default px-3 py-2 text-toned"
>
{{ t('Set a width or height to see a preview.') }}
</p>
</template>
</div>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const SAMPLE_PATH = '/static/uploadedImages/photo.png?v=thumb'
const ERROR_BODY = `{
"status": 400,
"error": "unknown_variant",
"message": "Unknown variant",
"detail": "banner"
}`
const rows = computed(() => [
{ request: SAMPLE_PATH, status: 200, result: t('Image'), mono: true },
{
request: t('Unknown or disabled variant'),
status: 400,
result: t('Unknown variant'),
mono: false,
},
{
request: t('width, height, format or quality'),
status: 400,
result: t('Unsupported parameter'),
mono: false,
},
{ request: t('Missing source image'), status: 404, result: t('Not found'), mono: false },
])
const columns = [
{ id: 'request', header: '' },
{ id: 'status', header: '', meta: { class: { td: 'w-15.5' } } },
{ id: 'result', header: '', meta: { class: { td: 'w-30 sm:w-32.5' } } },
]
function isSuccess(status: number) {
return status === 200
}
</script>
<template>
<UCard
data-testid="request-reference"
:ui="{
root: 'rounded-none border border-default bg-default ring-0',
body: 'flex flex-col gap-3 p-4 sm:p-4',
}"
>
<h2 class="font-semibold text-highlighted">{{ t('How requests are handled') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<div class="border border-default">
<UTable
:data="rows"
:columns="columns"
:ui="{
base: 'w-full table-fixed',
thead: 'hidden',
separator: 'hidden',
td: 'h-11 whitespace-normal px-0 py-1.5 first:pl-3 sm:h-9',
tbody: 'divide-y-0',
tr: 'border-t border-muted first:border-t-0',
}"
>
<template #request-cell="{ row }">
<span
class="block pr-2.5 text-highlighted"
:class="
row.original.mono ? 'font-mono text-xs break-all' : 'break-words'
"
>{{ row.original.request }}</span
>
</template>
<template #status-cell="{ row }">
<UBadge
color="neutral"
variant="soft"
:icon="
isSuccess(row.original.status)
? 'i-lucide-circle-check'
: 'i-lucide-circle-x'
"
:class="
isSuccess(row.original.status)
? 'bg-(--rc-success-soft) text-success'
: 'bg-(--rc-error-soft) text-error'
"
:ui="{ base: 'h-5 gap-1 px-1.5 py-0 text-xs', leadingIcon: 'size-3' }"
>{{ row.original.status }}</UBadge
>
</template>
<template #result-cell="{ row }">
<span class="text-toned">{{ row.original.result }}</span>
</template>
</UTable>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs/5 text-muted">{{ t('Body of a 400 response') }}</span>
<pre
class="m-0 overflow-hidden bg-elevated p-3 font-mono text-xs/4.5 text-highlighted"
>{{ ERROR_BODY }}</pre
>
</div>
</div>
<p class="text-xs/5 text-muted">
{{
t(
'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.',
)
}}
</p>
</UCard>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import VariantRowMenu from '@/components/variants/VariantRowMenu.vue'
import VariantStatusSwitch from '@/components/variants/VariantStatusSwitch.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import { generatedLine, specLine, variantsSummary } from '@/lib/variants/variantFormat'
const props = defineProps<{
items: ImageVariant[]
isEnabled: (item: ImageVariant) => boolean
isLocked: (name: string) => boolean
isPurging: (name: string) => boolean
}>()
const emit = defineEmits<{
toggle: [name: string, value: boolean]
edit: [name: string]
purge: [name: string]
delete: [name: string]
}>()
const summary = computed(() => variantsSummary(props.items))
function nameTone(item: ImageVariant) {
return props.isEnabled(item) ? 'text-highlighted' : 'text-muted'
}
</script>
<template>
<div class="flex flex-col gap-2">
<UCard
v-for="item in items"
:key="item.name"
data-testid="variant-card"
:ui="{
root: 'rounded-none border border-default ring-0',
body: 'flex flex-col gap-1.5 p-3 sm:p-3',
}"
:class="props.isPurging(item.name) ? 'bg-elevated' : 'bg-default'"
>
<div class="flex items-center gap-2">
<span
class="min-w-0 flex-1 truncate font-mono text-base/5 font-medium"
:class="nameTone(item)"
>{{ item.name }}</span
>
<VariantStatusSwitch
:name="item.name"
:enabled="isEnabled(item)"
:purging="isPurging(item.name)"
:locked="isLocked(item.name)"
@toggle="emit('toggle', item.name, $event)"
/>
</div>
<span class="font-mono text-xs/5 text-toned">{{ specLine(item) }}</span>
<div class="flex items-center">
<span class="flex-1 text-muted">{{ generatedLine(item) }}</span>
<VariantRowMenu
:name="item.name"
:disabled="isPurging(item.name)"
@edit="emit('edit', item.name)"
@purge="emit('purge', item.name)"
@delete="emit('delete', item.name)"
/>
</div>
</UCard>
<p data-testid="variants-summary" class="py-1 text-muted">{{ summary }}</p>
</div>
</template>
@@ -0,0 +1,394 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { FormMode } from '@/composables/useVariantForm'
import type { VariantFit, VariantFormat } from '@/composables/useImageVariants'
import {
NAME_MAX_LENGTH,
QUALITY_MAX,
QUALITY_MIN,
sliderValue,
usesQuality,
type FieldErrors,
type FieldName,
type VariantFormValues,
} from '@/lib/variants/variantForm'
const FIELD_BASE =
'h-11 px-3 text-lg ring-inset focus:ring-2 focus:ring-primary aria-invalid:focus:ring-error disabled:bg-elevated disabled:text-highlighted disabled:opacity-60 sm:h-10 sm:text-sm'
const FIELD_UI = { help: 'text-xs leading-5', error: 'text-xs leading-5' }
const MONO_FIELD = `${FIELD_BASE} font-mono`
const ERROR_ROW = 'flex items-start gap-1.5'
const values = defineModel<VariantFormValues>('values', { required: true })
const props = defineProps<{
errors: FieldErrors
mode: FormMode
disabled: boolean
dimensionHelpHidden: boolean
generatedFiles: number
saveFailed: boolean
}>()
const emit = defineEmits<{ blur: [field: FieldName]; retry: [] }>()
const isEdit = computed(() => props.mode === 'edit')
const showCounter = computed(() => !isEdit.value && values.value.name !== '')
const fitOptions = computed(() => [
{ value: 'cover', label: t('Cover') },
{ value: 'contain', label: t('Contain') },
{ value: 'inside', label: t('Inside') },
])
const fitHelp = computed(() => {
const helps: Record<VariantFit, string> = {
cover: t('Fills the box and crops what overflows.'),
contain: t('Whole image inside the box, padded to the box size.'),
inside: t('Whole image inside the box, no padding.'),
}
return helps[values.value.fit]
})
const formatOptions = computed(() => [
{ value: 'original', label: t('Original') },
{ value: 'jpeg', label: 'JPEG' },
{ value: 'png', label: 'PNG' },
{ value: 'webp', label: 'WebP' },
])
const showQuality = computed(() => usesQuality(values.value.format))
const qualityModel = computed({
get: () => sliderValue(values.value.quality),
set: (value: number) => {
values.value.quality = String(value)
},
})
const editWarning = computed(() =>
props.generatedFiles === 1
? t(
'Changing dimensions, fit, format or quality purges 1 generated file; it will be regenerated on next request.',
)
: t(
'Changing dimensions, fit, format or quality purges {0} generated files; they will be regenerated on next request.',
[props.generatedFiles],
),
)
function setFit(value: unknown) {
values.value.fit = value as VariantFit
}
function setFormat(value: unknown) {
values.value.format = value as VariantFormat
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-4">
<UAlert
v-if="saveFailed"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t save the variant')"
:description="t('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 sm:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UAlert
v-if="isEdit"
data-testid="edit-warning"
color="warning"
variant="soft"
:close="false"
icon="i-lucide-triangle-alert"
:description="editWarning"
/>
<div class="flex min-w-0 flex-col gap-4">
<UFormField
:ui="FIELD_UI"
data-field="name"
name="name"
:label="t('Name')"
:error="errors.name"
:help="
isEdit
? t('The name can\'t be changed.')
: t('Lowercase letters, numbers and dashes. Can\'t be changed later.')
"
>
<template v-if="showCounter" #hint>
<span data-testid="name-counter" class="font-mono text-xs">{{
`${values.name.length}/${NAME_MAX_LENGTH}`
}}</span>
</template>
<UInput
v-model="values.name"
:placeholder="t('e.g. hero')"
:readonly="isEdit"
:disabled="disabled"
:trailing-icon="isEdit ? 'i-lucide-lock' : undefined"
autocomplete="off"
class="w-full"
:class="{ 'opacity-100': isEdit }"
:ui="{
base: `${MONO_FIELD} ${isEdit ? 'bg-elevated' : ''}`,
trailingIcon: 'size-3.5 text-muted',
}"
@blur="emit('blur', 'name')"
/>
<template v-if="errors.name" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon name="i-lucide-circle-alert" class="mt-0.75 size-3.5 shrink-0" />{{
error
}}</span
>
</template>
</UFormField>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-2 gap-3">
<UFormField
:ui="FIELD_UI"
data-field="width"
name="width"
:label="t('Width')"
:error="errors.width"
>
<UInput
v-model="values.width"
inputmode="numeric"
:placeholder="t('auto')"
:disabled="disabled"
:color="errors.width ? 'error' : undefined"
:highlight="Boolean(errors.width)"
autocomplete="off"
class="w-full"
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
@blur="emit('blur', 'width')"
>
<template #trailing>
<span class="font-mono text-sm text-muted">px</span>
</template>
</UInput>
<template v-if="errors.width" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon
name="i-lucide-circle-alert"
class="mt-0.75 size-3.5 shrink-0"
/>{{ error }}</span
>
</template>
</UFormField>
<UFormField
:ui="FIELD_UI"
data-field="height"
name="height"
:label="t('Height')"
:error="errors.height"
>
<UInput
v-model="values.height"
inputmode="numeric"
:placeholder="t('auto')"
:disabled="disabled"
:color="errors.height || errors.dimension ? 'error' : undefined"
:highlight="Boolean(errors.height || errors.dimension)"
autocomplete="off"
class="w-full"
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
@blur="emit('blur', 'height')"
>
<template #trailing>
<span class="font-mono text-sm text-muted">px</span>
</template>
</UInput>
<template v-if="errors.height" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon
name="i-lucide-circle-alert"
class="mt-0.75 size-3.5 shrink-0"
/>{{ error }}</span
>
</template>
</UFormField>
</div>
<p v-if="!dimensionHelpHidden" class="text-xs/5 text-muted">
{{ t('Set a width, a height, or both. 1–8000 px. Images are never enlarged.') }}
</p>
</div>
<UFormField :ui="FIELD_UI" :label="t('Fit')" :help="fitHelp">
<URadioGroup
:model-value="values.fit"
:items="fitOptions"
:disabled="disabled"
variant="card"
orientation="horizontal"
:class="{ 'opacity-60': disabled }"
:ui="{
fieldset: 'grid grid-cols-3 gap-2',
item: 'min-h-11 items-center justify-center rounded-none border-accented p-0 text-highlighted has-data-[state=checked]:border-primary has-data-[state=checked]:bg-elevated sm:min-h-9',
container: 'sr-only',
wrapper: 'ms-0 w-auto',
label: 'flex items-center justify-center gap-1.5 font-normal text-highlighted',
}"
@update:model-value="setFit"
>
<template #label="{ item }">
<span
class="flex items-center gap-1.5"
:class="values.fit === item.value ? 'font-semibold' : ''"
>
<UIcon
v-if="values.fit === item.value"
name="i-lucide-check"
class="size-3.5"
/>
{{ item.label }}
</span>
</template>
</URadioGroup>
</UFormField>
<UFormField
:ui="FIELD_UI"
:label="t('Format')"
:help="
values.format === 'original'
? t('Keeps the format of each source image.')
: undefined
"
>
<USelect
:model-value="values.format"
:items="formatOptions"
:disabled="disabled"
class="w-full"
:ui="{
base: 'h-11 px-3 text-lg ring-inset disabled:bg-elevated disabled:text-highlighted disabled:opacity-60 sm:h-10 sm:text-sm',
}"
@update:model-value="setFormat"
/>
</UFormField>
<UFormField
v-if="showQuality"
:ui="FIELD_UI"
data-field="quality"
name="quality"
:label="t('Quality')"
:error="errors.quality"
:help="t('1–100. Higher is sharper and larger.')"
>
<div class="flex items-center gap-4" :class="{ 'opacity-60': disabled }">
<USlider
v-model="qualityModel"
:min="QUALITY_MIN"
:max="QUALITY_MAX"
:step="1"
:disabled="disabled"
:aria-label="t('Quality')"
class="flex-1"
:ui="{
root: 'h-11 sm:h-6',
track: 'h-1 rounded-none bg-accented',
range: 'rounded-none bg-primary',
thumb: 'size-4 rounded-none border-2 border-default bg-primary ring-0 outline outline-1 outline-primary',
}"
/>
<div class="pt-1.5">
<UInput
v-model="values.quality"
inputmode="numeric"
:aria-label="t('Quality')"
:disabled="disabled"
:color="errors.quality ? 'error' : undefined"
:highlight="Boolean(errors.quality)"
autocomplete="off"
class="w-21"
:ui="{ base: `${MONO_FIELD} disabled:opacity-100` }"
@blur="emit('blur', 'quality')"
/>
</div>
</div>
<template v-if="errors.quality" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon name="i-lucide-circle-alert" class="mt-0.75 size-3.5 shrink-0" />{{
error
}}</span
>
</template>
</UFormField>
<div class="flex flex-col gap-3 pt-1">
<div class="flex min-h-8 items-start gap-3 max-sm:min-h-11">
<div class="flex min-w-0 flex-1 flex-col">
<span id="generate-on-upload-label" class="font-medium text-highlighted">{{
t('Generate on upload')
}}</span>
<span class="text-xs/5 text-muted">{{
t('Creates this variant when an image is uploaded.')
}}</span>
</div>
<div class="pt-0.5">
<USwitch
v-model="values.generateOnUpload"
:disabled="disabled"
aria-labelledby="generate-on-upload-label"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 disabled:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
/>
</div>
</div>
<div class="flex min-h-8 items-start gap-3 max-sm:min-h-11">
<div class="flex min-w-0 flex-1 flex-col">
<span id="variant-enabled-label" class="font-medium text-highlighted">{{
t('Enabled')
}}</span>
<span class="text-xs/5 text-muted">{{
t('Disabled variants return 400 on the public site.')
}}</span>
</div>
<div class="pt-0.5">
<USwitch
v-model="values.enabled"
:disabled="disabled"
aria-labelledby="variant-enabled-label"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 disabled:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import VariantFormFields from '@/components/variants/VariantFormFields.vue'
import VariantPreview from '@/components/variants/VariantPreview.vue'
import { ApiError } from '@/composables/useApi'
import type { ImageVariant } from '@/composables/useImageVariants'
import { useVariantForm, type FormMode } from '@/composables/useVariantForm'
import { useVariantToasts } from '@/composables/useVariantToasts'
import { isStatus } from '@/lib/variants/variantErrors'
import {
toDefinition,
toNewVariant,
type FieldName,
type VariantFormValues,
} from '@/lib/variants/variantForm'
import { useImageVariantsStore } from '@/stores/imageVariants'
const DESKTOP_QUERY = '(min-width: 40rem)'
const CONFLICT = 409
const UNPROCESSABLE = 422
const TOAST_STATUSES = [400, 403, 404]
const props = defineProps<{
mode: FormMode
initial: VariantFormValues
variant?: ImageVariant
}>()
const emit = defineEmits<{
close: []
saved: [result: { mode: FormMode; name: string; purgedFiles: number }]
}>()
const store = useImageVariantsStore()
const overlay = useLocalizedOverlay()
const { announceFailure } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const form = useVariantForm(props.mode, props.initial)
const saving = ref(false)
const saveFailed = ref(false)
const discardOpen = ref(false)
const title = computed(() =>
props.mode === 'edit' ? t('Edit variant: {0}', [props.initial.name]) : t('Add variant'),
)
const primaryLabel = computed(() => {
if (saving.value) {
return t('Saving…')
}
return props.mode === 'edit' ? t('Save changes') : t('Add variant')
})
const discardDescription = computed(() =>
form.values.name
? t("You have unsaved changes to “{0}”. If you close now, they're lost.", [
form.values.name,
])
: t("You have unsaved changes. If you close now, they're lost."),
)
function focusField(field: FieldName) {
const target = document.querySelector<HTMLElement>(
`[data-field="${field}"] input:not([type="hidden"])`,
)
target?.focus()
}
function focusOnOpen(event: Event) {
event.preventDefault()
focusField(props.mode === 'edit' ? 'width' : 'name')
}
async function confirmDiscard() {
if (discardOpen.value) {
return
}
discardOpen.value = true
const discard = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Discard changes?')
},
get description() {
return discardDescription.value
},
get confirmLabel() {
return t('Discard changes')
},
get cancelLabel() {
return t('Keep editing')
},
get pendingLabel() {
return t('Discard changes')
},
tone: 'danger',
action: async () => undefined,
},
})
.open()
discardOpen.value = false
if (discard) {
emit('close')
}
}
function requestClose() {
if (saving.value) {
return
}
if (!form.dirty.value) {
emit('close')
return
}
confirmDiscard()
}
function onOpenChange(isOpen: boolean) {
if (!isOpen) {
requestClose()
}
}
function showInvalidFromServer(body: string | undefined): FieldName | null {
const text = body ?? ''
if (text.includes('lowercase letters')) {
form.showRemote('name', t('Use lowercase letters, numbers and dashes, 2 to 32 characters.'))
return 'name'
}
if (text.includes('Width and height')) {
form.showRemote('width', t('Set a width, a height, or both.'))
return 'width'
}
if (text.includes('Quality')) {
form.showRemote('quality', t('Enter a value from 1 to 100.'))
return 'quality'
}
saveFailed.value = true
return null
}
function handleFailure(error: unknown): FieldName | null {
if (!(error instanceof ApiError)) {
saveFailed.value = true
return null
}
if (isStatus(error, CONFLICT)) {
form.showRemote(
'name',
t('A variant named “{0}” already exists. Choose another name.', [form.values.name]),
)
return 'name'
}
if (isStatus(error, UNPROCESSABLE)) {
return showInvalidFromServer(error.body)
}
if (TOAST_STATUSES.includes(error.status)) {
announceFailure(error)
return null
}
if (error.status !== 401) {
saveFailed.value = true
}
return null
}
async function persist() {
if (props.mode === 'edit' && props.variant) {
const saved = await store.save(props.variant.name, toDefinition(form.values))
emit('saved', { mode: 'edit', name: saved.name, purgedFiles: saved.purged_files ?? 0 })
return
}
const created = await store.create(toNewVariant(form.values))
emit('saved', { mode: 'create', name: created.name, purgedFiles: 0 })
}
async function submit() {
if (saving.value) {
return
}
saveFailed.value = false
const invalid = form.validateAll()
if (invalid) {
focusField(invalid)
return
}
saving.value = true
let toFocus: FieldName | null = null
try {
await persist()
} catch (error) {
toFocus = handleFailure(error)
} finally {
saving.value = false
}
if (toFocus) {
await nextTick()
focusField(toFocus)
}
}
</script>
<template>
<USlideover
:open="true"
side="right"
: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
"
: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',
}"
@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"
@submit="submit"
>
<div class="min-w-0 sm:overflow-y-auto sm:p-5">
<VariantFormFields
v-model:values="form.values"
:errors="form.errors.value"
:mode="mode"
:disabled="saving"
:dimension-help-hidden="form.dimensionHelpHidden.value"
:generated-files="variant?.generated_files ?? 0"
:save-failed="saveFailed"
@blur="form.onBlur"
@retry="submit"
/>
</div>
<div
class="min-w-0 border-t border-default pt-4 sm:overflow-y-auto sm:border-t-0 sm:border-l sm:bg-muted sm:p-5"
>
<VariantPreview :values="form.values" :disabled="saving" />
</div>
</UForm>
</template>
<template #footer>
<div class="flex w-full items-center gap-2 sm:gap-3">
<div v-if="isDesktop" class="flex flex-1 items-center gap-1.5 text-toned">
<template v-if="form.dirty.value">
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
{{ t('Unsaved changes') }}
</template>
</div>
<UButton
color="neutral"
variant="outline"
:disabled="saving"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8 sm:flex-none"
@click="requestClose"
>
{{ t('Cancel') }}
</UButton>
<UButton
:loading="saving"
:disabled="!form.dirty.value || saving"
class="h-11 flex-1 justify-center px-4 text-sm sm:h-8 sm:flex-none"
@click="submit"
>
{{ primaryLabel }}
</UButton>
</div>
</template>
</USlideover>
</template>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import ImageCompare from '@/components/variants/ImageCompare.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { SAMPLE_SOURCE, estimateLabel, estimateVariant } from '@/lib/variants/variantEstimate'
import { parseDimension, sliderValue, type VariantFormValues } from '@/lib/variants/variantForm'
import { formatVariantSize } from '@/lib/variants/variantFormat'
const URL_PREFIX = '/static/uploadedImages/'
const NAME_PLACEHOLDER = '<name>'
const props = defineProps<{
values: VariantFormValues
disabled: boolean
}>()
const position = ref(50)
const { copied, copy } = useCopyToClipboard()
const dimensionsAreValid = computed(
() =>
(props.values.width.trim() === '' || parseDimension(props.values.width) !== null) &&
(props.values.height.trim() === '' || parseDimension(props.values.height) !== null),
)
const estimate = computed(() =>
dimensionsAreValid.value
? estimateVariant({
width: parseDimension(props.values.width),
height: parseDimension(props.values.height),
fit: props.values.fit,
format: props.values.format,
quality: sliderValue(props.values.quality),
})
: null,
)
const shownName = computed(() => props.values.name || t('name'))
const outputLine = computed(() =>
estimate.value ? estimateLabel(estimate.value) : t('No output yet'),
)
const originalLine = computed(
() =>
`${SAMPLE_SOURCE.width} × ${SAMPLE_SOURCE.height} · ${formatVariantSize(SAMPLE_SOURCE.bytes)}`,
)
const hasName = computed(() => props.values.name !== '')
const url = computed(
() => `${URL_PREFIX}${SAMPLE_SOURCE.filename}?v=${props.values.name || NAME_PLACEHOLDER}`,
)
async function onCopy() {
try {
await copy(url.value)
} catch {
return
}
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex items-baseline gap-2">
<h3 class="flex-1 font-semibold text-highlighted">{{ t('Preview') }}</h3>
<span class="text-xs/5 text-muted">{{
t('Sample: {0}', [SAMPLE_SOURCE.filename])
}}</span>
</div>
<ImageCompare v-model="position" :name="values.name" :comparable="estimate !== null" />
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col">
<span class="font-medium text-highlighted">{{ t('Original') }}</span>
<span class="text-xs/5 text-muted">{{ originalLine }}</span>
</div>
<div class="flex flex-col">
<span class="font-mono font-medium text-highlighted">{{ shownName }}</span>
<span class="text-xs/5 text-muted">{{ outputLine }}</span>
</div>
</div>
<p class="text-xs/5 text-muted">
{{
t(
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.',
)
}}
</p>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('URL') }}</span>
<div class="flex gap-2">
<div
data-testid="variant-url"
class="flex min-h-11 min-w-0 flex-1 items-center truncate border border-default bg-elevated px-3 font-mono text-xs sm:min-h-8"
:class="hasName ? 'text-highlighted' : 'text-muted'"
>
{{ url }}
</div>
<UButton
color="neutral"
variant="outline"
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
:disabled="!hasName || disabled"
class="h-11 shrink-0 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
@click="onCopy"
>
{{ copied ? t('Copied') : t('Copy') }}
</UButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
name: string
disabled: boolean
}>()
const emit = defineEmits<{ edit: []; purge: []; delete: [] }>()
const label = computed(() => t('Actions for {0}', [props.name]))
const items = computed(() => [
[
{
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => emit('edit'),
},
{
get label() {
return t('Purge cache')
},
icon: 'i-lucide-eraser',
onSelect: () => emit('purge'),
},
{
get label() {
return t('Delete')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete'),
},
],
])
</script>
<template>
<UTooltip :text="label">
<UDropdownMenu
:items="items"
:content="{ align: 'end' }"
:ui="{
content: 'w-44',
item: 'h-8 gap-2 px-2 text-sm max-sm:h-11',
itemLeadingIcon: 'size-3.5',
}"
>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="label"
:disabled="disabled"
class="size-7 shrink-0 justify-center text-highlighted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-sm:size-11"
/>
</UDropdownMenu>
</UTooltip>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
name: string
enabled: boolean
purging: boolean
locked: boolean
}>()
const emit = defineEmits<{ toggle: [value: boolean] }>()
const word = computed(() => {
if (props.purging) {
return t('Purging…')
}
return props.enabled ? t('Enabled') : t('Disabled')
})
const icon = computed(() => {
if (props.purging) {
return 'i-lucide-loader-circle'
}
return props.enabled ? 'i-lucide-circle-check' : 'i-lucide-circle-off'
})
const tone = computed(() => {
if (props.purging) {
return 'text-info'
}
return props.enabled ? 'text-highlighted' : 'text-muted'
})
</script>
<template>
<div class="flex items-center gap-2 whitespace-nowrap" :class="tone">
<USwitch
:model-value="enabled"
:disabled="locked"
:aria-label="t('Enable variant {0}', [name])"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 data-[disabled]:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
@update:model-value="emit('toggle', $event)"
/>
<span class="flex items-center gap-1">
<UIcon :name="icon" class="size-3.5 shrink-0" :class="{ 'animate-spin': purging }" />
{{ word }}
</span>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { VARIANT_TEMPLATES } from '@/lib/variants/variantForm'
import { fitLabel, formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
const emit = defineEmits<{ add: []; template: [name: string] }>()
function description(template: (typeof VARIANT_TEMPLATES)[number]) {
return [
formatDimensions(template.width, template.height),
fitLabel('cover'),
formatLabel('webp'),
].join(' · ')
}
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-layers"
:title="t('No variants yet')"
:description="
t(
'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.',
)
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-10',
header: 'max-w-none gap-3',
body: 'w-full max-w-none items-center gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-95 text-pretty text-muted',
actions: 'w-full flex-col items-center justify-center gap-3',
}"
>
<template #actions>
<div class="flex w-full max-w-130 flex-col gap-3 sm:flex-row">
<UButton
v-for="template in VARIANT_TEMPLATES"
:key="template.name"
color="neutral"
variant="outline"
class="min-h-16 flex-1 flex-col items-start justify-start gap-0.5 border border-accented bg-default p-3 text-left ring-0 sm:min-h-0"
@click="emit('template', template.name)"
>
<span class="font-mono font-medium text-highlighted">{{ template.name }}</span>
<span class="text-xs/5 text-muted">{{ description(template) }}</span>
<span class="mt-2 flex items-center gap-1 font-medium text-highlighted">
<UIcon name="i-lucide-plus" class="size-3.5" />
{{ t('Use template') }}
</span>
</UButton>
</div>
<UButton icon="i-lucide-plus" class="h-8 px-4" @click="emit('add')">
{{ t('Add variant') }}
</UButton>
</template>
</UEmpty>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ home: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-lock"
:title="t('You don\'t have access')"
:description="
t('Only admins can manage image variants. Ask an admin if you need one changed.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-18',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-95 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-arrow-left"
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
@click="emit('home')"
>
{{ t('Back to Home') }}
</UButton>
</template>
</UEmpty>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ desktop: boolean }>()
const DESKTOP_ROWS = 5
const MOBILE_CARDS = 4
const ROW_BARS = ['w-30', 'w-22.5', 'w-15', 'w-15', 'w-20', 'w-40']
const BAR = 'rounded-none bg-accented'
</script>
<template>
<section :aria-label="t('Loading variants')" aria-busy="true">
<div v-if="desktop" class="border border-default bg-default">
<div class="h-9 bg-elevated" />
<div
v-for="row in DESKTOP_ROWS"
:key="row"
class="flex h-12 items-center gap-8 border-t border-muted px-4"
>
<USkeleton
v-for="(width, index) in ROW_BARS"
:key="index"
class="h-3.25"
:class="[BAR, width]"
/>
</div>
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="card in MOBILE_CARDS"
:key="card"
class="flex flex-col gap-2.5 border border-default bg-default p-3"
>
<USkeleton class="h-3.25 w-[40%]" :class="BAR" />
<USkeleton class="h-2.75 w-[70%]" :class="BAR" />
<USkeleton class="h-2.75 w-[55%]" :class="BAR" />
</div>
</div>
</section>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { computed } from 'vue'
import VariantRowMenu from '@/components/variants/VariantRowMenu.vue'
import VariantStatusSwitch from '@/components/variants/VariantStatusSwitch.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import {
fitLabel,
formatDimensions,
formatLabel,
formatVariantSize,
qualityCell,
variantsSummary,
} from '@/lib/variants/variantFormat'
const props = defineProps<{
items: ImageVariant[]
isEnabled: (item: ImageVariant) => boolean
isLocked: (name: string) => boolean
isPurging: (name: string) => boolean
}>()
const emit = defineEmits<{
toggle: [name: string, value: boolean]
edit: [name: string]
purge: [name: string]
delete: [name: string]
}>()
function fixed(width: string) {
return { meta: { class: { th: width, td: width } } }
}
const columns = computed(() => [
{
id: 'name',
get header() {
return t('Name')
},
},
{
id: 'dimensions',
get header() {
return t('Dimensions')
},
...fixed('w-26'),
},
{
id: 'fit',
get header() {
return t('Fit')
},
...fixed('w-19'),
},
{
id: 'format',
get header() {
return t('Format')
},
...fixed('w-18'),
},
{
id: 'quality',
get header() {
return t('Quality')
},
...fixed('w-17'),
},
{
id: 'generated',
get header() {
return t('Generated')
},
...fixed('w-23'),
},
{
id: 'cache_size',
get header() {
return t('Cache size')
},
...fixed('w-23'),
},
{
id: 'status',
get header() {
return t('Status')
},
...fixed('w-33'),
},
{ id: 'actions', header: '', ...fixed('w-11') },
])
const meta = {
class: {
tr: (row: { original: ImageVariant }) => {
const item = row.original
const tone = props.isEnabled(item) ? 'text-default' : 'text-muted'
return props.isPurging(item.name) ? `bg-elevated ${tone}` : `bg-default ${tone}`
},
},
}
const summary = computed(() => variantsSummary(props.items))
function variant(row: { original: ImageVariant }) {
return row.original
}
function nameTone(row: { original: ImageVariant }) {
return props.isEnabled(variant(row)) ? 'text-highlighted' : 'text-muted'
}
</script>
<template>
<div class="border border-default bg-default">
<UTable
:data="items"
:columns="columns"
:meta="meta"
:ui="{
base: 'w-full table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'h-9 px-0 py-0 first:pl-4 last:pr-2',
td: 'h-12 px-0 py-0 first:pl-4 last:pr-2',
tbody: 'divide-y-0',
tr: 'border-t border-muted',
}"
>
<template #name-cell="{ row }">
<span class="block truncate font-mono text-sm font-medium" :class="nameTone(row)">{{
variant(row).name
}}</span>
</template>
<template #dimensions-cell="{ row }">
<span class="font-mono text-xs">{{
formatDimensions(variant(row).width, variant(row).height)
}}</span>
</template>
<template #fit-cell="{ row }">{{ fitLabel(variant(row).fit) }}</template>
<template #format-cell="{ row }">{{ formatLabel(variant(row).format) }}</template>
<template #quality-cell="{ row }">{{ qualityCell(variant(row)) }}</template>
<template #generated-cell="{ row }">{{
formatNumber(variant(row).generated_files)
}}</template>
<template #cache_size-cell="{ row }">{{
formatVariantSize(variant(row).cache_size)
}}</template>
<template #status-cell="{ row }">
<VariantStatusSwitch
:name="variant(row).name"
:enabled="isEnabled(variant(row))"
:purging="isPurging(variant(row).name)"
:locked="isLocked(variant(row).name)"
@toggle="emit('toggle', variant(row).name, $event)"
/>
</template>
<template #actions-cell="{ row }">
<span class="flex justify-end">
<VariantRowMenu
:name="variant(row).name"
:disabled="isPurging(variant(row).name)"
@edit="emit('edit', variant(row).name)"
@purge="emit('purge', variant(row).name)"
@delete="emit('delete', variant(row).name)"
/>
</span>
</template>
</UTable>
<div data-testid="variants-summary" class="border-t border-default px-4 py-2.5 text-toned">
{{ summary }}
</div>
</div>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { t } from '@/i18n'
const TAB = 'h-9 gap-1.5 rounded-none border-b-2 px-0 py-0 text-sm hover:bg-transparent max-sm:h-11'
const ACTIVE = 'border-primary font-medium text-highlighted'
const INACTIVE = 'border-transparent font-normal text-toned aria-disabled:opacity-100'
</script>
<template>
<div
role="tablist"
:aria-label="t('Image variants')"
class="flex gap-5 border-b border-default"
>
<UButton
role="tab"
aria-selected="true"
icon="i-lucide-layers"
color="neutral"
variant="ghost"
:class="[TAB, ACTIVE, '-mb-px']"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ t('Variants') }}
</UButton>
<UButton
role="tab"
aria-selected="false"
aria-disabled="true"
icon="i-lucide-database"
color="neutral"
variant="ghost"
:class="[TAB, INACTIVE, '-mb-px']"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ t('Cache') }}
</UButton>
</div>
</template>