feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)

This commit is contained in:
Florian Sylvain committed 2026-10-15 05:25:44 +02:00
1 parent 3d81d2c717
commit 6b8990c55e
239 files changed
+9194 -1354

No files matched your search

+3 -1
View File
@@ -125,7 +125,9 @@ function moveFocus(event: KeyboardEvent) {
<USeparator class="my-1" />
<div role="group" :aria-labelledby="themeLabelId">
<div :id="themeLabelId" :class="SECTION_LABEL_CLASS">{{ t('Theme') }}</div>
<div :id="themeLabelId" :class="SECTION_LABEL_CLASS">
{{ t('Theme') }}
</div>
<UButton
v-for="option in colorModeOptions"
:key="option.value"
+16 -4
View File
@@ -4,9 +4,18 @@ import { computed } from 'vue'
export type ActivityBadgeTone = 'ok' | 'fail' | 'security' | 'deleted' | 'neutral'
const TONES: Record<ActivityBadgeTone, { class: string; icon?: string }> = {
ok: { class: 'border border-accented bg-transparent text-toned', icon: 'i-lucide-check' },
fail: { class: 'bg-(--rc-error-soft) text-error', icon: 'i-lucide-circle-x' },
security: { class: 'bg-(--rc-warning-soft) text-warning', icon: 'i-lucide-shield-alert' },
ok: {
class: 'border border-accented bg-transparent text-toned',
icon: 'i-lucide-check',
},
fail: {
class: 'bg-(--rc-error-soft) text-error',
icon: 'i-lucide-circle-x',
},
security: {
class: 'bg-(--rc-warning-soft) text-warning',
icon: 'i-lucide-shield-alert',
},
deleted: {
class: 'border border-accented bg-transparent text-toned',
icon: 'i-lucide-trash-2',
@@ -29,6 +38,9 @@ const config = computed(() => TONES[props.tone])
:label="label"
:icon="config.icon"
:class="config.class"
:ui="{ base: 'h-5 shrink-0 gap-1 px-1.5 whitespace-nowrap', leadingIcon: 'size-3' }"
:ui="{
base: 'h-5 shrink-0 gap-1 px-1.5 whitespace-nowrap',
leadingIcon: 'size-3',
}"
/>
</template>
+18 -6
View File
@@ -136,19 +136,27 @@ function onOpenChange(isOpen: boolean) {
}}</span>
</dd>
<dt class="text-muted">{{ t('Actor') }}</dt>
<dd class="m-0 min-w-0"><ActivityActor :actor="current.actor" /></dd>
<dd class="m-0 min-w-0">
<ActivityActor :actor="current.actor" />
</dd>
<dt class="text-muted">{{ t('Source') }}</dt>
<dd class="m-0 text-highlighted">{{ sourceLabel(current.source) }}</dd>
<dd class="m-0 text-highlighted">
{{ sourceLabel(current.source) }}
</dd>
<dt class="text-muted">{{ t('IP address') }}</dt>
<dd v-if="current.ip" class="m-0 font-mono text-xs/5 text-highlighted">
{{ current.ip }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
<dd v-else class="m-0 text-muted">
{{ t('Not applicable') }}
</dd>
<dt class="text-muted">{{ t('Browser') }}</dt>
<dd v-if="current.user_agent" class="m-0 text-highlighted">
{{ current.user_agent }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
<dd v-else class="m-0 text-muted">
{{ t('Not applicable') }}
</dd>
</dl>
<section
@@ -156,7 +164,9 @@ function onOpenChange(isOpen: boolean) {
class="flex flex-col gap-2"
data-testid="activity-target-section"
>
<h3 class="text-base font-semibold text-highlighted">{{ t('Target') }}</h3>
<h3 class="text-base font-semibold text-highlighted">
{{ t('Target') }}
</h3>
<div class="flex flex-col gap-1.5 border border-default px-3 py-2.5">
<div class="flex min-w-0 items-center gap-2">
<template v-if="!current.target.exists">
@@ -191,7 +201,9 @@ function onOpenChange(isOpen: boolean) {
</section>
<section v-if="current.changes.length > 0" class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">{{ t('Changes') }}</h3>
<h3 class="text-base font-semibold text-highlighted">
{{ t('Changes') }}
</h3>
<ActivityDiff :changes="current.changes" :is-desktop="isDesktop" />
</section>
</div>
+14 -4
View File
@@ -34,7 +34,10 @@ const CLOSE_BUTTON = {
const preparing = computed(() => props.stage === 'preparing')
const rangeItems = computed(() =>
EXPORT_RANGE_KEYS.map((key) => ({ value: key, label: exportRangeLabel(key) })),
EXPORT_RANGE_KEYS.map((key) => ({
value: key,
label: exportRangeLabel(key),
})),
)
const showCustomFields = computed(() => range.value === 'custom')
@@ -78,7 +81,10 @@ function onOpenChange(value: boolean) {
<UFormField
:label="t('Date range')"
:help="t('Default is all time. Filters on the page are ignored.')"
:ui="{ label: FIELD_LABEL_CLASS, help: 'mt-1.5 text-xs text-muted' }"
:ui="{
label: FIELD_LABEL_CLASS,
help: 'mt-1.5 text-xs text-muted',
}"
>
<USelect
v-model="range"
@@ -103,7 +109,9 @@ function onOpenChange(value: boolean) {
icon="i-lucide-calendar"
:aria-label="t('Start date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
:ui="{
base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm',
}"
/>
<UInput
v-model="to"
@@ -111,7 +119,9 @@ function onOpenChange(value: boolean) {
icon="i-lucide-calendar"
:aria-label="t('End date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
:ui="{
base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm',
}"
/>
</div>
</template>
+6 -1
View File
@@ -72,7 +72,12 @@ function onOutcome(outcome: OutcomeFilter) {
<span class="font-medium text-highlighted">{{ t('Category') }}</span>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
@select="
emit('update:filters', {
...filters,
category: $event,
})
"
/>
</div>
<div class="flex flex-col gap-1.5">
+4 -1
View File
@@ -74,7 +74,10 @@ function keepOpenOnOutsideInteraction(event: Event) {
:close="false"
icon="i-lucide-triangle-alert"
:title="t('You won\'t be able to see this key again.')"
:ui="{ root: 'gap-2.5 px-3 py-2.5', title: 'text-sm font-medium' }"
:ui="{
root: 'gap-2.5 px-3 py-2.5',
title: 'text-sm font-medium',
}"
/>
<CodeSnippet ref="snippet" :tabs="keyTabs" />
<span class="text-muted">{{
+4 -1
View File
@@ -42,7 +42,10 @@ const summary = computed(() => {
})
const sizeItems = computed(() =>
ARTICLE_PAGE_SIZES.map((value) => ({ label: t('{0} per page', [value]), value })),
ARTICLE_PAGE_SIZES.map((value) => ({
label: t('{0} per page', [value]),
value,
})),
)
const FOCUS_RING =
+10
View File
@@ -3,6 +3,8 @@ import { t } from '@/i18n'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import AuthorStack from '@/components/AuthorStack.vue'
import { authorsSummary } from '@/lib/articleMeta'
import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
@@ -140,6 +142,14 @@ function onRowClick(article: Article, event: MouseEvent) {
{{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
<AuthorStack
v-if="article.authors?.length"
:authors="article.authors"
:size="22"
/>
<span class="truncate">{{ authorsSummary(article.authors ?? []) }}</span>
</div>
</div>
<UDropdownMenu
:items="actionsFor(article)"
+21 -12
View File
@@ -110,6 +110,7 @@ async function copyUrl() {
{{ t('Details') }}
</h2>
</template>
<slot name="meta" :sheet="false" />
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
@@ -144,9 +145,28 @@ async function copyUrl() {
</div>
</UCard>
<USlideover v-model:open="open" side="right" :title="t('Details')" class="lg:hidden">
<USlideover
v-model:open="open"
side="bottom"
:title="t('Details')"
:close="{
'aria-label': t('Close'),
class: 'size-11 justify-center text-muted',
ui: { leadingIcon: 'size-4' },
}"
class="lg:hidden"
:ui="{
content:
'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: 'min-h-0 gap-3 px-4 pt-2 pb-0 sm:px-4',
title: 'text-lg/6 font-semibold',
description: 'sr-only',
body: 'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pt-3 pb-4 sm:px-4',
}"
>
<template #body>
<div class="flex flex-col gap-4 text-sm">
<slot name="meta" :sheet="true" />
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt class="text-muted">{{ t('Created') }}</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
@@ -156,10 +176,6 @@ async function copyUrl() {
<dd class="font-mono text-highlighted">
{{ article.id }}
</dd>
<dt class="text-muted">{{ t('Images') }}</dt>
<dd class="text-highlighted">
{{ formatNumber(imagesCount) }}
</dd>
</dl>
<div class="flex flex-col gap-1.5">
<p class="text-muted">{{ t('Public API URL') }}</p>
@@ -177,13 +193,6 @@ async function copyUrl() {
@click="copyUrl"
/>
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div class="flex flex-col gap-1 border-t border-default pt-3">
<p class="text-muted">{{ t('Title') }}</p>
<p class="text-xs/[18px] text-toned">
{{ t(TITLE_NOTE) }}
</p>
</div>
</div>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { Site } from '@/composables/useSites'
const ALL_SITES = 'all'
const SITE_PREFIX = 'site:'
const props = defineProps<{ sites: Site[] }>()
const siteKey = defineModel<string>({ default: '' })
const items = computed(() => [
{ label: t('All sites'), value: ALL_SITES },
...props.sites.map((site) => ({
label: site.name,
value: SITE_PREFIX + site.key,
})),
])
const selected = computed({
get: () => (siteKey.value ? SITE_PREFIX + siteKey.value : ALL_SITES),
set: (value: string) => {
siteKey.value = value === ALL_SITES ? '' : value.slice(SITE_PREFIX.length)
},
})
</script>
<template>
<USelectMenu
v-model="selected"
:items="items"
value-key="value"
icon="i-lucide-globe"
:search-input="false"
:aria-label="t('Site')"
class="w-full sm:w-37.5"
:ui="{
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) sm:h-8 sm:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
leadingIcon: 'size-3.5',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none shadow-(--shadow-popover)',
item: 'min-h-11 px-2 py-0 data-[state=checked]:bg-elevated sm:min-h-8',
}"
/>
</template>
+59 -6
View File
@@ -4,6 +4,8 @@ import { t } from '@/i18n'
import { computed, ref, watchPostEffect } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import AuthorStack from '@/components/AuthorStack.vue'
import type { Site } from '@/composables/useSites'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
@@ -15,9 +17,10 @@ const props = withDefaults(
skeletonRows?: number
sort?: string
busyIds: Set<number>
sites?: Site[]
now?: Date
}>(),
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT },
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
)
const emit = defineEmits<{
@@ -30,8 +33,13 @@ const emit = defineEmits<{
const router = useRouter()
const columnWidths =
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
const SINGLE_SITE_WIDTHS =
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-27.5 [&:nth-child(4)]:w-32.5 [&:nth-child(5)]:w-35 [&:nth-child(6)]:w-11'
const SEVERAL_SITES_WIDTHS =
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
const showSite = computed(() => props.sites.length > 1)
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
const sorting = computed(() => {
const [id, direction] = props.sort.split(':')
@@ -44,7 +52,7 @@ const SORTABLE_COLUMNS = ['title', 'status', 'created_at', 'updated_at']
watchPostEffect(() => {
const [active, direction] = props.sort.split(':')
tableRoot.value?.querySelectorAll('thead th').forEach((header, index) => {
if (SORTABLE_COLUMNS[index] === active) {
if (SORTABLE_COLUMNS.includes(active!) && columns.value[index]?.id === active) {
header.setAttribute('aria-sort', direction === 'desc' ? 'descending' : 'ascending')
} else {
header.removeAttribute('aria-sort')
@@ -70,14 +78,33 @@ const caption = computed(() => {
])
})
const columns = [
const columns = computed(() => [
{
accessorKey: 'title',
id: 'title',
get header() {
return t('Title')
},
enableSorting: false,
},
{
id: 'authors',
get header() {
return t('Authors')
},
enableSorting: false,
},
...(showSite.value
? [
{
id: 'site',
get header() {
return t('Site')
},
enableSorting: false,
},
]
: []),
{
accessorKey: 'is_online',
id: 'status',
@@ -89,6 +116,7 @@ const columns = [
},
{
accessorKey: 'created_at',
id: 'created_at',
get header() {
return t('Created')
},
@@ -97,6 +125,7 @@ const columns = [
},
{
accessorKey: 'updated_at',
id: 'updated_at',
get header() {
return t('Updated')
},
@@ -104,7 +133,15 @@ const columns = [
enableSorting: false,
},
{ id: 'actions', header: '', size: 44 },
]
])
function siteNameOf(article: Article) {
return props.sites.find((site) => site.id === article.site_id)?.name ?? ''
}
function authorNames(article: Article) {
return (article.authors ?? []).map((author) => author.name).join(', ')
}
function sortIcon(field: string) {
const [id, direction] = props.sort.split(':')
@@ -188,6 +225,8 @@ function onSelect(_event: Event, row: { original: Article }) {
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-6 w-15 rounded-none bg-accented" />
<USkeleton v-if="showSite" class="h-3 w-17.5 rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
@@ -290,6 +329,20 @@ function onSelect(_event: Event, row: { original: Article }) {
</RouterLink>
</template>
<template #authors-cell="{ row }">
<span class="flex min-w-0 items-center" :title="authorNames(row.original)">
<AuthorStack
v-if="row.original.authors?.length"
:authors="row.original.authors"
/>
<span v-else :aria-label="t('No authors')" class="text-dimmed">—</span>
</span>
</template>
<template #site-cell="{ row }">
<span class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
</template>
<template #status-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
+14 -3
View File
@@ -42,7 +42,12 @@ const props = withDefaults(
ownEmail?: string
knownSlugs?: string[]
}>(),
{ author: undefined, ownProfile: false, ownEmail: '', knownSlugs: () => [] },
{
author: undefined,
ownProfile: false,
ownEmail: '',
knownSlugs: () => [],
},
)
const emit = defineEmits<{
@@ -58,7 +63,11 @@ 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 }),
(link: SocialLink, index) => ({
key: index + 1,
type: link.type,
url: link.url,
}),
)
const values = reactive({
@@ -348,7 +357,9 @@ async function confirmUnlink() {
tone: 'neutral',
action: async () => {
try {
const updated = await updateAuthor(author.id, { user_id: null })
const updated = await updateAuthor(author.id, {
user_id: null,
})
baseUserId.value = null
linkedEmail.value = ''
emit('updated', updated)
+287
View File
@@ -0,0 +1,287 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, useId } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import type { Byline } from '@/composables/useArticles'
import { insertionTarget, moveItem } from '@/lib/articleMeta'
const DESKTOP_QUERY = '(min-width: 64rem)'
const DESKTOP_AVATAR = 22
const MOBILE_AVATAR = 28
const props = withDefaults(defineProps<{ authors: Byline[]; hintHidden?: boolean }>(), {
hintHidden: false,
})
const emit = defineEmits<{ 'update:authors': [authors: Byline[]] }>()
const labelId = useId()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const list = ref<HTMLElement | null>(null)
const working = ref<Byline[] | null>(null)
const grabbed = ref<number | null>(null)
const origin = ref(0)
const announcement = ref('')
const dragFrom = ref<number | null>(null)
const dropGap = ref<number | null>(null)
const shown = computed(() => working.value ?? props.authors)
const avatarSize = computed(() => (isDesktop.value ? DESKTOP_AVATAR : MOBILE_AVATAR))
const showPrimary = computed(() => shown.value.length > 1)
const grabbedText = computed(() => (grabbed.value === null ? '' : announcement.value))
const showHint = computed(
() => props.authors.length > 1 && !props.hintHidden && grabbed.value === null,
)
function positionText(key: 'moved' | 'dropped', author: Byline, position: number) {
const total = shown.value.length
return key === 'moved'
? t('{0} moved to position {1} of {2}.', [author.name, position, total])
: t('{0} dropped. Position {1} of {2}.', [author.name, position, total])
}
function focusHandle(id: number) {
nextTick(() => list.value?.querySelector<HTMLElement>(`[data-handle="${id}"]`)?.focus())
}
function grab(index: number) {
const author = props.authors[index]
if (!author) {
return
}
working.value = [...props.authors]
grabbed.value = index
origin.value = index
announcement.value = t(
'{0} grabbed. Position {1} of {2}. Use the up and down arrow keys to move, Space to drop, Escape to cancel.',
[author.name, index + 1, props.authors.length],
)
}
function move(delta: number) {
const current = grabbed.value
const items = working.value
if (current === null || !items) {
return
}
const target = current + delta
if (target < 0 || target >= items.length) {
return
}
const author = items[current]!
working.value = moveItem(items, current, target)
grabbed.value = target
announcement.value = positionText('moved', author, target + 1)
focusHandle(author.id)
}
function drop() {
const current = grabbed.value
const items = working.value
if (current === null || !items) {
return
}
const author = items[current]!
announcement.value = positionText('dropped', author, current + 1)
working.value = null
grabbed.value = null
if (current !== origin.value) {
emit('update:authors', items)
}
focusHandle(author.id)
}
function cancel() {
const current = grabbed.value
const items = working.value
if (current === null || !items) {
return
}
const author = items[current]!
announcement.value = t('Reorder cancelled. {0} is back at position {1} of {2}.', [
author.name,
origin.value + 1,
items.length,
])
working.value = null
grabbed.value = null
focusHandle(author.id)
}
function onHandleKeydown(event: KeyboardEvent, index: number) {
const grabKeys = [' ', 'Enter']
if (grabbed.value === null) {
if (grabKeys.includes(event.key)) {
event.preventDefault()
grab(index)
}
return
}
const actions: Record<string, () => void> = {
ArrowUp: () => move(-1),
ArrowDown: () => move(1),
' ': drop,
Enter: drop,
Escape: cancel,
}
const action = actions[event.key]
if (action) {
event.preventDefault()
event.stopPropagation()
action()
}
}
function rowElements() {
return Array.from(list.value?.querySelectorAll<HTMLElement>('[data-testid="author-row"]') ?? [])
}
function gapFor(clientY: number) {
const rows = rowElements()
const index = rows.findIndex((row) => {
const rect = row.getBoundingClientRect()
return clientY < rect.top + rect.height / 2
})
return index === -1 ? rows.length : index
}
function onPointerDown(event: PointerEvent, index: number) {
if (event.button > 0 || grabbed.value !== null) {
return
}
dragFrom.value = index
dropGap.value = gapFor(event.clientY)
;(event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId)
}
function onPointerMove(event: PointerEvent) {
if (dragFrom.value !== null) {
dropGap.value = gapFor(event.clientY)
}
}
function endDrag() {
dragFrom.value = null
dropGap.value = null
}
function onPointerUp(event: PointerEvent) {
const from = dragFrom.value
if (from === null) {
return
}
const target = insertionTarget(from, gapFor(event.clientY))
endDrag()
if (target !== from) {
emit('update:authors', moveItem(props.authors, from, target))
}
}
function remove(index: number) {
emit(
'update:authors',
props.authors.filter((_, position) => position !== index),
)
}
function rowClass(index: number) {
if (grabbed.value === index) {
return 'border-primary outline-2 outline-offset-1 outline-primary'
}
if (dragFrom.value === index) {
return 'relative z-10 translate-x-1 border-primary shadow-(--shadow-popover)'
}
return 'border-accented'
}
</script>
<template>
<div class="flex flex-col gap-2">
<span :id="labelId" class="font-medium text-highlighted">{{ t('Authors') }}</span>
<div
v-if="authors.length === 0"
class="border border-dashed border-accented p-3 text-pretty text-muted"
>
{{ t('No authors. This article will have no byline.') }}
</div>
<ul v-else ref="list" :aria-labelledby="labelId" class="flex flex-col gap-1">
<template v-for="(author, index) in shown" :key="author.id">
<li
v-if="dropGap === index"
role="presentation"
data-testid="drop-line"
class="-my-px h-0.5 bg-primary"
/>
<li
data-testid="author-row"
class="flex min-h-12 items-center gap-2 border bg-default pr-1 pl-0.5 lg:min-h-8 lg:gap-1.5 lg:pr-0.5"
:class="rowClass(index)"
>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-grip-vertical"
:data-handle="author.id"
:aria-label="t('Reorder {0}', [author.name])"
class="size-9 shrink-0 cursor-grab touch-none justify-center text-muted lg:size-6"
:class="{
'text-highlighted': grabbed === index || dragFrom === index,
}"
:ui="{ leadingIcon: 'size-3.5' }"
@keydown="onHandleKeydown($event, index)"
@pointerdown="onPointerDown($event, index)"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="endDrag"
/>
<AuthorAvatar :name="author.name" :size="avatarSize" />
<span
class="min-w-0 flex-1 truncate font-medium text-highlighted"
:title="author.name"
:aria-label="author.name"
>{{ author.name }}</span
>
<UBadge
v-if="index === 0 && showPrimary"
color="neutral"
variant="outline"
size="sm"
class="h-4.5 shrink-0 rounded-none px-1.5 text-xs font-medium text-toned"
>{{ t('Primary') }}</UBadge
>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove {0}', [author.name])"
class="size-11 shrink-0 justify-center text-muted lg:size-6"
:ui="{ leadingIcon: 'size-3.5' }"
@click="remove(index)"
/>
</li>
</template>
<li
v-if="dropGap === shown.length"
role="presentation"
data-testid="drop-line"
class="-my-px h-0.5 bg-primary"
/>
</ul>
<slot name="add" />
<p
v-if="grabbedText"
aria-hidden="true"
class="bg-elevated px-2 py-1.5 text-xs/4.5 text-toned"
>
{{ grabbedText }}
</p>
<p v-else-if="showHint" class="text-xs/4.5 text-muted">
{{ t('Drag to reorder. The first author is the primary byline.') }}
</p>
<p role="status" aria-live="polite" class="sr-only" data-testid="author-live">
{{ announcement }}
</p>
</div>
</template>
+217
View File
@@ -0,0 +1,217 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
import ProfileCard from '@/components/ProfileCard.vue'
import { useAuthors, type Author, type MyAuthor } from '@/composables/useAuthors'
import { useMyAuthorProfile } from '@/composables/useMyAuthorProfile'
import { useLocalizedToast } from '@/i18n/presentation'
import { baseSlugOf } from '@/lib/authorProfile'
import { copy } from '@/lib/directoryCopy'
import { useSessionStore } from '@/stores/session'
const SLUG_LOOKUP_SIZE = 25
const session = useSessionStore()
const profile = useMyAuthorProfile()
const { listAuthors } = useAuthors()
const toast = useLocalizedToast()
const failed = ref(false)
const created = ref<MyAuthor | null>(null)
const editing = ref<Author | null>(null)
const loadingEdit = ref(false)
const author = computed(() => session.session?.author)
const canManage = computed(() => session.canManageContent)
const nameLine = computed(() => {
const displayName = session.session?.displayName
return displayName
? t('Uses your display name, {0}. You can change it afterwards.', [displayName])
: t('Uses your username, {0}. You can change it afterwards.', [
session.session?.username ?? '',
])
})
const adjustedSlug = computed(() => {
const current = author.value
const result = created.value
if (!current || !result || result.id !== current.id) {
return null
}
return result.slug_adjusted ? current.slug : null
})
async function create() {
failed.value = false
const outcome = await profile.create()
if (outcome.kind === 'created') {
created.value = outcome.author
}
failed.value = outcome.kind === 'failed'
}
function announceLoadFailed() {
toast.add(() => ({
id: 'author-load-failed',
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return copy('loadAuthorsFailed')
},
get description() {
return copy('checkConnection')
},
}))
}
async function edit() {
const current = author.value
if (!current || loadingEdit.value) {
return
}
loadingEdit.value = true
try {
const page = await listAuthors({
q: current.slug,
page: 1,
size: SLUG_LOOKUP_SIZE,
})
const full = page.items.find((candidate) => candidate.id === current.id)
if (full) {
editing.value = full
} else {
announceLoadFailed()
}
} catch {
announceLoadFailed()
} finally {
loadingEdit.value = false
}
}
function closePanel() {
editing.value = null
}
function onSaved(saved: Author) {
session.setAuthor({ id: saved.id, name: saved.name, slug: saved.slug })
closePanel()
}
function onUpdated(updated: Author) {
if (updated.user_id === null) {
session.setAuthor(undefined)
closePanel()
return
}
session.setAuthor({
id: updated.id,
name: updated.name,
slug: updated.slug,
})
}
function onGone() {
session.setAuthor(undefined)
closePanel()
}
</script>
<template>
<ProfileCard
:title="t('Author profile')"
:description="t('Your public byline on articles. It\'s separate from your account.')"
>
<template v-if="author">
<div class="flex flex-wrap items-center gap-3">
<AuthorAvatar :name="author.name" :size="40" />
<div class="flex min-w-35 flex-1 flex-col">
<span class="font-medium text-highlighted">{{ author.name }}</span>
<span class="font-mono text-xs/4.5 text-muted">{{ author.slug }}</span>
</div>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-pencil"
:loading="loadingEdit"
class="h-11 justify-center border border-accented bg-default px-3 text-highlighted ring-0 lg:h-8"
@click="edit"
>
{{ t('Edit author') }}
</UButton>
</div>
<UAlert
v-if="adjustedSlug"
role="status"
color="info"
variant="soft"
icon="i-lucide-info"
:title="t('Your slug is {0}', [adjustedSlug])"
:description="
t('{0} was already used by another author. You can change it in Edit author.', [
baseSlugOf(adjustedSlug),
])
"
:close="false"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'text-toned',
}"
/>
<p class="flex items-center gap-1.5 text-xs text-muted">
<UIcon name="i-lucide-link" class="size-3 shrink-0" />
{{ t('Linked to your account. New articles start with this author.') }}
</p>
</template>
<template v-else>
<UAlert
v-if="failed"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Couldn\'t create your author profile')"
:description="t('Try again in a moment.')"
:close="false"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'text-toned',
}"
/>
<p class="text-pretty text-toned">
{{
t(
"You don't have an author profile yet. Create one to be credited on your own articles.",
)
}}
</p>
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
<UButton
icon="i-lucide-feather"
:loading="profile.pending.value"
:disabled="profile.pending.value"
class="h-11 justify-center px-3 lg:h-8"
@click="create"
>
{{ profile.pending.value ? t('Creating…') : t('Create my author profile') }}
</UButton>
<span class="text-xs text-muted">{{ nameLine }}</span>
</div>
</template>
<AuthorFormPanel
v-if="editing"
:author="editing"
:own-profile="!canManage"
:own-email="session.session?.email ?? ''"
@close="closePanel"
@saved="onSaved"
@updated="onUpdated"
@gone="onGone"
/>
</ProfileCard>
</template>
+183
View File
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import type { Byline } from '@/composables/useArticles'
import { copy } from '@/lib/directoryCopy'
const PAGE_SIZE = 100
const SEARCH_DELAY_MS = 200
type Status = 'loading' | 'ready' | 'error'
const props = defineProps<{ authors: Byline[]; canCreate: boolean }>()
const emit = defineEmits<{ pick: [author: Author]; create: [name: string] }>()
const open = defineModel<boolean>('open', { default: false })
const { listAuthors } = useAuthors()
const status = ref<Status>('loading')
const found = ref<Author[]>([])
const searchTerm = ref('')
let token = 0
let timer: ReturnType<typeof setTimeout> | null = null
const chosenIds = computed(() => props.authors.map((author) => author.id))
const query = computed(() => searchTerm.value.trim())
const items = computed(() =>
status.value === 'ready'
? found.value.map((author) => ({
label: author.name,
description: author.slug,
value: author.id,
avatarName: author.name,
class: chosenIds.value.includes(author.id) ? 'opacity-60' : undefined,
}))
: [],
)
const createLabel = computed(() =>
query.value ? t('Create author "{0}"…', [query.value]) : t('Create author…'),
)
const showCreate = computed(() => props.canCreate && status.value !== 'error')
async function load() {
const current = ++token
status.value = 'loading'
try {
const page = await listAuthors({
q: query.value,
page: 1,
size: PAGE_SIZE,
})
if (current !== token) {
return
}
found.value = page.items
status.value = 'ready'
} catch {
if (current === token) {
status.value = 'error'
}
}
}
function clearTimer() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
watch(open, (value) => {
if (value) {
searchTerm.value = ''
load()
return
}
clearTimer()
token++
})
watch(searchTerm, () => {
if (!open.value) {
return
}
clearTimer()
timer = setTimeout(load, SEARCH_DELAY_MS)
})
onBeforeUnmount(clearTimer)
function onSelect(ids: number[]) {
const added = ids.find((id) => !chosenIds.value.includes(id))
const author = found.value.find((candidate) => candidate.id === added)
if (author) {
emit('pick', author)
}
}
function createFromQuery() {
const name = query.value
open.value = false
emit('create', name)
}
</script>
<template>
<USelectMenu
:model-value="chosenIds"
v-model:open="open"
v-model:search-term="searchTerm"
multiple
ignore-filter
:portal="false"
:content="{ position: 'inline' }"
:items="items"
value-key="value"
:search-input="{ placeholder: t('Search authors') }"
class="w-full"
:ui="{
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) lg:h-10 lg:text-sm',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none shadow-(--shadow-popover)',
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated lg:min-h-9',
itemLabel: 'truncate font-medium',
itemDescription: 'truncate font-mono text-xs text-muted',
}"
@update:model-value="onSelect"
>
<template #default>
<UIcon name="i-lucide-plus" class="size-3.5 shrink-0 text-muted" />
<span class="flex-1 truncate text-left text-muted">{{ t('Add an author') }}</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-2">
<span class="font-medium text-error">{{ copy('loadAuthorsFailed') }}</span>
<span class="text-muted">{{ copy('checkConnection') }}</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 lg:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="load"
>
{{ t('Try again') }}
</UButton>
</div>
<div
v-else-if="status === 'loading'"
class="flex flex-col gap-2.5 p-2"
aria-busy="true"
>
<USkeleton v-for="n in 3" :key="n" class="h-6 rounded-none bg-accented" />
</div>
<div v-else-if="query" class="p-2 text-muted">
{{ t('No author matches "{0}".', [query]) }}
</div>
<div v-else class="p-2 text-muted">{{ t('No authors yet') }}</div>
</template>
<template #content-bottom>
<UButton
v-if="showCreate"
color="neutral"
variant="ghost"
icon="i-lucide-plus"
block
data-testid="create-author-row"
class="min-h-12 justify-start gap-2 rounded-none border-t border-default px-2 py-1 font-medium text-highlighted lg:min-h-9"
:ui="{ leadingIcon: 'size-4', label: 'truncate' }"
@click="createFromQuery"
>
{{ createLabel }}
</UButton>
</template>
</USelectMenu>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import type { Byline } from '@/composables/useArticles'
const props = withDefaults(
defineProps<{
authors: Byline[]
size?: number
max?: number
}>(),
{ size: 24, max: 3 },
)
const shown = computed(() => props.authors.slice(0, props.max))
const extra = computed(() => Math.max(0, props.authors.length - props.max))
</script>
<template>
<span class="flex min-w-0 items-center">
<UAvatarGroup
:ui="{ base: 'ring-0 -me-1.5 first-of-type:me-0' }"
data-testid="author-stack"
>
<AuthorAvatar
v-for="author in shown"
:key="author.id"
:name="author.name"
:size="size"
ring
/>
</UAvatarGroup>
<span v-if="extra > 0" class="ml-1.5 text-xs text-muted" data-testid="author-stack-more"
>+{{ extra }}</span
>
</span>
</template>
+4 -1
View File
@@ -8,7 +8,10 @@ import { useApiKeys, type CreateApiKeyInput, type CreatedApiKey } from '@/compos
import { expiryInstant, isFutureExpiry } from '@/lib/apiKeys'
const NAME_MAX_LENGTH = 60
const fieldUi = { help: 'mt-1.5 text-sm/5 text-muted', error: 'mt-1.5 text-sm/5' }
const fieldUi = {
help: 'mt-1.5 text-sm/5 text-muted',
error: 'mt-1.5 text-sm/5',
}
const OPTIONAL_SUFFIX = /\s*\([^)]*\)$/
const open = defineModel<boolean>('open', { default: false })
+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, reactive, ref } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import SlugField from '@/components/SlugField.vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSlugField } from '@/composables/useSlugField'
import { AUTHOR_SLUG_MAX, suggestFreeSlug } from '@/lib/directory'
import { classifyFailure } from '@/lib/directoryErrors'
import { authorSlugProblem, nameProblem } from '@/lib/directoryRules'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import { useSessionStore } from '@/stores/session'
const props = defineProps<{ initialName: string }>()
const emit = defineEmits<{ close: []; created: [author: Author] }>()
const session = useSessionStore()
const { createAuthor } = useAuthors()
const toasts = useDirectoryToasts()
const name = ref(props.initialName)
const slug = useSlugField(AUTHOR_SLUG_MAX)
slug.setSource(props.initialName)
const isMe = ref(false)
const pending = ref(false)
const notice = ref<DirectoryNotice | null>(null)
const errors = reactive({ name: '', slug: '', suggestion: '' })
const rejectedSlugs = new Set<string>()
const offerMe = computed(
() => session.session?.author === undefined && session.session?.id !== undefined,
)
const email = computed(() => session.session?.email ?? '')
function clearSlugError() {
errors.slug = ''
errors.suggestion = ''
}
function onName(value: string) {
name.value = value
slug.setSource(value)
errors.name = ''
clearSlugError()
}
function onSlug(value: string) {
slug.setValue(value)
clearSlugError()
}
function resetSlug() {
slug.reset()
clearSlugError()
}
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),
AUTHOR_SLUG_MAX,
)
}
function fail(error: unknown) {
const kind = classifyFailure(error, 'author')
if (kind === 'session') {
return
}
if (kind === 'slugTaken') {
showSlugTaken()
return
}
if (kind === 'rights') {
toasts.authorRights()
emit('close')
return
}
notice.value = {
title: t("Couldn't create the author"),
description: t('The article is unchanged. Try again in a moment.'),
}
}
function validate() {
errors.name = nameProblem(name.value)
errors.slug = authorSlugProblem(slug.value.value)
return errors.name === '' && errors.slug === ''
}
async function confirm() {
if (pending.value || !validate()) {
return
}
pending.value = true
notice.value = null
const userId = isMe.value ? session.session?.id : undefined
try {
const created = await createAuthor({
name: name.value.trim(),
slug: slug.value.value,
...(userId === undefined ? {} : { user_id: userId }),
})
if (userId !== undefined) {
session.setAuthor({
id: created.id,
name: created.name,
slug: created.slug,
})
}
emit('created', created)
} catch (error) {
fail(error)
} finally {
pending.value = false
}
}
</script>
<template>
<DirectoryDialog
:title="t('Create author')"
:description="
t('Adds a byline for this article. You can add a bio and avatar later in Authors.')
"
:confirm-label="t('Create author')"
:pending-label="t('Creating…')"
tone="neutral"
closable
:pending="pending"
:notice="notice"
:confirm-disabled="name.trim() === ''"
@confirm="confirm"
@close="emit('close')"
>
<DirectoryField :label="t('Name')" :error="errors.name">
<UInput
:model-value="name"
:placeholder="t('e.g. Camille Roche')"
:color="errors.name ? 'error' : undefined"
:disabled="pending"
data-autofocus
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
@update:model-value="onName(String($event))"
/>
</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. 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"
/>
<UCheckbox
v-if="offerMe"
v-model="isMe"
:label="t('This is me')"
:description="t('Links the author to {0}.', [email])"
:disabled="pending"
:ui="{
label: 'font-medium text-highlighted',
description: 'text-xs text-muted',
base: 'rounded-none',
}"
/>
</DirectoryDialog>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ pending?: boolean }>()
const emit = defineEmits<{ create: []; dismiss: [] }>()
</script>
<template>
<UAlert
data-testid="author-hint"
color="info"
variant="soft"
icon="i-lucide-info"
:title="t('You don\'t have an author profile yet')"
:description="t('Create one to be credited on your articles.')"
:close="{
'aria-label': t('Dismiss'),
class: 'size-11 justify-center lg:size-6',
ui: { leadingIcon: 'size-3.5' },
}"
:ui="{
root: 'items-start gap-2.5 p-3',
title: 'font-semibold',
description: 'text-toned',
close: 'static -mt-1 -mr-1 lg:-mt-1 lg:-mr-1',
}"
@update:open="emit('dismiss')"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-feather"
:loading="pending"
:disabled="pending"
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 lg:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('create')"
>
{{ t('Create my author profile') }}
</UButton>
</template>
</UAlert>
</template>
+4 -1
View File
@@ -61,7 +61,10 @@ function onCancel() {
:description="description"
:dismissible="!pending"
:close="closeButton"
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
: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',
+7 -1
View File
@@ -20,7 +20,13 @@ const props = withDefaults(
readOnly?: boolean
notice?: DirectoryNotice | null
}>(),
{ submitLabel: '', saving: false, dirty: false, readOnly: false, notice: null },
{
submitLabel: '',
saving: false,
dirty: false,
readOnly: false,
notice: null,
},
)
const emit = defineEmits<{ close: []; submit: [] }>()
+12 -1
View File
@@ -6,7 +6,7 @@ import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online'
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' | 'no-site'
query?: string
}>()
@@ -51,6 +51,17 @@ const variants = computed(() => ({
action: t('New article'),
primary: false,
},
'no-site': {
icon: 'i-lucide-globe',
get title() {
return t('No articles on \u0022{0}\u0022', [truncatedQuery.value])
},
get description() {
return t('Move articles here from the editor, or choose another site.')
},
action: t('Show all sites'),
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
get title() {
+5 -2
View File
@@ -17,7 +17,8 @@ const activeTab = ref('fetch')
const examples = computed(() => endpointExamples(props.endpoint, props.origin))
const rowLabel = computed(
() => `${props.endpoint.method} ${props.endpoint.path}. ${translate(props.endpoint.description)}`,
() =>
`${props.endpoint.method} ${props.endpoint.path}. ${translate(props.endpoint.description)}`,
)
const bodyTitle = computed(() => {
if (props.endpoint.bodyKind === 'multipart') {
@@ -62,7 +63,9 @@ const snippetTabs = computed(() => [
class="size-4 shrink-0 text-muted lg:hidden"
/>
</span>
<span class="min-w-0 text-toned lg:truncate">{{ translate(endpoint.description) }}</span>
<span class="min-w-0 text-toned lg:truncate">{{
translate(endpoint.description)
}}</span>
<span class="flex flex-wrap gap-1.5">
<UBadge
:label="translate(endpoint.access)"
+5 -1
View File
@@ -63,7 +63,11 @@ const variantDisplay = computed(() =>
removed.value ? t('{0} (removed)', [selected.value]) : selected.value,
)
const urlTabs = computed(() => [
{ k: 'url', label: t('URL'), code: variantUrl(props.origin, selected.value) },
{
k: 'url',
label: t('URL'),
code: variantUrl(props.origin, selected.value),
},
])
const previewLabel = computed(() => {
+4 -1
View File
@@ -46,7 +46,10 @@ watch(open, (value) => {
sideOffset: 8,
loop: true,
}"
:ui="{ content: `w-42 p-1 ${LANGUAGE_MENU_CLASS}`, itemTrailingIcon: 'size-4' }"
:ui="{
content: `w-42 p-1 ${LANGUAGE_MENU_CLASS}`,
itemTrailingIcon: 'size-4',
}"
>
<UButton
color="neutral"
+4 -1
View File
@@ -219,7 +219,10 @@ function onCancel() {
spellcheck="false"
:highlight="focusedField === 'url' || !!urlError"
:aria-invalid="urlError ? 'true' : undefined"
:ui="{ root: 'min-w-0', base: 'h-11 text-base md:h-10' }"
:ui="{
root: 'min-w-0',
base: 'h-11 text-base md:h-10',
}"
class="w-full min-w-0"
/>
</UFormField>
+8 -2
View File
@@ -13,7 +13,11 @@ import { classifyFailure } from '@/lib/directoryErrors'
const props = defineProps<{ author: Author }>()
const emit = defineEmits<{ close: []; linked: [author: Author]; gone: [id: number] }>()
const emit = defineEmits<{
close: []
linked: [author: Author]
gone: [id: number]
}>()
const { listLinkableUsers, updateAuthor } = useAuthors()
const toasts = useDirectoryToasts()
@@ -68,7 +72,9 @@ async function confirm() {
notice.value = null
fieldError.value = ''
try {
const updated = await updateAuthor(props.author.id, { user_id: userId.value })
const updated = await updateAuthor(props.author.id, {
user_id: userId.value,
})
emit('linked', updated)
} catch (error) {
fail(error)
+4 -1
View File
@@ -44,7 +44,10 @@ const sizeClass = computed(() => (props.size === 'md' ? 'h-5.5 px-1.5' : 'h-5 px
:icon="badge.icon"
:class="[badge.class, sizeClass]"
:data-kind="kind"
:ui="{ base: 'gap-1 py-0 text-xs whitespace-nowrap', leadingIcon: 'size-3' }"
:ui="{
base: 'gap-1 py-0 text-xs whitespace-nowrap',
leadingIcon: 'size-3',
}"
>
{{ label }}
</UBadge>
+4 -1
View File
@@ -193,7 +193,10 @@ function reviewDuplicates() {
<template v-else>
<div class="flex items-start gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 id="media-cleanup-h" class="text-[0.9375rem]/6 font-semibold text-highlighted">
<h2
id="media-cleanup-h"
class="text-[0.9375rem]/6 font-semibold text-highlighted"
>
{{ t('Media cleanup') }}
</h2>
<p class="text-pretty text-muted">
+10 -3
View File
@@ -241,7 +241,10 @@ const duplicateAlert = computed(() => {
icon="i-lucide-copy"
:title="duplicateAlert.title"
:description="duplicateAlert.description"
:ui="{ actions: 'mt-2 w-full', description: 'text-wrap' }"
:ui="{
actions: 'mt-2 w-full',
description: 'text-wrap',
}"
>
<template #actions>
<UButton
@@ -261,7 +264,9 @@ const duplicateAlert = computed(() => {
<dl class="grid grid-cols-[110px_minmax(0,1fr)] gap-x-3 gap-y-2 text-toned">
<template v-for="row in metadata" :key="row.label">
<dt class="text-muted">{{ row.label }}</dt>
<dd class="m-0 text-highlighted">{{ row.value }}</dd>
<dd class="m-0 text-highlighted">
{{ row.value }}
</dd>
</template>
<dt class="text-muted">URL</dt>
<dd class="m-0 truncate font-mono text-xs/5 text-highlighted">
@@ -284,7 +289,9 @@ const duplicateAlert = computed(() => {
:key="article.id"
data-testid="usage-row"
class="flex min-h-12 items-center gap-2 px-3 md:min-h-10"
:class="{ 'border-t border-muted': index > 0 }"
:class="{
'border-t border-muted': index > 0,
}"
>
<RouterLink
:to="`/article/${article.id}/edit`"
+3 -1
View File
@@ -6,7 +6,9 @@ import MediaLinksCard from './MediaLinksCard.vue'
describe('MediaLinksCard', () => {
it('links to the variants and cache pages', () => {
const wrapper = mount(MediaLinksCard, { global: { plugins: [router, uiPlugin] } })
const wrapper = mount(MediaLinksCard, {
global: { plugins: [router, uiPlugin] },
})
const hrefs = wrapper.findAll('a').map((link) => link.attributes('href'))
+3 -1
View File
@@ -8,7 +8,9 @@ const SEGMENT =
defineProps<{ modelValue: MediaVariantsFilter }>()
const emit = defineEmits<{ 'update:modelValue': [value: MediaVariantsFilter] }>()
const emit = defineEmits<{
'update:modelValue': [value: MediaVariantsFilter]
}>()
const OPTIONS: { value: MediaVariantsFilter; label: () => string }[] = [
{ value: 'all', label: () => t('All') },
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, useId } from 'vue'
import AuthorList from '@/components/AuthorList.vue'
import AuthorSelect from '@/components/AuthorSelect.vue'
import CreateAuthorDialog from '@/components/CreateAuthorDialog.vue'
import CreateAuthorHint from '@/components/CreateAuthorHint.vue'
import SiteSelect from '@/components/SiteSelect.vue'
import type { Byline } from '@/composables/useArticles'
import type { Author } from '@/composables/useAuthors'
import { useMyAuthorProfile } from '@/composables/useMyAuthorProfile'
import type { Site } from '@/composables/useSites'
import { metaSummary } from '@/lib/articleMeta'
import { readHintDismissed, storeHintDismissed } from '@/lib/metaSectionMemory'
import { useSessionStore } from '@/stores/session'
const props = withDefaults(
defineProps<{
sites: Site[]
siteId: number | null
authors: Byline[]
remembered: boolean | null
sheet?: boolean
}>(),
{ sheet: false },
)
const emit = defineEmits<{
'update:siteId': [siteId: number]
'update:authors': [authors: Byline[]]
'update:remembered': [open: boolean]
}>()
const session = useSessionStore()
const profile = useMyAuthorProfile()
const bodyId = useId()
const startedWithoutAuthors = props.authors.length === 0
const autoOpen = computed(() => !props.sheet && (props.sites.length > 1 || startedWithoutAuthors))
const isOpen = computed(() => props.remembered ?? autoOpen.value)
const hintDismissed = ref(readHintDismissed(session.session?.id))
const comboOpen = ref(false)
const creating = ref<string | null>(null)
const severalSites = computed(() => props.sites.length > 1)
const siteName = computed(() => props.sites.find((site) => site.id === props.siteId)?.name ?? null)
const summary = computed(() =>
metaSummary(props.authors, severalSites.value ? siteName.value : null),
)
const canCreate = computed(() => session.canManageContent)
const showHint = computed(
() =>
session.session !== null &&
session.session.author === undefined &&
props.authors.length === 0 &&
!hintDismissed.value,
)
function toByline(author: Author): Byline {
return { id: author.id, name: author.name, slug: author.slug }
}
function dismissHint() {
hintDismissed.value = true
storeHintDismissed(session.session?.id)
}
async function createMine() {
const outcome = await profile.create()
if (outcome.kind === 'created') {
emit('update:authors', [toByline(outcome.author), ...props.authors])
}
if (outcome.kind === 'failed') {
profile.announceFailed()
}
}
function append(author: Author) {
if (props.authors.some((existing) => existing.id === author.id)) {
return
}
emit('update:authors', [...props.authors, toByline(author)])
}
function onCreated(author: Author) {
creating.value = null
append(author)
}
</script>
<template>
<section class="flex flex-col gap-3 border-b border-default pb-4" data-testid="meta-section">
<UCollapsible :open="isOpen" @update:open="emit('update:remembered', $event)">
<UButton
color="neutral"
variant="link"
class="min-h-6 w-full justify-start gap-2 p-0 text-sm font-semibold text-highlighted"
:aria-controls="bodyId"
data-testid="meta-toggle"
>
<UIcon
name="i-lucide-chevron-down"
class="size-3.5 shrink-0 transition-transform"
:class="{ '-rotate-90': !isOpen }"
/>
{{ t('Site and authors') }}
</UButton>
<template #content>
<div :id="bodyId" class="mt-3 flex flex-col gap-4" data-testid="meta-body">
<CreateAuthorHint
v-if="showHint"
:pending="profile.pending.value"
@create="createMine"
@dismiss="dismissHint"
/>
<SiteSelect
v-if="severalSites"
:sites="sites"
:model-value="siteId"
@update:model-value="(id) => id !== null && emit('update:siteId', id)"
/>
<AuthorList
:authors="authors"
:hint-hidden="comboOpen"
@update:authors="emit('update:authors', $event)"
>
<template #add>
<AuthorSelect
v-model:open="comboOpen"
:authors="authors"
:can-create="canCreate"
@pick="append"
@create="creating = $event"
/>
</template>
</AuthorList>
</div>
</template>
</UCollapsible>
<span
v-if="!isOpen"
class="truncate pl-5.5 text-muted"
data-testid="meta-summary"
:title="summary"
>{{ summary }}</span
>
<CreateAuthorDialog
v-if="creating !== null"
:initial-name="creating"
@close="creating = null"
@created="onCreated"
/>
</section>
</template>
+3 -1
View File
@@ -9,7 +9,9 @@ defineProps<{
<section class="border border-default bg-default">
<div class="flex flex-col gap-3.5 p-4 lg:p-5">
<div class="flex flex-col gap-0.5">
<h2 class="text-base/5 font-semibold text-highlighted">{{ title }}</h2>
<h2 class="text-base/5 font-semibold text-highlighted">
{{ title }}
</h2>
<p class="text-pretty text-muted">{{ description }}</p>
</div>
<slot />
+3 -1
View File
@@ -12,7 +12,9 @@ import { t } from '@/i18n'
<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>
<p class="text-pretty text-muted">
{{ t('Where your content is shown.') }}
</p>
</div>
<div class="flex flex-col">
<RouterLink
+8 -2
View File
@@ -164,7 +164,10 @@ async function settle(result: PurgeResult) {
close()
return
}
partialCounts.value = { purged: result.groups_purged, total: result.groups_total }
partialCounts.value = {
purged: result.groups_purged,
total: result.groups_total,
}
notice.value = 'partial'
status.value = 'partial'
emit('partial', result)
@@ -238,7 +241,10 @@ onBeforeUnmount(clearSkeletonTimer)
:description="nothing ? t(NOTHING_COPY) : t(DESCRIPTION)"
:dismissible="!busy"
:close="false"
:content="{ role: 'alertdialog', 'aria-busy': busy ? 'true' : undefined }"
:content="{
role: 'alertdialog',
'aria-busy': busy ? 'true' : undefined,
}"
:ui="ui"
@update:open="onOpenChange"
>
+3 -1
View File
@@ -39,7 +39,9 @@ const props = withDefaults(
const emit = defineEmits<{ confirm: []; close: [] }>()
const open = defineModel<boolean>('open', { default: true })
const selected = defineModel<string | number | undefined>('selected', { default: undefined })
const selected = defineModel<string | number | undefined>('selected', {
default: undefined,
})
const selectOpen = ref(false)
const current = computed(() => props.options.find((option) => option.value === selected.value))
+3 -1
View File
@@ -37,7 +37,9 @@ const SUBGROUP_TITLE_CLASS =
:key="group.title"
class="border-t border-dashed border-accented"
>
<div :class="SUBGROUP_TITLE_CLASS">{{ translate(group.title) }}</div>
<div :class="SUBGROUP_TITLE_CLASS">
{{ translate(group.title) }}
</div>
<ApiRouteRow
v-for="row in group.rows"
:key="`${row.method} ${row.path}`"
+7 -1
View File
@@ -56,7 +56,13 @@ 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 errors = reactive({
name: '',
key: '',
suggestion: '',
url: '',
locale: '',
})
const saving = ref(false)
const notice = ref<DirectoryNotice | null>(null)
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import type { Site } from '@/composables/useSites'
import { hostOf } from '@/lib/directory'
const props = defineProps<{ sites: Site[] }>()
const siteId = defineModel<number | null>({ default: null })
const open = ref(false)
const ordered = computed(() =>
[...props.sites].sort((left, right) => Number(right.is_default) - Number(left.is_default)),
)
const items = computed(() =>
ordered.value.map((site) => ({
label: site.name,
description: hostOf(site.base_url),
value: site.id,
})),
)
const chosen = computed(() => props.sites.find((site) => site.id === siteId.value))
const chosenHost = computed(() => (chosen.value ? hostOf(chosen.value.base_url) : ''))
</script>
<template>
<div class="flex flex-col gap-1.5">
<DirectoryField :label="t('Site')">
<USelectMenu
v-model="siteId"
v-model:open="open"
:portal="false"
:content="{ position: 'inline' }"
:items="items"
value-key="value"
:search-input="false"
class="w-full"
:ui="{
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) lg:h-10 lg:text-sm',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none shadow-(--shadow-popover)',
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated lg:min-h-9',
itemLabel: 'truncate font-medium',
itemDescription: 'truncate text-xs text-muted',
}"
>
<template #default>
<span v-if="chosen" class="truncate text-highlighted"
>{{ chosen.name }} · {{ chosenHost }}</span
>
</template>
</USelectMenu>
</DirectoryField>
<p v-if="chosen && !open" class="flex items-center gap-1.5 text-xs/4.5 text-muted">
<UIcon name="i-lucide-link" class="size-3 shrink-0" />
<span
>{{ t('Canonical host:') }}
<span class="font-mono text-toned">{{ chosenHost }}</span></span
>
</p>
</div>
</template>
+3 -1
View File
@@ -75,7 +75,9 @@ function update(key: number, patch: Partial<SocialLinkRow>) {
: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' }"
: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)"
/>
+3 -1
View File
@@ -147,7 +147,9 @@ const items = computed(() => [
:aria-expanded="chipExpanded"
:class="chipClass"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current max-lg:hidden' }"
:ui="{
trailingIcon: 'ms-auto size-3.5 text-current max-lg:hidden',
}"
:aria-label="triggerAriaLabel"
>
<template #leading>
+3 -1
View File
@@ -109,7 +109,9 @@ function onOpen(value: boolean) {
: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>
<div v-else class="p-3 text-muted">
{{ t('Every user already has an author.') }}
</div>
</template>
</USelectMenu>
</template>
@@ -8,7 +8,9 @@ import RootComponent from '@/App.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
vi.mock('@nuxt/ui/composables', () => ({
useOverlay: () => ({ create: () => ({ open: () => Promise.resolve(false) }) }),
useOverlay: () => ({
create: () => ({ open: () => Promise.resolve(false) }),
}),
useToast: () => ({ add: vi.fn() }),
}))
@@ -96,7 +98,9 @@ describe('insertImage stamps the inserted image with its numeric id', () => {
const editor = wrapper.findComponent(ArticleEditor)
;(
editor.vm as unknown as { insertImage: (src: string, alt: string, id: number) => void }
editor.vm as unknown as {
insertImage: (src: string, alt: string, id: number) => void
}
).insertImage('/static/uploadedImages/photo.png', 'alt text', 10)
await flushPromises()
@@ -267,7 +267,10 @@ describe('The link button across its states', () => {
try {
await mountEditor()
linkButton().dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }),
new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'mouse',
}),
)
await vi.advanceTimersByTimeAsync(399)
expect(document.body.textContent).not.toContain('Insert link (Ctrl+K)')
@@ -284,7 +287,10 @@ describe('The link button across its states', () => {
try {
await mountEditor()
linkButton().dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, pointerType: 'mouse' }),
new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'mouse',
}),
)
await vi.advanceTimersByTimeAsync(401)
expect(document.body.textContent).toContain('Insert link (⌘K)')
@@ -299,7 +305,10 @@ describe('The link button across its states', () => {
try {
await mountEditor(SENTENCE, PHONE)
linkButton().dispatchEvent(
new PointerEvent('pointermove', { bubbles: true, pointerType: 'touch' }),
new PointerEvent('pointermove', {
bubbles: true,
pointerType: 'touch',
}),
)
await vi.advanceTimersByTimeAsync(1000)
expect(document.body.textContent).not.toContain('Insert link (')
@@ -380,16 +389,27 @@ describe('Ctrl+K', () => {
const Host = defineComponent({
render: () =>
h(UApp, null, () =>
h(ArticleEditor, { modelValue: '<p>Hello</p>', articleId: 1, disabled: true }),
h(ArticleEditor, {
modelValue: '<p>Hello</p>',
articleId: 1,
disabled: true,
}),
),
})
const host = mount(Host, { attachTo: document.body, global: { plugins: [uiPlugin] } })
const host = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await settle()
const vm = host.findComponent(ArticleEditor).vm as unknown as {
$: { setupState: { editorInstance: Editor } }
}
vm.$.setupState.editorInstance.view.dom.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }),
new KeyboardEvent('keydown', {
key: 'k',
ctrlKey: true,
bubbles: true,
}),
)
await settle()
@@ -403,7 +423,11 @@ describe('Ctrl+K', () => {
preview.click()
await settle()
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true }),
new KeyboardEvent('keydown', {
key: 'k',
ctrlKey: true,
bubbles: true,
}),
)
await settle()
@@ -731,7 +755,11 @@ describe('Leaving the dialog without saving', () => {
it('on Esc', async () => {
await openWithChanges()
field('text').dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expectUntouched()
@@ -809,7 +837,11 @@ describe('Dialog or sheet by viewport, popover or sheet by input', () => {
editor()
.view.dom.querySelector('a')!
.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
await settle()
@@ -870,8 +902,14 @@ describe('Toolbar overflow', () => {
function layout(scrollLeft: number) {
const element = toolbar()
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390 })
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100 })
Object.defineProperty(element, 'clientWidth', {
configurable: true,
value: 390,
})
Object.defineProperty(element, 'scrollWidth', {
configurable: true,
value: 1100,
})
element.scrollLeft = scrollLeft
element.dispatchEvent(new Event('scroll'))
}
@@ -919,7 +957,10 @@ describe('Selections that straddle a link or span blocks', () => {
await mountEditor(BODY)
const [first, last] = [selection.split(' ')[0]!, selection.split(' ').at(-1)!]
const from = positionOf(first)
editor().commands.setTextSelection({ from, to: positionOf(last) + last.length })
editor().commands.setTextSelection({
from,
to: positionOf(last) + last.length,
})
const before = editor().state.doc.textContent
await openDialog()
type(field('url'), '/b')
@@ -15,7 +15,10 @@ let wrapper: VueWrapper | null = null
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
@@ -40,7 +40,13 @@ async function mountShell(path: string, options: ShellOptions = {}) {
setViewportWidth(width)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName, role }),
getMe: vi.fn().mockResolvedValue({
verified: true,
username,
email,
displayName,
role,
}),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
@@ -408,7 +414,9 @@ describe('DashboardShell Settings expands to the Media subpages', () => {
)
it('marks Variants current and Settings not current on the variants page', async () => {
const wrapper = await mountShell('/settings/media/variants', { role: 'admin' })
const wrapper = await mountShell('/settings/media/variants', {
role: 'admin',
})
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.find('a[href="/settings/media/variants"]').attributes('aria-current')).toBe(
@@ -418,7 +426,9 @@ describe('DashboardShell Settings expands to the Media subpages', () => {
})
it('marks Cache current and Settings not current on the cache page', async () => {
const wrapper = await mountShell('/settings/media/cache', { role: 'admin' })
const wrapper = await mountShell('/settings/media/cache', {
role: 'admin',
})
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.find('a[href="/settings/media/cache"]').attributes('aria-current')).toBe('page')
@@ -471,7 +481,9 @@ describe('DashboardShell selected nav rows use sand with on-sand text', () => {
})
it('keeps the unfilled Settings parent bold when a child is selected', async () => {
const wrapper = await mountShell('/settings/media/variants', { role: 'owner' })
const wrapper = await mountShell('/settings/media/variants', {
role: 'owner',
})
const parent = wrapper.find('nav[aria-label="Main"] a[href="/settings"]')
expect(parent.attributes('aria-current')).toBeUndefined()
@@ -21,7 +21,12 @@ const ROWS: Row[] = [
function menu(row: Row): DirectoryMenuItem[] {
return [
{ key: 'edit', label: 'Edit', icon: 'i-lucide-pencil', onSelect: () => undefined },
{
key: 'edit',
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => undefined,
},
{
key: 'delete',
label: 'Delete…',
@@ -103,7 +103,11 @@ describe('DirectoryPanel', () => {
it('shows the failure notice inside the panel', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add', notice: { description: 'Could not save.' } },
{
title: 'T',
submitLabel: 'Add',
notice: { description: 'Could not save.' },
},
{ default: body },
)
+6 -18
View File
@@ -18,9 +18,7 @@ async function mountNotFound(breadcrumb?: string) {
const router = makeRouter()
await router.push('/')
return mount(ErrorView, {
props: breadcrumb
? { kind: 'not-found', breadcrumb }
: { kind: 'not-found' },
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
global: {
plugins: [router],
},
@@ -31,30 +29,20 @@ describe('ErrorView not-found kind', () => {
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
const wrapper = await mountNotFound('Not found')
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe(
'Not found',
)
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe('Not found')
expect(wrapper.find('h1').text()).toBe('Page not found')
expect(wrapper.text()).toContain(
'The link may be mistyped, or the page may have moved.',
)
expect(wrapper.text()).toContain('The link may be mistyped, or the page may have moved.')
const buttons = wrapper.findAll('button')
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(
true,
)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(
true,
)
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(true)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(true)
})
it('routes the primary action to /home and the secondary action to /article', async () => {
const wrapper = await mountNotFound()
const router = wrapper.vm.$router
const primary = wrapper
.findAll('button')
.find((button) => button.text() === 'Go to Home')
const primary = wrapper.findAll('button').find((button) => button.text() === 'Go to Home')
await primary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/home')
@@ -45,7 +45,10 @@ vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
readNaturalImageSize: vi.fn(async () => ({
width: 4032,
height: 3024,
})),
renderCroppedImage: vi.fn(async () => new Blob([new Uint8Array(430080)])),
}
})
@@ -127,7 +130,10 @@ function mib(value: number) {
function fileFixture(name: string, type: string, sizeMB: number) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
Object.defineProperty(file, 'size', {
value: mib(sizeMB),
configurable: true,
})
return file
}
@@ -219,7 +225,9 @@ describe('Dragging a file over the drop zone shows the dragging-over state', ()
describe('Dropping a file opens Upload with the file', () => {
it('looks the hash up, sends no list request and opens the crop step', async () => {
installApi()
wrapper = mountUploader({ initialFile: fileFixture('before.png', 'image/png', 3.9) })
wrapper = mountUploader({
initialFile: fileFixture('before.png', 'image/png', 3.9),
})
await openUploader(wrapper)
expect(hashCalls()).toEqual([NORTH_WALL_HASH])
@@ -326,7 +334,9 @@ describe('A file whose content exists shows the duplicate card and sends nothing
})
it('drops the "by" part when the uploader is unknown', async () => {
await openOnUpload({ hash: () => pageBody([mediaItem({ uploaded_by: '' })], null) })
await openOnUpload({
hash: () => pageBody([mediaItem({ uploaded_by: '' })], null),
})
await chooseFile(fileFixture('north-wall.jpg', 'image/jpeg', 3.9))
@@ -544,7 +554,9 @@ describe('A server answer for a file that passed the browser checks', () => {
async ({ file, type, sizeMB, status, title, text, confirm }) => {
await openOnUpload()
const chosen = fileFixture(file, type, sizeMB)
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({ ok: true })
vi.mocked(imageUpload.validateImageFile).mockReturnValueOnce({
ok: true,
})
await chooseFile(chosen)
if (findButton('Upload image')) {
await clickButton('Upload image')
@@ -98,7 +98,10 @@ function mib(value: number) {
function fileFixture(name: string, type: string, sizeMB: number) {
const file = new File(['x'], name, { type })
Object.defineProperty(file, 'size', { value: mib(sizeMB), configurable: true })
Object.defineProperty(file, 'size', {
value: mib(sizeMB),
configurable: true,
})
return file
}
@@ -208,9 +211,27 @@ describe('An SVG skips the crop step and uploads immediately', () => {
describe('Choosing an aspect ratio locks the frame to it', () => {
it.each([
{ ratio: '16:9', cropWidth: 4032, cropHeight: 2268, outWidth: 1600, outHeight: 900 },
{ ratio: '4:3', cropWidth: 4032, cropHeight: 3024, outWidth: 1600, outHeight: 1200 },
{ ratio: '1:1', cropWidth: 3024, cropHeight: 3024, outWidth: 1600, outHeight: 1600 },
{
ratio: '16:9',
cropWidth: 4032,
cropHeight: 2268,
outWidth: 1600,
outHeight: 900,
},
{
ratio: '4:3',
cropWidth: 4032,
cropHeight: 3024,
outWidth: 1600,
outHeight: 1200,
},
{
ratio: '1:1',
cropWidth: 3024,
cropHeight: 3024,
outWidth: 1600,
outHeight: 1600,
},
])(
'locks the frame for $ratio',
async ({ ratio, cropWidth, cropHeight, outWidth, outHeight }) => {
@@ -221,7 +242,9 @@ describe('Choosing an aspect ratio locks the frame to it', () => {
await selectRatioPill(ratio)
const rect = (
wrapper.findComponent(ImageUploader).vm as unknown as { cropRect: CropRect }
wrapper.findComponent(ImageUploader).vm as unknown as {
cropRect: CropRect
}
).cropRect
expect(rect.width).toBe(cropWidth)
expect(rect.height).toBe(cropHeight)
@@ -308,7 +331,11 @@ describe('Arrow keys move the crop frame and Shift+arrow keys resize it', () =>
expect(document.body.textContent).toContain('Uploads as 1600 × 1600 px · about')
cropFrame().dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true, bubbles: true }),
new KeyboardEvent('keydown', {
key: 'ArrowRight',
shiftKey: true,
bubbles: true,
}),
)
await nextTick()
@@ -329,7 +356,11 @@ describe('Dragging the crop frame moves it, and dragging a corner handle resizes
const before = { ...uploader.cropRect }
cropFrame().dispatchEvent(
new MouseEvent('mousedown', { clientX: 0, clientY: 0, bubbles: true }),
new MouseEvent('mousedown', {
clientX: 0,
clientY: 0,
bubbles: true,
}),
)
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 40 }))
window.dispatchEvent(new MouseEvent('mouseup'))
@@ -348,7 +379,13 @@ describe('Dragging the crop frame moves it, and dragging a corner handle resizes
const handle = document.body.querySelectorAll<HTMLElement>(
'[data-testid="crop-handle"]',
)[3]!
handle.dispatchEvent(new MouseEvent('mousedown', { clientX: 0, clientY: 0, bubbles: true }))
handle.dispatchEvent(
new MouseEvent('mousedown', {
clientX: 0,
clientY: 0,
bubbles: true,
}),
)
const afterMove = { ...uploader.cropRect }
window.dispatchEvent(new MouseEvent('mousemove', { clientX: -50, clientY: -50 }))
window.dispatchEvent(new MouseEvent('mouseup'))
@@ -34,7 +34,10 @@ vi.mock('@/lib/imageCrop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/imageCrop')>()
return {
...actual,
readNaturalImageSize: vi.fn(async () => ({ width: 4032, height: 3024 })),
readNaturalImageSize: vi.fn(async () => ({
width: 4032,
height: 3024,
})),
}
})
@@ -353,9 +356,12 @@ describe('Insert', () => {
[
'presses Escape',
async () => {
document.body
.querySelector('[role="dialog"]')!
.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
document.body.querySelector('[role="dialog"]')!.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
}),
)
await settle()
},
],
@@ -82,7 +82,10 @@ describe('MediaField', () => {
})
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 })
const wrapper = await mountInApp(MediaField, {
label: 'Avatar',
picker: PICKER,
})
byText('button', 'Choose image')!.click()
await flushPromises()
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
@@ -34,7 +34,10 @@ const Host = defineComponent({
emits: ['created'],
setup(_props, { emit }) {
const open = ref(true)
return { open, onCreated: (article: Article) => emit('created', article) }
return {
open,
onCreated: (article: Article) => emit('created', article),
}
},
template: `<NewArticleModal v-model:open="open" @created="onCreated" />`,
})
@@ -155,7 +158,10 @@ describe('Submitting disables the form and cannot be dismissed', () => {
resolveRequest = resolve
}),
)
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
})
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
@@ -199,7 +205,10 @@ describe('Submitting disables the form and cannot be dismissed', () => {
describe('A successful create closes the modal and hands off to the editor with a toast', () => {
it('closes the modal and emits created with the response body', async () => {
requestMock = vi.fn().mockResolvedValue(articleFixture())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
})
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
@@ -219,7 +228,10 @@ describe('A successful create closes the modal and hands off to the editor with
describe("Title rejected — a 400 that got past this form's own check", () => {
it('keeps the values, shows the title error and the form alert', async () => {
requestMock = vi.fn().mockRejectedValue(new ApiError(400))
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
})
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
@@ -246,7 +258,10 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr
{ name: 'a network failure', error: () => new NetworkError() },
])('keeps the values and shows the generic alert for $name', async ({ error }) => {
requestMock = vi.fn().mockRejectedValue(error())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
})
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
@@ -269,7 +284,10 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr
describe('Retrying after an Error succeeds', () => {
it('sends the same body again and hands off to the editor on success', async () => {
requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500))
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
})
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
@@ -3,11 +3,7 @@ import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import PublishControl from '../PublishControl.vue'
function mountControl(props: {
online: boolean
dirty: boolean
disabled?: boolean
}) {
function mountControl(props: { online: boolean; dirty: boolean; disabled?: boolean }) {
return mount(PublishControl, {
props,
global: { plugins: [uiPlugin] },
@@ -46,8 +42,6 @@ describe('Disabled', () => {
disabled: true,
})
expect(
(wrapper.find('button').element as HTMLButtonElement).disabled,
).toBe(true)
expect((wrapper.find('button').element as HTMLButtonElement).disabled).toBe(true)
})
})
@@ -4,7 +4,12 @@ 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: 1,
label: 'Renew',
description: 'renew.example',
display: 'Renew · renew.example',
},
{
value: 3,
label: 'Projects',
@@ -75,7 +80,12 @@ describe('ReassignDialog', () => {
it('shows the failure notice with its title and text', async () => {
await mountInApp(
ReassignDialog,
props({ notice: { title: 'Failed', description: 'Nothing was changed.' } }),
props({
notice: {
title: 'Failed',
description: 'Nothing was changed.',
},
}),
)
expect(text(dialog().querySelector('[role="alert"]'))).toBe('Failed Nothing was changed.')
@@ -77,7 +77,12 @@ describe('SlugField', () => {
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." }),
props({
mode: 'saved',
value: 'renew',
locked: true,
lockedHelp: "Can't change.",
}),
)
expect(input().readOnly).toBe(true)
@@ -33,8 +33,7 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin() {
setActivePinia(createPinia())
global.fetch = (() =>
mockResponse(200, { open: false })) as unknown as typeof fetch
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push('/login')
const wrapper = mount(LoginView, {
attachTo: document.body,
@@ -70,9 +69,7 @@ describe('AppLogo renders the brand mark as inline SVG, and theme-switches', ()
expect(card.element.contains(svg.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<svg')).toBeGreaterThan(
html.indexOf('data-testid="auth-card"'),
)
expect(html.indexOf('<svg')).toBeGreaterThan(html.indexOf('data-testid="auth-card"'))
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
let centeringAncestor = svg.element.parentElement
@@ -113,9 +110,7 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(wrapper.text()).toContain(
'Welcome back. Log in to manage your articles.',
)
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
expect(wrapper.findAll('p').length).toBe(0)
})
})
@@ -180,12 +175,8 @@ describe('The login form is a UForm', () => {
const form = wrapper.find('form')
expect(form.exists()).toBe(true)
expect(form.attributes('id')).toMatch(/^v-/)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(
true,
)
expect(
form.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(form.find('button[type="submit"]').exists()).toBe(true)
})
})
@@ -194,20 +185,14 @@ describe('"Forgot password?" renders in the Password field\'s hint slot', () =>
it('renders the link through the hint slot, and the label reads only Password', async () => {
const wrapper = await mountLogin()
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
const fieldRoot = passwordInput.element.closest(
'[data-slot="root"][data-orientation]',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
expect(fieldRoot).toBeTruthy()
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
expect(hint).toBeTruthy()
expect(hint!.textContent?.trim()).toBe('Forgot password?')
expect(
hint!.querySelector('a[href="/register/reset/request"]'),
).toBeTruthy()
expect(hint!.querySelector('a[href="/register/reset/request"]')).toBeTruthy()
const label = fieldRoot!.querySelector('[data-slot="label"]')
expect(label!.textContent?.trim()).toBe('Password')
@@ -219,9 +204,7 @@ describe('Inputs are neutral by default and turn to the error edge only when the
const wrapper = await mountLogin()
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect(usernameInput.classes()).toContain('ring-accented')
expect(usernameInput.classes()).not.toContain('ring-error')
expect(passwordInput.classes()).toContain('ring-accented')
@@ -232,9 +215,7 @@ describe('Inputs are neutral by default and turn to the error edge only when the
await flushPromises()
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
const passwordAfter = wrapper.find(
'input[autocomplete="current-password"]',
)
const passwordAfter = wrapper.find('input[autocomplete="current-password"]')
expect(usernameAfter.classes()).toContain('ring-error')
expect(usernameAfter.classes()).not.toContain('ring-accented')
expect(passwordAfter.classes()).toContain('ring-error')
+4 -1
View File
@@ -25,7 +25,10 @@ export async function mountInApp(
default: () => h(component, props, slots as never),
}),
}),
{ attachTo: document.body, global: { plugins: [router, uiPlugin, pinia] } },
{
attachTo: document.body,
global: { plugins: [router, uiPlugin, pinia] },
},
)
await flushPromises()
return holder.wrapper
@@ -81,7 +81,11 @@ afterEach(async () => {
function press(element: Element, value: string) {
element.dispatchEvent(
new KeyboardEvent('keydown', { key: value, bubbles: true, cancelable: true }),
new KeyboardEvent('keydown', {
key: value,
bubbles: true,
cancelable: true,
}),
)
}
@@ -140,7 +144,11 @@ async function openAccountMenu(surface: Surface) {
if (surface === 'desktop UserMenu popover') {
wrapper = mount(UserMenu, {
attachTo: document.body,
props: { username: 'marta', email: SIGNED_IN.email, collapsed: false },
props: {
username: 'marta',
email: SIGNED_IN.email,
collapsed: false,
},
global: { plugins: [ui, router] },
})
document.body.querySelector<HTMLButtonElement>('button[aria-haspopup]')!.click()
@@ -330,7 +338,9 @@ describe('Choosing a language in the account menu applies it at once and keeps t
true,
true,
true,
expect.objectContaining({ mock: expect.objectContaining({ calls: [] }) }),
expect.objectContaining({
mock: expect.objectContaining({ calls: [] }),
}),
'/profile',
'light',
])
@@ -351,7 +361,10 @@ describe('Choosing a language in the account menu applies it at once and keeps t
signIn()
setViewportWidth(layout === 'desktop' ? DESKTOP_WIDTH : MOBILE_WIDTH)
await router.push('/profile')
wrapper = mount(App, { attachTo: document.body, global: { plugins: [ui, router] } })
wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
await wrapper.find('input[autocomplete="off"]').setValue('Alice B.')
if (layout === 'mobile') {
@@ -541,7 +554,9 @@ describe('Auth footer shows the language chip beside the theme chip', () => {
)
it('keeps the footer slot content to the left of the chips', async () => {
await mountAuthShell(false, { footer: '<a href="/register">Create an account</a>' })
await mountAuthShell(false, {
footer: '<a href="/register">Create an account</a>',
})
const footer = document.body.querySelector('footer')!
const link = footer.querySelector('a')!
@@ -635,7 +650,10 @@ describe('Auth language popover', () => {
signOut()
await router.push('/login')
const { default: view } = await import('@/views/LoginView.vue')
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
wrapper = mount(view, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
authChip().focus()
authChip().click()
@@ -712,7 +730,10 @@ describe('Each auth screen carries the language chip', () => {
session.status = 'ready'
await router.push(path)
const { default: view } = await load()
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
wrapper = mount(view, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
expect(authChip().textContent?.trim()).toBe('English')
@@ -724,7 +745,10 @@ describe('Each auth screen carries the language chip', () => {
signOut()
await router.push('/login')
const { default: view } = await import('@/views/LoginView.vue')
wrapper = mount(view, { attachTo: document.body, global: { plugins: [ui, router] } })
wrapper = mount(view, {
attachTo: document.body,
global: { plugins: [ui, router] },
})
await flushPromises()
await wrapper.find('input[autocomplete="username"]').setValue('marta')
+7 -1
View File
@@ -15,7 +15,13 @@ export const FIRST_PAGE_TOTAL = 48
export const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
export function pageBody(items: MediaItem[] = fixture(), total: number | null = FIRST_PAGE_TOTAL) {
return { items, total: total ?? items.length, page: 1, size: 25, counts: PICKER_COUNTS }
return {
items,
total: total ?? items.length,
page: 1,
size: 25,
counts: PICKER_COUNTS,
}
}
export function secondPage() {
@@ -20,7 +20,9 @@ const largest = computed(() => Math.max(0, ...props.items.map((item) => item.byt
}"
>
<template #header>
<h2 class="font-semibold text-highlighted">{{ t('By variant') }}</h2>
<h2 class="font-semibold text-highlighted">
{{ t('By variant') }}
</h2>
</template>
<div
v-for="item in items"
@@ -36,7 +38,10 @@ const largest = computed(() => Math.max(0, ...props.items.map((item) => item.byt
variant="outline"
icon="i-lucide-circle-off"
class="border border-accented bg-transparent text-toned ring-0"
:ui="{ base: 'h-4.5 gap-1 px-1.5 py-0 text-xs', leadingIcon: 'size-3' }"
:ui="{
base: 'h-4.5 gap-1 px-1.5 py-0 text-xs',
leadingIcon: 'size-3',
}"
>
{{ t('Disabled') }}
</UBadge>
@@ -26,7 +26,9 @@ const CARD_UI = {
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[460px_minmax(0,1fr)]">
<UCard :ui="CARD_UI">
<template #header>
<h2 class="font-semibold text-highlighted">{{ t('By variant') }}</h2>
<h2 class="font-semibold text-highlighted">
{{ t('By variant') }}
</h2>
</template>
<div v-for="row in VARIANT_ROWS" :key="row" class="flex flex-col gap-2">
<USkeleton class="h-3.25 w-[50%]" :class="BAR" />
@@ -25,7 +25,12 @@ const rows = computed(() => [
result: t('Unsupported parameter'),
mono: false,
},
{ request: t('Missing source image'), status: 404, result: t('Not found'), mono: false },
{
request: t('Missing source image'),
status: 404,
result: t('Not found'),
mono: false,
},
])
const columns = [
@@ -47,7 +52,9 @@ function isSuccess(status: number) {
body: 'flex flex-col gap-3 p-4 sm:p-4',
}"
>
<h2 class="font-semibold text-highlighted">{{ t('How requests are handled') }}</h2>
<h2 class="font-semibold text-highlighted">
{{ t('How requests are handled') }}
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<div class="border border-default">
<UTable
@@ -85,7 +92,10 @@ function isSuccess(status: number) {
? 'bg-(--rc-success-soft) text-success'
: 'bg-(--rc-error-soft) text-error'
"
:ui="{ base: 'h-5 gap-1 px-1.5 py-0 text-xs', leadingIcon: 'size-3' }"
:ui="{
base: 'h-5 gap-1 px-1.5 py-0 text-xs',
leadingIcon: 'size-3',
}"
>{{ row.original.status }}</UBadge
>
</template>
@@ -64,6 +64,8 @@ function nameTone(item: ImageVariant) {
/>
</div>
</UCard>
<p data-testid="variants-summary" class="py-1 text-muted">{{ summary }}</p>
<p data-testid="variants-summary" class="py-1 text-muted">
{{ summary }}
</p>
</div>
</template>
@@ -186,7 +186,10 @@ function setFormat(value: unknown) {
:highlight="Boolean(errors.width)"
autocomplete="off"
class="w-full"
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
:ui="{
base: `${FIELD_BASE} pe-10`,
trailing: 'pe-3',
}"
@blur="emit('blur', 'width')"
>
<template #trailing>
@@ -218,7 +221,10 @@ function setFormat(value: unknown) {
:highlight="Boolean(errors.height || errors.dimension)"
autocomplete="off"
class="w-full"
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
:ui="{
base: `${FIELD_BASE} pe-10`,
trailing: 'pe-3',
}"
@blur="emit('blur', 'height')"
>
<template #trailing>
@@ -196,7 +196,11 @@ function handleFailure(error: unknown): FieldName | null {
async function persist() {
if (props.mode === 'edit' && props.variant) {
const saved = await store.save(props.variant.name, toDefinition(form.values))
emit('saved', { mode: 'edit', name: saved.name, purgedFiles: saved.purged_files ?? 0 })
emit('saved', {
mode: 'edit',
name: saved.name,
purgedFiles: saved.purged_files ?? 0,
})
return
}
const created = await store.create(toNewVariant(form.values))
@@ -43,7 +43,10 @@ const ACTIONS_BY_STATUS: Record<VariantRow['status'], ActionKey[]> = {
const item = computed(() => props.row.item)
const generated = computed(() => props.row.status === 'generated')
const actions = computed(() =>
ACTIONS_BY_STATUS[props.row.status].map((key) => ({ key, ...ACTIONS[key] })),
ACTIONS_BY_STATUS[props.row.status].map((key) => ({
key,
...ACTIONS[key],
})),
)
const meta = computed(() => {
const base = `${item.value.output_width}×${item.value.output_height} · ${formatLabel(item.value.output_format as VariantFormat)}`
@@ -19,7 +19,8 @@ defineProps<{ item: MediaItem }>()
<span class="truncate font-semibold text-highlighted">{{ item.filename }}</span>
<span class="text-xs/5 text-toned">
{{ formatType(item.content_type) }} ·
{{ formatDimensions(item.width, item.height) }} · {{ formatSize(item.size) }}
{{ formatDimensions(item.width, item.height) }} ·
{{ formatSize(item.size) }}
</span>
</div>
</div>
@@ -21,7 +21,9 @@ const props = defineProps<{
ready: boolean
}>()
const variant = defineModel<string>('variant', { default: ORIGINAL_OPTION_VALUE })
const variant = defineModel<string>('variant', {
default: ORIGINAL_OPTION_VALUE,
})
const options = computed(() => buildOptions(props.image, props.variants))
const vector = computed(() => isVector(props.image))
@@ -77,7 +77,9 @@ async function onCopy() {
<template>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex items-baseline gap-2">
<h3 class="flex-1 font-semibold text-highlighted">{{ t('Preview') }}</h3>
<h3 class="flex-1 font-semibold text-highlighted">
{{ t('Preview') }}
</h3>
<span class="text-xs/5 text-muted">{{
t('Sample: {0}', [SAMPLE_SOURCE.filename])
}}</span>
@@ -23,7 +23,10 @@ function toItemValue(value: string) {
}
const items = computed(() =>
props.options.map((option) => ({ ...option, value: toItemValue(option.value) })),
props.options.map((option) => ({
...option,
value: toItemValue(option.value),
})),
)
const current = computed(() =>
props.modelValue === undefined ? undefined : toItemValue(props.modelValue),
+3 -1
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { t } from '@/i18n'
withDefaults(defineProps<{ active?: 'variants' | 'cache' }>(), { active: 'variants' })
withDefaults(defineProps<{ active?: 'variants' | 'cache' }>(), {
active: 'variants',
})
const TAB = 'h-9 gap-1.5 rounded-none border-b-2 px-0 py-0 text-sm hover:bg-transparent max-sm:h-11'
const ACTIVE = 'border-primary font-medium text-highlighted'
+10 -3
View File
@@ -244,14 +244,19 @@ describe('useApi abort and blob handling', () => {
it('rethrows an AbortError without flagging the app offline', async () => {
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
await expect(useApi().request('/v1/x')).rejects.toMatchObject({ name: 'AbortError' })
await expect(useApi().request('/v1/x')).rejects.toMatchObject({
name: 'AbortError',
})
expect(isOffline.value).toBe(false)
})
it('requestBlob returns the response blob', async () => {
const blob = new Blob(['a,b'])
fetchMock.mockResolvedValue({ ...mockResponse(200), blob: async () => blob })
fetchMock.mockResolvedValue({
...mockResponse(200),
blob: async () => blob,
})
await expect(useApi().requestBlob('/v1/activity/export')).resolves.toBe(blob)
})
@@ -259,7 +264,9 @@ describe('useApi abort and blob handling', () => {
it('requestBlob reuses the 401 handling and redirects to login', async () => {
fetchMock.mockResolvedValue(mockResponse(401))
await expectApiError(useApi().requestBlob('/v1/activity/export'), { status: 401 })
await expectApiError(useApi().requestBlob('/v1/activity/export'), {
status: 401,
})
await settle()
expect(router.currentRoute.value.path).toBe('/login')
@@ -1,9 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import {
useArticleDraft,
readMirroredDraft,
} from '@/composables/useArticleDraft'
import { useArticleDraft, readMirroredDraft } from '@/composables/useArticleDraft'
function mockResponse(status: number, body: unknown = null) {
return {
@@ -63,9 +60,7 @@ describe('A successful save sets state saved and records the time', () => {
setActivePinia(createPinia())
global.fetch = vi
.fn()
.mockResolvedValue(
mockResponse(200, { id: 12, body: '<p>Changed</p>' }),
)
.mockResolvedValue(mockResponse(200, { id: 12, body: '<p>Changed</p>' }))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
@@ -29,7 +29,12 @@ describe('useArticles', () => {
})
it('sends the search text and the status when they are set', async () => {
await useArticles().listArticles({ page: 2, size: 50, q: 'plaster', status: 'draft' })
await useArticles().listArticles({
page: 2,
size: 50,
q: 'plaster',
status: 'draft',
})
expect(request).toHaveBeenCalledWith(
'/v1/article/?format=html&page=2&size=50&q=plaster&status=draft',
@@ -37,7 +42,11 @@ describe('useArticles', () => {
})
it('sends the sort when it is set', async () => {
await useArticles().listArticles({ page: 1, size: 25, sort: 'title:asc' })
await useArticles().listArticles({
page: 1,
size: 25,
sort: 'title:asc',
})
expect(request).toHaveBeenCalledWith(
'/v1/article/?format=html&page=1&size=25&sort=title%3Aasc',
@@ -45,7 +54,12 @@ describe('useArticles', () => {
})
it('omits an empty search and an empty status', async () => {
await useArticles().listArticles({ page: 3, size: 5, q: '', status: '' })
await useArticles().listArticles({
page: 3,
size: 5,
q: '',
status: '',
})
expect(request).toHaveBeenCalledWith('/v1/article/?format=html&page=3&size=5')
})
+25 -5
View File
@@ -77,7 +77,11 @@ describe('useMedia list state', () => {
request.mockResolvedValue(listBody([item(1)], { total: 312, page: 7 }))
const media = useMedia()
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 7, size: 50 })
const outcome = await media.load({
...DEFAULT_CONTROLS,
page: 7,
size: 50,
})
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=7&size=50')
expect(media.total.value).toBe(312)
@@ -116,7 +120,12 @@ describe('useMedia list state', () => {
request.mockResolvedValueOnce(listBody(second, { total: 130, page: 2 }))
const media = useMedia()
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 })
await media.load({
...DEFAULT_CONTROLS,
filter: 'duplicates',
page: 4,
size: 5,
})
expect(request).toHaveBeenCalledTimes(2)
expect(request).toHaveBeenNthCalledWith(
@@ -251,7 +260,13 @@ describe('useMedia list state', () => {
describe('useMedia requests', () => {
it('fetches the usage of an image', async () => {
const usage = [{ id: 10, title: 'How we price a bathroom renovation', published: true }]
const usage = [
{
id: 10,
title: 'How we price a bathroom renovation',
published: true,
},
]
request.mockResolvedValue(usage)
const media = useMedia()
@@ -298,7 +313,9 @@ describe('useMedia requests', () => {
request.mockResolvedValueOnce(undefined)
await media.deleteItem(1)
expect(request).toHaveBeenLastCalledWith('/v1/media/1', { method: 'DELETE' })
expect(request).toHaveBeenLastCalledWith('/v1/media/1', {
method: 'DELETE',
})
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
})
@@ -316,7 +333,10 @@ describe('useMedia requests', () => {
it('copies the absolute address of the image', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
const media = useMedia()
await media.copyUrl(item(1, { url: '/media/north-wall.jpg' }))
@@ -17,8 +17,14 @@ function mountEdges() {
}
function layout(element: HTMLElement, scrollLeft: number) {
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390 })
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100 })
Object.defineProperty(element, 'clientWidth', {
configurable: true,
value: 390,
})
Object.defineProperty(element, 'scrollWidth', {
configurable: true,
value: 1100,
})
element.scrollLeft = scrollLeft
element.dispatchEvent(new Event('scroll'))
}
@@ -53,8 +59,14 @@ describe('useScrollEdges', () => {
it('shows no fade when the content fits', async () => {
const { element, edges } = mountEdges()
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 500 })
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 500 })
Object.defineProperty(element, 'clientWidth', {
configurable: true,
value: 500,
})
Object.defineProperty(element, 'scrollWidth', {
configurable: true,
value: 500,
})
element.dispatchEvent(new Event('scroll'))
await nextTick()
+6 -1
View File
@@ -61,7 +61,12 @@ export function useActivity() {
return {
users: users.flatMap((item) =>
item.kind === 'user'
? [{ id: item.id, name: item.display_name || item.username }]
? [
{
id: item.id,
name: item.display_name || item.username,
},
]
: [],
),
keys: keys.map((key) => ({ id: key.id, name: key.name })),
+3 -1
View File
@@ -208,7 +208,9 @@ export function useActivityPage() {
const { size: current, ...rest } = route.query
const wanted = size.value === DEFAULT_PAGE_SIZE ? undefined : String(size.value)
if (current === wanted) return
router.replace({ query: wanted === undefined ? rest : { ...rest, size: wanted } })
router.replace({
query: wanted === undefined ? rest : { ...rest, size: wanted },
})
}
syncSizeQuery()
+152
View File
@@ -0,0 +1,152 @@
import { t } from '@/i18n'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
interface PublishHandlers {
onUpdated: (updated: Article) => void
onGone: () => void
retry: () => void
}
interface DeleteHandlers {
onDeleted: () => void
onGone: () => void
}
type ArticleRef = Pick<Article, 'id' | 'title'>
function deleteDescription(article: Pick<Article, 'title' | 'body'>) {
const count = bodyImageCount(article.body)
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
article.title,
imagesPhrase,
])
}
function isNotFound(err: unknown) {
return err instanceof ApiError && err.status === 404
}
export function useArticleActions() {
const { request } = useApi()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
function announcePublished(article: ArticleRef, makeOnline: boolean) {
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return makeOnline ? t('Article published') : t('Article unpublished')
},
get description() {
return makeOnline
? t('\u0022{0}\u0022 is now online.', [article.title])
: t('\u0022{0}\u0022 is now offline.', [article.title])
},
}))
}
function announcePublishFailed(id: number, makeOnline: boolean, retry: () => void) {
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
},
get description() {
return t("The server didn't respond. Nothing changed.")
},
duration: 0,
actions: [
{
get label() {
return t('Retry')
},
onClick: retry,
},
],
}))
}
function announceDeleted(article: ArticleRef) {
toast.add(() => ({
id: `article-deleted-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Article deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone.', [article.title])
},
}))
}
async function publish(article: ArticleRef, makeOnline: boolean, handlers: PublishHandlers) {
try {
const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
handlers.onUpdated(updated)
announcePublished(updated, makeOnline)
} catch (err) {
if (isNotFound(err)) {
handlers.onGone()
return
}
announcePublishFailed(article.id, makeOnline, handlers.retry)
}
}
async function deleteArticle(article: ArticleRef, handlers: DeleteHandlers) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
handlers.onDeleted()
announceDeleted(article)
} catch (err) {
if (isNotFound(err)) {
handlers.onGone()
return
}
throw new Error(
t("Couldn't delete the article, so it's still there. Try again in a moment."),
)
}
}
function confirmDelete(article: Article, action: () => Promise<void>) {
return overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Delete this article?')
},
get description() {
return deleteDescription(article)
},
get confirmLabel() {
return t('Delete article')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Deleting…')
},
tone: 'danger',
action,
},
})
.open()
}
return { publish, deleteArticle, confirmDelete }
}
+136 -27
View File
@@ -1,33 +1,86 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import type { Article, Byline } from '@/composables/useArticles'
import { sameIds, toBylines } from '@/lib/articleMeta'
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
export type ArticleDraftBusinessState =
| 'clean'
| 'dirty'
| 'saving'
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
export type SaveOutcome =
| 'saved'
| 'error'
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
export type SaveOutcome = 'saved' | 'error' | 'not-found' | 'unauthorized'
| 'not-found'
| 'unauthorized'
| 'site-gone'
| 'author-gone'
export interface ArticleMeta {
siteId: number | null
authors: Byline[]
}
function draftStorageKey(articleId: number) {
return `renewcms:article-draft:${articleId}`
}
function metaStorageKey(articleId: number) {
return `renewcms:article-draft-meta:${articleId}`
}
function readStorage(key: string): string | null {
try {
return sessionStorage.getItem(key)
} catch {
return null
}
}
function writeStorage(key: string, value: string) {
try {
sessionStorage.setItem(key, value)
} catch {
return
}
}
function removeStorage(key: string) {
try {
sessionStorage.removeItem(key)
} catch {
return
}
}
function readMirroredMeta(articleId: number): ArticleMeta | null {
const raw = readStorage(metaStorageKey(articleId))
if (raw === null) {
return null
}
try {
const parsed = JSON.parse(raw) as {
site_id: number | null
authors: Byline[]
}
return { siteId: parsed.site_id, authors: toBylines(parsed.authors) }
} catch {
return null
}
}
export function readMirroredDraft(articleId: number): string | null {
return sessionStorage.getItem(draftStorageKey(articleId))
return readStorage(draftStorageKey(articleId))
}
export function useArticleDraft(articleId: number) {
const { request } = useApi()
const body = ref('')
const bodyEdited = ref(false)
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] })
const businessState = ref<ArticleDraftBusinessState>('clean')
const savedAt = ref<Date | null>(null)
const offline = ref(
typeof navigator === 'undefined' ? false : !navigator.onLine,
)
const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine)
function handleOffline() {
offline.value = true
@@ -51,44 +104,100 @@ export function useArticleDraft(articleId: number) {
offline.value ? 'offline' : businessState.value,
)
function siteChanged() {
return meta.value.siteId !== savedMeta.value.siteId
}
function authorsChanged() {
return !sameIds(meta.value.authors, savedMeta.value.authors)
}
function mirrorToStorage() {
sessionStorage.setItem(draftStorageKey(articleId), body.value)
if (bodyEdited.value) {
writeStorage(draftStorageKey(articleId), body.value)
}
if (siteChanged() || authorsChanged()) {
writeStorage(
metaStorageKey(articleId),
JSON.stringify({
site_id: meta.value.siteId,
authors: meta.value.authors,
}),
)
}
}
function clearStorage() {
sessionStorage.removeItem(draftStorageKey(articleId))
removeStorage(draftStorageKey(articleId))
removeStorage(metaStorageKey(articleId))
}
function load(initialBody: string, initialSavedAt: Date) {
function load(initialBody: string, initialSavedAt: Date, initialMeta?: ArticleMeta) {
const mirrored = readMirroredDraft(articleId)
const mirroredMeta = readMirroredMeta(articleId)
savedAt.value = initialSavedAt
if (mirrored !== null) {
body.value = mirrored
businessState.value = 'dirty'
return
}
body.value = initialBody
businessState.value = 'clean'
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
meta.value = mirroredMeta ?? savedMeta.value
bodyEdited.value = mirrored !== null
body.value = mirrored ?? initialBody
businessState.value = mirrored !== null || mirroredMeta !== null ? 'dirty' : 'clean'
}
function edit(newBody: string) {
body.value = newBody
bodyEdited.value = true
businessState.value = 'dirty'
}
function editMeta(next: Partial<ArticleMeta>) {
meta.value = { ...meta.value, ...next }
businessState.value = 'dirty'
}
function savePayload() {
return {
...(bodyEdited.value ? { body: body.value } : {}),
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
...(authorsChanged()
? { author_ids: meta.value.authors.map((author) => author.id) }
: {}),
}
}
function goneOutcome(err: unknown): SaveOutcome | null {
if (statusOf(err) !== 404) {
return null
}
const code = codeOf(err)
if (code === SITE_CODES.notFound) {
return 'site-gone'
}
return code === AUTHOR_CODES.notFound ? 'author-gone' : null
}
async function save(): Promise<SaveOutcome> {
businessState.value = 'saving'
const payload = savePayload()
try {
await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: body.value }),
})
if (Object.keys(payload).length > 0) {
await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
businessState.value = 'saved'
savedAt.value = new Date()
savedMeta.value = meta.value
bodyEdited.value = false
clearStorage()
return 'saved'
} catch (err) {
const gone = goneOutcome(err)
if (gone) {
businessState.value = 'error'
return gone
}
if (err instanceof ApiError && err.status === 404) {
businessState.value = 'dirty'
return 'not-found'
@@ -103,5 +212,5 @@ export function useArticleDraft(articleId: number) {
}
}
return { body, state, savedAt, load, edit, save }
return { body, meta, state, savedAt, load, edit, editMeta, save }
}
+192
View File
@@ -0,0 +1,192 @@
import { nextTick, ref, type Ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { useArticleActions } from '@/composables/useArticleActions'
import type { Article, ArticleCounts } from '@/composables/useArticles'
import type { ArticleListQuery } from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
interface ArticleRowActionsState {
items: Ref<Article[]>
total: Ref<number>
counts: Ref<ArticleCounts>
shown: Ref<ArticleListQuery>
reload: (query: ArticleListQuery, mode: 'push' | 'replace') => void
}
export function useArticleRowActions({
items,
total,
counts,
shown,
reload,
}: ArticleRowActionsState) {
const actions = useArticleActions()
const toast = useLocalizedToast()
const busyIds = ref<Set<number>>(new Set())
function setBusy(id: number, busy: boolean) {
const next = new Set(busyIds.value)
if (busy) {
next.add(id)
} else {
next.delete(id)
}
busyIds.value = next
}
function shiftCounts(article: Article, delta: number) {
const key = article.is_online ? 'online' : 'draft'
counts.value = {
...counts.value,
all: Math.max(0, counts.value.all + delta),
[key]: Math.max(0, counts.value[key] + delta),
}
}
function detachRow(id: number) {
if (!items.value.some((article) => article.id === id)) {
return
}
items.value = items.value.filter((article) => article.id !== id)
total.value = Math.max(0, total.value - 1)
if (items.value.length === 0 && total.value > 0) {
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
reload({ ...shown.value, page }, 'replace')
}
}
function removeArticle(article: Article) {
shiftCounts(article, -1)
detachRow(article.id)
}
function replaceArticle(updated: Article, previous: Article) {
items.value = items.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
if (updated.is_online === previous.is_online) {
return
}
counts.value = {
...counts.value,
online: counts.value.online + (updated.is_online ? 1 : -1),
draft: counts.value.draft + (updated.is_online ? -1 : 1),
}
const filter = shown.value.status
if (filter && (filter === 'online') !== updated.is_online) {
detachRow(updated.id)
}
}
function notFoundToast(article: Article) {
return {
id: `article-not-found-${article.id}`,
color: 'info' as const,
icon: 'i-lucide-info',
get title() {
return t('That article no longer exists')
},
get description() {
return t('It was deleted somewhere else, so we removed it from the list.')
},
}
}
async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true)
try {
await actions.publish(article, makeOnline, {
onUpdated: (updated) => replaceArticle(updated, article),
onGone: () => goneFromList(article),
retry: () => setOnline(article, makeOnline),
})
} finally {
setBusy(article.id, false)
}
}
function findItem(id: number) {
return items.value.find((existing) => existing.id === id)
}
function handlePublish(id: number) {
const article = findItem(id)
if (article) {
setOnline(article, true)
}
}
function handleUnpublish(id: number) {
const article = findItem(id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = findItem(id)
if (!article) {
return
}
const url = `${window.location.origin}/v1/article/${id}`
try {
await navigator.clipboard.writeText(url)
toast.add(() => ({
id: `article-link-copied-${id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Link copied')
},
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
article.title,
])
},
}))
} catch {
toast.add(() => ({
id: `article-link-copy-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't copy the link")
},
}))
}
}
function goneFromList(article: Article) {
removeArticle(article)
toast.add(() => notFoundToast(article))
}
function performDelete(article: Article) {
return actions.deleteArticle(article, {
onDeleted: () => removeArticle(article),
onGone: () => goneFromList(article),
})
}
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
await nextTick()
const index = idsAtOpen.indexOf(deletedId)
const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0]
if (nextId != null) {
document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus()
return
}
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
}
async function handleDelete(article: Article) {
const idsAtOpen = items.value.map((existing) => existing.id)
const confirmed = await actions.confirmDelete(article, () => performDelete(article))
if (confirmed) {
focusRowOrHeading(idsAtOpen, article.id)
}
}
return { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete }
}
+9
View File
@@ -1,11 +1,16 @@
import { useApi } from '@/composables/useApi'
import type { Byline } from '@/lib/articleTypes'
import type { ArticleStatus } from '@/lib/articleListQuery'
export type { Byline }
export interface Article {
id: number
title: string
body: string
is_online: boolean
site_id?: number
authors?: Byline[]
created_at: string
updated_at: string
}
@@ -32,6 +37,7 @@ export interface ListArticlesParams {
q?: string
status?: ArticleStatus | ''
sort?: string
site?: string
}
export function useArticles() {
@@ -52,6 +58,9 @@ export function useArticles() {
if (params.sort) {
query.set('sort', params.sort)
}
if (params.site) {
query.set('site', params.site)
}
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
}
+24 -3
View File
@@ -24,6 +24,10 @@ export interface Author {
user_email?: string
}
export interface MyAuthor extends Author {
slug_adjusted: boolean
}
export interface AuthorPage {
items: Author[]
total: number
@@ -47,6 +51,12 @@ export interface NewAuthor {
user_id: number | null
}
export interface NewByline {
name: string
slug: string
user_id?: number
}
export type AuthorPatch = Partial<NewAuthor>
export type ReassignTarget = number | 'none' | undefined
@@ -75,7 +85,11 @@ export function useAuthors() {
let page = 1
let total = 0
do {
const response = await listAuthors({ q: '', page, size: OPTION_PAGE_SIZE })
const response = await listAuthors({
q: '',
page,
size: OPTION_PAGE_SIZE,
})
all.push(...response.items)
total = response.total
page += 1
@@ -91,16 +105,22 @@ export function useAuthors() {
return listUsersWithoutAuthor(new Set(authors.map((author) => author.user_id)))
}
function createAuthor(author: NewAuthor): Promise<Author> {
function createAuthor(author: NewAuthor | NewByline): Promise<Author> {
return request<Author>('/v1/author/', jsonInit('POST', author))
}
function createMyAuthor(): Promise<MyAuthor> {
return request<MyAuthor>('/v1/author/me', { method: 'POST' })
}
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 request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, {
method: 'DELETE',
})
}
return {
@@ -108,6 +128,7 @@ export function useAuthors() {
listAllAuthors,
listLinkableUsers,
createAuthor,
createMyAuthor,
updateAuthor,
deleteAuthor,
}
+1 -3
View File
@@ -7,9 +7,7 @@ export function useColorMode() {
return {
get preference(): ColorModePreference {
return store.value === 'auto'
? 'system'
: (store.value as ColorModePreference)
return store.value === 'auto' ? 'system' : (store.value as ColorModePreference)
},
set preference(value: ColorModePreference) {
store.value = value === 'system' ? 'auto' : value
+8 -1
View File
@@ -72,5 +72,12 @@ export function useDirectoryView() {
open(item)
}
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
return {
rememberOpener,
restoreFocus,
editParam,
dropEditParam,
leavePanel,
followEditLink,
}
}
+131
View File
@@ -0,0 +1,131 @@
import { ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ArticleEditor from '@/components/ArticleEditor.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { MediaItem } from '@/composables/useMedia'
import { bodyImageIds } from '@/lib/bodyImageIds'
import { formatSize } from '@/lib/mediaFormat'
import { useSeenImagesStore } from '@/stores/seenImages'
export function useEditorImages(body: () => string) {
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const seenImages = useSeenImagesStore()
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
const imageUploaderOpen = ref(false)
const imageUploaderFile = ref<File | null>(null)
const imageIdsInArticle = ref<number[]>([])
function openImageUploader(file: File | null) {
imageUploaderFile.value = file
imageIdsInArticle.value = bodyImageIds(body())
imageUploaderOpen.value = true
}
function onRequestImage() {
openImageUploader(null)
}
function onEditorDrop(event: DragEvent) {
const file = event.dataTransfer?.files?.[0]
if (!file) {
return
}
openImageUploader(file)
}
function announceImageAdded() {
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Image added')
},
get description() {
return t('Save to keep it in the article.')
},
}))
}
function onImageUploaded(item: MediaItem) {
seenImages.remember([item])
editorRef.value?.insertImage(item.url, '', item.id)
announceImageAdded()
}
function onImageInserted(item: MediaItem, alt: string, src: string) {
seenImages.remember([item])
editorRef.value?.insertImage(src, alt, item.id)
announceImageAdded()
}
async function performRemoveImage(imageId: number) {
editorRef.value?.removeImage(imageId)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Image removed')
},
get description() {
return t("It's still in your library.")
},
}))
}
function removalPreview(imageId: number) {
const seen = seenImages.find(imageId)
return {
src: editorRef.value?.imageSrc(imageId) ?? '',
filename: seen?.filename,
size: seen ? formatSize(seen.size) : undefined,
}
}
async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Remove this image from the article?')
},
get description() {
return t(
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
)
},
get confirmLabel() {
return t('Remove image')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Removing…')
},
tone: 'neutral',
preview: removalPreview(imageId),
action: () => performRemoveImage(imageId),
},
})
.open()
}
function onImageUploaderClosed() {
imageUploaderFile.value = null
}
return {
editorRef,
imageUploaderOpen,
imageUploaderFile,
imageIdsInArticle,
onRequestImage,
onEditorDrop,
onImageUploaded,
onImageInserted,
onRequestRemoveImage,
onImageUploaderClosed,
}
}
+5 -1
View File
@@ -47,7 +47,11 @@ export function useImageVariantRows(imageId: number, onGone: () => void) {
if (cause && !item.generated) {
return { item, status: 'failed', cause }
}
return { item, status: item.generated ? 'generated' : 'missing', cause: null }
return {
item,
status: item.generated ? 'generated' : 'missing',
cause: null,
}
}),
)
const generatedCount = computed(() => items.value.filter((item) => item.generated).length)
+3 -1
View File
@@ -85,7 +85,9 @@ export function useImageVariants() {
}
function purgeVariant(name: string): Promise<PurgeResult> {
return request<PurgeResult>(`${variantPath(name)}/purge`, { method: 'POST' })
return request<PurgeResult>(`${variantPath(name)}/purge`, {
method: 'POST',
})
}
return {
+23 -5
View File
@@ -141,14 +141,26 @@ export function useMedia() {
async function requestList(controls: MediaControls) {
if (isFullSet(controls)) {
return { response: await requestFullSet(controls), page: 1, redirected: false }
return {
response: await requestFullSet(controls),
page: 1,
redirected: false,
}
}
const response = await requestPage(controls)
if (!isPastLastPage(response, controls.page, controls.size)) {
return { response, page: response.total === 0 ? 1 : controls.page, redirected: false }
return {
response,
page: response.total === 0 ? 1 : controls.page,
redirected: false,
}
}
const page = pageCountFor(response.total, controls.size)
return { response: await requestPage({ ...controls, page }), page, redirected: true }
return {
response: await requestPage({ ...controls, page }),
page,
redirected: true,
}
}
async function load(
@@ -227,7 +239,9 @@ export function useMedia() {
const outcomes = await Promise.all(
targets.map(async (item) => {
try {
await request<void>(`/v1/media/${item.id}`, { method: 'DELETE' })
await request<void>(`/v1/media/${item.id}`, {
method: 'DELETE',
})
return { item, outcome: 'deleted' as const }
} catch (error) {
const failure = classifyMediaError(error)
@@ -245,7 +259,11 @@ export function useMedia() {
bumpMediaRevision()
}
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
return {
deleted,
failed: pick('failed'),
handled: pick('handled').length > 0,
}
}
async function mergeCopies(keepId: number, mergeIds: number[]) {
+10 -1
View File
@@ -125,5 +125,14 @@ export function useMediaCleanupSettings(onForbidden: () => void) {
clearSavedTimer()
})
return { settings, loadState, showSkeleton, saveStatus, saveFailure, load, refresh, save }
return {
settings,
loadState,
showSkeleton,
saveStatus,
saveFailure,
load,
refresh,
save,
}
}
+6 -2
View File
@@ -49,7 +49,9 @@ export function useMediaVariants() {
}
function generate(imageId: number, name: string) {
return request<MediaVariantItem>(namePath(imageId, name), { method: 'POST' })
return request<MediaVariantItem>(namePath(imageId, name), {
method: 'POST',
})
}
function generateAll(imageId: number) {
@@ -57,7 +59,9 @@ export function useMediaVariants() {
}
function purge(imageId: number, name: string) {
return request<PurgedVariant>(namePath(imageId, name), { method: 'DELETE' })
return request<PurgedVariant>(namePath(imageId, name), {
method: 'DELETE',
})
}
return { list, generate, generateAll, purge }
+79
View File
@@ -0,0 +1,79 @@
import { ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { useAuthors, type Author, type MyAuthor } from '@/composables/useAuthors'
import { AUTHOR_CODES, codeOf, isSessionError, statusOf } from '@/lib/directoryErrors'
import { useSessionStore } from '@/stores/session'
export type MyAuthorOutcome =
| { kind: 'created'; author: MyAuthor }
| { kind: 'exists' }
| { kind: 'failed' }
| { kind: 'handled' }
const HANDLED_STATUSES = [401, 403]
export function useMyAuthorProfile() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { createMyAuthor } = useAuthors()
const pending = ref(false)
function announceCreated(author: Author) {
toast.add(() => ({
id: 'author-profile-created',
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Author profile created')
},
get description() {
return t('{0} is now your byline.', [author.name])
},
}))
}
function announceFailed() {
toast.add(() => ({
id: 'author-profile-failed',
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't create your author profile")
},
get description() {
return t('Try again in a moment.')
},
}))
}
async function create(): Promise<MyAuthorOutcome> {
if (pending.value) {
return { kind: 'handled' }
}
pending.value = true
try {
const author = await createMyAuthor()
session.setAuthor({
id: author.id,
name: author.name,
slug: author.slug,
})
announceCreated(author)
return { kind: 'created', author }
} catch (error) {
if (statusOf(error) === 409 && codeOf(error) === AUTHOR_CODES.userLinked) {
await session.refresh()
return { kind: 'exists' }
}
if (isSessionError(error) || HANDLED_STATUSES.includes(statusOf(error))) {
return { kind: 'handled' }
}
return { kind: 'failed' }
} finally {
pending.value = false
}
}
return { pending, create, announceFailed }
}
+3 -1
View File
@@ -54,7 +54,9 @@ export function useSites() {
}
function deleteSite(id: number, reassignTo?: number): Promise<void> {
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, {
method: 'DELETE',
})
}
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
+6 -2
View File
@@ -49,11 +49,15 @@ export function useUpdate() {
}
function checkForUpdate(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update/check', { method: 'POST' })
return request<UpdateStatus>('/v1/system/update/check', {
method: 'POST',
})
}
function startUpdateDownload(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update/download', { method: 'POST' })
return request<UpdateStatus>('/v1/system/update/download', {
method: 'POST',
})
}
function requestRestart(kind: RestartKind): Promise<UpdateStatus> {
+17 -3
View File
@@ -57,7 +57,13 @@ export function useUsers() {
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 }]
? [
{
id: user.id,
label: user.display_name || user.username,
email: user.email,
},
]
: [],
)
}
@@ -75,8 +81,16 @@ export function useUsers() {
}
function renewInvite(id: number): Promise<CreatedInvite> {
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, { method: 'POST' })
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, {
method: 'POST',
})
}
return { listUsers, listLinkableUsers, inviteUser, cancelInvite, renewInvite }
return {
listUsers,
listLinkableUsers,
inviteUser,
cancelInvite,
renewInvite,
}
}
+9 -1
View File
@@ -89,5 +89,13 @@ export function useVariantForm(mode: FormMode, initial: VariantFormValues) {
() => delete remote.quality,
)
return { values, dirty, errors, dimensionHelpHidden, onBlur, validateAll, showRemote }
return {
values,
dirty,
errors,
dimensionHelpHidden,
onBlur,
validateAll,
showRemote,
}
}
+38 -6
View File
@@ -49,6 +49,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
],
Settings: ['GET /v1/settings'],
Sites: ['GET /v1/site/'],
Authors: ['GET /v1/author/'],
}
const EXPECTED_SESSION: Record<string, string[]> = {
@@ -72,6 +73,12 @@ const EXPECTED_SESSION: Record<string, string[]> = {
'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}',
],
'Authors · owner and admin': [
'POST /v1/author/',
'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}',
'POST /v1/author/me',
],
'Settings · owner only': ['PATCH /v1/settings'],
'Activity log': ['GET /v1/activity', 'GET /v1/activity/summary', 'GET /v1/activity/export'],
'API key management': ['GET /v1/api-key', 'POST /v1/api-key', 'DELETE /v1/api-key/{id}'],
@@ -166,20 +173,20 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
}
describe('Catalogue routes', () => {
it('holds exactly the 25 key-callable routes in 5 groups, in order', () => {
it('holds exactly the 26 key-callable routes in 6 groups, in order', () => {
expect(
Object.fromEntries(
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
),
).toEqual(EXPECTED_GROUPS)
expect(allEndpoints()).toHaveLength(25)
expect(allEndpoints()).toHaveLength(26)
})
it('holds the 21 session-only rows in 6 sub-groups', () => {
it('holds the 25 session-only rows in 7 sub-groups', () => {
expect(
Object.fromEntries(SESSION_GROUPS.map((group) => [group.title, group.rows.map(label)])),
).toEqual(EXPECTED_SESSION)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(21)
expect(SESSION_GROUPS.flatMap((group) => group.rows)).toHaveLength(25)
})
it('holds the 6 public auth flow rows', () => {
@@ -201,7 +208,7 @@ describe('Catalogue routes', () => {
),
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
]
expect(described).toHaveLength(5 + 25 + 21 + 6)
expect(described).toHaveLength(6 + 26 + 25 + 6)
const blank = described.filter(([, description]) => description.trim() === '')
expect(blank.map(([name]) => name)).toEqual([])
})
@@ -503,7 +510,10 @@ describe('Owner-only invite routes', () => {
const endpoint = find(route)
expect(endpoint.access).toBe('Owner only')
expect(endpoint.rateLimited).toBe(true)
expect(endpoint.errors).toContainEqual({ status: 403, caption: OWNER_REQUIRED_MESSAGE })
expect(endpoint.errors).toContainEqual({
status: 403,
caption: OWNER_REQUIRED_MESSAGE,
})
expect(RATE_LIMIT_SENTENCE).toContain('rate limited')
expect(RATE_LIMIT_SENTENCE).toContain('429')
expect(RATE_LIMIT_SENTENCE.replace('429', '')).not.toMatch(/\d/)
@@ -583,6 +593,10 @@ describe('Session only rows', () => {
'PATCH /v1/site/{id}',
'PATCH /v1/site/{id}/default',
'DELETE /v1/site/{id}',
'POST /v1/author/',
'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}',
'POST /v1/author/me',
'PATCH /v1/settings',
'GET /v1/activity',
'GET /v1/activity/summary',
@@ -593,6 +607,24 @@ describe('Session only rows', () => {
])
})
it('lists every /v1/author route that is not key-callable, POST /v1/author/me included, as a session row', () => {
const group = SESSION_GROUPS.find(
(candidate) => candidate.title === 'Authors · owner and admin',
)
expect(group?.rows.map(label)).toEqual([
'POST /v1/author/',
'PATCH /v1/author/{id}',
'DELETE /v1/author/{id}',
'POST /v1/author/me',
])
expect(group?.rows.every((row) => row.answers403)).toBe(true)
expect(
allEndpoints()
.filter((endpoint) => endpoint.path.startsWith('/v1/author'))
.map(label),
).toEqual(['GET /v1/author/'])
})
it('captions login, logout, register and password reset without a 403 claim', () => {
const family = rows.filter((row) => !row.answers403)
expect(family.map(label)).toEqual([
Loaded 100 of 239 files, more files were not shown because too many files have changed in this diff. Show more