feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { initialsOf } from '@/lib/directory'
|
||||
|
||||
const TONES = ['bg-(--rc-sand)', 'bg-(--ui-bg-accented)', 'bg-(--ui-bg-elevated)'] as const
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string
|
||||
size?: number
|
||||
ring?: boolean
|
||||
}>(),
|
||||
{ size: 32, ring: false },
|
||||
)
|
||||
|
||||
const tone = computed(() => TONES[props.name.length % TONES.length])
|
||||
const fontSize = computed(() => `${Math.round(props.size * 0.36)}px`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAvatar
|
||||
:alt="name"
|
||||
:text="initialsOf(name)"
|
||||
aria-hidden="true"
|
||||
:style="{ width: `${size}px`, height: `${size}px`, fontSize }"
|
||||
:class="[tone, ring ? 'border-2 border-(--ui-bg)' : 'border border-default']"
|
||||
:ui="{
|
||||
root: 'shrink-0 rounded-none text-highlighted',
|
||||
fallback: 'font-semibold leading-none text-highlighted',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,536 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import SocialLinksRepeater from '@/components/SocialLinksRepeater.vue'
|
||||
import UserSelect from '@/components/UserSelect.vue'
|
||||
import {
|
||||
useAuthors,
|
||||
type Author,
|
||||
type AuthorPatch,
|
||||
type SocialLink,
|
||||
} from '@/composables/useAuthors'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSlugField } from '@/composables/useSlugField'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import { AUTHOR_SLUG_MAX, characterCount, suggestFreeSlug } from '@/lib/directory'
|
||||
import { COPY, copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure, isSessionError } from '@/lib/directoryErrors'
|
||||
import {
|
||||
BIO_MAX,
|
||||
authorSlugProblem,
|
||||
bioProblem,
|
||||
nameProblem,
|
||||
optionalSecureUrlProblem,
|
||||
secureUrlProblem,
|
||||
} from '@/lib/directoryRules'
|
||||
import type { LinkableUser, DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { SocialLinkRow } from '@/lib/socialLinks'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
author?: Author
|
||||
ownProfile?: boolean
|
||||
ownEmail?: string
|
||||
knownSlugs?: string[]
|
||||
}>(),
|
||||
{ author: undefined, ownProfile: false, ownEmail: '', knownSlugs: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: [author: Author]
|
||||
updated: [author: Author]
|
||||
gone: [id: number]
|
||||
}>()
|
||||
|
||||
const { createAuthor, updateAuthor, listLinkableUsers } = useAuthors()
|
||||
const toasts = useDirectoryToasts()
|
||||
const overlay = useLocalizedOverlay()
|
||||
|
||||
const editing = computed(() => props.author !== undefined)
|
||||
const initialLinks: SocialLinkRow[] = (props.author?.social_links ?? []).map(
|
||||
(link: SocialLink, index) => ({ key: index + 1, type: link.type, url: link.url }),
|
||||
)
|
||||
|
||||
const values = reactive({
|
||||
name: props.author?.name ?? '',
|
||||
bio: props.author?.bio ?? '',
|
||||
website: props.author?.website ?? '',
|
||||
avatarId: props.author?.avatar_id ?? null,
|
||||
userId: null as number | null,
|
||||
})
|
||||
const links = ref<SocialLinkRow[]>(initialLinks)
|
||||
const avatarItem = ref<MediaItem | null>(null)
|
||||
const baseUserId = ref<number | null>(props.author?.user_id ?? null)
|
||||
const linkedEmail = ref(props.author?.user_email ?? props.ownEmail)
|
||||
const slug = useSlugField(AUTHOR_SLUG_MAX, props.author?.slug)
|
||||
slug.setSource(values.name)
|
||||
|
||||
const errors = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
suggestion: '',
|
||||
bio: '',
|
||||
website: '',
|
||||
user: '',
|
||||
})
|
||||
const linkErrors = ref<Record<number, string>>({})
|
||||
const saving = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
|
||||
const rejectedSlugs = new Set<string>()
|
||||
|
||||
const userStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||||
const users = ref<LinkableUser[]>([])
|
||||
const userMenuOpen = ref(false)
|
||||
|
||||
function otherFields() {
|
||||
return JSON.stringify([
|
||||
values.name,
|
||||
slug.value.value,
|
||||
values.bio,
|
||||
values.website,
|
||||
values.avatarId,
|
||||
links.value.map((link) => [link.type, link.url]),
|
||||
])
|
||||
}
|
||||
const initial = otherFields()
|
||||
const dirty = computed(() => otherFields() !== initial || values.userId !== null)
|
||||
|
||||
const bioCount = computed(() => characterCount(values.bio))
|
||||
const title = computed(() => (props.author ? t('Edit {0}', [props.author.name]) : t('Add author')))
|
||||
const submitLabel = computed(() => (editing.value ? t('Save changes') : t('Add author')))
|
||||
const showLinkedBox = computed(() => baseUserId.value !== null && !props.ownProfile)
|
||||
const picker = computed(() => ({
|
||||
title: t('Choose an image'),
|
||||
description: t('The avatar for {0}.', [values.name || t('this author')]),
|
||||
hint: t('Shown as a square. Choose an image with the face or logo near the middle.'),
|
||||
confirmLabel: t('Use image'),
|
||||
}))
|
||||
|
||||
function retryUsers() {
|
||||
userStatus.value = 'idle'
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
if (userStatus.value === 'loading' || userStatus.value === 'ready') {
|
||||
return
|
||||
}
|
||||
userStatus.value = 'loading'
|
||||
try {
|
||||
users.value = await listLinkableUsers()
|
||||
userStatus.value = 'ready'
|
||||
} catch {
|
||||
userStatus.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
function onName(value: string) {
|
||||
values.name = value
|
||||
slug.setSource(value)
|
||||
errors.name = ''
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function clearSlugError() {
|
||||
errors.slug = ''
|
||||
errors.suggestion = ''
|
||||
}
|
||||
|
||||
function onSlug(value: string) {
|
||||
slug.setValue(value)
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function resetSlug() {
|
||||
slug.reset()
|
||||
clearSlugError()
|
||||
}
|
||||
|
||||
function checkName() {
|
||||
errors.name = nameProblem(values.name)
|
||||
return errors.name === ''
|
||||
}
|
||||
|
||||
function checkBio() {
|
||||
errors.bio = bioProblem(values.bio)
|
||||
return errors.bio === ''
|
||||
}
|
||||
|
||||
function checkWebsite() {
|
||||
errors.website = optionalSecureUrlProblem(values.website)
|
||||
return errors.website === ''
|
||||
}
|
||||
|
||||
function checkLink(key: number) {
|
||||
const row = links.value.find((candidate) => candidate.key === key)
|
||||
if (!row) {
|
||||
return true
|
||||
}
|
||||
const problem = secureUrlProblem(row.url)
|
||||
linkErrors.value = { ...linkErrors.value, [key]: problem }
|
||||
return problem === ''
|
||||
}
|
||||
|
||||
function checkLinks() {
|
||||
return links.value.map((row) => checkLink(row.key)).every(Boolean)
|
||||
}
|
||||
|
||||
function checkSlug() {
|
||||
errors.slug = authorSlugProblem(slug.value.value)
|
||||
return errors.slug === ''
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const results = [checkName(), checkSlug(), checkBio(), checkWebsite(), checkLinks()]
|
||||
return results.every(Boolean)
|
||||
}
|
||||
|
||||
function onLinksChange(rows: SocialLinkRow[]) {
|
||||
links.value = rows
|
||||
}
|
||||
|
||||
function socialPayload(): SocialLink[] {
|
||||
return links.value.map((row) => ({ type: row.type, url: row.url.trim() }))
|
||||
}
|
||||
|
||||
function changedFields(author: Author) {
|
||||
const patch: AuthorPatch = {}
|
||||
if (values.name.trim() !== author.name) {
|
||||
patch.name = values.name.trim()
|
||||
}
|
||||
if (slug.value.value !== author.slug) {
|
||||
patch.slug = slug.value.value
|
||||
}
|
||||
if (values.bio !== author.bio) {
|
||||
patch.bio = values.bio
|
||||
}
|
||||
if (values.avatarId !== author.avatar_id) {
|
||||
patch.avatar_id = values.avatarId
|
||||
}
|
||||
if ((values.website.trim() || null) !== author.website) {
|
||||
patch.website = values.website.trim() || null
|
||||
}
|
||||
if (JSON.stringify(socialPayload()) !== JSON.stringify(author.social_links)) {
|
||||
patch.social_links = socialPayload()
|
||||
}
|
||||
if (values.userId !== null) {
|
||||
patch.user_id = values.userId
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) {
|
||||
return
|
||||
}
|
||||
notice.value = null
|
||||
if (!validate()) {
|
||||
panel.value?.focusFirstInvalid()
|
||||
return
|
||||
}
|
||||
const author = props.author
|
||||
if (author && !dirty.value) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const saved = author
|
||||
? await updateAuthor(author.id, changedFields(author))
|
||||
: await createAuthor({
|
||||
name: values.name.trim(),
|
||||
slug: slug.value.value,
|
||||
bio: values.bio,
|
||||
avatar_id: values.avatarId,
|
||||
website: values.website.trim() || null,
|
||||
social_links: socialPayload(),
|
||||
user_id: values.userId,
|
||||
})
|
||||
emit('saved', saved)
|
||||
} catch (error) {
|
||||
handleFailure(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showSlugTaken() {
|
||||
const taken = slug.value.value
|
||||
if (slug.mode.value === 'auto') {
|
||||
slug.unlock()
|
||||
}
|
||||
rejectedSlugs.add(taken)
|
||||
errors.slug = t('The slug "{0}" is already used by another author.', [taken])
|
||||
errors.suggestion = suggestFreeSlug(
|
||||
taken,
|
||||
(candidate) => rejectedSlugs.has(candidate) || props.knownSlugs.includes(candidate),
|
||||
AUTHOR_SLUG_MAX,
|
||||
)
|
||||
}
|
||||
|
||||
function showInvalidUrl() {
|
||||
const first = links.value[0]
|
||||
if (values.website.trim() || !first) {
|
||||
errors.website = copy('urlHttps')
|
||||
return
|
||||
}
|
||||
linkErrors.value = { ...linkErrors.value, [first.key]: copy('urlHttps') }
|
||||
}
|
||||
|
||||
function handleFailure(error: unknown) {
|
||||
const kind = classifyFailure(error, 'author')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'slugTaken') {
|
||||
showSlugTaken()
|
||||
return
|
||||
}
|
||||
if (kind === 'userLinked') {
|
||||
errors.user = copy('userLinked')
|
||||
return
|
||||
}
|
||||
if (kind === 'bioTooLong') {
|
||||
errors.bio = copy('shortenBio')
|
||||
return
|
||||
}
|
||||
if (kind === 'invalidUrl') {
|
||||
showInvalidUrl()
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'author')) {
|
||||
if (kind === 'gone' && props.author) {
|
||||
emit('gone', props.author.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
notice.value = { description: copy('authorSaveFailed') }
|
||||
}
|
||||
|
||||
async function confirmUnlink() {
|
||||
const author = props.author
|
||||
if (!author) {
|
||||
return
|
||||
}
|
||||
const email = linkedEmail.value
|
||||
await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Unlink {0}?', [author.name])
|
||||
},
|
||||
get description() {
|
||||
return t(
|
||||
'{0} will stay on their articles. New articles by {1} will no longer start with this author.',
|
||||
[author.name, email],
|
||||
)
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Unlink user')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Cancel')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Unlinking…')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {
|
||||
try {
|
||||
const updated = await updateAuthor(author.id, { user_id: null })
|
||||
baseUserId.value = null
|
||||
linkedEmail.value = ''
|
||||
emit('updated', updated)
|
||||
toasts.userUnlinked(author.name)
|
||||
} catch (error) {
|
||||
if (isSessionError(error)) {
|
||||
return false
|
||||
}
|
||||
throw new Error(COPY.nothingChanged)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
.open()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryPanel
|
||||
ref="panel"
|
||||
:title="title"
|
||||
:submit-label="submitLabel"
|
||||
:saving="saving"
|
||||
:dirty="dirty"
|
||||
:notice="notice"
|
||||
@close="emit('close')"
|
||||
@submit="submit"
|
||||
>
|
||||
<MediaField
|
||||
v-model="values.avatarId"
|
||||
v-model:item="avatarItem"
|
||||
:label="t('Avatar')"
|
||||
square
|
||||
:avatar-name="values.name || 'Author'"
|
||||
:picker="picker"
|
||||
/>
|
||||
|
||||
<DirectoryField :label="t('Name')" :error="errors.name">
|
||||
<UInput
|
||||
:model-value="values.name"
|
||||
:placeholder="t('e.g. Camille Roche')"
|
||||
:color="errors.name ? 'error' : undefined"
|
||||
:data-autofocus="editing ? undefined : ''"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
@update:model-value="onName(String($event))"
|
||||
@blur="checkName"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<SlugField
|
||||
:label="t('Slug')"
|
||||
:mode="slug.mode.value"
|
||||
:value="slug.value.value"
|
||||
:error="errors.slug"
|
||||
:suggestion="errors.suggestion"
|
||||
:help="
|
||||
t(
|
||||
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
|
||||
)
|
||||
"
|
||||
:manual-help="t('Lowercase letters, numbers and hyphens. Unique across all authors.')"
|
||||
@update:value="onSlug"
|
||||
@unlock="slug.unlock"
|
||||
@reset="resetSlug"
|
||||
@suggest="onSlug"
|
||||
/>
|
||||
|
||||
<DirectoryField :label="t('Bio')" optional :error="errors.bio">
|
||||
<template #hint>
|
||||
<CharacterCounter :value="bioCount" :max="BIO_MAX" variant="field" />
|
||||
</template>
|
||||
<UTextarea
|
||||
v-model="values.bio"
|
||||
:placeholder="t('A sentence or two about the author.')"
|
||||
:color="errors.bio ? 'error' : undefined"
|
||||
class="w-full"
|
||||
:ui="{ base: 'min-h-30 text-lg md:min-h-24 md:text-sm' }"
|
||||
@update:model-value="errors.bio = ''"
|
||||
@blur="checkBio"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField :label="t('Website')" optional :error="errors.website">
|
||||
<UInput
|
||||
v-model="values.website"
|
||||
placeholder="https://example.com"
|
||||
:color="errors.website ? 'error' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="errors.website = ''"
|
||||
@blur="checkWebsite"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<SocialLinksRepeater
|
||||
:model-value="links"
|
||||
:errors="linkErrors"
|
||||
:disabled="saving"
|
||||
@update:model-value="onLinksChange"
|
||||
@blur="checkLink"
|
||||
/>
|
||||
|
||||
<DirectoryField
|
||||
v-if="ownProfile"
|
||||
:label="t('Linked user')"
|
||||
:help="t('This is your author profile. Only an owner or admin can change the link.')"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-11 items-center gap-2.5 border border-default bg-elevated px-2.5 md:min-h-10"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="size-3.5 shrink-0 text-info" />
|
||||
<span class="min-w-0 flex-1 truncate text-highlighted">{{ linkedEmail }}</span>
|
||||
<UIcon name="i-lucide-lock" class="size-3.5 shrink-0 text-muted" />
|
||||
</div>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField
|
||||
v-else-if="showLinkedBox"
|
||||
:label="t('Linked user')"
|
||||
optional
|
||||
:help="t('New articles by this user start with this author.')"
|
||||
>
|
||||
<div
|
||||
class="flex min-h-11 items-center gap-2.5 border border-accented py-0 pr-1.5 pl-2.5 md:min-h-10"
|
||||
>
|
||||
<UIcon name="i-lucide-link" class="size-3.5 shrink-0 text-info" />
|
||||
<span class="min-w-0 flex-1 truncate text-highlighted">{{ linkedEmail }}</span>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
@click="confirmUnlink"
|
||||
>
|
||||
{{ t('Unlink') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField
|
||||
v-else
|
||||
:label="t('Linked user')"
|
||||
optional
|
||||
:error="errors.user"
|
||||
:help="
|
||||
userMenuOpen
|
||||
? ''
|
||||
: t(
|
||||
'Link a CMS user if this author has an account. Guest writers need no link.',
|
||||
)
|
||||
"
|
||||
>
|
||||
<UserSelect
|
||||
v-model="values.userId"
|
||||
v-model:open="userMenuOpen"
|
||||
:users="users"
|
||||
:status="userStatus === 'idle' ? 'ready' : userStatus"
|
||||
:idle-placeholder="t('No linked user')"
|
||||
:invalid="errors.user !== ''"
|
||||
clearable
|
||||
@open="loadUsers"
|
||||
@retry="retryUsers"
|
||||
@update:model-value="errors.user = ''"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<template #preview>
|
||||
<div class="flex items-start gap-3 bg-elevated p-3">
|
||||
<AuthorAvatar :name="values.name || 'Author'" :size="40" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span class="text-xs text-muted">{{ t('Byline preview') }}</span>
|
||||
<span class="truncate font-semibold text-highlighted">{{
|
||||
values.name || t('Author name')
|
||||
}}</span>
|
||||
<span class="line-clamp-2 text-xs/4.5 text-toned">{{
|
||||
values.bio || t('The bio appears here.')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DirectoryPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import { pluralArticles } from '@/lib/directory'
|
||||
|
||||
defineProps<{ author: Author }>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryPanel :title="author.name" read-only @close="emit('close')">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<AuthorAvatar :name="author.name" :size="64" />
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="truncate font-mono text-xs/4.5 text-muted">{{ author.slug }}</span>
|
||||
<div class="flex items-center gap-2 text-xs text-muted">
|
||||
<DirectoryBadge :kind="author.is_linked ? 'linked' : 'guest'" />
|
||||
<span>{{ pluralArticles(author.article_count) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DirectoryField v-if="author.bio" :label="t('Bio')">
|
||||
<p class="text-pretty text-toned">{{ author.bio }}</p>
|
||||
</DirectoryField>
|
||||
<DirectoryField v-if="author.website" :label="t('Website')">
|
||||
<ULink
|
||||
:to="author.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-mono text-code text-highlighted underline underline-offset-3"
|
||||
>{{ author.website }}</ULink
|
||||
>
|
||||
</DirectoryField>
|
||||
<div class="flex gap-2.5 bg-elevated p-3 text-toned">
|
||||
<UIcon name="i-lucide-lock" class="mt-0.75 size-3.5 shrink-0" />
|
||||
<span>{{ t('Only an owner or admin can change this author.') }}</span>
|
||||
</div>
|
||||
</DirectoryPanel>
|
||||
</template>
|
||||
@@ -64,9 +64,15 @@ const session = useSessionStore()
|
||||
const update = useUpdateStore()
|
||||
const api = useApi()
|
||||
|
||||
const canManageMedia = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const publishingChildren = computed(() => [
|
||||
{ label: t('Publishing'), type: 'label' as const },
|
||||
{
|
||||
label: t('Sites'),
|
||||
to: '/settings/sites',
|
||||
icon: 'i-lucide-globe',
|
||||
active: !props.noCurrentNav && route.path === '/settings/sites',
|
||||
},
|
||||
])
|
||||
|
||||
const settingsChildren = computed(() => [
|
||||
{ label: t('Media'), type: 'label' as const },
|
||||
@@ -82,6 +88,7 @@ const settingsChildren = computed(() => [
|
||||
icon: 'i-lucide-database',
|
||||
active: !props.noCurrentNav && route.path === '/settings/media/cache',
|
||||
},
|
||||
...(session.isOwner ? publishingChildren.value : []),
|
||||
])
|
||||
|
||||
const navItems = computed(() =>
|
||||
@@ -107,6 +114,13 @@ const navItems = computed(() =>
|
||||
to: '/media',
|
||||
icon: 'i-lucide-images',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Authors')
|
||||
},
|
||||
to: '/authors',
|
||||
icon: 'i-lucide-feather',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Users')
|
||||
@@ -144,7 +158,7 @@ const navItems = computed(() =>
|
||||
return {
|
||||
...item,
|
||||
active: !props.noCurrentNav && parentActive,
|
||||
...(isSettings && canManageMedia.value
|
||||
...(isSettings && session.canManageContent
|
||||
? {
|
||||
children: settingsChildren.value,
|
||||
defaultOpen: route.path.startsWith(item.to),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ pinned?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
icon="i-lucide-plus"
|
||||
:class="
|
||||
pinned
|
||||
? 'fixed inset-x-4 bottom-4 h-12 justify-center text-md shadow-(--shadow-popover)'
|
||||
: 'h-8 px-3'
|
||||
"
|
||||
:ui="pinned ? { base: 'gap-2', leadingIcon: 'size-4.5' } : undefined"
|
||||
>
|
||||
<slot />
|
||||
</UButton>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'default' | 'noindex' | 'linked' | 'guest'
|
||||
}>()
|
||||
|
||||
const badges = {
|
||||
default: {
|
||||
get label() {
|
||||
return t('Default')
|
||||
},
|
||||
icon: 'i-lucide-star',
|
||||
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
|
||||
},
|
||||
noindex: {
|
||||
get label() {
|
||||
return t('Noindex')
|
||||
},
|
||||
icon: 'i-lucide-eye-off',
|
||||
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||
},
|
||||
linked: {
|
||||
get label() {
|
||||
return t('Linked')
|
||||
},
|
||||
icon: 'i-lucide-link',
|
||||
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
|
||||
},
|
||||
guest: {
|
||||
get label() {
|
||||
return t('Guest')
|
||||
},
|
||||
icon: 'i-lucide-user',
|
||||
class: 'border border-accented bg-transparent text-toned',
|
||||
},
|
||||
} as const
|
||||
|
||||
const badge = computed(() => badges[props.kind])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:icon="badge.icon"
|
||||
:class="badge.class"
|
||||
:ui="{
|
||||
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs whitespace-nowrap',
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
pendingLabel: string
|
||||
cancelLabel?: string
|
||||
tone?: 'danger' | 'neutral'
|
||||
pending?: boolean
|
||||
notice?: DirectoryNotice | null
|
||||
closable?: boolean
|
||||
confirmDisabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
cancelLabel: '',
|
||||
tone: 'danger',
|
||||
pending: false,
|
||||
notice: null,
|
||||
closable: false,
|
||||
confirmDisabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ confirm: []; close: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const confirmText = computed(() => (props.pending ? props.pendingLabel : props.confirmLabel))
|
||||
const closeButton = computed(() =>
|
||||
props.closable && !props.pending
|
||||
? { class: 'size-7 justify-center text-muted max-md:size-11' }
|
||||
: false,
|
||||
)
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (!value && props.pending) {
|
||||
return
|
||||
}
|
||||
open.value = value
|
||||
if (!value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
onOpenChange(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:dismissible="!pending"
|
||||
:close="closeButton"
|
||||
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
|
||||
:ui="{
|
||||
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
|
||||
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
description: 'mt-1 text-sm text-muted text-pretty',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: SHEET_FOOTER_CLASS,
|
||||
}"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
v-if="notice"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="notice.title"
|
||||
:description="notice.description"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-3',
|
||||
title: 'font-semibold',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="h-11 justify-center text-sm text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3"
|
||||
:disabled="pending"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ cancelLabel || t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:color="tone === 'danger' ? 'error' : 'primary'"
|
||||
:loading="pending"
|
||||
:disabled="pending || confirmDisabled"
|
||||
block
|
||||
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
optional?: boolean
|
||||
error?: string
|
||||
help?: string
|
||||
name?: string
|
||||
}>(),
|
||||
{ label: '', optional: false, error: '', help: '', name: undefined },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UFormField
|
||||
:name="name"
|
||||
:error="error || undefined"
|
||||
:ui="{
|
||||
root: 'min-w-0',
|
||||
wrapper: 'w-full',
|
||||
labelWrapper: 'w-full justify-between gap-2',
|
||||
label: 'font-medium text-highlighted',
|
||||
hint: 'font-mono text-xs/4 font-normal text-muted',
|
||||
container: 'mt-1.5',
|
||||
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
|
||||
error: 'mt-1.5 text-xs/4.5 text-error',
|
||||
}"
|
||||
>
|
||||
<template v-if="label" #label>
|
||||
{{ label }}
|
||||
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
|
||||
</template>
|
||||
<template v-if="$slots.hint" #hint>
|
||||
<slot name="hint" />
|
||||
</template>
|
||||
<slot />
|
||||
<template v-if="error" #error>
|
||||
<span class="flex items-start gap-1.5" role="alert">
|
||||
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{{ error }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="help && !error" #help>
|
||||
{{ help }}
|
||||
</template>
|
||||
</UFormField>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts" generic="T extends { id: number }">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const DEFAULT_SKELETON_ROWS = 3
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[]
|
||||
columns: string
|
||||
headings: string[]
|
||||
loading?: boolean
|
||||
skeletonRows?: number
|
||||
readOnly?: boolean
|
||||
rowTitle: (item: T) => string
|
||||
menuItems?: (item: T) => DirectoryMenuItem[]
|
||||
label: string
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
skeletonRows: DEFAULT_SKELETON_ROWS,
|
||||
readOnly: false,
|
||||
menuItems: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ open: [item: T] }>()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const openMenuId = ref<number | null>(null)
|
||||
|
||||
function setMenuOpen(item: T, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
openMenuId.value = item.id
|
||||
} else if (openMenuId.value === item.id) {
|
||||
openMenuId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function rowClass(item: T) {
|
||||
return [
|
||||
openMenuId.value === item.id ? 'bg-elevated' : 'bg-default',
|
||||
props.readOnly ? 'cursor-pointer hover:bg-elevated' : '',
|
||||
]
|
||||
}
|
||||
|
||||
function activate(item: T) {
|
||||
if (props.readOnly) {
|
||||
emit('open', item)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="group"
|
||||
:aria-label="label"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
class="border border-default bg-default max-md:-mx-4 max-md:border-x-0"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<span role="status" class="sr-only">{{ t('Loading') }}</span>
|
||||
<div
|
||||
v-for="n in skeletonRows"
|
||||
:key="n"
|
||||
data-testid="directory-skeleton-row"
|
||||
class="flex h-13 items-center gap-3 border-b border-muted px-4 max-md:h-18"
|
||||
>
|
||||
<USkeleton class="size-7 shrink-0 rounded-none bg-accented max-md:size-8" />
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<USkeleton class="h-3.25 w-2/5 rounded-none bg-accented" />
|
||||
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isDesktop">
|
||||
<div
|
||||
class="grid h-9 items-center gap-3 bg-elevated px-3 font-medium text-muted"
|
||||
:style="{ gridTemplateColumns: columns }"
|
||||
>
|
||||
<span v-for="(heading, index) in headings" :key="index">{{ heading }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
data-testid="directory-row"
|
||||
class="grid min-h-13 items-center gap-3 border-t border-default px-3"
|
||||
:class="rowClass(item)"
|
||||
:style="{ gridTemplateColumns: columns }"
|
||||
@click="activate(item)"
|
||||
>
|
||||
<slot name="cells" :item="item" />
|
||||
<span class="flex justify-center">
|
||||
<UButton
|
||||
v-if="readOnly"
|
||||
icon="i-lucide-chevron-right"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('View {0}', [rowTitle(item)])"
|
||||
class="size-8 justify-center text-muted"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click.stop="emit('open', item)"
|
||||
/>
|
||||
<DirectoryRowMenu
|
||||
v-else
|
||||
:items="menuItems(item)"
|
||||
:title="rowTitle(item)"
|
||||
:desktop="true"
|
||||
@update:open="setMenuOpen(item, $event)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
data-testid="directory-card"
|
||||
class="flex min-h-19 items-center gap-2 border-b border-default py-3 pr-1 pl-4"
|
||||
:class="rowClass(item)"
|
||||
@click="activate(item)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<slot name="card" :item="item" />
|
||||
</div>
|
||||
<UButton
|
||||
v-if="readOnly"
|
||||
icon="i-lucide-chevron-right"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('View {0}', [rowTitle(item)])"
|
||||
class="size-11 justify-center text-muted"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click.stop="emit('open', item)"
|
||||
/>
|
||||
<DirectoryRowMenu
|
||||
v-else
|
||||
:items="menuItems(item)"
|
||||
:title="rowTitle(item)"
|
||||
:desktop="false"
|
||||
@update:open="setMenuOpen(item, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref, useId } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const AUTOFOCUS_SELECTOR = '[data-autofocus]'
|
||||
const INVALID_SELECTOR = '[aria-invalid="true"]'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
submitLabel?: string
|
||||
saving?: boolean
|
||||
dirty?: boolean
|
||||
readOnly?: boolean
|
||||
notice?: DirectoryNotice | null
|
||||
}>(),
|
||||
{ submitLabel: '', saving: false, dirty: false, readOnly: false, notice: null },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ close: []; submit: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
|
||||
const overlay = useLocalizedOverlay()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const formId = useId()
|
||||
const form = ref<{ $el: HTMLElement } | null>(null)
|
||||
const side = computed(() => (isDesktop.value ? 'right' : 'bottom'))
|
||||
const primaryLabel = computed(() => (props.saving ? t('Saving…') : props.submitLabel))
|
||||
let discardOpen = false
|
||||
|
||||
const closeButton = computed(() => ({
|
||||
icon: 'i-lucide-x',
|
||||
color: 'neutral' as const,
|
||||
variant: 'ghost' as const,
|
||||
'aria-label': t('Close'),
|
||||
class: 'size-7 justify-center text-muted max-md:top-1.5 max-md:end-1.5 max-md:size-11',
|
||||
ui: { leadingIcon: 'size-4' },
|
||||
}))
|
||||
|
||||
const overlayUi = computed(() => ({
|
||||
content: isDesktop.value
|
||||
? 'w-full max-w-120'
|
||||
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'h-14 min-h-14 gap-3 px-4 py-0 sm:px-4 md:px-5',
|
||||
wrapper: 'min-w-0 flex-1',
|
||||
title: 'truncate text-lg font-semibold',
|
||||
description: 'sr-only',
|
||||
body: 'flex min-h-0 flex-1 flex-col gap-4.5 overflow-y-auto p-4 sm:p-4 md:p-5',
|
||||
footer: `gap-2 px-4 py-3 sm:px-4 max-md:flex-col-reverse md:px-5 ${props.readOnly ? 'md:justify-end' : 'md:justify-between'}`,
|
||||
}))
|
||||
|
||||
function focusOnOpen(event: Event) {
|
||||
const container = event.target instanceof HTMLElement ? event.target : null
|
||||
const target = container?.querySelector<HTMLElement>(AUTOFOCUS_SELECTOR)
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
nextTick(() => target.focus())
|
||||
}
|
||||
|
||||
function focusFirstInvalid() {
|
||||
nextTick(() => form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus())
|
||||
}
|
||||
|
||||
defineExpose({ focusFirstInvalid })
|
||||
|
||||
async function confirmDiscard() {
|
||||
if (discardOpen) {
|
||||
return
|
||||
}
|
||||
discardOpen = true
|
||||
const discard = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Discard changes?')
|
||||
},
|
||||
get description() {
|
||||
return t("You have unsaved changes. If you close now, they're lost.")
|
||||
},
|
||||
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 = false
|
||||
if (discard) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
function requestClose() {
|
||||
if (props.saving) {
|
||||
return
|
||||
}
|
||||
if (!props.dirty || props.readOnly) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
confirmDiscard()
|
||||
}
|
||||
|
||||
function onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
requestClose()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
:open="open"
|
||||
:side="side"
|
||||
:title="title"
|
||||
:description="title"
|
||||
:close="closeButton"
|
||||
:dismissible="!saving"
|
||||
:content="{ onOpenAutoFocus: focusOnOpen }"
|
||||
:ui="overlayUi"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<template #body>
|
||||
<UAlert
|
||||
v-if="notice"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:title="notice.title"
|
||||
:description="notice.description"
|
||||
:ui="{
|
||||
root: 'shrink-0 items-start gap-2.5 px-3.5 py-3',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
<UForm
|
||||
:id="formId"
|
||||
ref="form"
|
||||
:state="{}"
|
||||
:validate-on="[]"
|
||||
:disabled="saving"
|
||||
class="flex flex-col gap-4.5"
|
||||
@submit="emit('submit')"
|
||||
>
|
||||
<slot />
|
||||
</UForm>
|
||||
<slot name="preview" />
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="saving"
|
||||
class="h-11 w-full justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8 md:w-auto"
|
||||
@click="requestClose"
|
||||
>
|
||||
{{ readOnly ? t('Close') : t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="!readOnly"
|
||||
type="submit"
|
||||
:form="formId"
|
||||
:loading="saving"
|
||||
:disabled="saving"
|
||||
class="h-11 w-full justify-center px-4 text-sm md:h-8 md:w-auto"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
|
||||
const props = defineProps<{
|
||||
items: DirectoryMenuItem[]
|
||||
title: string
|
||||
desktop: boolean
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const hintId = useId()
|
||||
const trigger = ref<{ $el: HTMLElement } | null>(null)
|
||||
const label = computed(() => t('Actions for {0}', [props.title]))
|
||||
const hint = computed(() => props.items.find((item) => item.unavailable)?.hint ?? '')
|
||||
|
||||
const groups = computed(() => {
|
||||
const result: DirectoryMenuItem[][] = []
|
||||
for (const item of props.items) {
|
||||
if (item.separated || result.length === 0) {
|
||||
result.push([])
|
||||
}
|
||||
result[result.length - 1]!.push(item)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function select(item: DirectoryMenuItem, event?: Event) {
|
||||
if (item.unavailable) {
|
||||
event?.preventDefault()
|
||||
return
|
||||
}
|
||||
open.value = false
|
||||
item.onSelect(trigger.value?.$el ?? null)
|
||||
}
|
||||
|
||||
const menuItems = computed(() =>
|
||||
groups.value.map((group) =>
|
||||
group.map((item) => ({
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
color: item.destructive ? ('error' as const) : undefined,
|
||||
slot: item.unavailable ? ('unavailable' as const) : undefined,
|
||||
'aria-disabled': item.unavailable ? 'true' : undefined,
|
||||
'aria-describedby': item.unavailable ? hintId : undefined,
|
||||
class: item.unavailable ? 'opacity-40' : undefined,
|
||||
onSelect: (event: Event) => select(item, event),
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
const TRIGGER_CLASS =
|
||||
'size-8 shrink-0 justify-center text-muted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-md:size-11'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu
|
||||
v-if="desktop"
|
||||
v-model:open="open"
|
||||
:items="menuItems"
|
||||
:content="{ align: 'end' }"
|
||||
:ui="{
|
||||
content: 'w-55 p-1',
|
||||
group: 'p-0',
|
||||
separator: 'my-1',
|
||||
item: 'h-8 gap-2 px-2 text-sm',
|
||||
itemLeadingIcon: 'size-4',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
ref="trigger"
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="label"
|
||||
:class="TRIGGER_CLASS"
|
||||
@click.stop
|
||||
/>
|
||||
<template #unavailable="{ item }">
|
||||
<UTooltip :text="hint" :content="{ side: 'left' }">
|
||||
<span class="flex items-center gap-2">
|
||||
<UIcon :name="item.icon" class="size-4 shrink-0 text-error" />
|
||||
<span class="truncate text-error">{{ item.label }}</span>
|
||||
<span :id="hintId" aria-hidden="true" class="sr-only">{{ hint }}</span>
|
||||
</span>
|
||||
</UTooltip>
|
||||
</template>
|
||||
</UDropdownMenu>
|
||||
|
||||
<template v-else>
|
||||
<UButton
|
||||
ref="trigger"
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="label"
|
||||
:class="TRIGGER_CLASS"
|
||||
:ui="{ leadingIcon: 'size-4.5' }"
|
||||
@click.stop="open = true"
|
||||
/>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="title"
|
||||
:description="title"
|
||||
:ui="{
|
||||
content:
|
||||
'max-h-none pt-3 pb-6 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'min-h-11 border-0 px-4 py-0 sm:px-4',
|
||||
title: 'text-md font-semibold text-highlighted',
|
||||
description: 'sr-only',
|
||||
close: 'top-0 end-1.5 size-11 justify-center p-0',
|
||||
body: 'p-0 sm:p-0',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div role="menu" :aria-label="title" class="flex flex-col">
|
||||
<template v-for="item in items" :key="item.key">
|
||||
<USeparator v-if="item.separated" class="my-1" />
|
||||
<UButton
|
||||
role="menuitem"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
:icon="item.icon"
|
||||
:aria-disabled="item.unavailable ? 'true' : undefined"
|
||||
:aria-describedby="item.unavailable ? hintId : undefined"
|
||||
class="min-h-12 justify-start gap-3 px-4 text-start text-md"
|
||||
:class="[
|
||||
item.destructive ? 'text-error' : 'text-highlighted',
|
||||
item.unavailable ? 'opacity-40' : '',
|
||||
]"
|
||||
:ui="{ leadingIcon: 'size-4.5' }"
|
||||
@click="select(item, $event)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</UButton>
|
||||
</template>
|
||||
<p v-if="hint" :id="hintId" class="px-4 pt-1 pl-11.5 text-xs/4.5 text-muted">
|
||||
{{ hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
</template>
|
||||
@@ -49,13 +49,15 @@ import {
|
||||
|
||||
type Corner = 'tl' | 'tr' | 'bl' | 'br'
|
||||
import { useImageUpload } from '@/composables/useImageUpload'
|
||||
import type { ImageChoice } from '@/lib/imageChoice'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
initialFile?: File | null
|
||||
inArticleIds?: number[]
|
||||
choose?: ImageChoice
|
||||
}>(),
|
||||
{ initialFile: null, inArticleIds: () => [] },
|
||||
{ initialFile: null, inArticleIds: () => [], choose: undefined },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -186,11 +188,17 @@ const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'c
|
||||
|
||||
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
phase.value === 'crop' ? t('Crop and resize') : t('Add an image'),
|
||||
)
|
||||
const modalTitle = computed(() => {
|
||||
if (phase.value === 'crop') {
|
||||
return t('Crop and resize')
|
||||
}
|
||||
return props.choose?.title ?? t('Add an image')
|
||||
})
|
||||
|
||||
const modalDescription = computed(() => {
|
||||
if (props.choose && phase.value !== 'crop') {
|
||||
return props.choose.description
|
||||
}
|
||||
if (phase.value !== 'crop' || !chosenFile.value) {
|
||||
return undefined
|
||||
}
|
||||
@@ -805,6 +813,8 @@ onUnmounted(() => {
|
||||
:first-new-id="library.firstNewId.value"
|
||||
:in-article-ids="inArticleIds"
|
||||
:selected="selected"
|
||||
:plain="choose !== undefined"
|
||||
:hint="choose?.hint"
|
||||
@search="searchLibrary"
|
||||
@select="selectItem"
|
||||
@load-more="library.loadMore"
|
||||
@@ -1123,7 +1133,7 @@ onUnmounted(() => {
|
||||
:disabled="!selected"
|
||||
@click="insertSelected"
|
||||
>
|
||||
{{ t('Insert image') }}
|
||||
{{ choose?.confirmLabel ?? t('Insert image') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="showRejectedActions"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { onMounted, ref } from 'vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import type { DirectoryNotice, LinkableUser } from '@/lib/directoryTypes'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import UserSelect from '@/components/UserSelect.vue'
|
||||
import { useAuthors, type Author } from '@/composables/useAuthors'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
|
||||
const props = defineProps<{ author: Author }>()
|
||||
|
||||
const emit = defineEmits<{ close: []; linked: [author: Author]; gone: [id: number] }>()
|
||||
|
||||
const { listLinkableUsers, updateAuthor } = useAuthors()
|
||||
const toasts = useDirectoryToasts()
|
||||
|
||||
const userId = ref<number | null>(null)
|
||||
const users = ref<LinkableUser[]>([])
|
||||
const status = ref<'loading' | 'ready' | 'error'>('loading')
|
||||
const pending = ref(false)
|
||||
const fieldError = ref('')
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
|
||||
async function loadUsers() {
|
||||
status.value = 'loading'
|
||||
try {
|
||||
users.value = await listLinkableUsers()
|
||||
status.value = 'ready'
|
||||
} catch {
|
||||
status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
|
||||
function fail(error: unknown) {
|
||||
const kind = classifyFailure(error, 'author')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'userLinked') {
|
||||
fieldError.value = copy('userLinked')
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'author')) {
|
||||
if (kind === 'gone') {
|
||||
emit('gone', props.author.id)
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
return
|
||||
}
|
||||
notice.value = {
|
||||
title: copy('authorSaveFailedTitle'),
|
||||
description: copy('nothingChanged'),
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (userId.value === null || pending.value) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
fieldError.value = ''
|
||||
try {
|
||||
const updated = await updateAuthor(props.author.id, { user_id: userId.value })
|
||||
emit('linked', updated)
|
||||
} catch (error) {
|
||||
fail(error)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
:title="t('Link {0} to a user?', [author.name])"
|
||||
:description="t('Only users without an author are listed.')"
|
||||
:confirm-label="t('Link user')"
|
||||
:pending-label="t('Saving…')"
|
||||
tone="neutral"
|
||||
closable
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:confirm-disabled="userId === null"
|
||||
@confirm="confirm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<DirectoryField :label="t('Linked user')" :error="fieldError">
|
||||
<UserSelect
|
||||
v-model="userId"
|
||||
:users="users"
|
||||
:status="status"
|
||||
:disabled="pending"
|
||||
:invalid="fieldError !== ''"
|
||||
@retry="loadUsers"
|
||||
@update:model-value="fieldError = ''"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { ImageChoice } from '@/lib/imageChoice'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
help?: string
|
||||
picker: ImageChoice
|
||||
avatarName?: string
|
||||
square?: boolean
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ help: '', avatarName: '', square: false, disabled: false },
|
||||
)
|
||||
|
||||
const imageId = defineModel<number | null>({ default: null })
|
||||
const item = defineModel<MediaItem | null>('item', { default: null })
|
||||
|
||||
const pickerOpen = ref(false)
|
||||
const BUTTON_CLASS =
|
||||
'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
|
||||
|
||||
const hasImage = computed(() => imageId.value !== null)
|
||||
const chooseLabel = computed(() => (hasImage.value ? t('Change image') : t('Choose image')))
|
||||
const caption = computed(() => {
|
||||
if (item.value) {
|
||||
return item.value.filename
|
||||
}
|
||||
return imageId.value === null ? '' : t('Image #{0}', [imageId.value])
|
||||
})
|
||||
|
||||
function choose(picked: MediaItem) {
|
||||
item.value = picked
|
||||
imageId.value = picked.id
|
||||
}
|
||||
|
||||
function remove() {
|
||||
item.value = null
|
||||
imageId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryField :label="label" optional :help="help">
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="square">
|
||||
<MediaThumb
|
||||
v-if="item"
|
||||
:item="item"
|
||||
size="md"
|
||||
class="size-16 border border-default"
|
||||
/>
|
||||
<AuthorAvatar v-else :name="avatarName" :size="64" />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-12.5 w-24 shrink-0 items-center justify-center overflow-hidden border text-muted"
|
||||
:class="
|
||||
hasImage
|
||||
? 'border-accented bg-(--rc-sand)'
|
||||
: 'border-dashed border-accented bg-default'
|
||||
"
|
||||
>
|
||||
<MediaThumb v-if="item" :item="item" size="sm" class="size-full" />
|
||||
<UIcon v-else name="i-lucide-image" class="size-4.5" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<span
|
||||
v-if="caption"
|
||||
class="truncate font-mono text-xs text-highlighted"
|
||||
data-testid="media-field-caption"
|
||||
>{{ caption }}</span
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-images"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="pickerOpen = true"
|
||||
>
|
||||
{{ chooseLabel }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="hasImage"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="disabled"
|
||||
:class="BUTTON_CLASS"
|
||||
@click="remove"
|
||||
>
|
||||
{{ t('Remove') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ImageUploader
|
||||
v-model:open="pickerOpen"
|
||||
:choose="props.picker"
|
||||
@inserted="choose"
|
||||
@uploaded="choose"
|
||||
/>
|
||||
</DirectoryField>
|
||||
</template>
|
||||
@@ -38,6 +38,8 @@ const props = defineProps<{
|
||||
selected: MediaItem | null
|
||||
variants?: ImageVariant[]
|
||||
variantsReady?: boolean
|
||||
plain?: boolean
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -64,7 +66,12 @@ const searching = computed(() => props.query !== '')
|
||||
const summary = computed(() =>
|
||||
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
|
||||
)
|
||||
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
|
||||
const scrollHeight = computed(() => {
|
||||
if (props.plain) {
|
||||
return 'max-h-71 md:max-h-89'
|
||||
}
|
||||
return props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
|
||||
})
|
||||
const showEmptyLibrary = computed(
|
||||
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
|
||||
)
|
||||
@@ -72,7 +79,9 @@ const showNoResults = computed(
|
||||
() => props.status === 'ready' && props.items.length === 0 && searching.value,
|
||||
)
|
||||
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
|
||||
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
|
||||
const showLoadMore = computed(
|
||||
() => showGrid.value && props.hasMore && (props.plain || !props.selected),
|
||||
)
|
||||
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
|
||||
|
||||
function clearDebounce() {
|
||||
@@ -239,9 +248,15 @@ onBeforeUnmount(clearDebounce)
|
||||
</div>
|
||||
|
||||
<template v-else-if="showGrid">
|
||||
<span data-testid="picker-summary" class="text-xs/5 text-muted">{{ summary }}</span>
|
||||
<span v-if="!plain" data-testid="picker-summary" class="text-xs/5 text-muted">{{
|
||||
summary
|
||||
}}</span>
|
||||
<div class="-m-1 min-h-0 overflow-y-auto p-1" :class="scrollHeight">
|
||||
<div ref="grid" class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3">
|
||||
<div
|
||||
ref="grid"
|
||||
class="grid grid-cols-3 md:grid-cols-4"
|
||||
:class="plain ? 'gap-2.5' : 'gap-x-2 gap-y-3 md:gap-x-3'"
|
||||
>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
@@ -311,8 +326,10 @@ onBeforeUnmount(clearDebounce)
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-if="hint" class="text-xs/5 text-muted">{{ hint }}</p>
|
||||
|
||||
<div
|
||||
v-if="selected"
|
||||
v-if="selected && !plain"
|
||||
data-testid="picker-panel"
|
||||
class="flex flex-col gap-3 border-t border-default pt-3"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
aria-labelledby="publishing-links-h"
|
||||
data-testid="publishing-links-card"
|
||||
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h2 id="publishing-links-h" class="text-md/6 font-semibold text-highlighted">
|
||||
{{ t('Publishing') }}
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">{{ t('Where your content is shown.') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<RouterLink
|
||||
to="/settings/sites"
|
||||
class="flex min-h-11 items-center gap-3 border-t border-default py-2 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
|
||||
>
|
||||
<UIcon name="i-lucide-globe" class="size-4 shrink-0" />
|
||||
<span class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="font-medium">{{ t('Manage sites') }}</span>
|
||||
<span class="text-muted">{{
|
||||
t('Base URLs, default image and robots policy')
|
||||
}}</span>
|
||||
</span>
|
||||
<UIcon name="i-lucide-chevron-right" class="size-3.5 shrink-0" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import DirectoryDialog from '@/components/DirectoryDialog.vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
export interface ReassignOption {
|
||||
value: string | number
|
||||
label: string
|
||||
description: string
|
||||
display: string
|
||||
avatarName?: string
|
||||
none?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
pendingLabel: string
|
||||
selectLabel?: string
|
||||
options?: ReassignOption[]
|
||||
summary?: string
|
||||
pending?: boolean
|
||||
notice?: DirectoryNotice | null
|
||||
closable?: boolean
|
||||
}>(),
|
||||
{
|
||||
selectLabel: '',
|
||||
options: () => [],
|
||||
summary: '',
|
||||
pending: false,
|
||||
notice: null,
|
||||
closable: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ confirm: []; close: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
const selected = defineModel<string | number | undefined>('selected', { default: undefined })
|
||||
|
||||
const selectOpen = ref(false)
|
||||
const current = computed(() => props.options.find((option) => option.value === selected.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryDialog
|
||||
v-model:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:confirm-label="confirmLabel"
|
||||
:pending-label="pendingLabel"
|
||||
:pending="pending"
|
||||
:notice="notice"
|
||||
:closable="closable"
|
||||
@confirm="emit('confirm')"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<UFormField
|
||||
v-if="options.length"
|
||||
:label="selectLabel"
|
||||
:ui="{
|
||||
label: 'font-medium text-highlighted',
|
||||
container: 'mt-1.5',
|
||||
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
|
||||
}"
|
||||
>
|
||||
<USelectMenu
|
||||
v-model="selected"
|
||||
v-model:open="selectOpen"
|
||||
:portal="false"
|
||||
:content="{ position: 'inline' }"
|
||||
:items="options"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
:disabled="pending"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) data-[state=open]:ring-2 data-[state=open]:ring-primary md:h-10 md:text-sm',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content:
|
||||
'mt-1.5 max-h-63 rounded-none border border-default shadow-(--shadow-popover)',
|
||||
itemTrailingIcon: 'size-3.5 text-highlighted',
|
||||
itemLabel: 'font-medium',
|
||||
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated md:min-h-9',
|
||||
itemDescription: 'truncate text-xs text-muted',
|
||||
}"
|
||||
>
|
||||
<template #default>
|
||||
<span class="truncate">{{ current?.display }}</span>
|
||||
</template>
|
||||
<template #item-leading="{ item }">
|
||||
<span
|
||||
v-if="item.none"
|
||||
class="flex size-6 items-center justify-center border border-dashed border-accented text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-user-x" class="size-3.5" />
|
||||
</span>
|
||||
<AuthorAvatar v-else-if="item.avatarName" :name="item.avatarName" :size="24" />
|
||||
</template>
|
||||
</USelectMenu>
|
||||
<template v-if="summary && !selectOpen" #help>
|
||||
{{ summary }}
|
||||
</template>
|
||||
</UFormField>
|
||||
<slot />
|
||||
</DirectoryDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,429 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSites, type Site, type SitePatch, type SiteRobots } from '@/composables/useSites'
|
||||
import { useSlugField } from '@/composables/useSlugField'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import {
|
||||
SITE_KEY_MAX,
|
||||
canonicalPreview,
|
||||
siteUrlMessage,
|
||||
siteUrlProblem,
|
||||
suggestFreeSlug,
|
||||
} from '@/lib/directory'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import { baseUrlProblem, nameProblem, siteKeyProblem } from '@/lib/directoryRules'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
const PREVIEW_FALLBACK_URL = 'https://journal.example.com'
|
||||
|
||||
const props = defineProps<{
|
||||
site?: Site
|
||||
sites: Site[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: [site: Site]
|
||||
updated: [site: Site]
|
||||
defaulted: [site: Site]
|
||||
gone: [id: number]
|
||||
}>()
|
||||
|
||||
const { createSite, updateSite, setDefaultSite } = useSites()
|
||||
const toasts = useDirectoryToasts()
|
||||
|
||||
const editing = computed(() => props.site !== undefined)
|
||||
const isCurrentDefault = computed(() => props.site?.is_default === true)
|
||||
const currentDefault = computed(() => props.sites.find((site) => site.is_default))
|
||||
|
||||
const values = reactive({
|
||||
name: props.site?.name ?? '',
|
||||
baseUrl: props.site?.base_url ?? '',
|
||||
imageId: props.site?.default_social_image_id ?? null,
|
||||
robots: (props.site?.default_robots ?? 'index') as SiteRobots,
|
||||
locale: props.site?.locale ?? '',
|
||||
makeDefault: props.site?.is_default ?? false,
|
||||
})
|
||||
const imageItem = ref<MediaItem | null>(null)
|
||||
const slug = useSlugField(SITE_KEY_MAX, props.site?.key)
|
||||
slug.setSource(values.name)
|
||||
|
||||
const errors = reactive({ name: '', key: '', suggestion: '', url: '', locale: '' })
|
||||
const saving = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
|
||||
|
||||
function snapshot() {
|
||||
return JSON.stringify([
|
||||
values.name,
|
||||
slug.value.value,
|
||||
values.baseUrl,
|
||||
values.imageId,
|
||||
values.robots,
|
||||
values.locale,
|
||||
values.makeDefault,
|
||||
])
|
||||
}
|
||||
const initial = snapshot()
|
||||
const dirty = computed(() => snapshot() !== initial)
|
||||
|
||||
const title = computed(() => (props.site ? t('Edit {0}', [props.site.name]) : t('Add site')))
|
||||
const submitLabel = computed(() => (editing.value ? t('Save changes') : t('Add site')))
|
||||
const robotsItems = computed(() => [
|
||||
{ label: t('Index'), value: 'index' },
|
||||
{ label: t('Noindex'), value: 'noindex' },
|
||||
])
|
||||
const robotsHelp = computed(() =>
|
||||
values.robots === 'index'
|
||||
? t("Search engines may list this site's pages.")
|
||||
: t("Asks search engines not to list this site's pages."),
|
||||
)
|
||||
const switchLabel = computed(() =>
|
||||
isCurrentDefault.value ? t('Default site') : t('Make this the default site'),
|
||||
)
|
||||
const switchHelp = computed(() =>
|
||||
isCurrentDefault.value
|
||||
? t('New articles start on the default site. To change it, make another site the default.')
|
||||
: t('New articles start here. The current default is {0}.', [
|
||||
currentDefault.value?.name ?? '',
|
||||
]),
|
||||
)
|
||||
const urlError = computed(() => errors.url)
|
||||
const previewUrl = computed(() =>
|
||||
values.baseUrl.trim() && !urlError.value && siteUrlProblem(values.baseUrl) === null
|
||||
? canonicalPreview(values.baseUrl)
|
||||
: canonicalPreview(PREVIEW_FALLBACK_URL),
|
||||
)
|
||||
const picker = computed(() => ({
|
||||
title: t('Choose an image'),
|
||||
description: t('The default social image for {0}.', [values.name || t('this site')]),
|
||||
confirmLabel: t('Use image'),
|
||||
}))
|
||||
|
||||
function takenKeys() {
|
||||
return props.sites.filter((site) => site.id !== props.site?.id).map((site) => site.key)
|
||||
}
|
||||
|
||||
function checkKey() {
|
||||
if (editing.value) {
|
||||
return true
|
||||
}
|
||||
const key = slug.value.value
|
||||
if (!key) {
|
||||
return true
|
||||
}
|
||||
if (takenKeys().includes(key)) {
|
||||
showKeyTaken(key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function showKeyTaken(key: string) {
|
||||
if (slug.mode.value === 'auto') {
|
||||
slug.unlock()
|
||||
}
|
||||
errors.key = t('A site with the key "{0}" already exists. Choose another key.', [key])
|
||||
errors.suggestion = suggestFreeSlug(
|
||||
key,
|
||||
(candidate) => takenKeys().includes(candidate),
|
||||
SITE_KEY_MAX,
|
||||
)
|
||||
}
|
||||
|
||||
function clearKeyError() {
|
||||
errors.key = ''
|
||||
errors.suggestion = ''
|
||||
}
|
||||
|
||||
function onName(value: string) {
|
||||
values.name = value
|
||||
slug.setSource(value)
|
||||
errors.name = ''
|
||||
clearKeyError()
|
||||
}
|
||||
|
||||
function onKey(value: string) {
|
||||
slug.setValue(value)
|
||||
clearKeyError()
|
||||
}
|
||||
|
||||
function resetKey() {
|
||||
slug.reset()
|
||||
clearKeyError()
|
||||
}
|
||||
|
||||
function onBaseUrl(value: string) {
|
||||
values.baseUrl = value
|
||||
errors.url = ''
|
||||
}
|
||||
|
||||
function checkUrl() {
|
||||
errors.url = baseUrlProblem(values.baseUrl)
|
||||
return errors.url === ''
|
||||
}
|
||||
|
||||
function checkName() {
|
||||
errors.name = nameProblem(values.name)
|
||||
return errors.name === ''
|
||||
}
|
||||
|
||||
function checkKeyShape() {
|
||||
if (editing.value || !values.name.trim()) {
|
||||
return true
|
||||
}
|
||||
errors.key = siteKeyProblem(slug.value.value)
|
||||
return errors.key === ''
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const nameOk = checkName()
|
||||
const keyOk = nameOk ? checkKeyShape() : true
|
||||
const urlOk = checkUrl()
|
||||
return nameOk && keyOk && urlOk
|
||||
}
|
||||
|
||||
function changedFields(site: Site) {
|
||||
const patch: SitePatch = {}
|
||||
if (values.name.trim() !== site.name) {
|
||||
patch.name = values.name.trim()
|
||||
}
|
||||
if (values.baseUrl.trim() !== site.base_url) {
|
||||
patch.base_url = values.baseUrl.trim()
|
||||
}
|
||||
if (values.robots !== site.default_robots) {
|
||||
patch.default_robots = values.robots
|
||||
}
|
||||
if (values.locale.trim() !== (site.locale ?? '')) {
|
||||
patch.locale = values.locale.trim() === '' ? null : values.locale.trim()
|
||||
}
|
||||
if (values.imageId !== site.default_social_image_id) {
|
||||
patch.default_social_image_id = values.imageId
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const created = await createSite({
|
||||
name: values.name.trim(),
|
||||
key: slug.value.value,
|
||||
base_url: values.baseUrl.trim(),
|
||||
default_robots: values.robots,
|
||||
...(values.locale.trim() ? { locale: values.locale.trim() } : {}),
|
||||
...(values.imageId !== null ? { default_social_image_id: values.imageId } : {}),
|
||||
is_default: values.makeDefault,
|
||||
})
|
||||
emit('saved', created)
|
||||
}
|
||||
|
||||
async function update(site: Site) {
|
||||
const patch = changedFields(site)
|
||||
let latest = site
|
||||
if (Object.keys(patch).length > 0) {
|
||||
latest = await updateSite(site.id, patch)
|
||||
emit('updated', latest)
|
||||
}
|
||||
if (values.makeDefault && !site.is_default) {
|
||||
latest = await setDefaultSite(site.id)
|
||||
emit('defaulted', latest)
|
||||
}
|
||||
emit('saved', latest)
|
||||
}
|
||||
|
||||
function handleFailure(error: unknown) {
|
||||
const kind = classifyFailure(error, 'site')
|
||||
if (kind === 'session') {
|
||||
return
|
||||
}
|
||||
if (kind === 'keyTaken') {
|
||||
showKeyTaken(slug.value.value)
|
||||
return
|
||||
}
|
||||
if (kind === 'invalidSiteUrl') {
|
||||
errors.url = siteUrlMessage('invalid')
|
||||
return
|
||||
}
|
||||
if (kind === 'invalidLocale') {
|
||||
errors.locale = t('Use a language tag like en or fr-CH.')
|
||||
return
|
||||
}
|
||||
if (toasts.report(kind, 'site')) {
|
||||
if (kind === 'gone' && props.site) {
|
||||
emit('gone', props.site.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
notice.value = { description: copy('siteSaveFailed') }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) {
|
||||
return
|
||||
}
|
||||
notice.value = null
|
||||
if (!validate()) {
|
||||
panel.value?.focusFirstInvalid()
|
||||
return
|
||||
}
|
||||
if (props.site && !dirty.value) {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (props.site) {
|
||||
await update(props.site)
|
||||
} else {
|
||||
await create()
|
||||
}
|
||||
} catch (error) {
|
||||
handleFailure(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryPanel
|
||||
ref="panel"
|
||||
:title="title"
|
||||
:submit-label="submitLabel"
|
||||
:saving="saving"
|
||||
:dirty="dirty"
|
||||
:notice="notice"
|
||||
@close="emit('close')"
|
||||
@submit="submit"
|
||||
>
|
||||
<DirectoryField :label="t('Name')" :error="errors.name">
|
||||
<UInput
|
||||
:model-value="values.name"
|
||||
:placeholder="t('e.g. Journal')"
|
||||
:color="errors.name ? 'error' : undefined"
|
||||
:data-autofocus="editing ? undefined : ''"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
@update:model-value="onName(String($event))"
|
||||
@blur="checkName"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<SlugField
|
||||
:label="t('Key')"
|
||||
:mode="slug.mode.value"
|
||||
:value="slug.value.value"
|
||||
:error="errors.key"
|
||||
:suggestion="errors.suggestion"
|
||||
:locked="editing"
|
||||
:help="
|
||||
t(
|
||||
'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.',
|
||||
)
|
||||
"
|
||||
:locked-help="t('Used in API paths, so it can\'t change.')"
|
||||
:edit-label="t('Edit key')"
|
||||
@update:value="onKey"
|
||||
@unlock="slug.unlock"
|
||||
@reset="resetKey"
|
||||
@suggest="onKey"
|
||||
@blur="checkKey"
|
||||
/>
|
||||
|
||||
<DirectoryField
|
||||
:label="t('Base URL')"
|
||||
:error="errors.url"
|
||||
:help="t('The public address of the frontend. Use https and no path.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="values.baseUrl"
|
||||
placeholder="https://blog.example.com"
|
||||
:color="errors.url ? 'error' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="onBaseUrl(String($event))"
|
||||
@blur="checkUrl"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<MediaField
|
||||
v-model="values.imageId"
|
||||
v-model:item="imageItem"
|
||||
:label="t('Default social image')"
|
||||
:help="t('Used when an article has no image of its own.')"
|
||||
:picker="picker"
|
||||
/>
|
||||
|
||||
<DirectoryField :label="t('Default robots policy')" :help="robotsHelp">
|
||||
<URadioGroup
|
||||
v-model="values.robots"
|
||||
:items="robotsItems"
|
||||
orientation="horizontal"
|
||||
variant="card"
|
||||
indicator="hidden"
|
||||
:legend="t('Default robots policy')"
|
||||
:ui="{
|
||||
root: 'w-full',
|
||||
legend: 'sr-only',
|
||||
fieldset: 'w-full gap-0',
|
||||
item: 'flex-1 rounded-none border-accented p-0 text-toned has-data-[state=checked]:border-accented has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand)',
|
||||
label: 'flex h-11 w-full items-center justify-center text-sm font-normal text-inherit md:h-8',
|
||||
wrapper: 'w-full',
|
||||
}"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<DirectoryField
|
||||
:label="t('Locale')"
|
||||
optional
|
||||
:error="errors.locale"
|
||||
:help="t('A language tag. Leave empty if the site has no single language.')"
|
||||
>
|
||||
<UInput
|
||||
v-model="values.locale"
|
||||
:placeholder="t('e.g. en or fr-CH')"
|
||||
:color="errors.locale ? 'error' : undefined"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="errors.locale = ''"
|
||||
/>
|
||||
</DirectoryField>
|
||||
|
||||
<USwitch
|
||||
v-model="values.makeDefault"
|
||||
:disabled="isCurrentDefault"
|
||||
:label="switchLabel"
|
||||
:description="switchHelp"
|
||||
:ui="{
|
||||
root: 'items-start gap-3',
|
||||
base: 'mt-0.5 h-4.5 w-8',
|
||||
thumb: 'size-3.5',
|
||||
label: 'font-medium text-highlighted',
|
||||
description: 'text-xs/4.5 text-pretty text-muted',
|
||||
}"
|
||||
/>
|
||||
|
||||
<template #preview>
|
||||
<div class="flex flex-col gap-1 bg-elevated p-3">
|
||||
<span class="text-xs text-muted">{{ t('Canonical URL preview') }}</span>
|
||||
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
|
||||
previewUrl
|
||||
}}</span>
|
||||
<span v-if="errors.url" class="text-xs text-muted">{{
|
||||
t('Shown when the base URL is valid.')
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
</DirectoryPanel>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('You don\'t have access to Sites')"
|
||||
:description="
|
||||
t('Only the owner can manage sites. Ask the owner of this site if you need a change.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-14',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-md font-semibold',
|
||||
description: 'max-w-85 text-pretty text-muted',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 sm:h-8"
|
||||
@click="emit('back')"
|
||||
>
|
||||
{{ t('Back to Settings') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import type { SlugMode } from '@/composables/useSlugField'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
mode: SlugMode
|
||||
value: string
|
||||
name?: string
|
||||
error?: string
|
||||
suggestion?: string
|
||||
help?: string
|
||||
manualHelp?: string
|
||||
locked?: boolean
|
||||
lockedHelp?: string
|
||||
editLabel?: string
|
||||
}>(),
|
||||
{
|
||||
name: undefined,
|
||||
error: '',
|
||||
suggestion: '',
|
||||
help: '',
|
||||
manualHelp: '',
|
||||
locked: false,
|
||||
lockedHelp: '',
|
||||
editLabel: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: string]
|
||||
unlock: []
|
||||
reset: []
|
||||
suggest: [value: string]
|
||||
blur: []
|
||||
}>()
|
||||
|
||||
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
|
||||
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
|
||||
const inputUi = computed(() => ({
|
||||
base: `h-11 font-mono text-base md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5' : ''}`,
|
||||
}))
|
||||
const showReset = computed(
|
||||
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
|
||||
)
|
||||
const shownHelp = computed(() => {
|
||||
if (props.locked) {
|
||||
return props.lockedHelp
|
||||
}
|
||||
if (props.mode === 'manual' || props.mode === 'saved') {
|
||||
return props.manualHelp || props.help
|
||||
}
|
||||
return props.help
|
||||
})
|
||||
const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<DirectoryField :label="label" :name="name" :error="error" :help="shownHelp">
|
||||
<UInput
|
||||
:model-value="value"
|
||||
:readonly="readonly"
|
||||
:placeholder="t('Generated from the name')"
|
||||
:color="error ? 'error' : undefined"
|
||||
:highlight="mode === 'manual' && !error"
|
||||
class="w-full"
|
||||
:ui="inputUi"
|
||||
@update:model-value="emit('update:value', String($event))"
|
||||
@blur="emit('blur')"
|
||||
>
|
||||
<template v-if="locked" #trailing>
|
||||
<UIcon
|
||||
name="i-lucide-lock"
|
||||
data-testid="slug-lock"
|
||||
class="size-3.5 text-muted"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="showAuto" #trailing>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="inline-flex h-5 items-center border border-dashed border-accented px-1.5 font-mono text-2xs font-medium text-toned"
|
||||
>{{ t('Auto') }}</span
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-pencil"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="pencilLabel"
|
||||
class="size-7 justify-center text-highlighted max-md:size-11"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('unlock')"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</UInput>
|
||||
</DirectoryField>
|
||||
<div v-if="suggestion" class="flex flex-wrap items-center gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-wand-sparkles"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('suggest', suggestion)"
|
||||
>
|
||||
{{ t('Use {0}', [suggestion]) }}
|
||||
</UButton>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="showReset"
|
||||
color="neutral"
|
||||
variant="link"
|
||||
icon="i-lucide-rotate-ccw"
|
||||
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ t('Reset to match the name') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import {
|
||||
MAX_SOCIAL_LINKS,
|
||||
SOCIAL_PLACEHOLDERS,
|
||||
SOCIAL_TYPES,
|
||||
type SocialLinkRow,
|
||||
type SocialType,
|
||||
} from '@/lib/socialLinks'
|
||||
|
||||
defineProps<{
|
||||
errors: Record<number, string>
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ blur: [key: number] }>()
|
||||
|
||||
const rows = defineModel<SocialLinkRow[]>({ default: () => [] })
|
||||
|
||||
const typeItems = computed(() => [
|
||||
{ label: 'X', value: 'x' },
|
||||
{ label: 'LinkedIn', value: 'linkedin' },
|
||||
{ label: 'GitHub', value: 'github' },
|
||||
{ label: 'Mastodon', value: 'mastodon' },
|
||||
{ label: t('Custom'), value: 'custom' },
|
||||
])
|
||||
|
||||
const full = computed(() => rows.value.length >= MAX_SOCIAL_LINKS)
|
||||
const counter = computed(() => t('{0} of {1}', [rows.value.length, MAX_SOCIAL_LINKS]))
|
||||
|
||||
function nextKey() {
|
||||
return rows.value.reduce((highest, row) => Math.max(highest, row.key), 0) + 1
|
||||
}
|
||||
|
||||
function add() {
|
||||
if (full.value) {
|
||||
return
|
||||
}
|
||||
rows.value = [...rows.value, { key: nextKey(), type: SOCIAL_TYPES[0], url: '' }]
|
||||
}
|
||||
|
||||
function remove(key: number) {
|
||||
rows.value = rows.value.filter((row) => row.key !== key)
|
||||
}
|
||||
|
||||
function update(key: number, patch: Partial<SocialLinkRow>) {
|
||||
rows.value = rows.value.map((row) => (row.key === key ? { ...row, ...patch } : row))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-medium text-highlighted"
|
||||
>{{ t('Social links') }}
|
||||
<span class="font-normal text-muted"> {{ t('(optional)') }}</span></span
|
||||
>
|
||||
<DirectoryField v-for="(row, index) in rows" :key="row.key" :error="errors[row.key]">
|
||||
<div class="flex items-start gap-2" data-testid="social-link-row">
|
||||
<USelect
|
||||
:model-value="row.type"
|
||||
:items="typeItems"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Link type {0}', [index + 1])"
|
||||
class="w-27 shrink-0 max-md:w-28"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||
@update:model-value="update(row.key, { type: $event as SocialType })"
|
||||
/>
|
||||
<UInput
|
||||
:model-value="row.url"
|
||||
:placeholder="SOCIAL_PLACEHOLDERS[row.type]"
|
||||
:aria-label="t('Link URL {0}', [index + 1])"
|
||||
:color="errors[row.key] ? 'error' : undefined"
|
||||
:disabled="disabled"
|
||||
class="min-w-0 flex-1"
|
||||
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||
@update:model-value="update(row.key, { url: String($event) })"
|
||||
@blur="emit('blur', row.key)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:disabled="disabled"
|
||||
:aria-label="t('Remove link {0}', [index + 1])"
|
||||
class="size-11 shrink-0 justify-center border border-accented bg-default text-muted ring-0 md:size-10"
|
||||
@click="remove(row.key)"
|
||||
/>
|
||||
</div>
|
||||
</DirectoryField>
|
||||
<div class="flex items-center gap-3">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-plus"
|
||||
:disabled="disabled || full"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="add"
|
||||
>
|
||||
{{ t('Add link') }}
|
||||
</UButton>
|
||||
<span v-if="full" class="text-xs text-muted">{{
|
||||
t('You can add up to {0} links.', [MAX_SOCIAL_LINKS])
|
||||
}}</span>
|
||||
<span class="text-xs text-muted">{{ counter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
users: LinkableUser[]
|
||||
status: 'loading' | 'ready' | 'error'
|
||||
idlePlaceholder?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
invalid?: boolean
|
||||
}>(),
|
||||
{ idlePlaceholder: '', clearable: false, disabled: false, invalid: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ retry: []; open: [] }>()
|
||||
|
||||
const menuOpen = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const userId = defineModel<number | null>({ default: null })
|
||||
|
||||
const items = computed(() =>
|
||||
props.users.length === 0
|
||||
? []
|
||||
: [
|
||||
{ type: 'label' as const, label: t('Users without an author') },
|
||||
...props.users.map((user) => ({
|
||||
label: user.label,
|
||||
description: user.email,
|
||||
value: user.id,
|
||||
avatarName: user.label,
|
||||
})),
|
||||
],
|
||||
)
|
||||
|
||||
const placeholder = computed(() =>
|
||||
menuOpen.value || !props.idlePlaceholder ? t('Choose a user') : props.idlePlaceholder,
|
||||
)
|
||||
const chosen = computed(() => props.users.find((user) => user.id === userId.value))
|
||||
|
||||
function onOpen(value: boolean) {
|
||||
if (value) {
|
||||
emit('open')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="userId"
|
||||
v-model:open="menuOpen"
|
||||
:portal="false"
|
||||
:content="{ position: 'inline' }"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
:placeholder="placeholder"
|
||||
:loading="status === 'loading'"
|
||||
:disabled="disabled"
|
||||
:color="invalid ? 'error' : undefined"
|
||||
:highlight="invalid"
|
||||
:clear="clearable && userId !== null"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) md:h-10 md:text-sm',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
label: 'px-2 py-1.5 text-xs text-muted',
|
||||
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated md:min-h-9',
|
||||
itemDescription: 'truncate text-xs text-muted',
|
||||
placeholder: 'text-muted',
|
||||
}"
|
||||
@update:open="onOpen"
|
||||
>
|
||||
<template #default>
|
||||
<span v-if="chosen" class="truncate text-highlighted">{{ chosen.label }}</span>
|
||||
<span v-else class="truncate text-muted">{{ placeholder }}</span>
|
||||
</template>
|
||||
<template #item-leading="{ item }">
|
||||
<AuthorAvatar v-if="'avatarName' in item" :name="String(item.avatarName)" :size="24" />
|
||||
</template>
|
||||
<template #empty>
|
||||
<div v-if="status === 'error'" class="flex flex-col items-start gap-2 p-3">
|
||||
<span class="text-error">{{ t("Couldn't load users") }}</span>
|
||||
<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 md:h-7"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'loading'"
|
||||
class="flex flex-col gap-2.5 p-3"
|
||||
aria-busy="true"
|
||||
>
|
||||
<USkeleton
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="h-3 rounded-none bg-accented"
|
||||
:class="n === 3 ? 'w-3/5' : 'w-4/5'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="p-3 text-muted">{{ t('Every user already has an author.') }}</div>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
@@ -62,8 +62,8 @@ afterEach(async () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
|
||||
describe('DashboardShell renders the eight navigation destinations', () => {
|
||||
it('lists Home, Articles, Media, Authors, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
const hrefs = wrapper
|
||||
@@ -73,6 +73,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
@@ -90,7 +91,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
expect(integration.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
|
||||
it('lists the same eight destinations in the mobile drawer and Settings current', async () => {
|
||||
await mountShell('/settings', { width: MOBILE_WIDTH })
|
||||
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
@@ -104,13 +105,14 @@ describe('DashboardShell renders the seven navigation destinations', () => {
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/users',
|
||||
'/activity',
|
||||
'/integration',
|
||||
'/settings',
|
||||
])
|
||||
expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
||||
expect(links[6].getAttribute('aria-current')).toBe('page')
|
||||
expect(links[7].querySelector('[class*="lucide"], svg')).not.toBeNull()
|
||||
expect(links[7].getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
|
||||
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
|
||||
@@ -269,11 +271,11 @@ describe('The account chip shows the display name and no item is current on /pro
|
||||
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
|
||||
})
|
||||
|
||||
it('marks none of the seven navigation items current on /profile', async () => {
|
||||
it('marks none of the eight navigation items current on /profile', async () => {
|
||||
const wrapper = await mountShell('/profile')
|
||||
|
||||
const links = wrapper.findAll('nav[aria-label="Main"] a')
|
||||
expect(links).toHaveLength(7)
|
||||
expect(links).toHaveLength(8)
|
||||
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
|
||||
})
|
||||
})
|
||||
@@ -747,7 +749,7 @@ describe('DashboardShell mobile drawer rows', () => {
|
||||
const drawer = await openDrawer('/settings')
|
||||
|
||||
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
|
||||
expect(links).toHaveLength(9)
|
||||
expect(links).toHaveLength(11)
|
||||
for (const link of links) {
|
||||
expect(classesOf(link)).toContain('max-lg:h-11')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import DirectoryList from '../DirectoryList.vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
interface Row {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ id: 1, name: 'First' },
|
||||
{
|
||||
id: 2,
|
||||
name: 'A very long name that must be truncated in the row and shown in full on hover',
|
||||
},
|
||||
]
|
||||
|
||||
function menu(row: Row): DirectoryMenuItem[] {
|
||||
return [
|
||||
{ key: 'edit', label: 'Edit', icon: 'i-lucide-pencil', onSelect: () => undefined },
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete…',
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
separated: true,
|
||||
unavailable: row.id === 1,
|
||||
hint: 'Not on the first row.',
|
||||
onSelect: () => undefined,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
items: ROWS,
|
||||
columns: 'minmax(0,1fr) 44px',
|
||||
headings: ['Name', ''],
|
||||
label: 'Rows',
|
||||
rowTitle: (row: Row) => row.name,
|
||||
menuItems: menu,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
const slots = {
|
||||
cells: ({ item }: { item: Row }) =>
|
||||
h('span', { class: 'truncate', title: item.name }, item.name),
|
||||
card: ({ item }: { item: Row }) =>
|
||||
h('span', { class: 'truncate', title: item.name }, item.name),
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('DirectoryList', () => {
|
||||
it('draws a header and one row per item with a menu button on desktop', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
|
||||
expect(
|
||||
text(document.querySelector('[data-testid="directory-row"]')!.previousElementSibling),
|
||||
).toBe('Name')
|
||||
expect(document.querySelectorAll('[data-testid="directory-row"]')).toHaveLength(2)
|
||||
expect(byText('button', 'Actions for First')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the full name available on hover for a truncated row', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
|
||||
const long = document.querySelectorAll<HTMLElement>('span.truncate')[1]!
|
||||
expect(long.getAttribute('title')).toBe(ROWS[1]!.name)
|
||||
})
|
||||
|
||||
it('draws cards below md', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots, MOBILE_WIDTH)
|
||||
|
||||
expect(document.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(2)
|
||||
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('draws skeleton rows with aria-busy while loading', async () => {
|
||||
await mountInApp(DirectoryList, props({ loading: true, skeletonRows: 4 }), slots)
|
||||
|
||||
expect(document.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(document.querySelectorAll('[data-testid="directory-skeleton-row"]')).toHaveLength(4)
|
||||
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('swaps the menu for a chevron and emits open in the read-only variant', async () => {
|
||||
const wrapper = await mountInApp(DirectoryList, props({ readOnly: true }), slots)
|
||||
|
||||
expect(document.querySelector('button[aria-label^="Actions for"]')).toBeNull()
|
||||
byText('button', 'View First')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const list = wrapper.findComponent(DirectoryList)
|
||||
expect(list.emitted('open')![0]).toEqual([ROWS[0]])
|
||||
})
|
||||
|
||||
it('keeps an unavailable item focusable with aria-disabled and its hint', async () => {
|
||||
await mountInApp(DirectoryList, props(), slots)
|
||||
byText('button', 'Actions for First')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const item = byText('[role="menuitem"]', /^Delete…/)!
|
||||
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(item.hasAttribute('disabled')).toBe(false)
|
||||
expect(text(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
|
||||
'Not on the first row.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import DirectoryPanel from '../DirectoryPanel.vue'
|
||||
import { MOBILE_WIDTH } from './viewport'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
function body() {
|
||||
return h('input', { 'data-autofocus': '', placeholder: 'Name' })
|
||||
}
|
||||
|
||||
function content() {
|
||||
return document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
}
|
||||
|
||||
function escape() {
|
||||
content().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('DirectoryPanel', () => {
|
||||
it('opens a right slide-over on desktop with a title, a Cancel and a submit button', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add thing' },
|
||||
{ default: body },
|
||||
)
|
||||
|
||||
expect(text(content().querySelector('h2'))).toBe('Add thing')
|
||||
expect(content().className).toContain('max-w-120')
|
||||
expect(byText('button', 'Cancel', content())).toBeTruthy()
|
||||
expect(byText('button', 'Add thing', content())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens a 92 percent bottom sheet below md', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add thing' },
|
||||
{ default: body },
|
||||
MOBILE_WIDTH,
|
||||
)
|
||||
|
||||
expect(content().className).toContain('h-23/25')
|
||||
expect(content().className).toContain('before:w-9')
|
||||
})
|
||||
|
||||
it('moves focus into the panel', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'Add thing', submitLabel: 'Add' },
|
||||
{ default: body },
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(content().contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('closes straight away when nothing changed', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add' },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('asks Discard changes? when the form is dirty and closes only on confirmation', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', dirty: true },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
const ask = document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
|
||||
expect(text(ask)).toContain('Discard changes?')
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
|
||||
|
||||
byText('button', 'Discard changes', ask)!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores Escape while a request is running and labels the button Saving…', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', saving: true, dirty: true },
|
||||
{ default: body },
|
||||
)
|
||||
await escape()
|
||||
|
||||
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
|
||||
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
|
||||
expect(byText('button', 'Saving…', content())).toBeTruthy()
|
||||
expect(byText('button', 'Cancel', content())!.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the failure notice inside the panel', async () => {
|
||||
await mountInApp(
|
||||
DirectoryPanel,
|
||||
{ title: 'T', submitLabel: 'Add', notice: { description: 'Could not save.' } },
|
||||
{ default: body },
|
||||
)
|
||||
|
||||
expect(text(content().querySelector('[role="alert"]'))).toBe('Could not save.')
|
||||
})
|
||||
|
||||
it('offers Close as the only action in the read-only variant', async () => {
|
||||
await mountInApp(DirectoryPanel, { title: 'Detail', readOnly: true }, { default: body })
|
||||
|
||||
const labels = Array.from(content().querySelectorAll('button'))
|
||||
.map((button) => text(button) || button.getAttribute('aria-label'))
|
||||
.filter(Boolean)
|
||||
expect(labels.filter((label) => label !== 'Close')).toEqual([])
|
||||
expect(byText('button', 'Cancel', content())).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import MediaField from '../MediaField.vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const ITEM = {
|
||||
id: 55,
|
||||
filename: 'face.jpg',
|
||||
url: '/media/face.jpg',
|
||||
size: 1000,
|
||||
content_type: 'image/jpeg',
|
||||
width: 400,
|
||||
height: 400,
|
||||
sha256: '0abc',
|
||||
uploaded_by: 'Marta Alves',
|
||||
created_at: '2026-09-12T12:00:00Z',
|
||||
used_in: 0,
|
||||
duplicate: false,
|
||||
variant_count: 0,
|
||||
variant_size: 0,
|
||||
}
|
||||
|
||||
const PICKER = {
|
||||
title: 'Choose an image',
|
||||
description: 'The default social image for Journal.',
|
||||
hint: 'Shown as a square.',
|
||||
confirmLabel: 'Use image',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: vi.fn().mockResolvedValue({
|
||||
items: [ITEM],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
}),
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
})
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('MediaField', () => {
|
||||
it('shows an optional label, its help and a Choose image button when empty', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Default social image',
|
||||
help: 'Used when an article has no image of its own.',
|
||||
picker: PICKER,
|
||||
})
|
||||
|
||||
const content = text(document.body)
|
||||
expect(content).toContain('Default social image')
|
||||
expect(content).toContain('(optional)')
|
||||
expect(content).toContain('Used when an article has no image of its own.')
|
||||
expect(byText('button', 'Choose image')).toBeTruthy()
|
||||
expect(byText('button', 'Remove')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('opens the existing picker with the given title, description, hint and confirm label', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
square: true,
|
||||
avatarName: 'Camille',
|
||||
})
|
||||
byText('button', 'Choose image')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
expect(text(picker)).toContain('Choose an image')
|
||||
expect(text(picker)).toContain('The default social image for Journal.')
|
||||
expect(text(picker)).toContain('Shown as a square.')
|
||||
expect(byText('button', 'Use image', picker)!.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns the picked image id through Use image and offers Change image and Remove', async () => {
|
||||
const wrapper = await mountInApp(MediaField, { label: 'Avatar', picker: PICKER })
|
||||
byText('button', 'Choose image')!.click()
|
||||
await flushPromises()
|
||||
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
|
||||
await flushPromises()
|
||||
byText('button', 'Use image', picker)!.click()
|
||||
await flushPromises()
|
||||
|
||||
const field = wrapper.findComponent(MediaField)
|
||||
expect(field.emitted('update:modelValue')![0]).toEqual([55])
|
||||
expect(byText('button', 'Change image')).toBeTruthy()
|
||||
expect(byText('button', 'Remove')).toBeTruthy()
|
||||
expect(text(document.body)).toContain('face.jpg')
|
||||
})
|
||||
|
||||
it('shows the chosen filename, Change image and Remove, and Remove clears the id', async () => {
|
||||
const wrapper = await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
modelValue: 55,
|
||||
item: ITEM,
|
||||
})
|
||||
|
||||
expect(text(document.body)).toContain('face.jpg')
|
||||
expect(byText('button', 'Change image')).toBeTruthy()
|
||||
byText('button', 'Remove')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(MediaField).emitted('update:modelValue')![0]).toEqual([null])
|
||||
})
|
||||
|
||||
it('draws the avatar variant as a square with initials when no image is chosen', async () => {
|
||||
await mountInApp(MediaField, {
|
||||
label: 'Avatar',
|
||||
picker: PICKER,
|
||||
square: true,
|
||||
avatarName: 'Camille Roche',
|
||||
})
|
||||
|
||||
const avatar = document.body.querySelector<HTMLElement>(
|
||||
'[data-slot="root"] [style*="64px"]',
|
||||
)
|
||||
expect(avatar ?? document.body.querySelector('[style*="width: 64px"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain('CR')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import ReassignDialog from '../ReassignDialog.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 1, label: 'Renew', description: 'renew.example', display: 'Renew · renew.example' },
|
||||
{
|
||||
value: 3,
|
||||
label: 'Projects',
|
||||
description: 'projects.example',
|
||||
display: 'Projects · projects.example',
|
||||
},
|
||||
]
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
title: 'Delete this thing?',
|
||||
description: 'It will be deleted.',
|
||||
confirmLabel: 'Move 5 and delete',
|
||||
pendingLabel: 'Moving…',
|
||||
selectLabel: 'Move to',
|
||||
options: OPTIONS,
|
||||
selected: 1,
|
||||
summary: '5 will move to Renew.',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function dialog() {
|
||||
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('ReassignDialog', () => {
|
||||
it('shows the copy, the selected option, the summary and both buttons', async () => {
|
||||
await mountInApp(ReassignDialog, props())
|
||||
|
||||
const content = text(dialog())
|
||||
expect(content).toContain('Delete this thing?')
|
||||
expect(content).toContain('It will be deleted.')
|
||||
expect(content).toContain('Move to')
|
||||
expect(text(dialog().querySelector('button[aria-haspopup="listbox"]'))).toBe(
|
||||
'Renew · renew.example',
|
||||
)
|
||||
expect(content).toContain('5 will move to Renew.')
|
||||
expect(byText('button', 'Cancel', dialog())).toBeTruthy()
|
||||
expect(byText('button', 'Move 5 and delete', dialog())).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lists the options it is given with a name and a description', async () => {
|
||||
await mountInApp(ReassignDialog, props())
|
||||
dialog().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const labels = Array.from(dialog().querySelectorAll('[role="option"]')).map(text)
|
||||
expect(labels).toEqual(['Renew renew.example', 'Projects projects.example'])
|
||||
})
|
||||
|
||||
it('draws no select when there are no options', async () => {
|
||||
await mountInApp(ReassignDialog, props({ options: [], summary: '' }))
|
||||
|
||||
expect(dialog().querySelector('button[aria-haspopup="listbox"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('switches the confirm label and disables the buttons while pending', async () => {
|
||||
await mountInApp(ReassignDialog, props({ pending: true }))
|
||||
|
||||
expect(byText('button', 'Moving…', dialog())).toBeTruthy()
|
||||
expect(byText('button', 'Cancel', dialog())!.hasAttribute('disabled')).toBe(true)
|
||||
expect(dialog().getAttribute('aria-busy')).toBe('true')
|
||||
})
|
||||
|
||||
it('shows the failure notice with its title and text', async () => {
|
||||
await mountInApp(
|
||||
ReassignDialog,
|
||||
props({ notice: { title: 'Failed', description: 'Nothing was changed.' } }),
|
||||
)
|
||||
|
||||
expect(text(dialog().querySelector('[role="alert"]'))).toBe('Failed Nothing was changed.')
|
||||
})
|
||||
|
||||
it('emits confirm from the primary button', async () => {
|
||||
const wrapper = await mountInApp(ReassignDialog, props())
|
||||
byText('button', 'Move 5 and delete', dialog())!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(ReassignDialog).emitted('confirm')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import SlugField from '../SlugField.vue'
|
||||
import { byText, mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
function props(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
label: 'Slug',
|
||||
mode: 'empty',
|
||||
value: '',
|
||||
help: 'Generated from the name.',
|
||||
manualHelp: 'Lowercase letters.',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function input() {
|
||||
return document.body.querySelector<HTMLInputElement>('input')!
|
||||
}
|
||||
|
||||
afterEach(unmountApp)
|
||||
|
||||
describe('SlugField', () => {
|
||||
it('shows a read-only placeholder when there is no name yet', async () => {
|
||||
await mountInApp(SlugField, props())
|
||||
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(input().placeholder).toBe('Generated from the name')
|
||||
expect(text(document.body)).toContain('Generated from the name.')
|
||||
})
|
||||
|
||||
it('shows the automatic value with the Auto badge and a pencil that unlocks it', async () => {
|
||||
const wrapper = await mountInApp(SlugField, props({ mode: 'auto', value: 'camille-roche' }))
|
||||
|
||||
expect(input().value).toBe('camille-roche')
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(text(document.body)).toContain('Auto')
|
||||
byText('button', 'Edit slug')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(SlugField).emitted('unlock')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('is editable in manual mode and offers Reset to match the name', async () => {
|
||||
const wrapper = await mountInApp(SlugField, props({ mode: 'manual', value: 'custom' }))
|
||||
const field = wrapper.findComponent(SlugField)
|
||||
|
||||
expect(input().readOnly).toBe(false)
|
||||
expect(text(document.body)).toContain('Lowercase letters.')
|
||||
input().value = 'custom-2'
|
||||
input().dispatchEvent(new Event('input', { bubbles: true }))
|
||||
expect(field.emitted('update:value')![0]).toEqual(['custom-2'])
|
||||
|
||||
byText('button', 'Reset to match the name')!.click()
|
||||
await flushPromises()
|
||||
expect(field.emitted('reset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the error and a suggestion that emits its value', async () => {
|
||||
const wrapper = await mountInApp(
|
||||
SlugField,
|
||||
props({
|
||||
mode: 'manual',
|
||||
value: 'camille-roche',
|
||||
error: 'Already used.',
|
||||
suggestion: 'camille-roche-2',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(text(document.body)).toContain('Already used.')
|
||||
byText('button', 'Use camille-roche-2')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findComponent(SlugField).emitted('suggest')![0]).toEqual(['camille-roche-2'])
|
||||
})
|
||||
|
||||
it('draws the locked variant with a lock and its own help, with no pencil', async () => {
|
||||
await mountInApp(
|
||||
SlugField,
|
||||
props({ mode: 'saved', value: 'renew', locked: true, lockedHelp: "Can't change." }),
|
||||
)
|
||||
|
||||
expect(input().readOnly).toBe(true)
|
||||
expect(document.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
|
||||
expect(text(document.body)).toContain("Can't change.")
|
||||
expect(byText('button', 'Edit slug')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineComponent, h, type Component } from 'vue'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
|
||||
|
||||
export const holder: { wrapper: VueWrapper | null } = { wrapper: null }
|
||||
|
||||
export async function mountInApp(
|
||||
component: Component,
|
||||
props: Record<string, unknown> = {},
|
||||
slots: Record<string, () => unknown> = {},
|
||||
width = DESKTOP_WIDTH,
|
||||
) {
|
||||
setViewportWidth(width)
|
||||
Element.prototype.scrollIntoView = () => undefined
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
holder.wrapper = mount(
|
||||
defineComponent({
|
||||
render: () =>
|
||||
h(UApp, null, {
|
||||
default: () => h(component, props, slots as never),
|
||||
}),
|
||||
}),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin, pinia] } },
|
||||
)
|
||||
await flushPromises()
|
||||
return holder.wrapper
|
||||
}
|
||||
|
||||
export function unmountApp() {
|
||||
holder.wrapper?.unmount()
|
||||
holder.wrapper = null
|
||||
document.body.innerHTML = ''
|
||||
}
|
||||
|
||||
export function text(element: Element | null | undefined) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
|
||||
const parts: string[] = []
|
||||
while (walker.nextNode()) {
|
||||
const value = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
|
||||
if (value) {
|
||||
parts.push(value)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function byText(selector: string, label: string | RegExp, root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(selector)).find((element) => {
|
||||
const value = element.getAttribute('aria-label') || text(element)
|
||||
return typeof label === 'string' ? value === label : label.test(value)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user