feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)
This commit is contained in:
1 parent
3d81d2c717
commit
6b8990c55e
239 files changed
+9194
-1354
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">{{
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [] }>()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`"
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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>
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}`"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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)"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,5 +72,12 @@ export function useDirectoryView() {
|
||||
open(item)
|
||||
}
|
||||
|
||||
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
|
||||
return {
|
||||
rememberOpener,
|
||||
restoreFocus,
|
||||
editParam,
|
||||
dropEditParam,
|
||||
leavePanel,
|
||||
followEditLink,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user