feat: image variants settings screens
This commit is contained in:
1 parent
f1d56ef50e
commit
f567866161
42 files changed
+7192
-11
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user