feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

+32
View File
@@ -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>
+536
View File
@@ -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>
+45
View File
@@ -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>
+18 -4
View File
@@ -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),
+17
View File
@@ -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>
+57
View File
@@ -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>
+119
View File
@@ -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>
+49
View File
@@ -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>
+151
View File
@@ -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>
+189
View File
@@ -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>
+149
View File
@@ -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>
+15 -5
View File
@@ -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"
+107
View File
@@ -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>
+114
View File
@@ -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>
+22 -5
View File
@@ -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>
+110
View File
@@ -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>
+429
View File
@@ -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>
+33
View File
@@ -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>
+126
View File
@@ -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>
+111
View File
@@ -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>
+115
View File
@@ -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)
})
}