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)
})
}
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError } from '@/composables/useApi'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { useSlugField } from '@/composables/useSlugField'
import router from '@/router'
let wrapper: VueWrapper | null = null
function inComponent<T>(setup: () => T) {
let result!: T
wrapper = mount(
defineComponent({
setup() {
result = setup()
return () => h('div')
},
}),
{ global: { plugins: [router] } },
)
return result
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
})
describe('useDelayedSkeleton', () => {
it('shows only after the delay and hides on stop', async () => {
vi.useFakeTimers()
const skeleton = inComponent(() => useDelayedSkeleton(150))
skeleton.start()
await vi.advanceTimersByTimeAsync(149)
expect(skeleton.visible.value).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(skeleton.visible.value).toBe(true)
skeleton.stop()
expect(skeleton.visible.value).toBe(false)
})
})
describe('useDirectoryCollection', () => {
it('loads, keeps the data on a later load and ignores a stale answer', async () => {
const first = deferred<string>()
const second = deferred<string>()
const answers = [first, second]
const collection = inComponent(() => useDirectoryCollection(() => answers.shift()!.promise))
const early = collection.load([])
const late = collection.load([])
second.resolve('new')
first.resolve('old')
expect(await late).toBe('ready')
expect(await early).toBe('stale')
expect(collection.data.value).toBe('new')
expect(collection.state.value).toBe('ready')
})
it('can refuse a result without committing it', async () => {
const collection = inComponent(() => useDirectoryCollection(async () => 'page'))
expect(await collection.load([], { accept: () => false })).toBe('rejected')
expect(collection.data.value).toBeNull()
})
it('maps a 403 to forbidden, other failures to error and ignores a 401', async () => {
const failures = [new ApiError(403), new ApiError(500), new ApiError(401)]
const collection = inComponent(() =>
useDirectoryCollection(async () => {
throw failures.shift()
}),
)
await collection.load([])
expect(collection.state.value).toBe('forbidden')
await collection.load([])
expect(collection.state.value).toBe('error')
expect(await collection.load([])).toBe('stale')
expect(collection.state.value).toBe('loading')
})
it('draws the skeleton only after the delay on the first load', async () => {
vi.useFakeTimers()
const hold = deferred<string>()
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
const loading = collection.load([])
expect(collection.showSkeleton.value).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(collection.showSkeleton.value).toBe(true)
hold.resolve('done')
await loading
expect(collection.showSkeleton.value).toBe(false)
})
it('drops an answer that arrives after the component is gone', async () => {
const hold = deferred<string>()
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
const loading = collection.load([])
wrapper!.unmount()
wrapper = null
hold.resolve('late')
expect(await loading).toBe('stale')
expect(collection.data.value).toBeNull()
})
})
describe('useDirectoryDelete', () => {
it('runs once, clears the notice and reports failures to the handler', async () => {
const removal = useDirectoryDelete<{ id: number }>()
removal.open({ id: 1 })
removal.notice.value = { description: 'old' }
const run = vi.fn().mockRejectedValue(new Error('no'))
const onFailure = vi.fn()
const first = removal.confirm(run, onFailure)
await removal.confirm(run, onFailure)
await first
expect(run).toHaveBeenCalledTimes(1)
expect(onFailure).toHaveBeenCalledWith(expect.any(Error), { id: 1 })
expect(removal.pending.value).toBe(false)
expect(removal.notice.value).toBeNull()
})
it('does nothing without a target and closes cleanly', async () => {
const removal = useDirectoryDelete<{ id: number }>()
const run = vi.fn()
await removal.confirm(run, vi.fn())
expect(run).not.toHaveBeenCalled()
removal.open({ id: 2 })
removal.close()
expect(removal.target.value).toBeNull()
})
})
describe('useSlugField', () => {
it('follows the name until unlocked and goes back with reset', () => {
const slug = useSlugField(32)
expect(slug.mode.value).toBe('empty')
slug.setSource('Camille Roche')
expect(slug.mode.value).toBe('auto')
expect(slug.value.value).toBe('camille-roche')
slug.unlock()
slug.setSource('Someone Else')
expect(slug.mode.value).toBe('manual')
expect(slug.value.value).toBe('camille-roche')
slug.reset()
expect(slug.value.value).toBe('someone-else')
expect(slug.mode.value).toBe('auto')
})
it('leaves the saved mode as soon as the value is edited and reset follows the name', () => {
const slug = useSlugField(100, 'ines-duarte')
slug.setSource('Inès Duarte')
expect(slug.mode.value).toBe('saved')
slug.setValue('ines-d')
expect(slug.mode.value).toBe('manual')
slug.setValue('ines-duarte')
expect(slug.mode.value).toBe('saved')
slug.setValue('other')
slug.reset()
expect(slug.value.value).toBe('ines-duarte')
})
})
describe('useDirectoryView', () => {
beforeEach(() => setActivePinia(createPinia()))
it('opens a deep link once and ignores a lookup that finishes after unmount', async () => {
await router.push('/authors?edit=zoe')
const hold = deferred<string | null>()
const view = inComponent(() => useDirectoryView())
const open = vi.fn()
const missing = vi.fn()
const following = view.followEditLink(() => hold.promise, open, missing)
await view.followEditLink(() => hold.promise, open, missing)
wrapper!.unmount()
wrapper = null
hold.resolve('found')
await following
expect(open).not.toHaveBeenCalled()
expect(missing).not.toHaveBeenCalled()
await router.push('/login')
})
it('reports an unknown value once and drops it from the URL', async () => {
await router.push('/authors?edit=nobody&q=x')
const view = inComponent(() => useDirectoryView())
const missing = vi.fn()
await view.followEditLink(() => null, vi.fn(), missing)
await flushPromises()
expect(missing).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.query).toEqual({ q: 'x' })
await router.push('/login')
})
})
+10 -1
View File
@@ -23,6 +23,12 @@ export class NetworkError extends Error {
export type UserRole = 'owner' | 'admin'
export interface MeAuthor {
id: number
name: string
slug: string
}
export interface MeResult {
id?: number
verified: boolean
@@ -30,6 +36,7 @@ export interface MeResult {
email?: string
displayName: string
role?: UserRole
author?: MeAuthor
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -129,7 +136,7 @@ export function useApi() {
if (response.status === 403) {
redirectUnverified(onForbidden)
throw new ApiError(403)
throw new ApiError(403, undefined, await readErrorBody(response))
}
if (response.status === 429) {
@@ -165,6 +172,7 @@ export function useApi() {
email?: string
display_name?: string | null
role?: string
author?: MeAuthor | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
id: body.id,
@@ -173,6 +181,7 @@ export function useApi() {
email: body.email,
displayName: body.display_name ?? '',
role: body.role as UserRole | undefined,
...(body.author ? { author: body.author } : {}),
}
}
+114
View File
@@ -0,0 +1,114 @@
import { useApi } from '@/composables/useApi'
import { useUsers } from '@/composables/useUsers'
import { reassignQuery } from '@/lib/directory'
import { jsonInit } from '@/lib/directoryRequest'
import type { LinkableUser } from '@/lib/directoryTypes'
import type { SocialType } from '@/lib/socialLinks'
export interface SocialLink {
type: SocialType
url: string
}
export interface Author {
id: number
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: SocialLink[]
user_id: number | null
is_linked: boolean
article_count: number
user_email?: string
}
export interface AuthorPage {
items: Author[]
total: number
page: number
size: number
}
export interface AuthorListQuery {
q: string
page: number
size: number
}
export interface NewAuthor {
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: SocialLink[]
user_id: number | null
}
export type AuthorPatch = Partial<NewAuthor>
export type ReassignTarget = number | 'none' | undefined
const OPTION_PAGE_SIZE = 100
export function authorListPath(query: AuthorListQuery) {
const parts = [
...(query.q ? [`q=${encodeURIComponent(query.q)}`] : []),
`page=${query.page}`,
`size=${query.size}`,
]
return `/v1/author/?${parts.join('&')}`
}
export function useAuthors() {
const { request } = useApi()
const { listLinkableUsers: listUsersWithoutAuthor } = useUsers()
function listAuthors(query: AuthorListQuery): Promise<AuthorPage> {
return request<AuthorPage>(authorListPath(query))
}
async function listAllAuthors(): Promise<Author[]> {
const all: Author[] = []
let page = 1
let total = 0
do {
const response = await listAuthors({ q: '', page, size: OPTION_PAGE_SIZE })
all.push(...response.items)
total = response.total
page += 1
if (response.items.length === 0) {
break
}
} while (all.length < total)
return all
}
async function listLinkableUsers(): Promise<LinkableUser[]> {
const authors = await listAllAuthors()
return listUsersWithoutAuthor(new Set(authors.map((author) => author.user_id)))
}
function createAuthor(author: NewAuthor): Promise<Author> {
return request<Author>('/v1/author/', jsonInit('POST', author))
}
function updateAuthor(id: number, patch: AuthorPatch): Promise<Author> {
return request<Author>(`/v1/author/${id}`, jsonInit('PATCH', patch))
}
function deleteAuthor(id: number, reassignTo: ReassignTarget): Promise<void> {
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
}
return {
listAuthors,
listAllAuthors,
listLinkableUsers,
createAuthor,
updateAuthor,
deleteAuthor,
}
}
+27
View File
@@ -0,0 +1,27 @@
import { onBeforeUnmount, ref } from 'vue'
export const SKELETON_DELAY_MS = 150
export function useDelayedSkeleton(delay = SKELETON_DELAY_MS) {
const visible = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function stop() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
visible.value = false
}
function start() {
stop()
timer = setTimeout(() => {
visible.value = true
}, delay)
}
onBeforeUnmount(stop)
return { visible, start, stop }
}
@@ -0,0 +1,58 @@
import { onBeforeUnmount, ref, type Ref } from 'vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { isSessionError, statusOf } from '@/lib/directoryErrors'
export type CollectionState = 'loading' | 'ready' | 'error' | 'forbidden'
export interface LoadOptions<T> {
accept?: (result: T) => boolean
}
export function useDirectoryCollection<T, A extends unknown[]>(
fetcher: (...args: A) => Promise<T>,
) {
const state = ref<CollectionState>('loading')
const data = ref<T | null>(null) as Ref<T | null>
const skeleton = useDelayedSkeleton()
let ticket = 0
let alive = true
onBeforeUnmount(() => {
alive = false
})
async function load(args: A, options: LoadOptions<T> = {}) {
ticket += 1
const current = ticket
const first = data.value === null
if (first) {
state.value = 'loading'
skeleton.start()
}
try {
const result = await fetcher(...args)
if (!alive || current !== ticket) {
return 'stale' as const
}
if (options.accept && !options.accept(result)) {
return 'rejected' as const
}
data.value = result
state.value = 'ready'
return 'ready' as const
} catch (error) {
if (!alive || current !== ticket || isSessionError(error)) {
return 'stale' as const
}
data.value = null
state.value = statusOf(error) === 403 ? 'forbidden' : 'error'
return 'failed' as const
} finally {
if (alive && current === ticket) {
skeleton.stop()
}
}
}
return { state, data, showSkeleton: skeleton.visible, load }
}
+39
View File
@@ -0,0 +1,39 @@
import { ref, type Ref } from 'vue'
import type { DirectoryNotice } from '@/lib/directoryTypes'
export function useDirectoryDelete<T>() {
const target = ref<T | null>(null) as Ref<T | null>
const pending = ref(false)
const notice = ref<DirectoryNotice | null>(null)
function open(item: T) {
notice.value = null
target.value = item
}
function close() {
target.value = null
notice.value = null
}
async function confirm(
run: (item: T) => Promise<void>,
onFailure: (error: unknown, item: T) => Promise<void> | void,
) {
const item = target.value
if (!item || pending.value) {
return
}
pending.value = true
notice.value = null
try {
await run(item)
} catch (error) {
await onFailure(error, item)
} finally {
pending.value = false
}
}
return { target, pending, notice, open, close, confirm }
}
+147
View File
@@ -0,0 +1,147 @@
import { t } from '@/i18n'
import { copy } from '@/lib/directoryCopy'
import type { FailureContext, FailureKind } from '@/lib/directoryErrors'
import { useLocalizedToast } from '@/i18n/presentation'
type ToastColor = 'success' | 'info' | 'error'
const ICONS: Record<ToastColor, string> = {
success: 'i-lucide-circle-check',
info: 'i-lucide-info',
error: 'i-lucide-circle-x',
}
export function useDirectoryToasts() {
const toast = useLocalizedToast()
function show(id: string, color: ToastColor, title: () => string, description?: () => string) {
toast.add(() => ({
id,
color,
icon: ICONS[color],
get title() {
return title()
},
get description() {
return description?.()
},
}))
}
function siteGone() {
show(
'site-gone',
'info',
() => t('That site no longer exists'),
() => t('It was deleted somewhere else, so we removed it from the list.'),
)
}
function authorGone() {
show(
'author-gone',
'info',
() => t('That author no longer exists'),
() => t('It was deleted somewhere else, so we removed it from the list.'),
)
}
function ownerOnly() {
show(
'site-owner-only',
'error',
() => t('Only the owner can manage sites'),
() => t('Ask the owner of this site.'),
)
}
function defaultSiteBlocked() {
show('site-default-blocked', 'error', () => copy('defaultSiteBlocked'))
}
function authorRights() {
show(
'author-rights',
'error',
() => t('Only an owner or admin can change authors'),
() => t('You can still edit your own author profile.'),
)
}
function siteDefaultChanged(name: string) {
show(
'site-default-changed',
'success',
() => t('Default site changed'),
() => t('"{0}" is now the default site.', [name]),
)
}
function siteDeleted(count: number, destination: string) {
show(
'site-deleted',
'success',
() => t('Site deleted'),
count > 0
? () =>
count === 1
? t('1 article moved to {0}.', [destination])
: t('{0} articles moved to {1}.', [count, destination])
: undefined,
)
}
function authorDeleted(count: number, destination: string | null) {
show(
'author-deleted',
'success',
() => t('Author deleted'),
count > 0
? () => {
if (destination === null) {
return count === 1
? t('1 article no longer has a byline.')
: t('{0} articles no longer have a byline.', [count])
}
return count === 1
? t('1 article now credits {0}.', [destination])
: t('{0} articles now credit {1}.', [count, destination])
}
: undefined,
)
}
function userUnlinked(name: string) {
show(
'author-unlinked',
'success',
() => t('User unlinked'),
() => t('{0} is now a guest author.', [name]),
)
}
function report(kind: FailureKind, context: FailureContext) {
const handlers: Partial<Record<FailureKind, () => void>> = {
forbidden: ownerOnly,
defaultSite: defaultSiteBlocked,
rights: authorRights,
gone: context === 'site' ? siteGone : authorGone,
}
const handler = handlers[kind]
handler?.()
return handler !== undefined
}
return {
report,
siteGone,
authorGone,
ownerOnly,
defaultSiteBlocked,
authorRights,
siteDefaultChanged,
siteDeleted,
authorDeleted,
userUnlinked,
}
}
+76
View File
@@ -0,0 +1,76 @@
import { nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useDirectoryView() {
const route = useRoute()
const router = useRouter()
let opener: HTMLElement | null = null
let followed = ''
let ticket = 0
let alive = true
onBeforeUnmount(() => {
alive = false
})
function rememberOpener(element?: HTMLElement | null) {
opener =
element ??
(document.activeElement instanceof HTMLElement ? document.activeElement : null)
}
async function restoreFocus() {
await nextTick()
if (opener?.isConnected) {
opener.focus()
}
opener = null
}
function editParam() {
const value = route.query.edit
return typeof value === 'string' ? value : Array.isArray(value) ? (value[0] ?? '') : ''
}
function dropEditParam() {
followed = ''
if (route.query.edit !== undefined) {
router.replace({
query: Object.fromEntries(
Object.entries(route.query).filter(([key]) => key !== 'edit'),
),
})
}
}
function leavePanel() {
dropEditParam()
return restoreFocus()
}
async function followEditLink<T>(
lookup: (value: string) => T | null | Promise<T | null>,
open: (item: T) => void,
missing: () => void,
) {
const value = editParam()
if (!value || followed === value) {
return
}
followed = value
ticket += 1
const current = ticket
const item = await lookup(value)
if (!alive || current !== ticket) {
return
}
if (item === null) {
missing()
dropEditParam()
return
}
open(item)
}
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
}
+61
View File
@@ -0,0 +1,61 @@
import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest'
import { reassignQuery } from '@/lib/directory'
export type SiteRobots = 'index' | 'noindex'
export interface Site {
id: number
name: string
key: string
base_url: string
default_social_image_id: number | null
default_robots: SiteRobots
locale: string | null
is_default: boolean
article_count: number
}
export interface NewSite {
name: string
key: string
base_url: string
default_robots: SiteRobots
locale?: string
default_social_image_id?: number
is_default: boolean
}
export interface SitePatch {
name?: string
base_url?: string
default_robots?: SiteRobots
locale?: string | null
default_social_image_id?: number | null
}
export function useSites() {
const { request } = useApi()
function listSites(): Promise<Site[]> {
return request<Site[]>('/v1/site/')
}
function createSite(site: NewSite): Promise<Site> {
return request<Site>('/v1/site/', jsonInit('POST', site))
}
function updateSite(id: number, patch: SitePatch): Promise<Site> {
return request<Site>(`/v1/site/${id}`, jsonInit('PATCH', patch))
}
function setDefaultSite(id: number): Promise<Site> {
return request<Site>(`/v1/site/${id}/default`, { method: 'PATCH' })
}
function deleteSite(id: number, reassignTo?: number): Promise<void> {
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
}
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
}
+36
View File
@@ -0,0 +1,36 @@
import { computed, ref } from 'vue'
import { slugify } from '@/lib/directory'
export type SlugMode = 'empty' | 'auto' | 'manual' | 'saved'
export function useSlugField(max: number, saved?: string) {
const source = ref('')
const override = ref<string | null>(saved ?? null)
const isSaved = saved !== undefined
const value = computed(() => override.value ?? slugify(source.value, max))
const mode = computed<SlugMode>(() => {
if (override.value !== null) {
return isSaved && override.value === saved ? 'saved' : 'manual'
}
return source.value.trim() ? 'auto' : 'empty'
})
function setSource(text: string) {
source.value = text
}
function unlock() {
override.value = value.value
}
function setValue(next: string) {
override.value = next
}
function reset() {
override.value = isSaved ? slugify(source.value, max) : null
}
return { source, value, mode, setSource, unlock, setValue, reset }
}
+13 -1
View File
@@ -1,4 +1,5 @@
import { useApi } from '@/composables/useApi'
import type { LinkableUser } from '@/lib/directoryTypes'
export type UserListItem =
| {
@@ -50,6 +51,17 @@ export function useUsers() {
return request<UserListItem[]>('/v1/user/')
}
async function listLinkableUsers(
takenUserIds: ReadonlySet<number | null>,
): Promise<LinkableUser[]> {
const users = await listUsers()
return users.flatMap((user) =>
user.kind === 'user' && !takenUserIds.has(user.id)
? [{ id: user.id, label: user.display_name || user.username, email: user.email }]
: [],
)
}
function inviteUser(email: string): Promise<CreatedInvite> {
return request<CreatedInvite>('/v1/user/invite', {
method: 'POST',
@@ -66,5 +78,5 @@ export function useUsers() {
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, { method: 'POST' })
}
return { listUsers, inviteUser, cancelInvite, renewInvite }
return { listUsers, listLinkableUsers, inviteUser, cancelInvite, renewInvite }
}
+87
View File
@@ -0,0 +1,87 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { COPY } from '@/lib/directoryCopy'
import { messages } from './messages'
const SOURCES = [
'components/AuthorAvatar.vue',
'components/AuthorFormPanel.vue',
'components/AuthorViewPanel.vue',
'components/DashboardShell.vue',
'components/DirectoryBadge.vue',
'components/DirectoryDialog.vue',
'components/DirectoryField.vue',
'components/DirectoryList.vue',
'components/DirectoryPanel.vue',
'components/DirectoryRowMenu.vue',
'components/LinkUserDialog.vue',
'components/MediaField.vue',
'components/PublishingLinksCard.vue',
'components/ReassignDialog.vue',
'components/SiteFormPanel.vue',
'components/SitesForbidden.vue',
'components/SlugField.vue',
'components/SocialLinksRepeater.vue',
'components/UserSelect.vue',
'composables/useDirectoryToasts.ts',
'composables/useDirectoryView.ts',
'lib/directory.ts',
'lib/directoryCopy.ts',
'lib/directoryRules.ts',
'views/AuthorsView.vue',
'views/SitesView.vue',
]
const SAME_IN_FRENCH = new Set([
'Articles',
'Auto',
'Cache',
'Avatar',
'Bio',
'Noindex',
'Site',
'Sites',
'Guest',
'Linked',
])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Sites and authors messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of both screens', () => {
expect(keys.length).toBeGreaterThan(100)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has its own French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(key as string)) {
expect(french).not.toBe(key)
}
},
)
it.each(Object.values(COPY))('shared copy "%s" has its own French entry', (key) => {
const french = (messages as Record<string, string>)[key]
expect(french).toBeTruthy()
expect(french).not.toBe(key)
})
})
+222
View File
@@ -1685,6 +1685,228 @@ export const messages = {
'Get settings.': 'Récupère les paramètres.',
'Made for the browser. A key is ignored here, with no 403.':
'Prévue pour le navigateur. Une clé est ignorée ici, sans 403.',
'1 article moved to {0}.': '1 article déplacé vers {0}.',
'1 article no longer has a byline.': '1 article n’a plus de signature.',
'1 article now credits {0}.': '1 article est maintenant attribué à {0}.',
'1 article will move to {0}. Their canonical host changes to {1}.':
'1 article sera déplacé vers {0}. Son hôte canonique devient {1}.',
'A language tag. Leave empty if the site has no single language.':
'Une balise de langue. Laissez vide si le site n’a pas de langue unique.',
'A sentence or two about the author.': 'Une phrase ou deux sur l’auteur.',
'A site with the key "{0}" already exists. Choose another key.':
'Un site avec la clé « {0} » existe déjà. Choisissez une autre clé.',
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.':
'Ajoutez un autre site si votre contenu est affiché sur plusieurs frontends. Chaque article appartient à un seul site.',
'Add author': 'Ajouter un auteur',
'Add link': 'Ajouter un lien',
'Add site': 'Ajouter un site',
'Ask the owner of this site.': 'Demandez au propriétaire de ce site.',
"Asks search engines not to list this site's pages.":
'Demande aux moteurs de recherche de ne pas référencer les pages de ce site.',
Author: 'Auteur',
'Author deleted': 'Auteur supprimé',
'Author name': 'Nom de l’auteur',
Authors: 'Auteurs',
"Authors are the names readers see on articles. They don't need an account.":
'Les auteurs sont les noms que les lecteurs voient sur les articles. Ils n’ont pas besoin de compte.',
Auto: 'Auto',
Avatar: 'Avatar',
'Back to Settings': 'Retour aux paramètres',
'Base URLs, default image and robots policy':
'URL de base, image par défaut et politique des robots',
Bio: 'Bio',
'Byline preview': 'Aperçu de la signature',
"Can't delete this author": 'Impossible de supprimer cet auteur',
'Canonical URL preview': 'Aperçu de l’URL canonique',
'Change image': 'Changer d’image',
'Check the spelling, or search for a shorter part of the name.':
'Vérifiez l’orthographe, ou cherchez une partie plus courte du nom.',
'Choose a user': 'Choisir un utilisateur',
'Choose an image': 'Choisir une image',
'Choose image': 'Choisir une image',
'Choose where the articles go. This site has {0} articles.':
'Choisissez où vont les articles. Ce site compte {0} articles.',
"Couldn't delete the author": 'Impossible de supprimer l’auteur',
"Couldn't delete the site": 'Impossible de supprimer le site',
"Couldn't load authors": 'Impossible de charger les auteurs',
"Couldn't load sites": 'Impossible de charger les sites',
"Couldn't save the author": 'Impossible d’enregistrer l’auteur',
"Couldn't save the author. Your changes are still here. Try again in a moment.":
'Impossible d’enregistrer l’auteur. Vos modifications sont toujours là. Réessayez dans un instant.',
"Couldn't save the site. Your changes are still here. Try again in a moment.":
'Impossible d’enregistrer le site. Vos modifications sont toujours là. Réessayez dans un instant.',
'Credit these articles to': 'Attribuer ces articles à',
Default: 'Par défaut',
'Default robots policy': 'Politique des robots par défaut',
'Default site': 'Site par défaut',
'Default site changed': 'Site par défaut modifié',
'Default social image': 'Image sociale par défaut',
'Delete and reassign 1 article': 'Supprimer et réattribuer 1 article',
'Delete and reassign {0} articles': 'Supprimer et réattribuer {0} articles',
'Delete author': 'Supprimer l’auteur',
'Delete site': 'Supprimer le site',
'Delete this author?': 'Supprimer cet auteur ?',
'Delete this site?': 'Supprimer ce site ?',
'Edit key': 'Modifier la clé',
'Edit slug': 'Modifier l’identifiant',
'Edit {0}': 'Modifier {0}',
'Enter a full URL starting with https://.':
'Saisissez une URL complète commençant par https://.',
'Enter a full URL, like https://blog.example.com.':
'Saisissez une URL complète, comme https://blog.example.com.',
'Every user already has an author.': 'Chaque utilisateur a déjà un auteur.',
'Generated from the name': 'Généré à partir du nom',
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.':
'Généré à partir du nom. Utilisé dans les URL publiques des auteurs et les chemins d’API. Unique parmi tous les auteurs.',
Guest: 'Invité',
'Image #{0}': 'Image nº {0}',
Index: 'Indexer',
'Link URL {0}': 'URL du lien {0}',
'Link a CMS user if this author has an account. Guest writers need no link.':
'Liez un utilisateur du CMS si cet auteur a un compte. Les auteurs invités n’ont pas besoin de lien.',
'Link type {0}': 'Type du lien {0}',
'Link user': 'Lier un utilisateur',
'Link {0} to a user?': 'Lier {0} à un utilisateur ?',
Linked: 'Lié',
"Linked to {0}. The user account isn't affected.":
'Lié à {0}. Le compte utilisateur n’est pas modifié.',
'Linked user': 'Utilisateur lié',
Locale: 'Langue',
'Lowercase letters, numbers and hyphens. Unique across all authors.':
'Lettres minuscules, chiffres et traits d’union. Unique parmi tous les auteurs.',
"Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.":
'Lettres minuscules, chiffres et traits d’union. Utilisée dans les chemins d’API. Elle ne peut plus être modifiée après l’ajout du site.',
'Make default': 'Définir par défaut',
'Make this the default site': 'Faire de ce site le site par défaut',
'Make {0} the default site?': 'Faire de {0} le site par défaut ?',
'Manage sites': 'Gérer les sites',
'Move 1 article and delete': 'Déplacer 1 article et supprimer',
'Move articles to': 'Déplacer les articles vers',
'Move {0} articles and delete': 'Déplacer {0} articles et supprimer',
'Moving articles…': 'Déplacement des articles…',
'New articles by this user start with this author.':
'Les nouveaux articles de cet utilisateur commencent avec cet auteur.',
'New articles start here. The current default is {0}.':
'Les nouveaux articles commencent ici. Le site par défaut actuel est {0}.',
'New articles start on the default site. To change it, make another site the default.':
'Les nouveaux articles commencent sur le site par défaut. Pour le changer, définissez un autre site par défaut.',
'New articles start on this site. Articles that are already on other sites stay where they are.':
'Les nouveaux articles commencent sur ce site. Les articles déjà présents sur d’autres sites y restent.',
'No articles': 'Aucun article',
'No authors match "{0}"': 'Aucun auteur ne correspond à « {0} »',
'No authors yet': 'Aucun auteur pour l’instant',
'No linked user': 'Aucun utilisateur lié',
Nobody: 'Personne',
Noindex: 'Noindex',
'Nothing was changed. Try again in a moment.':
'Rien n’a été modifié. Réessayez dans un instant.',
'Only an owner or admin can change authors':
'Seul un propriétaire ou un administrateur peut modifier les auteurs',
'Only an owner or admin can change this author.':
'Seul un propriétaire ou un administrateur peut modifier cet auteur.',
'Only the owner can manage sites': 'Seul le propriétaire peut gérer les sites',
'Only the owner can manage sites. Ask the owner of this site if you need a change.':
'Seul le propriétaire peut gérer les sites. Demandez au propriétaire de ce site si vous avez besoin d’un changement.',
'Only users without an author are listed.': 'Seuls les utilisateurs sans auteur sont listés.',
Publishing: 'Publication',
'Reassign articles': 'Réattribuer les articles',
'Reassigning…': 'Réattribution…',
'Remove link {0}': 'Supprimer le lien {0}',
'Remove the byline': 'Supprimer la signature',
'Remove the path. Use only the host, like https://blog.example.com.':
'Retirez le chemin. Utilisez seulement l’hôte, comme https://blog.example.com.',
'Reset to match the name': 'Rétablir selon le nom',
'Search by name or slug': 'Rechercher par nom ou identifiant',
"Search engines may list this site's pages.":
'Les moteurs de recherche peuvent référencer les pages de ce site.',
'Set as default': 'Définir par défaut',
'Shorten the bio to 280 characters.': 'Raccourcissez la bio à 280 caractères.',
'Shown as a square. Choose an image with the face or logo near the middle.':
'Affichée en carré. Choisissez une image dont le visage ou le logo est près du centre.',
'Shown when the base URL is valid.': 'Affiché lorsque l’URL de base est valide.',
Site: 'Site',
'Site deleted': 'Site supprimé',
Sites: 'Sites',
Slug: 'Identifiant',
'Social links': 'Liens sociaux',
'That author no longer exists': 'Cet auteur n’existe plus',
'That site no longer exists': 'Ce site n’existe plus',
'That user already has an author. Choose another user.':
'Cet utilisateur a déjà un auteur. Choisissez un autre utilisateur.',
'The articles keep any other authors. Articles with only this author will have no byline.':
'Les articles conservent leurs autres auteurs. Les articles n’ayant que cet auteur n’auront plus de signature.',
'The avatar for {0}.': 'L’avatar de {0}.',
'The bio appears here.': 'La bio apparaît ici.',
"The default site can't be deleted. Make another site the default first.":
'Le site par défaut ne peut pas être supprimé. Définissez d’abord un autre site par défaut.',
'The default social image for {0}.': 'L’image sociale par défaut de {0}.',
'The frontends that display your content.': 'Les frontends qui affichent votre contenu.',
"The names readers see on your articles. An author doesn't need an account.":
'Les noms que les lecteurs voient sur vos articles. Un auteur n’a pas besoin de compte.',
'The names readers see on your articles. Only an owner or admin can change authors.':
'Les noms que les lecteurs voient sur vos articles. Seul un propriétaire ou un administrateur peut modifier les auteurs.',
'The public address of the frontend. Use https and no path.':
'L’adresse publique du frontend. Utilisez https et aucun chemin.',
'The slug "{0}" is already used by another author.':
'L’identifiant « {0} » est déjà utilisé par un autre auteur.',
'This is your author profile. Only an owner or admin can change the link.':
'Ceci est votre profil d’auteur. Seul un propriétaire ou un administrateur peut modifier le lien.',
Unlink: 'Dissocier',
'Unlink user': 'Dissocier l’utilisateur',
'Unlink {0}?': 'Dissocier {0} ?',
'Unlinking…': 'Dissociation…',
'Use a language tag like en or fr-CH.': 'Utilisez une balise de langue comme en ou fr-CH.',
'Use https. For example https://blog.example.com.':
'Utilisez https. Par exemple https://blog.example.com.',
'Use image': 'Utiliser l’image',
'Use lowercase letters, numbers and hyphens, 2 to 32 characters.':
'Utilisez des lettres minuscules, des chiffres et des traits d’union, de 2 à 32 caractères.',
'Use {0}': 'Utiliser {0}',
"Used in API paths, so it can't change.":
'Utilisée dans les chemins d’API, elle ne peut donc pas changer.',
'Used when an article has no image of its own.':
'Utilisée lorsqu’un article n’a pas d’image propre.',
'User unlinked': 'Utilisateur dissocié',
'Users without an author': 'Utilisateurs sans auteur',
'View {0}': 'Voir {0}',
Website: 'Site web',
'Where an article has several authors, {0} is removed and the others stay.':
'Lorsqu’un article a plusieurs auteurs, {0} est retiré et les autres restent.',
'Where your content is shown.': 'Où votre contenu est affiché.',
'You can add up to {0} links.': 'Vous pouvez ajouter jusqu’à {0} liens.',
'You can still edit your own author profile.':
'Vous pouvez toujours modifier votre propre profil d’auteur.',
"You don't have access to Sites": 'Vous n’avez pas accès aux sites',
'"{0}" is credited on 1 article. Choose another author to credit instead, or remove {0} from those articles first.':
'« {0} » est attribué à 1 article. Choisissez un autre auteur à lui attribuer, ou retirez {0} de cet article d’abord.',
'"{0}" is credited on {1} articles. Choose another author to credit instead, or remove {0} from those articles first.':
'« {0} » est attribué à {1} articles. Choisissez un autre auteur à leur attribuer, ou retirez {0} de ces articles d’abord.',
'"{0}" is now the default site.': '« {0} » est maintenant le site par défaut.',
'"{0}" will be deleted and is credited on 1 article. This can\'t be undone.':
'« {0} » sera supprimé et est attribué à 1 article. Cette action est irréversible.',
'"{0}" will be deleted and is credited on {1} articles. This can\'t be undone.':
'« {0} » sera supprimé et est attribué à {1} articles. Cette action est irréversible.',
'"{0}" will be deleted. 1 article belongs to it, so choose a site to move them to. This can\'t be undone.':
'« {0} » sera supprimé. 1 article lui appartient, choisissez donc un site vers lequel le déplacer. Cette action est irréversible.',
'"{0}" will be deleted. No articles belong to it. This can\'t be undone.':
'« {0} » sera supprimé. Aucun article ne lui appartient. Cette action est irréversible.',
'"{0}" will be deleted. No articles credit this author. This can\'t be undone.':
'« {0} » sera supprimé. Aucun article n’est attribué à cet auteur. Cette action est irréversible.',
'"{0}" will be deleted. {1} articles belong to it, so choose a site to move them to. This can\'t be undone.':
'« {0} » sera supprimé. {1} articles lui appartiennent, choisissez donc un site vers lequel les déplacer. Cette action est irréversible.',
'e.g. Camille Roche': 'p. ex. Camille Roche',
'e.g. Journal': 'p. ex. Journal',
'e.g. en or fr-CH': 'p. ex. en ou fr-CH',
'this author': 'cet auteur',
'this site': 'ce site',
'{0} articles moved to {1}.': '{0} articles déplacés vers {1}.',
'{0} articles no longer have a byline.': '{0} articles n’ont plus de signature.',
'{0} articles now credit {1}.': '{0} articles sont maintenant attribués à {1}.',
'{0} articles will move to {1}. Their canonical host changes to {2}.':
'{0} articles seront déplacés vers {1}. Leur hôte canonique devient {2}.',
'{0} is now a guest author.': '{0} est maintenant un auteur invité.',
'{0} will stay on their articles. New articles by {1} will no longer start with this author.':
'{0} restera sur ses articles. Les nouveaux articles de {1} ne commenceront plus avec cet auteur.',
} as const
export type MessageKey = keyof typeof messages
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest'
import {
AUTHOR_SLUG_MAX,
SITE_KEY_MAX,
SLUG_PATTERN,
siteUrlProblem,
slugify,
suggestFreeSlug,
} from '@/lib/directory'
import {
parseDirectoryQuery,
toDirectoryRouteQuery,
type DirectoryQuery,
} from '@/lib/directoryQuery'
const ALPHABET = 'abcXYZ019 -_.,é ñ ß æ Ł 日本語 ! /'
const SAMPLES = 400
function seeded(seed: number) {
let state = seed
return () => {
state = (state * 1664525 + 1013904223) % 4294967296
return state / 4294967296
}
}
function randomText(next: () => number, length: number) {
return Array.from(
{ length },
() => [...ALPHABET][Math.floor(next() * [...ALPHABET].length)],
).join('')
}
describe('slugify', () => {
it('never ends or starts with a dash, matches the slug pattern and respects the maximum', () => {
const next = seeded(7)
for (let index = 0; index < SAMPLES; index += 1) {
const max = 1 + Math.floor(next() * 40)
const slug = slugify(randomText(next, Math.floor(next() * 80)), max)
expect(slug.length).toBeLessThanOrEqual(max)
expect(slug === '' || SLUG_PATTERN.test(slug)).toBe(true)
}
})
it('trims the dash left by cutting at the maximum', () => {
expect(slugify('Journal Renovation', 8)).toBe('journal')
})
it('is idempotent', () => {
const next = seeded(11)
for (let index = 0; index < SAMPLES; index += 1) {
const once = slugify(randomText(next, 30), AUTHOR_SLUG_MAX)
expect(slugify(once, AUTHOR_SLUG_MAX)).toBe(once)
}
})
it('folds diacritics and ligatures like the server', () => {
expect(slugify('Inès Duarte', 100)).toBe('ines-duarte')
expect(slugify('Straße Œuvre', 100)).toBe('strasse-oeuvre')
})
})
describe('suggestFreeSlug', () => {
it('returns a free, valid candidate within the maximum for any taken set', () => {
const next = seeded(3)
for (let index = 0; index < SAMPLES; index += 1) {
const max = 6 + Math.floor(next() * 30)
const slug = slugify(randomText(next, 20), max) || 'a'
const taken = new Set<string>()
const rounds = Math.floor(next() * 6)
for (let round = 0; round < rounds; round += 1) {
taken.add(suggestFreeSlug(slug, (candidate) => taken.has(candidate), max))
}
const candidate = suggestFreeSlug(slug, (value) => taken.has(value), max)
expect(taken.has(candidate)).toBe(false)
expect(candidate.length).toBeLessThanOrEqual(max)
expect(SLUG_PATTERN.test(candidate)).toBe(true)
}
})
it('keeps a full-length site key within 32 characters', () => {
const key = 'a'.repeat(SITE_KEY_MAX)
expect(suggestFreeSlug(key, () => false, SITE_KEY_MAX)).toBe(`${'a'.repeat(30)}-2`)
})
it('replaces an existing numeric suffix', () => {
expect(suggestFreeSlug('journal-2', (value) => value === 'journal-2', 32)).toBe('journal-3')
})
})
describe('siteUrlProblem', () => {
it.each([
'https://blog.example.com',
'https://blog.example.com/',
'http://localhost:5173',
'http://127.0.0.1:8081',
'http://[::1]:5173',
'https://localhost',
])('accepts %s', (value) => {
expect(siteUrlProblem(value)).toBeNull()
})
it.each([
'https://user@blog.example.com',
'https://user:secret@blog.example.com',
'https://:secret@blog.example.com',
'http://user@localhost:5173',
])('rejects credentials in %s', (value) => {
expect(siteUrlProblem(value)).toBe('invalid')
})
it('rejects credentials hidden among random paths and hosts', () => {
const next = seeded(5)
for (let index = 0; index < SAMPLES; index += 1) {
const user = randomText(next, 1 + Math.floor(next() * 5)).replace(/[^a-z0-9]/gi, 'u')
const problem = siteUrlProblem(`https://${user || 'u'}@host.example`)
expect(problem).not.toBeNull()
}
})
it.each([
['http://blog.example.com', 'scheme'],
['https://blog.example.com/en', 'path'],
['https://blog.example.com?x=1', 'path'],
['blog', 'invalid'],
['ftp://blog.example.com', 'invalid'],
['', 'empty'],
])('classifies %s as %s', (value, problem) => {
expect(siteUrlProblem(value)).toBe(problem)
})
})
describe('parseDirectoryQuery', () => {
it('survives a round trip through the route query', () => {
const next = seeded(13)
const sizes = [5, 25, 50, 100, 30, 0, -1]
for (let index = 0; index < SAMPLES; index += 1) {
const raw: Record<string, string> = {}
if (next() > 0.3) {
raw.q = randomText(next, Math.floor(next() * 12))
}
if (next() > 0.3) {
raw.page = String(Math.floor(next() * 12) - 2)
}
if (next() > 0.3) {
raw.size = String(sizes[Math.floor(next() * sizes.length)])
}
const parsed = parseDirectoryQuery(raw)
const again = parseDirectoryQuery(toDirectoryRouteQuery(parsed))
expect(again).toEqual(parsed)
expect(parsed.page).toBeGreaterThanOrEqual(1)
expect([5, 25, 50, 100]).toContain(parsed.size)
}
})
it('keeps other parameters such as edit and drops defaults', () => {
const parsed: DirectoryQuery = { q: '', page: 1, size: 25 }
expect(toDirectoryRouteQuery(parsed, { edit: 'camille', page: '9' })).toEqual({
edit: 'camille',
})
})
})
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { ApiError } from '@/composables/useApi'
import { classifyFailure, type FailureContext, type FailureKind } from '@/lib/directoryErrors'
const CASES: [FailureContext, number, string | undefined, FailureKind][] = [
['site', 401, undefined, 'session'],
['site', 403, 'default_site', 'defaultSite'],
['site', 403, 'Only the owner can do this.', 'forbidden'],
['site', 404, 'site_not_found', 'gone'],
['site', 409, 'site_key_taken', 'keyTaken'],
['site', 409, 'site_has_articles', 'hasArticles'],
['site', 400, 'invalid_site_url', 'invalidSiteUrl'],
['site', 400, 'invalid_locale', 'invalidLocale'],
['site', 500, undefined, 'failed'],
['author', 403, 'author_rights', 'rights'],
['author', 404, 'author_not_found', 'gone'],
['author', 409, 'author_slug_taken', 'slugTaken'],
['author', 409, 'user_already_linked', 'userLinked'],
['author', 409, 'author_has_articles', 'hasArticles'],
['author', 422, 'bio_too_long', 'bioTooLong'],
['author', 422, 'invalid_url', 'invalidUrl'],
['author', 500, undefined, 'failed'],
['author', 409, 'something_else', 'failed'],
]
describe('classifyFailure', () => {
it.each(CASES)('%s %s %s is %s', (context, status, code, kind) => {
expect(classifyFailure(new ApiError(status, undefined, code), context)).toBe(kind)
})
it('treats a network error as a generic failure', () => {
expect(classifyFailure(new Error('offline'), 'site')).toBe('failed')
})
it('does not let a site code leak into the author context', () => {
expect(classifyFailure(new ApiError(409, undefined, 'site_key_taken'), 'author')).toBe(
'failed',
)
})
})
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
BIO_MAX,
authorSlugProblem,
baseUrlProblem,
bioProblem,
nameProblem,
optionalSecureUrlProblem,
secureUrlProblem,
siteKeyProblem,
} from '@/lib/directoryRules'
describe('directory validation rules', () => {
it('asks for a name', () => {
expect(nameProblem(' ')).toBe('Enter a name.')
expect(nameProblem('Zoe')).toBe('')
})
it('counts the bio in characters, not code units', () => {
expect(bioProblem('x'.repeat(BIO_MAX))).toBe('')
expect(bioProblem('x'.repeat(BIO_MAX + 1))).toBe('Shorten the bio to 280 characters.')
expect(bioProblem('😀'.repeat(BIO_MAX))).toBe('')
})
it('requires https for links and treats an empty website as fine', () => {
expect(secureUrlProblem('http://x')).toBe('Enter a full URL starting with https://.')
expect(secureUrlProblem('')).toBe('Enter a full URL starting with https://.')
expect(secureUrlProblem('https://x.example')).toBe('')
expect(optionalSecureUrlProblem('')).toBe('')
expect(optionalSecureUrlProblem('example.com')).not.toBe('')
})
it('validates author slugs and site keys', () => {
expect(authorSlugProblem('')).toBe('')
expect(authorSlugProblem('camille-roche')).toBe('')
expect(authorSlugProblem('Camille Roche')).not.toBe('')
expect(siteKeyProblem('journal')).toBe('')
expect(siteKeyProblem('j')).not.toBe('')
expect(siteKeyProblem('a'.repeat(33))).not.toBe('')
expect(siteKeyProblem('Journal')).not.toBe('')
})
it('maps a base URL to its message', () => {
expect(baseUrlProblem('https://blog.example.com')).toBe('')
expect(baseUrlProblem('http://blog.example.com')).toBe(
'Use https. For example https://blog.example.com.',
)
expect(baseUrlProblem('https://u:p@blog.example.com')).toBe(
'Enter a full URL, like https://blog.example.com.',
)
})
})
+134
View File
@@ -0,0 +1,134 @@
import { t } from '@/i18n'
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
export const AUTHOR_SLUG_MAX = 100
export const SITE_KEY_MAX = 32
export const SITE_KEY_MIN = 2
export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
const LIGATURES: Record<string, string> = {
æ: 'ae',
œ: 'oe',
ß: 'ss',
ø: 'o',
ł: 'l',
đ: 'd',
ı: 'i',
}
const COMBINING_MARKS = /\p{M}/gu
const NON_SLUG_RUN = /[^a-z0-9]+/g
const EDGE_DASHES = /^-+|-+$/g
const TITLE_PREFIX = /^Dr\.\s*/
const NUMBERED_SUFFIX = /-(\d+)$/
export function slugify(text: string, max: number) {
const folded = text
.toLowerCase()
.normalize('NFD')
.replace(COMBINING_MARKS, '')
.replace(/[æœßøłđı]/g, (letter) => LIGATURES[letter] ?? letter)
return folded.replace(NON_SLUG_RUN, '-').slice(0, max).replace(EDGE_DASHES, '')
}
export function suggestFreeSlug(
slug: string,
isTaken: (candidate: string) => boolean,
max: number,
) {
const base = slug.replace(NUMBERED_SUFFIX, '')
let counter = 2
for (;;) {
const suffix = `-${counter}`
const stem = base.slice(0, Math.max(1, max - suffix.length)).replace(EDGE_DASHES, '')
const candidate = `${stem}${suffix}`
if (!isTaken(candidate)) {
return candidate
}
counter += 1
}
}
export function hostOf(url: string) {
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
}
export function initialsOf(name: string) {
return name
.replace(TITLE_PREFIX, '')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => [...word][0] ?? '')
.join('')
.toUpperCase()
}
export function pluralArticles(count: number) {
if (count === 0) {
return t('No articles')
}
return t(count === 1 ? '1 article' : '{0} articles', [count])
}
export type SiteUrlProblem = 'empty' | 'scheme' | 'path' | 'invalid'
function parseUrl(value: string) {
try {
return new URL(value)
} catch {
return null
}
}
export function siteUrlProblem(value: string): SiteUrlProblem | null {
const trimmed = value.trim()
if (!trimmed) {
return 'empty'
}
const parsed = parseUrl(trimmed)
if (
!parsed ||
!/^https?:$/.test(parsed.protocol) ||
!parsed.hostname ||
parsed.username ||
parsed.password
) {
return 'invalid'
}
const loopback = LOOPBACK_HOSTS.includes(parsed.hostname)
if (parsed.protocol === 'http:' && !loopback) {
return 'scheme'
}
if (parsed.pathname.replace(/\/+$/, '') || parsed.search || parsed.hash) {
return 'path'
}
return null
}
export function siteUrlMessage(problem: SiteUrlProblem) {
if (problem === 'scheme') {
return t('Use https. For example https://blog.example.com.')
}
if (problem === 'path') {
return t('Remove the path. Use only the host, like https://blog.example.com.')
}
return t('Enter a full URL, like https://blog.example.com.')
}
export function isSecureUrl(value: string) {
const parsed = parseUrl(value.trim())
return parsed !== null && parsed.protocol === 'https:' && parsed.hostname !== ''
}
export function canonicalPreview(baseUrl: string) {
const trimmed = baseUrl.trim().replace(/\/+$/, '')
return `${trimmed}/how-we-price-a-bathroom-renovation`
}
export function characterCount(text: string) {
return [...text].length
}
export function reassignQuery(target: number | string | undefined) {
return target === undefined ? '' : `?reassign_to=${target}`
}
+21
View File
@@ -0,0 +1,21 @@
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
export const COPY = {
nothingChanged: 'Nothing was changed. Try again in a moment.',
siteSaveFailed: "Couldn't save the site. Your changes are still here. Try again in a moment.",
authorSaveFailed:
"Couldn't save the author. Your changes are still here. Try again in a moment.",
defaultSiteBlocked: "The default site can't be deleted. Make another site the default first.",
authorSaveFailedTitle: "Couldn't save the author",
enterName: 'Enter a name.',
shortenBio: 'Shorten the bio to 280 characters.',
urlHttps: 'Enter a full URL starting with https://.',
userLinked: 'That user already has an author. Choose another user.',
loadAuthorsFailed: "Couldn't load authors",
checkConnection: 'Check your connection and try again.',
} as const satisfies Record<string, MessageKey>
export function copy(key: keyof typeof COPY) {
return t(COPY[key])
}
+86
View File
@@ -0,0 +1,86 @@
import { ApiError } from '@/composables/useApi'
export const SITE_CODES = {
notFound: 'site_not_found',
keyTaken: 'site_key_taken',
hasArticles: 'site_has_articles',
defaultSite: 'default_site',
invalidUrl: 'invalid_site_url',
invalidLocale: 'invalid_locale',
} as const
export const AUTHOR_CODES = {
notFound: 'author_not_found',
slugTaken: 'author_slug_taken',
hasArticles: 'author_has_articles',
userLinked: 'user_already_linked',
rights: 'author_rights',
bioTooLong: 'bio_too_long',
invalidUrl: 'invalid_url',
userNotFound: 'user_not_found',
} as const
const SESSION_STATUSES = [401]
export function statusOf(error: unknown) {
return error instanceof ApiError ? error.status : 0
}
export function codeOf(error: unknown) {
return error instanceof ApiError ? (error.body ?? '').trim() : ''
}
export function isSessionError(error: unknown) {
return SESSION_STATUSES.includes(statusOf(error))
}
export type FailureContext = 'site' | 'author'
export type FailureKind =
| 'session'
| 'forbidden'
| 'defaultSite'
| 'rights'
| 'gone'
| 'keyTaken'
| 'invalidSiteUrl'
| 'invalidLocale'
| 'slugTaken'
| 'userLinked'
| 'bioTooLong'
| 'invalidUrl'
| 'hasArticles'
| 'failed'
export function classifyFailure(error: unknown, context: FailureContext): FailureKind {
const status = statusOf(error)
const code = codeOf(error)
if (isSessionError(error)) {
return 'session'
}
if (status === 404) {
return 'gone'
}
if (status === 403) {
if (context === 'author') {
return 'rights'
}
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'forbidden'
}
const table: Record<FailureContext, Record<string, FailureKind>> = {
site: {
[`409 ${SITE_CODES.keyTaken}`]: 'keyTaken',
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
},
author: {
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
[`409 ${AUTHOR_CODES.userLinked}`]: 'userLinked',
[`409 ${AUTHOR_CODES.hasArticles}`]: 'hasArticles',
[`422 ${AUTHOR_CODES.bioTooLong}`]: 'bioTooLong',
[`422 ${AUTHOR_CODES.invalidUrl}`]: 'invalidUrl',
},
}
return table[context][`${status} ${code}`] ?? 'failed'
}
+10
View File
@@ -0,0 +1,10 @@
export interface DirectoryMenuItem {
key: string
label: string
icon: string
destructive?: boolean
unavailable?: boolean
hint?: string
separated?: boolean
onSelect: (trigger: HTMLElement | null) => void
}
+48
View File
@@ -0,0 +1,48 @@
import type { LocationQuery } from 'vue-router'
import {
ARTICLE_PAGE_SIZES,
ARTICLE_SEARCH_MAX_LENGTH,
DEFAULT_ARTICLE_PAGE_SIZE,
} from '@/lib/articleListQuery'
export interface DirectoryQuery {
q: string
page: number
size: number
}
const POSITIVE_INTEGER = /^[1-9]\d*$/
function first(value: LocationQuery[string] | undefined) {
const single = Array.isArray(value) ? value[0] : value
return single ?? ''
}
export function parseDirectoryQuery(query: LocationQuery): DirectoryQuery {
const page = first(query.page)
const size = Number(first(query.size))
return {
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
}
}
export function toDirectoryRouteQuery(query: DirectoryQuery, rest: LocationQuery = {}) {
const route: Record<string, string> = {}
for (const [key, value] of Object.entries(rest)) {
if (key !== 'q' && key !== 'page' && key !== 'size') {
route[key] = first(value)
}
}
if (query.q) {
route.q = query.q
}
if (query.page !== 1) {
route.page = String(query.page)
}
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
route.size = String(query.size)
}
return route
}
+7
View File
@@ -0,0 +1,7 @@
export function jsonInit(method: string, body: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
}
+46
View File
@@ -0,0 +1,46 @@
import { t } from '@/i18n'
import { copy } from '@/lib/directoryCopy'
import {
SITE_KEY_MAX,
SITE_KEY_MIN,
SLUG_PATTERN,
characterCount,
isSecureUrl,
siteUrlMessage,
siteUrlProblem,
} from '@/lib/directory'
export const BIO_MAX = 280
export function nameProblem(name: string) {
return name.trim() ? '' : copy('enterName')
}
export function bioProblem(bio: string) {
return characterCount(bio) > BIO_MAX ? copy('shortenBio') : ''
}
export function secureUrlProblem(value: string) {
return isSecureUrl(value) ? '' : copy('urlHttps')
}
export function optionalSecureUrlProblem(value: string) {
return value.trim() ? secureUrlProblem(value) : ''
}
export function authorSlugProblem(slug: string) {
if (!slug || SLUG_PATTERN.test(slug)) {
return ''
}
return t('Lowercase letters, numbers and hyphens. Unique across all authors.')
}
export function siteKeyProblem(key: string) {
const valid = SLUG_PATTERN.test(key) && key.length >= SITE_KEY_MIN && key.length <= SITE_KEY_MAX
return valid ? '' : t('Use lowercase letters, numbers and hyphens, 2 to 32 characters.')
}
export function baseUrlProblem(value: string) {
const problem = siteUrlProblem(value)
return problem ? siteUrlMessage(problem) : ''
}
+10
View File
@@ -0,0 +1,10 @@
export interface LinkableUser {
id: number
label: string
email: string
}
export interface DirectoryNotice {
title?: string
description: string
}
+6
View File
@@ -0,0 +1,6 @@
export interface ImageChoice {
title: string
description: string
hint?: string
confirmLabel: string
}
+18
View File
@@ -0,0 +1,18 @@
export const SOCIAL_TYPES = ['x', 'linkedin', 'github', 'mastodon', 'custom'] as const
export type SocialType = (typeof SOCIAL_TYPES)[number]
export const MAX_SOCIAL_LINKS = 5
export interface SocialLinkRow {
key: number
type: SocialType
url: string
}
export const SOCIAL_PLACEHOLDERS: Record<SocialType, string> = {
x: 'https://x.com/username',
linkedin: 'https://www.linkedin.com/in/username',
github: 'https://github.com/username',
mastodon: 'https://mastodon.social/@username',
custom: 'https://example.com',
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import router from '@/router'
describe('Sites and authors routes', () => {
it.each([
['/settings/sites', 'Settings'],
['/authors', 'Authors'],
])('registers %s as a verified, lazy-loaded route', (path, title) => {
const route = router.getRoutes().find((candidate) => candidate.path === path)!
expect(route.meta.auth).toBe('verified')
expect(route.meta.title).toBe(title)
expect(typeof route.components?.default).toBe('function')
})
})
+10
View File
@@ -77,6 +77,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/MediaView.vue'),
meta: { auth: 'verified', title: 'Media' },
},
{
path: '/authors',
component: () => import('@/views/AuthorsView.vue'),
meta: { auth: 'verified', title: 'Authors' },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
@@ -112,6 +117,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/VariantsPage.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/sites',
component: () => import('@/views/SitesView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/media/cache',
component: () => import('@/views/CachePage.vue'),
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSessionStore } from '@/stores/session'
function sessionWith(role?: 'owner' | 'admin' | 'member') {
const store = useSessionStore()
store.session = { verified: true, displayName: '', ...(role ? { role: role as never } : {}) }
return store
}
describe('session role getters', () => {
beforeEach(() => setActivePinia(createPinia()))
it.each([
['owner', true, true],
['admin', false, true],
['member', false, false],
[undefined, false, false],
] as const)('%s: isOwner %s, canManageContent %s', (role, owner, manage) => {
const store = sessionWith(role)
expect(store.isOwner).toBe(owner)
expect(store.canManageContent).toBe(manage)
})
it('is false without a session', () => {
const store = useSessionStore()
expect(store.isOwner).toBe(false)
expect(store.canManageContent).toBe(false)
})
})
+19 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ApiError, useApi, type UserRole } from '@/composables/useApi'
import { computed, ref } from 'vue'
import { ApiError, useApi, type MeAuthor, type UserRole } from '@/composables/useApi'
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
@@ -11,6 +11,7 @@ export interface Session {
email?: string
displayName: string
role?: UserRole
author?: MeAuthor
}
export const useSessionStore = defineStore('session', () => {
@@ -18,6 +19,11 @@ export const useSessionStore = defineStore('session', () => {
const session = ref<Session | null>(null)
let pending: Promise<void> | null = null
const isOwner = computed(() => session.value?.role === 'owner')
const canManageContent = computed(
() => session.value?.role === 'owner' || session.value?.role === 'admin',
)
function check() {
if (pending) {
return pending
@@ -71,5 +77,15 @@ export const useSessionStore = defineStore('session', () => {
}
}
return { status, session, check, retry, refresh, clear, setDisplayName }
return {
status,
session,
isOwner,
canManageContent,
check,
retry,
refresh,
clear,
setDisplayName,
}
})
+1 -1
View File
@@ -67,7 +67,7 @@ export const useUpdateStore = defineStore('update', () => {
const dismissedVersion = ref<string | null>(readDismissedVersion())
let pendingLoad: Promise<void> | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const isOwner = computed(() => session.isOwner)
const available = computed(() => status.value?.available === true)
const showCue = computed(() => isOwner.value && available.value)
const restarting = computed(() => status.value?.state === 'restarting')
+752
View File
@@ -0,0 +1,752 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
import AuthorViewPanel from '@/components/AuthorViewPanel.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryList from '@/components/DirectoryList.vue'
import LinkUserDialog from '@/components/LinkUserDialog.vue'
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { sameRouteQuery } from '@/lib/articleListQuery'
import { pluralArticles } from '@/lib/directory'
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import {
parseDirectoryQuery,
toDirectoryRouteQuery,
type DirectoryQuery,
} from '@/lib/directoryQuery'
import { pageCountFor } from '@/lib/pagination'
import { useSessionStore } from '@/stores/session'
const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
const SKELETON_ROWS = 6
const PAGINATOR_THRESHOLD = 25
const NOBODY = 'none'
const LOOKUP_SIZE = 100
type PanelTarget =
| { kind: 'add' }
| { kind: 'edit'; author: Author; own: boolean }
| { kind: 'view'; author: Author }
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { listAuthors, listAllAuthors, deleteAuthor } = useAuthors()
const collection = useDirectoryCollection(listAuthors)
const removal = useDirectoryDelete<Author>()
const { data: result, showSkeleton } = collection
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const loadState = computed(() =>
collection.state.value === 'forbidden' ? 'error' : collection.state.value,
)
const pendingPage = ref<number | null>(null)
const searchText = ref(parseDirectoryQuery(route.query).q)
const panel = ref<PanelTarget | null>(null)
const linkTarget = ref<Author | null>(null)
const deleteOptions = ref<ReassignOption[]>([])
const destination = ref<string | number | undefined>(undefined)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(() => session.canManageContent)
const query = computed(() => parseDirectoryQuery(route.query))
const queryKey = computed(() => JSON.stringify(query.value))
const items = computed(() => result.value?.items ?? [])
const total = computed(() => result.value?.total ?? 0)
const pageCount = computed(() => pageCountFor(total.value, query.value.size))
const firstRun = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q === '',
)
const noResults = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q !== '',
)
const showToolbar = computed(() => loadState.value !== 'error' && !firstRun.value)
const showList = computed(
() =>
(loadState.value === 'ready' && total.value > 0) ||
(loadState.value === 'loading' && showSkeleton.value),
)
const showPaginator = computed(
() => loadState.value === 'ready' && (total.value > PAGINATOR_THRESHOLD || pageCount.value > 1),
)
const overlayOpen = computed(
() => panel.value !== null || linkTarget.value !== null || deleteTarget.value !== null,
)
const showPinnedButton = computed(
() =>
!isDesktop.value &&
canManage.value &&
!overlayOpen.value &&
loadState.value === 'ready' &&
!firstRun.value,
)
const description = computed(() =>
canManage.value
? t("The names readers see on your articles. An author doesn't need an account.")
: t('The names readers see on your articles. Only an owner or admin can change authors.'),
)
const headings = computed(() => [t('Author'), t('Slug'), t('Account'), t('Articles'), ''])
const ownSlug = computed(() => session.session?.author?.slug ?? '')
const knownSlugs = computed(() => items.value.map((author) => author.slug))
function clearSearchTimer() {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
async function load(requested: DirectoryQuery) {
if (result.value !== null) {
pendingPage.value = requested.page
}
const outcome = await collection.load([requested], {
accept: (response) => {
const lastPage = pageCountFor(response.total, requested.size)
if (response.total > 0 && requested.page > lastPage) {
replaceQuery({ ...requested, page: lastPage })
return false
}
return true
},
})
if (outcome !== 'stale') {
pendingPage.value = null
}
if (outcome === 'ready') {
followLink()
}
}
async function replaceQuery(next: DirectoryQuery, mode: 'push' | 'replace' = 'replace') {
const target = toDirectoryRouteQuery(next, route.query)
if (sameRouteQuery(route.query, target)) {
return
}
await router[mode]({ query: target })
}
function normalizeUrl() {
const target = toDirectoryRouteQuery(query.value, route.query)
if (!sameRouteQuery(route.query, target)) {
router.replace({ query: target })
}
}
watch(
[sessionReady, queryKey],
([ready]) => {
if (!ready) {
return
}
normalizeUrl()
load(query.value)
},
{ immediate: true },
)
watch(
() => query.value.q,
(value) => {
if (value !== searchText.value.trim()) {
searchText.value = value
}
},
)
watch(searchText, (value) => {
clearSearchTimer()
searchTimer = setTimeout(() => {
searchTimer = null
const text = value.trim()
if (text !== query.value.q) {
replaceQuery({ q: text, page: 1, size: query.value.size })
}
}, SEARCH_DEBOUNCE_MS)
})
watch(
() => route.query.edit,
() => {
if (loadState.value === 'ready') {
followLink()
}
},
)
onBeforeUnmount(clearSearchTimer)
function clearSearch() {
clearSearchTimer()
searchText.value = ''
replaceQuery({ q: '', page: 1, size: query.value.size })
}
function changePage(page: number) {
replaceQuery({ ...query.value, page }, 'push')
}
function changeSize(size: number) {
replaceQuery({ ...query.value, size, page: 1 }, 'push')
}
async function findBySlug(slug: string) {
const loaded = items.value.find((author) => author.slug === slug)
if (loaded) {
return loaded
}
try {
const found = await listAuthors({ q: slug, page: 1, size: LOOKUP_SIZE })
return found.items.find((author) => author.slug === slug) ?? null
} catch {
return null
}
}
function openFor(author: Author): PanelTarget {
if (canManage.value) {
return { kind: 'edit', author, own: false }
}
return author.slug === ownSlug.value
? { kind: 'edit', author, own: true }
: { kind: 'view', author }
}
function followLink() {
return followEditLink(
findBySlug,
(author) => {
panel.value = openFor(author)
},
toasts.authorGone,
)
}
function openAdd() {
rememberOpener()
panel.value = { kind: 'add' }
}
function openEdit(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
panel.value = { kind: 'edit', author, own: false }
}
function openRow(author: Author) {
rememberOpener()
panel.value = { kind: 'view', author }
}
function closePanel() {
panel.value = null
leavePanel()
}
function replaceItem(author: Author) {
if (!result.value) {
return
}
result.value = {
...result.value,
items: result.value.items.map((item) =>
item.id === author.id
? { ...author, user_email: author.user_email ?? item.user_email }
: item,
),
}
}
function removeItem(id: number) {
if (!result.value) {
return
}
const remaining = result.value.items.filter((item) => item.id !== id)
result.value = { ...result.value, items: remaining, total: Math.max(0, result.value.total - 1) }
if (remaining.length === 0 && result.value.total > 0) {
load(query.value)
}
}
function onSaved(author: Author) {
if (items.value.some((item) => item.id === author.id)) {
replaceItem(author)
} else {
load(query.value)
}
closePanel()
}
function onGone(id: number) {
removeItem(id)
closePanel()
}
function openLink(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
linkTarget.value = author
}
function closeLink() {
linkTarget.value = null
restoreFocus()
}
function onLinked(author: Author) {
replaceItem(author)
closeLink()
}
function onLinkGone(id: number) {
removeItem(id)
closeLink()
}
function nobodyOption(): ReassignOption {
return {
value: NOBODY,
label: t('Nobody'),
description: t('Remove the byline'),
display: t('Nobody'),
none: true,
}
}
async function loadDeleteOptions(author: Author) {
let others: Author[] = []
let failed = false
try {
others = (await listAllAuthors()).filter((candidate) => candidate.id !== author.id)
} catch {
failed = true
}
const options: ReassignOption[] = [
...others.map((candidate) => ({
value: candidate.id,
label: candidate.name,
description: candidate.slug,
display: candidate.name,
avatarName: candidate.name,
})),
nobodyOption(),
]
return { options, failed }
}
async function openDelete(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
const loaded = author.article_count > 0 ? await loadDeleteOptions(author) : null
deleteOptions.value = loaded?.options ?? []
destination.value = deleteOptions.value[0]?.value
removal.open(author)
if (loaded?.failed) {
removal.notice.value = {
title: copy('loadAuthorsFailed'),
description: copy('checkConnection'),
}
}
}
function closeDelete() {
removal.close()
restoreFocus()
}
const chosenAuthorName = computed(
() => deleteOptions.value.find((option) => option.value === destination.value)?.label ?? '',
)
const deleteBody = computed(() => {
const target = deleteTarget.value
if (!target) {
return { description: '', summary: '', confirm: '', pending: '' }
}
if (target.article_count === 0) {
return {
description: t(
'"{0}" will be deleted. No articles credit this author. This can\'t be undone.',
[target.name],
),
summary: '',
confirm: t('Delete author'),
pending: t('Deleting…'),
}
}
const many = target.article_count !== 1
return {
description: many
? t('"{0}" will be deleted and is credited on {1} articles. This can\'t be undone.', [
target.name,
target.article_count,
])
: t('"{0}" will be deleted and is credited on 1 article. This can\'t be undone.', [
target.name,
]),
summary:
destination.value === NOBODY
? t(
'The articles keep any other authors. Articles with only this author will have no byline.',
)
: t('Where an article has several authors, {0} is removed and the others stay.', [
target.name,
]),
confirm: many
? t('Delete and reassign {0} articles', [target.article_count])
: t('Delete and reassign 1 article'),
pending: t('Reassigning…'),
}
})
async function runDelete(target: Author) {
const credited = target.article_count
const reassignTo =
credited === 0
? undefined
: destination.value === NOBODY
? NOBODY
: Number(destination.value)
const receiver = reassignTo === NOBODY || credited === 0 ? null : chosenAuthorName.value
await deleteAuthor(target.id, reassignTo)
removeItem(target.id)
toasts.authorDeleted(credited, receiver)
closeDelete()
}
async function handleDeleteFailure(error: unknown, target: Author) {
const kind = classifyFailure(error, 'author')
if (kind === 'session') {
return
}
if (kind === 'hasArticles') {
closeDelete()
await showBlocked(target)
return
}
if (toasts.report(kind, 'author')) {
if (kind === 'gone') {
removeItem(target.id)
}
closeDelete()
return
}
removal.notice.value = {
title: t("Couldn't delete the author"),
description: copy('nothingChanged'),
}
}
function confirmDelete() {
return removal.confirm(runDelete, handleDeleteFailure)
}
async function creditedCount(target: Author) {
try {
const found = await listAuthors({ q: target.slug, page: 1, size: LOOKUP_SIZE })
return (
found.items.find((item) => item.id === target.id)?.article_count || target.article_count
)
} catch {
return target.article_count
}
}
async function showBlocked(target: Author) {
const count = Math.max(1, await creditedCount(target))
const reassign = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t("Can't delete this author")
},
get description() {
return count === 1
? t(
'"{0}" is credited on 1 article. Choose another author to credit instead, or remove {0} from those articles first.',
[target.name],
)
: t(
'"{0}" is credited on {1} articles. Choose another author to credit instead, or remove {0} from those articles first.',
[target.name, count],
)
},
get confirmLabel() {
return t('Reassign articles')
},
get cancelLabel() {
return t('Close')
},
get pendingLabel() {
return t('Reassign articles')
},
tone: 'neutral',
action: async () => undefined,
},
})
.open()
if (reassign) {
await openDelete({ ...target, article_count: count }, null)
} else {
restoreFocus()
}
}
function menuItems(author: Author): DirectoryMenuItem[] {
const items: DirectoryMenuItem[] = [
{
key: 'edit',
label: t('Edit'),
icon: 'i-lucide-pencil',
onSelect: (trigger) => openEdit(author, trigger),
},
]
if (!author.is_linked) {
items.push({
key: 'link',
label: t('Link user'),
icon: 'i-lucide-link',
onSelect: (trigger) => openLink(author, trigger),
})
}
items.push({
key: 'delete',
label: t('Delete…'),
icon: 'i-lucide-trash-2',
destructive: true,
separated: true,
onSelect: (trigger) => openDelete(author, trigger),
})
return items
}
const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q]))
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Authors')">
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Authors') }}</h1>
<p class="text-muted">{{ description }}</p>
</div>
<DirectoryAddButton v-if="isDesktop && canManage" @click="openAdd">
{{ t('Add author') }}
</DirectoryAddButton>
</div>
<UInput
v-if="showToolbar"
v-model="searchText"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by name or slug')"
:aria-label="t('Search by name or slug')"
class="w-full sm:w-70"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load authors')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="load(query)"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UEmpty
v-else-if="firstRun"
variant="naked"
icon="i-lucide-feather"
:title="t('No authors yet')"
:description="
t('Authors are the names readers see on articles. They don\'t need an account.')
"
: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 v-if="canManage" #actions>
<UButton icon="i-lucide-plus" class="h-11 px-4 md:h-8" @click="openAdd">
{{ t('Add author') }}
</UButton>
</template>
</UEmpty>
<UEmpty
v-else-if="noResults"
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
:description="t('Check the spelling, or search for a shorter part of the name.')"
: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-highlighted ring-0 md:h-8"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
<DirectoryList
v-else-if="showList"
:items="items"
:columns="LIST_COLUMNS"
:headings="headings"
:loading="loadState === 'loading'"
:skeleton-rows="SKELETON_ROWS"
:read-only="!canManage"
:label="t('Authors')"
:row-title="(author: Author) => author.name"
:menu-items="menuItems"
@open="openRow"
>
<template #cells="{ item }">
<div class="flex min-w-0 items-center gap-2.5">
<AuthorAvatar :name="item.name" :size="32" />
<span class="truncate font-medium text-highlighted" :title="item.name">{{
item.name
}}</span>
</div>
<span class="truncate font-mono text-xs/4.5 text-muted">{{ item.slug }}</span>
<span class="flex">
<DirectoryBadge :kind="item.is_linked ? 'linked' : 'guest'" />
</span>
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
</template>
<template #card="{ item }">
<AuthorAvatar :name="item.name" :size="40" />
<div class="flex min-w-0 flex-1 flex-col gap-0.75">
<span
class="truncate text-md/5.25 font-medium text-highlighted"
:title="item.name"
>{{ item.name }}</span
>
<span class="truncate font-mono text-xs/4 text-muted">{{ item.slug }}</span>
<div class="flex items-center gap-2 text-xs text-muted">
<DirectoryBadge :kind="item.is_linked ? 'linked' : 'guest'" />
<span>{{ pluralArticles(item.article_count) }}</span>
</div>
</div>
</template>
</DirectoryList>
<AppPaginator
v-if="showPaginator"
:page="query.page"
:page-count="pageCount"
:total="total"
:size="query.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
/>
</div>
<DirectoryAddButton v-if="showPinnedButton" pinned @click="openAdd">
{{ t('Add author') }}
</DirectoryAddButton>
<AuthorFormPanel
v-if="panel && (panel.kind === 'add' || panel.kind === 'edit')"
:key="panel.kind === 'edit' ? panel.author.id : 'add'"
:author="panel.kind === 'edit' ? panel.author : undefined"
:own-profile="panel.kind === 'edit' && panel.own"
:own-email="session.session?.email ?? ''"
:known-slugs="knownSlugs"
@close="closePanel"
@saved="onSaved"
@updated="replaceItem"
@gone="onGone"
/>
<AuthorViewPanel
v-if="panel && panel.kind === 'view'"
:author="panel.author"
@close="closePanel"
/>
<LinkUserDialog
v-if="linkTarget"
:author="linkTarget"
@close="closeLink"
@linked="onLinked"
@gone="onLinkGone"
/>
<ReassignDialog
v-if="deleteTarget"
v-model:selected="destination"
closable
:title="t('Delete this author?')"
:description="deleteBody.description"
:confirm-label="deleteBody.confirm"
:pending-label="deleteBody.pending"
:select-label="t('Credit these articles to')"
:options="deleteOptions"
:summary="deleteBody.summary"
:pending="deletePending"
:notice="deleteNotice"
@confirm="confirmDelete"
@close="closeDelete"
>
<p v-if="deleteTarget.is_linked && deleteTarget.user_email" class="text-xs text-muted">
{{
t("Linked to {0}. The user account isn't affected.", [deleteTarget.user_email])
}}
</p>
</ReassignDialog>
</DashboardShell>
</template>
+4 -1
View File
@@ -6,6 +6,7 @@ import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import MediaLinksCard from '@/components/MediaLinksCard.vue'
import PublishingLinksCard from '@/components/PublishingLinksCard.vue'
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
import RestartOverlay from '@/components/RestartOverlay.vue'
import RetentionCard from '@/components/RetentionCard.vue'
@@ -56,7 +57,7 @@ const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
const loadState = ref<LoadState>('loading')
const settings = ref<WorkspaceSettings | null>(null)
const version = computed(() => settings.value?.version ?? '')
const isOwner = computed(() => session.session?.role === 'owner')
const isOwner = computed(() => session.isOwner)
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
@@ -465,6 +466,8 @@ onBeforeUnmount(() => {
<MediaLinksCard />
<PublishingLinksCard v-if="isOwner" />
<MediaCleanupCard />
<RetentionCard
+541
View File
@@ -0,0 +1,541 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryList from '@/components/DirectoryList.vue'
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
import SiteFormPanel from '@/components/SiteFormPanel.vue'
import SitesForbidden from '@/components/SitesForbidden.vue'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { useSites, type Site } from '@/composables/useSites'
import { hostOf, pluralArticles } from '@/lib/directory'
import { COPY, copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
const SKELETON_ROWS = 3
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { listSites, setDefaultSite, deleteSite } = useSites()
const collection = useDirectoryCollection(listSites)
const removal = useDirectoryDelete<Site>()
const { state: loadState, showSkeleton } = collection
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const sites = computed({
get: () => collection.data.value ?? [],
set: (value: Site[]) => {
collection.data.value = value
},
})
const panel = ref<PanelTarget | null>(null)
const destinationId = ref<number | undefined>(undefined)
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const isOwner = computed(() => session.isOwner)
const forbidden = computed(
() => (sessionReady.value && !isOwner.value) || loadState.value === 'forbidden',
)
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
)
const onlyDefault = computed(
() => loadState.value === 'ready' && sites.value.length === 1 && sites.value[0]!.is_default,
)
const overlayOpen = computed(() => panel.value !== null || deleteTarget.value !== null)
const defaultSite = computed(() => sites.value.find((site) => site.is_default))
const panelSite = computed(() => {
const target = panel.value
if (!target || target.mode === 'add') {
return undefined
}
return sites.value.find((site) => site.id === target.site.id) ?? target.site
})
const destinationOptions = computed<ReassignOption[]>(() => {
const target = deleteTarget.value
if (!target || target.article_count === 0) {
return []
}
return sites.value
.filter((site) => site.id !== target.id)
.map((site) => ({
value: site.id,
label: site.name,
description: hostOf(site.base_url),
display: `${site.name} · ${hostOf(site.base_url)}`,
}))
})
const destination = computed(() => sites.value.find((site) => site.id === destinationId.value))
const deleteDescription = computed(() => {
const target = deleteTarget.value
if (!target) {
return ''
}
if (target.article_count === 0) {
return t('"{0}" will be deleted. No articles belong to it. This can\'t be undone.', [
target.name,
])
}
return target.article_count === 1
? t(
'"{0}" will be deleted. 1 article belongs to it, so choose a site to move them to. This can\'t be undone.',
[target.name],
)
: t(
'"{0}" will be deleted. {1} articles belong to it, so choose a site to move them to. This can\'t be undone.',
[target.name, target.article_count],
)
})
const deleteSummary = computed(() => {
const target = deleteTarget.value
const to = destination.value
if (!target || !to) {
return ''
}
return target.article_count === 1
? t('1 article will move to {0}. Their canonical host changes to {1}.', [
to.name,
hostOf(to.base_url),
])
: t('{0} articles will move to {1}. Their canonical host changes to {2}.', [
target.article_count,
to.name,
hostOf(to.base_url),
])
})
const deleteConfirmLabel = computed(() => {
const target = deleteTarget.value
if (!target || target.article_count === 0) {
return t('Delete site')
}
return target.article_count === 1
? t('Move 1 article and delete')
: t('Move {0} articles and delete', [target.article_count])
})
const deletePendingLabel = computed(() =>
deleteTarget.value && deleteTarget.value.article_count > 0
? t('Moving articles…')
: t('Deleting…'),
)
async function loadSites() {
const outcome = await collection.load([])
if (outcome === 'ready') {
followLink()
}
}
watch(
[sessionReady, isOwner],
([ready, owner]) => {
if (ready && owner && !requested) {
requested = true
loadSites()
}
},
{ immediate: true },
)
watch(
() => route.query.edit,
() => {
if (loadState.value === 'ready' && isOwner.value) {
followLink()
}
},
)
function followLink() {
if (!isOwner.value) {
return
}
return followEditLink(
(key) => sites.value.find((candidate) => candidate.key === key) ?? null,
(site) => {
panel.value = { mode: 'edit', site }
},
toasts.siteGone,
)
}
function openAdd() {
rememberOpener()
panel.value = { mode: 'add' }
}
function openEdit(site: Site, trigger: HTMLElement | null) {
rememberOpener(trigger)
panel.value = { mode: 'edit', site }
}
function closePanel() {
panel.value = null
leavePanel()
}
function upsert(site: Site) {
const exists = sites.value.some((candidate) => candidate.id === site.id)
sites.value = exists
? sites.value.map((candidate) => (candidate.id === site.id ? site : candidate))
: [...sites.value, site]
if (site.is_default) {
markDefault(site.id)
}
}
function markDefault(id: number) {
sites.value = sites.value.map((site) => ({ ...site, is_default: site.id === id }))
}
function removeSite(id: number) {
sites.value = sites.value.filter((site) => site.id !== id)
}
function onSaved(site: Site) {
upsert(site)
closePanel()
}
function onGone(id: number) {
removeSite(id)
closePanel()
}
function confirmDefault(site: Site, trigger: HTMLElement | null) {
rememberOpener(trigger)
overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Make {0} the default site?', [site.name])
},
get description() {
return t(
'New articles start on this site. Articles that are already on other sites stay where they are.',
)
},
get confirmLabel() {
return t('Make default')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Saving…')
},
tone: 'neutral',
action: async () => {
try {
await setDefaultSite(site.id)
} catch (error) {
const kind = classifyFailure(error, 'site')
if (kind === 'session') {
return
}
if (toasts.report(kind, 'site')) {
if (kind === 'gone') {
removeSite(site.id)
}
return
}
throw new Error(COPY.siteSaveFailed)
}
markDefault(site.id)
toasts.siteDefaultChanged(site.name)
},
},
})
.open()
.then(restoreFocus)
}
function openDelete(site: Site, trigger: HTMLElement | null) {
rememberOpener(trigger)
removal.open(site)
const options = destinationOptions.value
const preferred = options.find((option) => option.value === defaultSite.value?.id)
destinationId.value = Number((preferred ?? options[0])?.value) || undefined
}
function closeDelete() {
removal.close()
restoreFocus()
}
async function refreshTarget(id: number) {
try {
sites.value = await listSites()
removal.target.value = sites.value.find((site) => site.id === id) ?? null
} catch {
return
}
}
async function runDelete(target: Site) {
const moved = target.article_count
const to = moved > 0 ? destination.value : undefined
await deleteSite(target.id, to?.id)
removeSite(target.id)
if (to) {
to.article_count += moved
}
toasts.siteDeleted(moved, to?.name ?? '')
closeDelete()
}
async function handleDeleteFailure(error: unknown, target: Site) {
const kind = classifyFailure(error, 'site')
if (kind === 'session') {
return
}
if (kind === 'hasArticles') {
await refreshTarget(target.id)
destinationId.value = defaultSite.value?.id
removal.notice.value = {
description: t('Choose where the articles go. This site has {0} articles.', [
deleteTarget.value?.article_count ?? target.article_count,
]),
}
return
}
if (toasts.report(kind, 'site')) {
if (kind === 'gone') {
removeSite(target.id)
}
closeDelete()
return
}
removal.notice.value = {
title: t("Couldn't delete the site"),
description: copy('nothingChanged'),
}
}
function confirmDelete() {
return removal.confirm(runDelete, handleDeleteFailure)
}
function menuItems(site: Site): DirectoryMenuItem[] {
const items: DirectoryMenuItem[] = [
{
key: 'edit',
label: t('Edit'),
icon: 'i-lucide-pencil',
onSelect: (trigger) => openEdit(site, trigger),
},
]
if (!site.is_default) {
items.push({
key: 'default',
label: t('Set as default'),
icon: 'i-lucide-star',
onSelect: (trigger) => confirmDefault(site, trigger),
})
}
items.push({
key: 'delete',
label: t('Delete…'),
icon: 'i-lucide-trash-2',
destructive: true,
separated: !site.is_default,
unavailable: site.is_default,
hint: site.is_default ? copy('defaultSiteBlocked') : undefined,
onSelect: (trigger) => openDelete(site, trigger),
})
return items
}
const headings = computed(() => [t('Site'), t('Base URL'), t('Articles'), ''])
function backToSettings() {
router.push('/settings')
}
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Sites')">
<template #breadcrumb>
<UBreadcrumb
:items="[
{
get label() {
return t('Settings')
},
to: '/settings',
},
{
get label() {
return t('Sites')
},
},
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<SitesForbidden v-if="forbidden" @back="backToSettings" />
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Sites') }}</h1>
<p class="text-muted">{{ t('The frontends that display your content.') }}</p>
</div>
<DirectoryAddButton v-if="isDesktop" @click="openAdd">
{{ t('Add site') }}
</DirectoryAddButton>
</div>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load sites')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadSites"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<DirectoryList
v-if="showList"
:items="sites"
:columns="LIST_COLUMNS"
:headings="headings"
:loading="loadState === 'loading'"
:skeleton-rows="SKELETON_ROWS"
:label="t('Sites')"
:row-title="(site: Site) => site.name"
:menu-items="menuItems"
>
<template #cells="{ item }">
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 items-center gap-2">
<span
class="truncate font-medium text-highlighted"
:title="item.name"
>{{ item.name }}</span
>
<DirectoryBadge v-if="item.is_default" kind="default" />
<DirectoryBadge
v-if="item.default_robots === 'noindex'"
kind="noindex"
/>
</div>
<span class="font-mono text-xs/4 text-muted">{{ item.key }}</span>
</div>
<span class="truncate font-mono text-xs/4.5 text-toned">{{
item.base_url
}}</span>
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
</template>
<template #card="{ item }">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-medium text-highlighted">{{
item.name
}}</span>
<DirectoryBadge v-if="item.is_default" kind="default" />
<DirectoryBadge
v-if="item.default_robots === 'noindex'"
kind="noindex"
/>
</div>
<span class="truncate font-mono text-xs/4.5 text-toned">{{
hostOf(item.base_url)
}}</span>
<span class="text-xs/5 text-muted">{{
pluralArticles(item.article_count)
}}</span>
</div>
</template>
</DirectoryList>
<p v-if="onlyDefault" class="text-pretty text-muted">
{{
t(
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.',
)
}}
</p>
</div>
<DirectoryAddButton
v-if="!isDesktop && !forbidden && loadState !== 'error'"
v-show="!overlayOpen"
pinned
@click="openAdd"
>
{{ t('Add site') }}
</DirectoryAddButton>
<SiteFormPanel
v-if="panel"
:key="panel.mode === 'edit' ? panel.site.id : 'add'"
:site="panelSite"
:sites="sites"
@close="closePanel"
@saved="onSaved"
@updated="upsert"
@defaulted="upsert"
@gone="onGone"
/>
<ReassignDialog
v-if="deleteTarget"
v-model:selected="destinationId"
:title="t('Delete this site?')"
:description="deleteDescription"
:confirm-label="deleteConfirmLabel"
:pending-label="deletePendingLabel"
:select-label="t('Move articles to')"
:options="destinationOptions"
:summary="deleteSummary"
:pending="deletePending"
:notice="deleteNotice"
@confirm="confirmDelete"
@close="closeDelete"
/>
</DashboardShell>
</template>
+1 -3
View File
@@ -54,9 +54,7 @@ let formOpener: HTMLElement | null = null
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const canManage = computed(() => session.canManageContent)
const forbidden = computed(
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
)
+2 -1
View File
@@ -79,12 +79,13 @@ describe('Navigation', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
'/settings',
])
const activity = links[4]!
const activity = links[5]!
expect(activity.getAttribute('aria-current')).toBe('page')
await expectIcon(activity, 'history')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
textOf,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
async function openDelete(name: string, backend = createBackend()) {
await mountDirectory('authors', '/authors', { backend })
await openRowMenu(name)
await chooseMenuItem('Delete…')
return backend
}
function box() {
return dialog()!
}
function trigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
async function openSelect() {
trigger().click()
await flushPromises()
}
function optionLabels() {
return Array.from(box().querySelectorAll('[role="option"]')).map((option) => textOf(option))
}
describe('Authors delete: reassign (recommended)', () => {
it('explains, preselects the first other author and lists the buttons', async () => {
const backend = await openDelete('Camille Roche')
const text = textOf(box())
expect(box().getAttribute('role')).toBe('alertdialog')
expect(text).toContain('Delete this author?')
expect(text).toContain(
'"Camille Roche" will be deleted and is credited on 3 articles. This can\'t be undone.',
)
expect(text).toContain('Credit these articles to')
expect(textOf(trigger())).toBe('Inès Duarte')
expect(text).toContain(
'Where an article has several authors, Camille Roche is removed and the others stay.',
)
expect(button('Cancel', box())).toBeTruthy()
expect(button('Delete and reassign 3 articles', box())).toBeTruthy()
expect(
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
).toHaveLength(1)
})
it('adds the linked-account line for a linked author', async () => {
await openDelete('Inès Duarte')
expect(textOf(box())).toContain(
"Linked to ines.duarte@renew.example. The user account isn't affected.",
)
})
})
describe('Authors delete: reassign succeeds', () => {
it('sends one DELETE with reassign_to, reads Reassigning… and toasts', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['DELETE /v1/author/7'] = () => hold.promise
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
expect(button('Reassigning…', box())).toBeTruthy()
hold.resolve(undefined)
await flushPromises()
const deletes = callsTo(backend, 'DELETE', '/v1/author/7')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.get('reassign_to')).toBe('2')
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
expect(document.body.textContent).toContain('Author deleted')
expect(document.body.textContent).toContain('3 articles now credit Inès Duarte.')
})
})
describe('Authors delete: choose who', () => {
it('does not list the deleted author and ends with Nobody', async () => {
await openDelete('Camille Roche')
await openSelect()
const labels = optionLabels()
expect(labels.some((label) => label.includes('Camille Roche'))).toBe(false)
expect(labels.at(-1)).toContain('Nobody')
expect(labels.at(-1)).toContain('Remove the byline')
expect(labels[0]).toContain('Inès Duarte')
})
})
describe('Authors delete: nobody', () => {
it('explains the byline and sends reassign_to=none', async () => {
const backend = await openDelete('Camille Roche')
await openSelect()
Array.from(box().querySelectorAll<HTMLElement>('[role="option"]'))
.find((option) => textOf(option).includes('Nobody'))!
.click()
await flushPromises()
expect(textOf(box())).toContain(
'The articles keep any other authors. Articles with only this author will have no byline.',
)
await click('Delete and reassign 3 articles', box())
expect(callsTo(backend, 'DELETE', '/v1/author/7')[0]!.query.get('reassign_to')).toBe('none')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors delete: failed with %s', (_name, responder) => {
it('keeps the dialog open with the notice and enables the buttons again', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = responder
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't delete the author")
expect(alert).toContain('Nothing was changed. Try again in a moment.')
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
expect(button('Delete and reassign 3 articles', box())!.hasAttribute('disabled')).toBe(
false,
)
})
})
describe('Authors delete: server fallback (409)', () => {
it('opens the fallback dialog and Reassign articles reopens the reassign dialog', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = failWith(409, 'author_has_articles')
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
await flushPromises()
const text = textOf(box())
expect(text).toContain("Can't delete this author")
expect(text).toContain(
'"Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first.',
)
expect(button('Close', box())).toBeTruthy()
expect(button('Reassign articles', box())).toBeTruthy()
await click('Reassign articles', box())
await flushPromises()
expect(textOf(box())).toContain('Delete this author?')
})
})
describe('Authors delete: no articles', () => {
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
const backend = await openDelete('Léa Fontaine')
const text = textOf(box())
expect(text).toContain('Delete this author?')
expect(text).toContain(
'"Léa Fontaine" will be deleted. No articles credit this author. This can\'t be undone.',
)
expect(trigger()).toBeNull()
expect(button('Delete author', box())).toBeTruthy()
await click('Delete author', box())
const deletes = callsTo(backend, 'DELETE', '/v1/author/10')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
expect(rowTexts().join(' ')).not.toContain('Léa Fontaine')
})
})
describe('Authors delete: author already gone', () => {
it('closes, removes the row and shows the info toast on 404', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = failWith(404, 'author_not_found')
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
expect(document.body.textContent).toContain('That author no longer exists')
})
})
+543
View File
@@ -0,0 +1,543 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
authorsFixture,
blur,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
labelled,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
textOf,
typeInto,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const URL_MESSAGE = 'Enter a full URL starting with https://.'
const SAVE_FAILED = "Couldn't save the author. Your changes are still here. Try again in a moment."
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function nameField() {
return field('e.g. Camille Roche')
}
function bioField() {
return field('A sentence or two about the author.')
}
function websiteField() {
return field('https://example.com')
}
function slugField() {
return labelled('Slug') as HTMLInputElement
}
function submit(label = 'Add author') {
return click(label, box())
}
function userTrigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('authors', '/authors', options)
await click('Add author')
return backend
}
async function openEdit(name: string, options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('authors', '/authors', options)
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
describe('Authors form: add, empty', () => {
it('opens a slide-over with every field, placeholder, counter and the byline preview', async () => {
await openAdd()
const text = textOf(box())
for (const copy of [
'Add author',
'Avatar (optional)',
'Choose image',
'Name',
'Slug',
'Bio (optional)',
'0 / 280',
'Website (optional)',
'Social links (optional)',
'Add link',
'0 of 5',
'Linked user (optional)',
'Link a CMS user if this author has an account. Guest writers need no link.',
'Byline preview',
'Author name',
'The bio appears here.',
'Cancel',
]) {
expect(text).toContain(copy)
}
expect(nameField()).toBeTruthy()
expect(field('Generated from the name')).toBeTruthy()
expect(bioField()).toBeTruthy()
expect(websiteField()).toBeTruthy()
expect(textOf(userTrigger())).toBe('No linked user')
expect(button('Add author', box())).toBeTruthy()
})
})
describe('Authors form: slug from the name', () => {
it('fills the slug with an Auto badge, an Edit slug pencil and follows the byline preview', async () => {
await openAdd()
await typeInto(nameField(), 'Camille Roche')
expect(slugField().value).toBe('camille-roche')
const text = textOf(box())
expect(text).toContain('Auto')
expect(button('Edit slug', box())).toBeTruthy()
expect(text).toContain(
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
)
expect(text).toContain('Byline preview Camille Roche')
})
})
describe('Authors form: add succeeds', () => {
it('sends exactly one POST with the exact body, reads Saving… and lists the author', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['POST /v1/author/'] = (call) => {
backend.authors.push({
id: 200,
is_linked: false,
article_count: 0,
...call.body,
} as never)
return hold.promise
}
await openAdd({ backend })
await typeInto(nameField(), 'Zoe Lambert')
await typeInto(bioField(), 'Writes about plaster.')
await typeInto(websiteField(), 'https://zoe.example')
await click('Add link', box())
await typeInto(
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
'https://x.com/zoe',
)
await submit()
expect(button('Saving…', box())).toBeTruthy()
hold.resolve({ id: 200 })
await flushPromises()
const posts = callsTo(backend, 'POST', '/v1/author/')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toEqual({
name: 'Zoe Lambert',
slug: 'zoe-lambert',
bio: 'Writes about plaster.',
avatar_id: null,
website: 'https://zoe.example',
social_links: [{ type: 'x', url: 'https://x.com/zoe' }],
user_id: null,
})
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).toContain('Zoe Lambert')
})
})
describe('Authors form: slug collision', () => {
it('shows the message, a suggestion and the reset link, and the suggestion keeps the other values', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
await openAdd({ backend })
await typeInto(nameField(), 'Camille Roche')
await typeInto(bioField(), 'A bio')
await submit()
const text = textOf(box())
expect(text).toContain('The slug "camille-roche" is already used by another author.')
expect(text).toContain('Use camille-roche-2')
expect(text).toContain('Reset to match the name')
await click('Use camille-roche-2', box())
expect(slugField().value).toBe('camille-roche-2')
expect(bioField().value).toBe('A bio')
expect(nameField().value).toBe('Camille Roche')
})
})
describe('Authors form: validation', () => {
it('asks for a name and sends nothing', async () => {
const backend = await openAdd()
await submit()
expect(textOf(box())).toContain('Enter a name.')
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a bio of 291 characters with the counter in the error colour', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await typeInto(bioField(), 'x'.repeat(291))
await submit()
expect(textOf(box())).toContain('Shorten the bio to 280 characters.')
const counter = Array.from(box().querySelectorAll('span')).find(
(span) => textOf(span) === '291 / 280' && !span.querySelector('span'),
)!
expect(counter.className).toContain('text-error')
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a website without https', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await typeInto(websiteField(), 'example.com')
await submit()
expect(textOf(box())).toContain(URL_MESSAGE)
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a social link with an http URL under its row', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await click('Add link', box())
await typeInto(
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
'http://x',
)
await submit()
const row = box()
.querySelector('[data-testid="social-link-row"]')!
.closest('[data-slot="root"]')!
expect(textOf(row)).toContain(URL_MESSAGE)
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
})
describe.each([
[422, 'bio_too_long', 'Shorten the bio to 280 characters.'],
[422, 'invalid_url', URL_MESSAGE],
[409, 'user_already_linked', 'That user already has an author. Choose another user.'],
])('Authors form: server validation %s %s', (status, code, message) => {
it('shows the message under the field', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(status, code)
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await typeInto(websiteField(), 'https://zoe.example')
await submit()
expect(textOf(box())).toContain(message)
})
})
describe('Authors form: social links', () => {
it('adds rows with a type select, a type-specific placeholder, and a limit of five', async () => {
await openAdd()
for (let index = 0; index < 5; index += 1) {
await click('Add link', box())
}
expect(box().querySelectorAll('[data-testid="social-link-row"]')).toHaveLength(5)
expect(button('Add link', box())!.hasAttribute('disabled')).toBe(true)
expect(textOf(box())).toContain('You can add up to 5 links.')
expect(textOf(box())).toContain('5 of 5')
expect(box().querySelectorAll('input[placeholder="https://x.com/username"]')).toHaveLength(
5,
)
})
})
describe('Authors form: linked user, choose', () => {
it('lists the users without an author under their group', async () => {
const backend = await openAdd()
userTrigger().click()
await flushPromises()
const text = textOf(box())
expect(text).toContain('Users without an author')
expect(text).toContain('Marta Alves marta@renew.example')
expect(text).toContain('Rui Santos rui@renew.example')
expect(text).not.toContain('Inès Duarte ines.duarte')
expect(callsTo(backend, 'GET', '/v1/user/')).toHaveLength(1)
expect(
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
).toHaveLength(1)
})
it('pages through the authors to find every linked user', async () => {
const backend = createBackend()
backend.authors = Array.from({ length: 120 }, (_, index) => ({
...authorsFixture()[1]!,
id: 600 + index,
name: `Writer ${index}`,
slug: `writer-${index}`,
user_id: index === 119 ? 1 : null,
}))
await openAdd({ backend })
userTrigger().click()
await flushPromises()
const paths = backend.calls
.filter((call) => call.path === '/v1/author/')
.map((call) => call.raw)
expect(paths).toContain('/v1/author/?page=1&size=100')
expect(paths).toContain('/v1/author/?page=2&size=100')
expect(textOf(box())).not.toContain('Marta Alves')
expect(textOf(box())).toContain('Rui Santos')
})
it('says so when every user already has an author', async () => {
const backend = createBackend()
backend.authors = authorsFixture().map((item, index) => ({ ...item, user_id: index + 1 }))
backend.users = backend.users.slice(0, 1)
await openAdd({ backend })
userTrigger().click()
await flushPromises()
expect(textOf(box())).toContain('Every user already has an author.')
})
})
describe('Authors form: edit a linked author', () => {
it('fills the fields, shows the linked user with Unlink and sends only the changed fields', async () => {
const backend = await openEdit('Inès Duarte')
const text = textOf(box())
expect(text).toContain('Edit Inès Duarte')
expect(nameField().value).toBe('Inès Duarte')
expect(slugField().value).toBe('ines-duarte')
expect(text).toContain('ines.duarte@renew.example')
expect(button('Unlink', box())).toBeTruthy()
expect(text).toContain('New articles by this user start with this author.')
await typeInto(bioField(), 'A new bio')
await submit('Save changes')
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ bio: 'A new bio' })
})
})
describe('Authors form: unlink', () => {
it('asks, sends one PATCH with user_id null, returns to No linked user and toasts', async () => {
const backend = await openEdit('Inès Duarte')
await click('Unlink', box())
const ask = document.body.querySelector('[role="alertdialog"]')!
const text = textOf(ask)
expect(text).toContain('Unlink Inès Duarte?')
expect(text).toContain(
'Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author.',
)
expect(button('Cancel', ask)).toBeTruthy()
await click('Unlink user', ask)
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ user_id: null })
expect(textOf(userTrigger())).toBe('No linked user')
expect(document.body.textContent).toContain('User unlinked')
expect(document.body.textContent).toContain('Inès Duarte is now a guest author.')
})
})
describe('Authors form: link a guest', () => {
it('links a user from the row menu and shows Linked on the row', async () => {
const backend = await mountDirectory('authors', '/authors')
await openRowMenu('Camille Roche')
await chooseMenuItem('Link user')
const ask = document.body.querySelector('[role="alertdialog"]')!
expect(textOf(ask)).toContain('Link Camille Roche to a user?')
expect(textOf(ask)).toContain('Only users without an author are listed.')
expect(button('Link user', ask)!.hasAttribute('disabled')).toBe(true)
ask.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
await flushPromises()
expect(textOf(ask)).toContain('Marta Alves marta@renew.example')
const option = Array.from(ask.querySelectorAll<HTMLElement>('[role="option"]')).find(
(item) => textOf(item).includes('Marta Alves'),
)!
option.click()
await flushPromises()
await click('Link user', ask)
const patches = callsTo(backend, 'PATCH', '/v1/author/7')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ user_id: 1 })
expect(rowTexts()[1]).toContain('Linked')
})
})
describe('Authors form: avatar picker', () => {
it('opens the media picker for the avatar, returns the id and sends avatar_id on save', async () => {
const backend = createBackend()
backend.responders['GET /v1/media/'] = () => ({
items: [
{
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,
},
],
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 1, duplicates: 0 },
})
await openEdit('Camille Roche', { backend })
await click('Choose image', box())
await flushPromises()
const picker = Array.from(document.body.querySelectorAll('[role="dialog"]')).at(-1)!
const text = textOf(picker)
expect(text).toContain('Choose an image')
expect(text).toContain('The avatar for Camille Roche.')
expect(text).toContain(
'Shown as a square. Choose an image with the face or logo near the middle.',
)
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
await flushPromises()
await click('Use image', picker)
expect(textOf(box())).toContain('face.jpg')
await submit('Save changes')
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({ avatar_id: 55 })
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors form: save failed with %s', (_name, responder) => {
it('shows the notice and keeps the values', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = responder
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await typeInto(bioField(), 'Keep me')
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(nameField().value).toBe('Zoe')
expect(bioField().value).toBe('Keep me')
expect(nameField().disabled).toBe(false)
})
})
describe('Authors form: role changed while open', () => {
it('raises an error toast on 403 author_rights', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await submit()
expect(document.body.textContent).toContain('Only an owner or admin can change authors')
expect(document.body.textContent).toContain('You can still edit your own author profile.')
})
})
describe('Authors form: deep link', () => {
it('opens the edit slide-over for an owner or admin', async () => {
await mountDirectory('authors', '/authors?edit=camille-roche', { role: 'admin' })
expect(textOf(box())).toContain('Edit Camille Roche')
})
it('shows an info toast and opens nothing for an unknown slug', async () => {
await mountDirectory('authors', '/authors?edit=nobody-here')
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('That author no longer exists')
})
})
describe('Authors form: own author, any role, by deep link only', () => {
function memberBackend() {
const backend = createBackend()
backend.authors = authorsFixture(false)
return backend
}
const own = { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }
it('opens the form with the linked user locked, no Unlink, no Link user and no delete', async () => {
const backend = memberBackend()
await mountDirectory('authors', '/authors?edit=ines-duarte', {
backend,
role: 'member',
author: own,
email: 'ines.duarte@renew.example',
})
const text = textOf(box())
expect(text).toContain('Edit Inès Duarte')
expect(text).toContain('ines.duarte@renew.example')
expect(text).toContain(
'This is your author profile. Only an owner or admin can change the link.',
)
expect(button('Unlink', box())).toBeUndefined()
expect(button('Link user', box())).toBeUndefined()
expect(button(/Delete/, box())).toBeUndefined()
await typeInto(bioField(), 'Mine')
await submit('Save changes')
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({ bio: 'Mine' })
})
it('opens the read-only detail for another author', async () => {
await mountDirectory('authors', '/authors?edit=camille-roche', {
backend: memberBackend(),
role: 'member',
author: own,
})
expect(textOf(box())).toContain('Only an owner or admin can change this author.')
expect(box().querySelectorAll('input, textarea')).toHaveLength(0)
})
})
void blur
+324
View File
@@ -0,0 +1,324 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import type { Author } from '@/composables/useAuthors'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
authorsFixture,
button,
callsTo,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
rows,
textOf,
typeInto,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const OWNER_DESCRIPTION =
"The names readers see on your articles. An author doesn't need an account."
const READ_ONLY_DESCRIPTION =
'The names readers see on your articles. Only an owner or admin can change authors.'
afterEach(unmountDirectory)
function listCalls(backend: ReturnType<typeof createBackend>) {
return backend.calls.filter((call) => call.method === 'GET' && call.path === '/v1/author/')
}
function manyAuthors(count: number): Author[] {
return Array.from({ length: count }, (_, index) => ({
...authorsFixture()[1]!,
id: 500 + index,
name: `Writer ${String(index).padStart(2, '0')}`,
slug: `writer-${index}`,
}))
}
describe.each(['owner', 'admin'])('Authors: default for %s', (role) => {
it('shows the header, the search field, the columns and the rows', async () => {
const backend = await mountDirectory('authors', '/authors', { role })
expect(document.body.querySelector('h1')?.textContent).toBe('Authors')
expect(document.body.textContent).toContain(OWNER_DESCRIPTION)
expect(button('Add author')).toBeTruthy()
expect(
document.body.querySelector('input[placeholder="Search by name or slug"]'),
).toBeTruthy()
const header = document.body.querySelector(
'[data-testid="directory-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Author Slug Account Articles')
expect(rowTexts()).toEqual([
'ID Inès Duarte ines-duarte Linked 9 articles',
'CR Camille Roche camille-roche Guest 3 articles',
'AK Dr. Alexandra Konstantinopoulou-Whitfield dr-alexandra-konstantinopoulou-whitfield Guest 1 article',
'LF Léa Fontaine lea-fontaine Guest No articles',
])
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?page=1&size=25')
})
it('puts Authors between Media and Users in the sidebar, current on /authors', async () => {
await mountDirectory('authors', '/authors', { role })
const links = Array.from(
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
)
const hrefs = links.map((link) => link.getAttribute('href'))
expect(hrefs.slice(2, 5)).toEqual(['/media', '/authors', '/users'])
expect(links[3]!.getAttribute('aria-current')).toBe('page')
})
it('shows a long name in full on hover', async () => {
await mountDirectory('authors', '/authors', { role })
const name = Array.from(document.body.querySelectorAll('span.truncate')).find((span) =>
textOf(span).startsWith('Dr. Alexandra'),
)!
expect(name.getAttribute('title')).toBe('Dr. Alexandra Konstantinopoulou-Whitfield')
})
})
describe('Authors: loading', () => {
it('draws skeleton rows after the delay and marks the list busy', async () => {
vi.useFakeTimers()
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['GET /v1/author/'] = () => hold.promise
await mountDirectory('authors', '/authors', { backend })
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(6)
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors: error on load with %s', (_name, responder) => {
it('shows the alert and requests the list again on Try again', async () => {
const backend = createBackend()
backend.responders['GET /v1/author/'] = responder
await mountDirectory('authors', '/authors', { backend })
const alert = textOf(document.body.querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't load authors")
expect(alert).toContain('Check your connection and try again.')
delete backend.responders['GET /v1/author/']
await click('Try again')
expect(listCalls(backend)).toHaveLength(2)
expect(rows()).toHaveLength(4)
})
})
describe.each([
['owner', true],
['admin', true],
['member', false],
])('Authors: empty, first run for %s', (role, canAdd) => {
it('hides the toolbar and shows the empty state', async () => {
const backend = createBackend()
backend.authors = []
await mountDirectory('authors', '/authors', { backend, role })
expect(
document.body.querySelector('input[placeholder="Search by name or slug"]'),
).toBeNull()
expect(document.body.textContent).toContain('No authors yet')
expect(document.body.textContent).toContain(
"Authors are the names readers see on articles. They don't need an account.",
)
const emptyButton = Array.from(document.body.querySelectorAll('button')).filter(
(element) => textOf(element) === 'Add author',
)
expect(emptyButton.length > 0).toBe(canAdd)
})
})
describe('Authors: no results', () => {
it('names the search and clears it from the URL', async () => {
const backend = await mountDirectory('authors', '/authors?q=zoe')
expect(document.body.textContent).toContain('No authors match "zoe"')
expect(document.body.textContent).toContain(
'Check the spelling, or search for a shorter part of the name.',
)
await click('Clear search')
await flushPromises()
expect(router.currentRoute.value.query.q).toBeUndefined()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
})
})
describe('Authors: search and pagination in the URL', () => {
it('debounces the search, requests it and keeps it in the URL', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors')
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by name or slug"]',
)!
await typeInto(input, 'cam')
expect(listCalls(backend)).toHaveLength(1)
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=cam&page=1&size=25')
expect(router.currentRoute.value.query.q).toBe('cam')
})
it('requests the page and size found in the URL', async () => {
const backend = createBackend()
backend.authors = manyAuthors(130)
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend })
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50')
expect(document.body.textContent).toContain('Showing 51–100 of 130')
})
it('shows the paginator only when the total exceeds 25', async () => {
await mountDirectory('authors', '/authors')
expect(document.body.textContent).not.toContain('per page')
await unmountDirectory()
const backend = createBackend()
backend.authors = manyAuthors(40)
await mountDirectory('authors', '/authors', { backend })
expect(document.body.textContent).toContain('Showing 1–25 of 40')
})
})
describe.each([['size=30'], ['page=abc'], ['page=0']])(
'Authors: invalid value in the URL (%s)',
(query) => {
it('replaces the value by its default with no notice and never reaches the API', async () => {
const backend = await mountDirectory('authors', `/authors?${query}`)
await flushPromises()
expect(router.currentRoute.value.query.size).toBeUndefined()
expect(router.currentRoute.value.query.page).toBeUndefined()
expect(listCalls(backend).map((call) => call.raw)).toEqual([
'/v1/author/?page=1&size=25',
])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
},
)
describe('Authors: row menu for an owner or admin', () => {
it('offers Edit, Link user and Delete… for a guest and Edit and Delete… for a linked author', async () => {
await mountDirectory('authors', '/authors')
await openRowMenu('Camille Roche')
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
['Edit', 'Link user', 'Delete…'],
)
})
it('lists Edit and Delete… only for a linked author', async () => {
await mountDirectory('authors', '/authors')
await openRowMenu('Inès Duarte')
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
['Edit', 'Delete…'],
)
})
it('opens a bottom sheet below md', async () => {
await mountDirectory('authors', '/authors', { width: MOBILE_WIDTH })
await openRowMenu('Camille Roche')
expect(textOf(dialog()!.querySelector('h2'))).toBe('Camille Roche')
expect(dialog()!.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
})
})
describe('Authors: read-only list for other roles', () => {
it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => {
await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH })
expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION)
expect(button('Add author')).toBeUndefined()
expect(
Array.from(document.body.querySelectorAll('button[aria-label^="Actions for"]')),
).toHaveLength(0)
expect(document.body.querySelectorAll('button[aria-label^="View "]')).toHaveLength(4)
})
it('keeps search working and shows the Authors sidebar entry on desktop', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors', { role: 'member' })
expect(
document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'),
).not.toBeNull()
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by name or slug"]',
)!
await typeInto(input, 'lea')
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=lea&page=1&size=25')
})
})
describe('Authors: read-only detail', () => {
async function openDetail() {
await mountDirectory('authors', '/authors', { role: 'member' })
await click('View Inès Duarte')
}
it('shows the author with Close as the only action and no email', async () => {
await openDetail()
const text = textOf(dialog())
for (const copy of [
'Inès Duarte',
'Linked',
'9 articles',
'Bio',
'Website',
'Only an owner or admin can change this author.',
]) {
expect(text).toContain(copy)
}
expect(text).not.toContain('ines.duarte@renew.example')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
const actions = Array.from(dialog()!.querySelectorAll('button')).map(textOf)
expect(actions.filter((label) => label !== '')).toEqual(['Close'])
})
it('opens the same panel from /authors?edit=ines-duarte', async () => {
await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' })
expect(textOf(dialog())).toContain('Only an owner or admin can change this author.')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
})
})
void callsTo
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale, setLocale } from '@/i18n'
import { ApiError } from '@/composables/useApi'
import type { Author } from '@/composables/useAuthors'
import {
authorsFixture,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
labelled,
mountDirectory,
openRowMenu,
textOf,
typeInto,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function manyAuthors(count: number): Author[] {
return Array.from({ length: count }, (_, index) => ({
...authorsFixture()[1]!,
id: 500 + index,
name: `Writer ${String(index).padStart(3, '0')}`,
slug: `writer-${index}`,
article_count: index === 0 ? 3 : 1,
}))
}
describe('Author slug suggestions are never taken', () => {
it('skips slugs that are already on the loaded page and ones the server just refused', async () => {
const backend = createBackend()
backend.authors.push({
...authorsFixture()[1]!,
id: 90,
name: 'Other',
slug: 'camille-roche-2',
})
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
await mountDirectory('authors', '/authors', { backend })
await click('Add author')
await typeInto(field('e.g. Camille Roche'), 'Camille Roche')
await click('Add author', box())
expect(textOf(box())).toContain('Use camille-roche-3')
await click('Use camille-roche-3', box())
await click('Add author', box())
expect(textOf(box())).toContain('Use camille-roche-4')
expect((labelled('Slug') as HTMLInputElement).value).toBe('camille-roche-3')
})
})
describe('Author reassign options', () => {
it('walks every page of authors so nobody past the first hundred is missing', async () => {
const backend = createBackend()
backend.authors = manyAuthors(130)
await mountDirectory('authors', '/authors', { backend })
await openRowMenu('Writer 000')
await chooseMenuItem('Delete…')
const paths = backend.calls
.filter((call) => call.path === '/v1/author/')
.map((call) => call.raw)
expect(paths).toContain('/v1/author/?page=1&size=100')
expect(paths).toContain('/v1/author/?page=2&size=100')
const trigger = box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
trigger.click()
await flushPromises()
const labels = Array.from(box().querySelectorAll('[role="option"]')).map(textOf)
expect(labels.some((label) => label.includes('Writer 129'))).toBe(true)
expect(labels.some((label) => label.includes('Writer 000'))).toBe(false)
})
it('says so when the options cannot be loaded and still offers Nobody', async () => {
const backend = createBackend()
await mountDirectory('authors', '/authors', { backend })
backend.responders['GET /v1/author/?page=1&size=100'] = failWith(500)
await openRowMenu('Camille Roche')
await chooseMenuItem('Delete…')
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't load authors")
expect(alert).toContain('Check your connection and try again.')
expect(textOf(box().querySelector('button[aria-haspopup="listbox"]'))).toBe('Nobody')
})
})
describe('Making another site the default from the form', () => {
async function openProjects(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Projects')
await chooseMenuItem('Edit')
return backend
}
it('keeps the list truthful when only the default step fails and retries just that step', async () => {
const backend = createBackend()
let defaultCalls = 0
backend.responders['PATCH /v1/site/3/default'] = () => {
defaultCalls += 1
if (defaultCalls === 1) {
throw new ApiError(500)
}
backend.sites.forEach((site) => (site.is_default = site.id === 3))
return backend.sites.find((site) => site.id === 3)
}
await openProjects(backend)
await typeInto(field('e.g. Journal'), 'Projects 2')
box().querySelector<HTMLElement>('[role="switch"]')!.click()
await flushPromises()
await click('Save changes', box())
expect(textOf(box().querySelector('[role="alert"]'))).toContain("Couldn't save the site")
expect(
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
).toContain('Projects 2')
await click('Save changes', box())
expect(callsTo(backend, 'PATCH', '/v1/site/3')).toHaveLength(1)
expect(callsTo(backend, 'PATCH', '/v1/site/3/default')).toHaveLength(2)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
})
describe('Focus returns to the row menu button', () => {
it('puts focus back on the ellipsis button after closing a panel opened from the menu', async () => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Projects')
await chooseMenuItem('Edit')
await click('Cancel', box())
await flushPromises()
expect(document.activeElement).toBe(button('Actions for Projects'))
})
})
describe('Failure copy passes through the dialog in the active language', () => {
beforeEach(() => setLocale('fr'))
afterEach(() => initializeLocale())
it('shows the French notice when setting the default fails', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
await mountDirectory('sites', '/settings/sites', { backend })
await click('Actions pour Journal')
await chooseMenuItem('Définir par défaut')
await click('Définir par défaut', box())
expect(textOf(box().querySelector('[role="alert"]'))).toContain(
'Impossible d’enregistrer le site.',
)
})
})
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale, setLocale } from '@/i18n'
import { messages } from '@/i18n/messages'
import { chooseMenuItem, click, mountDirectory, textOf, unmountDirectory } from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const MIN_LENGTH = 14
const translatable = Object.entries(messages).filter(
([english, french]) =>
english !== french && !english.includes('{') && english.length >= MIN_LENGTH,
)
function leftovers() {
const shown = textOf(document.body)
return translatable.filter(([english]) => shown.includes(english)).map(([english]) => english)
}
beforeEach(() => {
localStorage.clear()
setLocale('fr')
})
afterEach(async () => {
await unmountDirectory()
localStorage.clear()
initializeLocale()
})
describe('Language: every string of the sites screen has a French entry', () => {
it('translates the list, the row menu and the add form', async () => {
await mountDirectory('sites', '/settings/sites')
expect(document.body.textContent).toContain('Les frontends qui affichent votre contenu.')
expect(leftovers()).toEqual([])
await click('Actions pour Journal')
expect(document.body.textContent).toContain('Définir par défaut')
expect(leftovers()).toEqual([])
await chooseMenuItem('Définir par défaut')
expect(leftovers()).toEqual([])
})
it('translates the add form and the delete dialog', async () => {
await mountDirectory('sites', '/settings/sites')
await click('Ajouter un site')
expect(leftovers()).toEqual([])
})
it('translates the delete dialog', async () => {
await mountDirectory('sites', '/settings/sites')
await click('Actions pour Journal')
await chooseMenuItem('Supprimer…')
expect(document.body.textContent).toContain('Supprimer ce site ?')
expect(leftovers()).toEqual([])
})
it('translates the 403 page', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
expect(document.body.textContent).toContain('Vous n’avez pas accès aux sites')
expect(document.body.textContent).toContain('Retour aux paramètres')
expect(leftovers()).toEqual([])
})
})
describe('Language: every string of the authors screen has a French entry', () => {
it('translates the list, the add form and the delete dialog', async () => {
await mountDirectory('authors', '/authors')
expect(document.body.textContent).toContain('Auteurs')
expect(
document.body.querySelector('input[placeholder="Rechercher par nom ou identifiant"]'),
).not.toBeNull()
expect(leftovers()).toEqual([])
await click('Ajouter un auteur')
await flushPromises()
expect(document.body.textContent).toContain('Aperçu de la signature')
expect(leftovers()).toEqual([])
})
it('translates the delete dialog', async () => {
await mountDirectory('authors', '/authors')
await click('Actions pour Camille Roche')
await chooseMenuItem('Supprimer…')
expect(document.body.textContent).toContain('Supprimer cet auteur ?')
expect(leftovers()).toEqual([])
})
it('translates the read-only list', async () => {
await mountDirectory('authors', '/authors', { role: 'member' })
expect(document.body.textContent).toContain('Seul un propriétaire ou un administrateur')
expect(leftovers()).toEqual([])
})
})
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } 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 { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
let wrapper: VueWrapper | null = null
function reply(status: number, body = '') {
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } })
}
async function mountWith(
view: typeof AuthorsView | typeof SitesView,
path: string,
verified: boolean,
) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.status = 'ready'
await router.push(path)
wrapper = mount(defineComponent({ render: () => h(UApp, null, { default: () => h(view) }) }), {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.unstubAllGlobals()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Session problems use the app-wide handling', () => {
it.each([
['/authors', AuthorsView, '/v1/author/'],
['/settings/sites', SitesView, '/v1/site/'],
])('sends a 401 on %s to the login page with the expired reason', async (path, view, api) => {
const calls: string[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (input: string) => {
calls.push(input)
return reply(401)
}),
)
await mountWith(view, path, true)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(calls.some((call) => call.startsWith(api))).toBe(true)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toEqual({ redirect: path, reason: 'expired' })
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('sends an unverified 403 on /authors to /register/pending', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => reply(403)),
)
await mountWith(AuthorsView, '/authors', false)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
})
@@ -1505,6 +1505,7 @@ describe('Sidebar', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the seven navigation items as the current page', async () => {
it('marks none of the eight navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(7)
expect(links).toHaveLength(8)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
+34 -2
View File
@@ -30,10 +30,10 @@ function deferred<T>() {
return { promise, resolve }
}
async function mountPage(settings: () => Promise<unknown>) {
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1 }
session.session = { verified: true, username: 'marta', id: 1, ...(role ? { role } : {}) }
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === SETTINGS_PATH) {
@@ -185,3 +185,35 @@ describe('A 401 or an unverified 403 is handled globally, not by the page', () =
expect(tryAgainButton()).toBeUndefined()
})
})
describe('Publishing card: the owner manages sites from Settings', () => {
it('shows a Publishing card with a Manage sites row linking to /settings/sites', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
const card = document.body.querySelector('[data-testid="publishing-links-card"]')!
expect(card.textContent).toContain('Publishing')
expect(card.textContent).toContain('Where your content is shown.')
const row = card.querySelector('a')!
expect(row.getAttribute('href')).toBe('/settings/sites')
expect(row.textContent).toContain('Manage sites')
expect(row.textContent).toContain('Base URLs, default image and robots policy')
})
it('shows the sidebar Publishing group with a Sites entry linking to /settings/sites', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(nav.textContent).toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')?.textContent).toContain('Sites')
})
it('hides the Publishing card, the Manage sites row and the Sites entry from an admin', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'admin')
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
expect(document.body.textContent).not.toContain('Manage sites')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(nav.textContent).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
})
})
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
textOf,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
async function openDelete(name: string, backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu(name)
await chooseMenuItem('Delete…')
return backend
}
function box() {
return dialog()!
}
function trigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
describe('Sites delete: site with articles', () => {
it('explains, defaults the destination to the default site and shows the move summary', async () => {
await openDelete('Journal')
const text = textOf(box())
expect(box().getAttribute('role')).toBe('alertdialog')
expect(text).toContain('Delete this site?')
expect(text).toContain(
'"Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can\'t be undone.',
)
expect(text).toContain('Move articles to')
expect(textOf(trigger())).toBe('Renew · renew.example')
expect(text).toContain(
'38 articles will move to Renew. Their canonical host changes to renew.example.',
)
expect(button('Cancel', box())).toBeTruthy()
expect(button('Move 38 articles and delete', box())).toBeTruthy()
})
})
describe('Sites delete: choose destination', () => {
it('does not offer the deleted site and shows the name and the host on each option', async () => {
await openDelete('Journal')
trigger().click()
await flushPromises()
const options = Array.from(box().querySelectorAll('[role="option"]')).map((option) =>
textOf(option),
)
expect(options).toEqual([
'Renew renew.example',
'Projects projects.example',
'Archive archive.example',
])
})
})
describe('Sites delete: with articles succeeds', () => {
it('sends one DELETE with reassign_to, reads Moving articles… and toasts', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['DELETE /v1/site/2'] = () => hold.promise
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(button('Moving articles…', box())).toBeTruthy()
hold.resolve(undefined)
await flushPromises()
const deletes = callsTo(backend, 'DELETE', '/v1/site/2')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.get('reassign_to')).toBe('1')
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('Site deleted')
expect(document.body.textContent).toContain('38 articles moved to Renew.')
})
})
describe('Sites delete: no articles', () => {
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
const backend = await openDelete('Archive')
const text = textOf(box())
expect(text).toContain('Delete this site?')
expect(text).toContain(
'"Archive" will be deleted. No articles belong to it. This can\'t be undone.',
)
expect(trigger()).toBeNull()
expect(button('Delete site', box())).toBeTruthy()
await click('Delete site', box())
const deletes = callsTo(backend, 'DELETE', '/v1/site/4')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
expect(rowTexts().join(' ')).not.toContain('Archive')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites delete: failure with %s', (_name, responder) => {
it('keeps the dialog open with the notice and enables the buttons again', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = responder
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't delete the site")
expect(alert).toContain('Nothing was changed. Try again in a moment.')
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
expect(button('Move 38 articles and delete', box())!.hasAttribute('disabled')).toBe(false)
})
})
describe('Sites delete: server refusals', () => {
it('keeps the dialog open and asks for a destination on 409 site_has_articles', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(409, 'site_has_articles')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).not.toBeNull()
expect(textOf(box())).toContain('Choose where the articles go. This site has 38 articles.')
})
it('closes and toasts on 403 default_site', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(403, 'default_site')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain(
"The default site can't be deleted. Make another site the default first.",
)
})
it('closes, removes the row and toasts on 404', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(404, 'site_not_found')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('That site no longer exists')
})
})
+417
View File
@@ -0,0 +1,417 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useApi } from '@/composables/useApi'
import {
blur,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
labelled,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
textOf,
typeInto,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const BASE_URL_MESSAGES = {
scheme: 'Use https. For example https://blog.example.com.',
path: 'Remove the path. Use only the host, like https://blog.example.com.',
invalid: 'Enter a full URL, like https://blog.example.com.',
}
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
afterEach(unmountDirectory)
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('sites', '/settings/sites', options)
await click('Add site')
return backend
}
async function openEdit(name: string) {
const backend = await mountDirectory('sites', '/settings/sites')
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
async function fillValid(name = 'Journal 2', url = 'https://journal2.example') {
await typeInto(field('e.g. Journal'), name)
await typeInto(field('https://blog.example.com'), url)
}
function box() {
return dialog()!
}
function submit(label = 'Add site') {
return click(label, box())
}
describe('Sites form: add, empty', () => {
it('opens a slide-over with every field, placeholder and help', async () => {
await openAdd()
const text = textOf(box())
for (const copy of [
'Add site',
'Name',
'Key',
'Base URL',
'The public address of the frontend. Use https and no path.',
'Default social image (optional)',
'Choose image',
'Used when an article has no image of its own.',
'Default robots policy',
'Index',
'Noindex',
"Search engines may list this site's pages.",
'Locale (optional)',
'A language tag. Leave empty if the site has no single language.',
'Make this the default site',
'New articles start here. The current default is Renew.',
'Canonical URL preview',
'Cancel',
]) {
expect(text).toContain(copy)
}
expect(field('e.g. Journal')).toBeTruthy()
expect(field('Generated from the name')).toBeTruthy()
expect(field('https://blog.example.com')).toBeTruthy()
expect(field('e.g. en or fr-CH')).toBeTruthy()
expect(button('Add site', box())).toBeTruthy()
})
it('focuses Name', async () => {
await openAdd()
await flushPromises()
expect(document.activeElement).toBe(field('e.g. Journal'))
})
})
describe('Sites form: key from the name', () => {
it('fills the key with an Auto badge and unlocks it with the pencil', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Journal Online')
const key = labelled('Key') as HTMLInputElement
expect(key.value).toBe('journal-online')
expect(textOf(box())).toContain('Auto')
expect(textOf(box())).toContain(
"Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.",
)
await click('Edit key', box())
await typeInto(labelled('Key') as HTMLInputElement, 'custom-key')
await typeInto(field('e.g. Journal'), 'Something else')
expect((labelled('Key') as HTMLInputElement).value).toBe('custom-key')
expect(textOf(box())).not.toContain('Auto')
})
})
describe.each([
['http://blog.example.com', BASE_URL_MESSAGES.scheme],
['https://blog.example.com/en', BASE_URL_MESSAGES.path],
['blog', BASE_URL_MESSAGES.invalid],
])('Sites form: base URL "%s" is not valid', (value, message) => {
it('shows the message on leaving the field and sends nothing', async () => {
const backend = await openAdd()
await fillValid('Journal 2', value)
await blur(field('https://blog.example.com'))
expect(textOf(box())).toContain(message)
await submit()
expect(callsTo(backend, 'POST', '/v1/site/')).toHaveLength(0)
expect(textOf(box())).toContain(message)
})
})
describe('Sites form: base URL refused by the server', () => {
it('shows the generic URL message on 400 invalid_site_url', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_site_url')
await openAdd({ backend })
await fillValid()
await submit()
expect(textOf(box())).toContain(BASE_URL_MESSAGES.invalid)
})
})
describe.each(['http://localhost:5173', 'http://127.0.0.1:8081', 'http://[::1]:5173'])(
'Sites form: loopback host %s accepts http',
(value) => {
it('shows no base URL error', async () => {
await openAdd()
await fillValid('Journal 2', value)
await blur(field('https://blog.example.com'))
const text = textOf(box())
expect(text).not.toContain(BASE_URL_MESSAGES.scheme)
expect(text).not.toContain(BASE_URL_MESSAGES.invalid)
expect(text).not.toContain(BASE_URL_MESSAGES.path)
})
},
)
describe('Sites form: add succeeds', () => {
it('sends exactly one POST, shows Saving… with disabled fields, then closes and lists the site', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['POST /v1/site/'] = () => hold.promise
await openAdd({ backend })
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
await flushPromises()
await submit()
expect(button('Saving…', box())).toBeTruthy()
expect(field('e.g. Journal').disabled).toBe(true)
hold.resolve({
id: 5,
name: 'Notes',
key: 'notes',
base_url: 'https://notes.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 0,
})
await flushPromises()
const posts = callsTo(backend, 'POST', '/v1/site/')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toEqual({
name: 'Notes',
key: 'notes',
base_url: 'https://notes.example',
default_robots: 'noindex',
is_default: false,
})
expect(dialog()).toBeNull()
expect(rowTexts().at(-1)).toContain('Notes')
})
})
describe('Sites form: duplicate key', () => {
it('flags the key on leaving the field and applies the suggestion', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Journal')
await click('Edit key', box())
const key = labelled('Key') as HTMLInputElement
await blur(key)
expect(textOf(box())).toContain(
'A site with the key "journal" already exists. Choose another key.',
)
await click('Use journal-2', box())
expect((labelled('Key') as HTMLInputElement).value).toBe('journal-2')
expect(textOf(box())).not.toContain('already exists')
})
it('flags the key when the server answers 409 site_key_taken', async () => {
const backend = createBackend()
backend.sites = backend.sites.filter((site) => site.key !== 'journal')
backend.responders['POST /v1/site/'] = failWith(409, 'site_key_taken')
await openAdd({ backend })
await fillValid('Journal', 'https://journal.example')
await submit()
expect(textOf(box())).toContain(
'A site with the key "journal" already exists. Choose another key.',
)
expect(textOf(box())).toContain('Use journal-2')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites form: save failed with %s', (_name, responder) => {
it('shows the notice, keeps the values and enables the form again', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = responder
await openAdd({ backend })
await fillValid()
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(field('e.g. Journal').value).toBe('Journal 2')
expect(field('https://blog.example.com').value).toBe('https://journal2.example')
expect(field('e.g. Journal').disabled).toBe(false)
expect(button('Add site', box())).toBeTruthy()
})
})
describe('Sites form: invalid locale from the server', () => {
it('shows the locale message and keeps the panel open', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_locale')
await openAdd({ backend })
await fillValid()
await typeInto(field('e.g. en or fr-CH'), 'english')
await submit()
expect(textOf(box())).toContain('Use a language tag like en or fr-CH.')
expect(dialog()).not.toBeNull()
})
})
describe('Sites form: edit the default site', () => {
it('fills the fields, locks the key and keeps the default switch on and locked', async () => {
await openEdit('Renew')
const text = textOf(box())
expect(text).toContain('Edit Renew')
expect(field('e.g. Journal').value).toBe('Renew')
const key = labelled('Key') as HTMLInputElement
expect(key.value).toBe('renew')
expect(key.readOnly || key.disabled).toBe(true)
expect(box().querySelector('[data-testid="slug-lock"]')).not.toBeNull()
expect(text).toContain("Used in API paths, so it can't change.")
expect(text).toContain('Default site')
expect(text).toContain(
'New articles start on the default site. To change it, make another site the default.',
)
const toggle = box().querySelector<HTMLButtonElement>('[role="switch"]')!
expect(toggle.getAttribute('aria-checked')).toBe('true')
expect(toggle.disabled || toggle.getAttribute('data-disabled') !== null).toBe(true)
expect(button('Save changes', box())).toBeTruthy()
})
})
describe('Sites form: edit another site, noindex', () => {
it('shows Noindex selected with its help and sends a PATCH without the key', async () => {
const backend = await openEdit('Projects')
const text = textOf(box())
expect(text).toContain('Edit Projects')
expect(text).toContain("Asks search engines not to list this site's pages.")
expect(text).toContain('Make this the default site')
const noindex = box().querySelector('[role="radio"][value="noindex"]')!
expect(noindex.getAttribute('aria-checked')).toBe('true')
await typeInto(field('e.g. Journal'), 'Projects 2')
await submit('Save changes')
const patches = callsTo(backend, 'PATCH', '/v1/site/3')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ name: 'Projects 2' })
expect(patches[0]!.body).not.toHaveProperty('key')
})
})
describe('Sites form: make another site the default from the form', () => {
it('sends the changed fields, then the default route, and never is_default in the first PATCH', async () => {
const backend = await openEdit('Projects')
await typeInto(field('e.g. Journal'), 'Projects 2')
box().querySelector<HTMLElement>('[role="switch"]')!.click()
await flushPromises()
await submit('Save changes')
const order = backend.calls
.filter((call) => call.method === 'PATCH')
.map((call) => call.path)
expect(order).toEqual(['/v1/site/3', '/v1/site/3/default'])
expect(backend.calls.find((call) => call.path === '/v1/site/3')!.body).not.toHaveProperty(
'is_default',
)
expect(
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
).toContain('Default')
})
})
describe('Sites form: discard changes', () => {
it('asks before closing a changed form with Escape and keeps it on Keep editing', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
const ask = document.body.querySelector('[role="alertdialog"]')!
expect(textOf(ask)).toContain('Discard changes?')
await click('Keep editing', ask)
expect(field('e.g. Journal').value).toBe('Notes')
})
it('discards on confirmation and closes an unchanged form with Cancel straight away', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await click('Cancel', box())
await click('Discard changes', document.body.querySelector('[role="alertdialog"]')!)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await click('Add site')
await click('Cancel', box())
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
})
describe('Sites form: deep link', () => {
it('opens the edit slide-over for a known key', async () => {
await mountDirectory('sites', '/settings/sites?edit=projects')
expect(textOf(box())).toContain('Edit Projects')
})
it('shows an info toast and opens nothing for an unknown key', async () => {
await mountDirectory('sites', '/settings/sites?edit=nope')
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('That site no longer exists')
})
})
describe('Slide-over accessibility', () => {
it('moves focus into the panel and returns it to the opening control on close', async () => {
await mountDirectory('sites', '/settings/sites')
const opener = button('Add site')!
opener.focus()
opener.click()
await flushPromises()
await flushPromises()
expect(box().contains(document.activeElement)).toBe(true)
await click('Cancel', box())
await flushPromises()
expect(document.activeElement).toBe(opener)
})
it('ignores Escape while a request is running', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = () => pending<unknown>().promise
await openAdd({ backend })
await fillValid()
await submit()
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(dialog()).not.toBeNull()
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
})
})
void useApi
+323
View File
@@ -0,0 +1,323 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
rows,
textOf,
unmountDirectory,
button,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SKELETON_DELAY_MS = 150
const DEFAULT_SITE_TIP = "The default site can't be deleted. Make another site the default first."
afterEach(unmountDirectory)
describe('Sites: default (owner)', () => {
it('shows the header, the columns, the rows, the counts and the badges', async () => {
await mountDirectory('sites', '/settings/sites')
expect(document.body.querySelector('h1')?.textContent).toBe('Sites')
expect(document.body.textContent).toContain('The frontends that display your content.')
expect(button('Add site')).toBeTruthy()
const header = document.body.querySelector(
'[data-testid="directory-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Site Base URL Articles')
expect(rowTexts()).toEqual([
'Renew Default renew https://renew.example 268 articles',
'Journal journal https://journal.example 38 articles',
'Projects Noindex projects https://projects.example 6 articles',
'Archive archive https://archive.example No articles',
])
})
it('draws cards below md with a full-width Add site button pinned to the bottom', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
expect(document.body.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(4)
expect(document.body.querySelector('[data-testid="directory-row"]')).toBeNull()
const pinned = button('Add site')!
expect(pinned.className).toContain('fixed')
expect(pinned.className).toContain('inset-x-4')
})
})
describe('Sites: only the default site', () => {
it('lists the row with the hint and draws no empty-state wall', async () => {
const backend = createBackend()
backend.sites = backend.sites.slice(0, 1)
await mountDirectory('sites', '/settings/sites', { backend })
expect(rows()).toHaveLength(1)
expect(document.body.textContent).toContain(
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.',
)
expect(document.body.textContent).not.toContain('No sites yet')
})
})
describe('Sites: loading', () => {
it('draws nothing before 150 ms, then skeleton rows with aria-busy', async () => {
vi.useFakeTimers()
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['GET /v1/site/'] = () => hold.promise
await mountDirectory('sites', '/settings/sites', { backend })
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(0)
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(3)
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites: error on load with %s', (_name, responder) => {
it('shows the alert, no rows, and Try again requests the list again', async () => {
const backend = createBackend()
backend.responders['GET /v1/site/'] = responder
await mountDirectory('sites', '/settings/sites', { backend })
const alert = document.body.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load sites")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(rows()).toHaveLength(0)
expect(textOf(alert)).not.toContain('500')
delete backend.responders['GET /v1/site/']
await click('Try again')
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(4)
})
})
describe('Sites: row menu', () => {
it('offers Edit, Set as default and Delete… for a non-default site', async () => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Journal')
const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
expect(labels).toEqual(['Edit', 'Set as default', 'Delete…'])
})
it('opens a bottom sheet titled with the site name below md', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await openRowMenu('Journal')
const sheet = dialog()!
expect(textOf(sheet.querySelector('h2'))).toBe('Journal')
expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
})
})
describe('Sites: delete unavailable on the default site', () => {
it('keeps Delete… focusable with aria-disabled and sends nothing when activated', async () => {
const backend = await mountDirectory('sites', '/settings/sites')
await openRowMenu('Renew')
const item = button(/^Delete…/)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.hasAttribute('disabled')).toBe(false)
expect(item.getAttribute('data-disabled')).toBeNull()
expect(textOf(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
DEFAULT_SITE_TIP,
)
item.focus()
expect(document.activeElement).toBe(item)
item.click()
await flushPromises()
expect(dialog()?.getAttribute('role')).not.toBe('alertdialog')
expect(backend.calls.filter((call) => call.method === 'DELETE')).toHaveLength(0)
})
it('writes the explanation in the sheet below md', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await openRowMenu('Renew')
expect(textOf(dialog())).toContain(DEFAULT_SITE_TIP)
expect(button('Delete…')!.getAttribute('aria-disabled')).toBe('true')
})
})
describe('Sites: set as default', () => {
async function openDialog(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Journal')
await chooseMenuItem('Set as default')
return backend
}
it('asks, sends one PATCH without a body, shows Saving… and moves the badge with a toast', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['PATCH /v1/site/2/default'] = () => hold.promise
await openDialog(backend)
const box = dialog()!
expect(textOf(box)).toContain('Make Journal the default site?')
expect(textOf(box)).toContain(
'New articles start on this site. Articles that are already on other sites stay where they are.',
)
expect(button('Cancel', box)).toBeTruthy()
await click('Make default', box)
expect(button('Saving…', box)).toBeTruthy()
hold.resolve({})
await flushPromises()
const patches = callsTo(backend, 'PATCH', '/v1/site/2/default')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toBeUndefined()
expect(textOf(rows()[1])).toContain('Default')
expect(textOf(rows()[0])).not.toContain('Default')
expect(document.body.textContent).toContain('Default site changed')
expect(document.body.textContent).toContain('"Journal" is now the default site.')
})
it('keeps the dialog open with a notice on a 500', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).not.toBeNull()
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toContain(
"Couldn't save the site. Your changes are still here. Try again in a moment.",
)
})
it('closes, removes the row and toasts on a 404', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(404, 'site_not_found')
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('That site no longer exists')
expect(document.body.textContent).toContain(
'It was deleted somewhere else, so we removed it from the list.',
)
})
it('closes and shows the owner-only toast on a 403', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(
403,
'Only the owner can do this.',
)
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
})
describe.each([
['admin', '/settings/sites'],
['member', '/settings/sites'],
['admin', '/settings/sites?edit=journal'],
])('Sites: forbidden (403 page) for %s on %s', (role, path) => {
it('explains instead of redirecting, opens no panel and Back to Settings goes to /settings', async () => {
const backend = await mountDirectory('sites', path, { role })
const text = document.body.textContent ?? ''
expect(text).toContain("You don't have access to Sites")
expect(text).toContain(
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
)
expect(router.currentRoute.value.fullPath).toBe(path)
expect(dialog()).toBeNull()
expect(
document.body.querySelector('nav[aria-label="Main"] a[href="/settings/sites"]'),
).toBeNull()
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(0)
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
await click('Back to Settings')
expect(push).toHaveBeenCalledWith('/settings')
push.mockRestore()
})
})
describe('Sites: the API answers 403 where the SPA expected the owner', () => {
it('shows the 403 page when the answer is for the page load', async () => {
const backend = createBackend()
backend.responders['GET /v1/site/'] = failWith(403, 'Only the owner can do this.')
await mountDirectory('sites', '/settings/sites', { backend })
expect(document.body.textContent).toContain("You don't have access to Sites")
expect(rows()).toHaveLength(0)
})
it('shows an error toast when the answer is for an action', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/4'] = failWith(403, 'Only the owner can do this.')
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Archive')
await chooseMenuItem('Delete…')
await click('Delete site', dialog()!)
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
})
describe('Sites: the sidebar for the owner and for an admin', () => {
it('shows a Publishing group with a Sites entry linking to /settings/sites for the owner', async () => {
await mountDirectory('sites', '/settings/sites')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).toContain('Publishing')
const link = nav.querySelector<HTMLAnchorElement>('a[href="/settings/sites"]')!
expect(textOf(link)).toBe('Sites')
expect(link.getAttribute('aria-current')).toBe('page')
})
it('has no Publishing group and no Sites entry for an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
})
})
void useApi
void DESKTOP_WIDTH
+445
View File
@@ -0,0 +1,445 @@
import { vi } from 'vitest'
import { defineComponent, h } 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 { ApiError, NetworkError, useApi, type MeAuthor } from '@/composables/useApi'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
export interface Call {
method: string
path: string
raw: string
query: URLSearchParams
body: Record<string, unknown> | undefined
}
export type Responder = (call: Call) => unknown | Promise<unknown>
export interface Backend {
sites: Site[]
authors: Author[]
users: unknown[]
calls: Call[]
responders: Record<string, Responder>
}
export function sitesFixture(): Site[] {
const base = {
default_social_image_id: null,
default_robots: 'index' as const,
locale: null,
}
return [
{
...base,
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
is_default: true,
article_count: 268,
},
{
...base,
id: 2,
name: 'Journal',
key: 'journal',
base_url: 'https://journal.example',
is_default: false,
article_count: 38,
},
{
...base,
id: 3,
name: 'Projects',
key: 'projects',
base_url: 'https://projects.example',
default_robots: 'noindex',
is_default: false,
article_count: 6,
},
{
...base,
id: 4,
name: 'Archive',
key: 'archive',
base_url: 'https://archive.example',
is_default: false,
article_count: 0,
},
]
}
function author(overrides: Partial<Author> & Pick<Author, 'id' | 'name' | 'slug'>): Author {
return {
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 0,
...overrides,
}
}
export function authorsFixture(withEmails = true): Author[] {
const list = [
author({
id: 2,
name: 'Inès Duarte',
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter.',
website: 'https://ines-duarte.example',
social_links: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
user_id: 3,
is_linked: true,
article_count: 9,
user_email: 'ines.duarte@renew.example',
}),
author({
id: 7,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
article_count: 3,
}),
author({
id: 9,
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
slug: 'dr-alexandra-konstantinopoulou-whitfield',
article_count: 1,
}),
author({ id: 10, name: 'Léa Fontaine', slug: 'lea-fontaine' }),
]
return withEmails ? list : list.map((item) => ({ ...item, user_email: undefined }))
}
export function usersFixture() {
const user = (id: number, username: string, display: string, email: string, role: string) => ({
kind: 'user',
id,
username,
display_name: display,
email,
role,
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
})
return [
user(3, 'ines', 'Inès Duarte', 'ines.duarte@renew.example', 'admin'),
user(1, 'marta', 'Marta Alves', 'marta@renew.example', 'owner'),
user(4, 'rui', 'Rui Santos', 'rui@renew.example', 'admin'),
]
}
function fail(status: number, code?: string): never {
throw new ApiError(status, undefined, code)
}
function parseBody(options: RequestInit | undefined) {
return typeof options?.body === 'string'
? (JSON.parse(options.body) as Record<string, unknown>)
: undefined
}
function pageOf(backend: Backend, query: URLSearchParams) {
const q = (query.get('q') ?? '').toLowerCase()
const size = Number(query.get('size') ?? 25)
const page = Number(query.get('page') ?? 1)
const matching = backend.authors.filter((item) =>
`${item.name} ${item.slug}`.toLowerCase().includes(q),
)
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
}
}
function defaultRoute(backend: Backend, call: Call): unknown {
const { method, path, query, body } = call
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
if (method === 'GET' && path === '/v1/site/') {
return structuredClone(backend.sites)
}
if (method === 'POST' && path === '/v1/site/') {
if (backend.sites.some((site) => site.key === body?.key)) {
fail(409, 'site_key_taken')
}
const created = {
id: 100,
default_social_image_id: null,
locale: null,
article_count: 0,
...body,
} as unknown as Site
backend.sites.push(created)
return created
}
if (siteMatch && method === 'PATCH') {
const site = backend.sites.find((candidate) => candidate.id === Number(siteMatch[1]))
if (!site) {
fail(404, 'site_not_found')
}
if (siteMatch[2]) {
backend.sites.forEach((candidate) => (candidate.is_default = candidate === site))
} else {
Object.assign(site, body)
}
return structuredClone(site)
}
if (siteMatch && method === 'DELETE') {
const id = Number(siteMatch[1])
const site = backend.sites.find((candidate) => candidate.id === id)
if (!site) {
fail(404, 'site_not_found')
}
if (site.is_default) {
fail(403, 'default_site')
}
const reassign = query.get('reassign_to')
if (site.article_count > 0 && reassign === null) {
fail(409, 'site_has_articles')
}
backend.sites = backend.sites.filter((candidate) => candidate.id !== id)
return undefined
}
if (method === 'GET' && path === '/v1/author/') {
return pageOf(backend, query)
}
if (method === 'POST' && path === '/v1/author/') {
const created = { id: 200, is_linked: body?.user_id != null, article_count: 0, ...body }
backend.authors.push(created as unknown as Author)
return created
}
if (authorMatch && method === 'PATCH') {
const target = backend.authors.find((candidate) => candidate.id === Number(authorMatch[1]))
if (!target) {
fail(404, 'author_not_found')
}
Object.assign(target, body)
if (body && 'user_id' in body) {
target.is_linked = body.user_id !== null
}
return structuredClone(target)
}
if (authorMatch && method === 'DELETE') {
const id = Number(authorMatch[1])
backend.authors = backend.authors.filter((candidate) => candidate.id !== id)
return undefined
}
if (method === 'GET' && path === '/v1/user/') {
return backend.users
}
if (method === 'GET' && path === '/v1/media/') {
return {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
}
throw new Error(`Unexpected request: ${method} ${path}`)
}
export function createBackend(): Backend {
return {
sites: sitesFixture(),
authors: authorsFixture(),
users: usersFixture(),
calls: [],
responders: {},
}
}
export function callsTo(backend: Backend, method: string, path: string) {
return backend.calls.filter((call) => call.method === method && call.path === path)
}
export function failWith(status: number, code?: string): Responder {
return () => fail(status, code)
}
export const networkFailure: Responder = () => {
throw new NetworkError()
}
export function pending<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
export interface MountOptions {
role?: string
width?: number
backend?: Backend
author?: MeAuthor
email?: string
}
export const kit: { wrapper: VueWrapper | null; backend: Backend } = {
wrapper: null,
backend: createBackend(),
}
export async function mountDirectory(
view: 'sites' | 'authors',
url: string,
options: MountOptions = {},
) {
setViewportWidth(options.width ?? DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
setActivePinia(createPinia())
const backend = options.backend ?? createBackend()
kit.backend = backend
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
email: options.email ?? 'marta@renew.example',
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
...(options.author ? { author: options.author } : {}),
}
session.status = 'ready'
const request = vi.fn(async (path: string, init?: RequestInit) => {
const [pathname = '', search = ''] = path.split('?')
const call: Call = {
method: init?.method ?? 'GET',
path: pathname,
raw: path,
query: new URLSearchParams(search),
body: parseBody(init),
}
backend.calls.push(call)
const responder =
backend.responders[`${call.method} ${path}`] ??
backend.responders[`${call.method} ${pathname}`]
return responder ? responder(call) : defaultRoute(backend, call)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(url)
const component = view === 'sites' ? SitesView : AuthorsView
kit.wrapper = mount(
defineComponent({ render: () => h(UApp, null, { default: () => h(component) }) }),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
return backend
}
export async function unmountDirectory() {
kit.wrapper?.unmount()
kit.wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
if (!element) {
return ''
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
export function button(label: string | RegExp, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('button, [role="menuitem"], a')).find(
(element) => {
const text = element.getAttribute('aria-label') || textOf(element)
return typeof label === 'string' ? text === label : label.test(text)
},
)
}
export async function click(label: string | RegExp, root: ParentNode = document.body) {
const target = button(label, root)
if (!target) {
throw new Error(`No control named ${String(label)}`)
}
target.click()
await flushPromises()
}
export function dialog() {
return document.body.querySelector<HTMLElement>('[role="dialog"], [role="alertdialog"]')
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[data-testid="directory-row"], [data-testid="directory-card"]',
),
)
}
export function rowTexts() {
return rows().map((row) => textOf(row))
}
export async function openRowMenu(name: string) {
await click(`Actions for ${name}`)
}
export async function chooseMenuItem(label: string) {
await click(label)
}
export async function typeInto(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
element.value = value
element.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function blur(element: HTMLElement) {
element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
element.dispatchEvent(new Event('blur'))
await flushPromises()
}
export function field(placeholder: string) {
return document.body.querySelector<HTMLInputElement>(`[placeholder="${placeholder}"]`)!
}
export function labelled(label: string) {
const labelElement = Array.from(document.body.querySelectorAll('label')).find((element) =>
textOf(element).startsWith(label),
)
const id = labelElement?.getAttribute('for')
return id ? document.getElementById(id)! : null
}
export function toasts() {
return textOf(
document.body.querySelector(
'[data-slot="viewport"], [role="region"][aria-label*="otification"]',
),
)
}