feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
|
||||
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
||||
import { useDirectoryView } from '@/composables/useDirectoryView'
|
||||
import { useSlugField } from '@/composables/useSlugField'
|
||||
import router from '@/router'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function inComponent<T>(setup: () => T) {
|
||||
let result!: T
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = setup()
|
||||
return () => h('div')
|
||||
},
|
||||
}),
|
||||
{ global: { plugins: [router] } },
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((done, fail) => {
|
||||
resolve = done
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useDelayedSkeleton', () => {
|
||||
it('shows only after the delay and hides on stop', async () => {
|
||||
vi.useFakeTimers()
|
||||
const skeleton = inComponent(() => useDelayedSkeleton(150))
|
||||
skeleton.start()
|
||||
await vi.advanceTimersByTimeAsync(149)
|
||||
expect(skeleton.visible.value).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(skeleton.visible.value).toBe(true)
|
||||
skeleton.stop()
|
||||
expect(skeleton.visible.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDirectoryCollection', () => {
|
||||
it('loads, keeps the data on a later load and ignores a stale answer', async () => {
|
||||
const first = deferred<string>()
|
||||
const second = deferred<string>()
|
||||
const answers = [first, second]
|
||||
const collection = inComponent(() => useDirectoryCollection(() => answers.shift()!.promise))
|
||||
const early = collection.load([])
|
||||
const late = collection.load([])
|
||||
second.resolve('new')
|
||||
first.resolve('old')
|
||||
|
||||
expect(await late).toBe('ready')
|
||||
expect(await early).toBe('stale')
|
||||
expect(collection.data.value).toBe('new')
|
||||
expect(collection.state.value).toBe('ready')
|
||||
})
|
||||
|
||||
it('can refuse a result without committing it', async () => {
|
||||
const collection = inComponent(() => useDirectoryCollection(async () => 'page'))
|
||||
|
||||
expect(await collection.load([], { accept: () => false })).toBe('rejected')
|
||||
expect(collection.data.value).toBeNull()
|
||||
})
|
||||
|
||||
it('maps a 403 to forbidden, other failures to error and ignores a 401', async () => {
|
||||
const failures = [new ApiError(403), new ApiError(500), new ApiError(401)]
|
||||
const collection = inComponent(() =>
|
||||
useDirectoryCollection(async () => {
|
||||
throw failures.shift()
|
||||
}),
|
||||
)
|
||||
|
||||
await collection.load([])
|
||||
expect(collection.state.value).toBe('forbidden')
|
||||
await collection.load([])
|
||||
expect(collection.state.value).toBe('error')
|
||||
expect(await collection.load([])).toBe('stale')
|
||||
expect(collection.state.value).toBe('loading')
|
||||
})
|
||||
|
||||
it('draws the skeleton only after the delay on the first load', async () => {
|
||||
vi.useFakeTimers()
|
||||
const hold = deferred<string>()
|
||||
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
|
||||
const loading = collection.load([])
|
||||
expect(collection.showSkeleton.value).toBe(false)
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
expect(collection.showSkeleton.value).toBe(true)
|
||||
hold.resolve('done')
|
||||
await loading
|
||||
expect(collection.showSkeleton.value).toBe(false)
|
||||
})
|
||||
|
||||
it('drops an answer that arrives after the component is gone', async () => {
|
||||
const hold = deferred<string>()
|
||||
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
|
||||
const loading = collection.load([])
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
hold.resolve('late')
|
||||
|
||||
expect(await loading).toBe('stale')
|
||||
expect(collection.data.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDirectoryDelete', () => {
|
||||
it('runs once, clears the notice and reports failures to the handler', async () => {
|
||||
const removal = useDirectoryDelete<{ id: number }>()
|
||||
removal.open({ id: 1 })
|
||||
removal.notice.value = { description: 'old' }
|
||||
const run = vi.fn().mockRejectedValue(new Error('no'))
|
||||
const onFailure = vi.fn()
|
||||
|
||||
const first = removal.confirm(run, onFailure)
|
||||
await removal.confirm(run, onFailure)
|
||||
await first
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
expect(onFailure).toHaveBeenCalledWith(expect.any(Error), { id: 1 })
|
||||
expect(removal.pending.value).toBe(false)
|
||||
expect(removal.notice.value).toBeNull()
|
||||
})
|
||||
|
||||
it('does nothing without a target and closes cleanly', async () => {
|
||||
const removal = useDirectoryDelete<{ id: number }>()
|
||||
const run = vi.fn()
|
||||
await removal.confirm(run, vi.fn())
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
|
||||
removal.open({ id: 2 })
|
||||
removal.close()
|
||||
expect(removal.target.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSlugField', () => {
|
||||
it('follows the name until unlocked and goes back with reset', () => {
|
||||
const slug = useSlugField(32)
|
||||
expect(slug.mode.value).toBe('empty')
|
||||
slug.setSource('Camille Roche')
|
||||
expect(slug.mode.value).toBe('auto')
|
||||
expect(slug.value.value).toBe('camille-roche')
|
||||
slug.unlock()
|
||||
slug.setSource('Someone Else')
|
||||
expect(slug.mode.value).toBe('manual')
|
||||
expect(slug.value.value).toBe('camille-roche')
|
||||
slug.reset()
|
||||
expect(slug.value.value).toBe('someone-else')
|
||||
expect(slug.mode.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('leaves the saved mode as soon as the value is edited and reset follows the name', () => {
|
||||
const slug = useSlugField(100, 'ines-duarte')
|
||||
slug.setSource('Inès Duarte')
|
||||
expect(slug.mode.value).toBe('saved')
|
||||
slug.setValue('ines-d')
|
||||
expect(slug.mode.value).toBe('manual')
|
||||
slug.setValue('ines-duarte')
|
||||
expect(slug.mode.value).toBe('saved')
|
||||
slug.setValue('other')
|
||||
slug.reset()
|
||||
expect(slug.value.value).toBe('ines-duarte')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useDirectoryView', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it('opens a deep link once and ignores a lookup that finishes after unmount', async () => {
|
||||
await router.push('/authors?edit=zoe')
|
||||
const hold = deferred<string | null>()
|
||||
const view = inComponent(() => useDirectoryView())
|
||||
const open = vi.fn()
|
||||
const missing = vi.fn()
|
||||
|
||||
const following = view.followEditLink(() => hold.promise, open, missing)
|
||||
await view.followEditLink(() => hold.promise, open, missing)
|
||||
wrapper!.unmount()
|
||||
wrapper = null
|
||||
hold.resolve('found')
|
||||
await following
|
||||
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(missing).not.toHaveBeenCalled()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
it('reports an unknown value once and drops it from the URL', async () => {
|
||||
await router.push('/authors?edit=nobody&q=x')
|
||||
const view = inComponent(() => useDirectoryView())
|
||||
const missing = vi.fn()
|
||||
|
||||
await view.followEditLink(() => null, vi.fn(), missing)
|
||||
await flushPromises()
|
||||
|
||||
expect(missing).toHaveBeenCalledTimes(1)
|
||||
expect(router.currentRoute.value.query).toEqual({ q: 'x' })
|
||||
await router.push('/login')
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,12 @@ export class NetworkError extends Error {
|
||||
|
||||
export type UserRole = 'owner' | 'admin'
|
||||
|
||||
export interface MeAuthor {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface MeResult {
|
||||
id?: number
|
||||
verified: boolean
|
||||
@@ -30,6 +36,7 @@ export interface MeResult {
|
||||
email?: string
|
||||
displayName: string
|
||||
role?: UserRole
|
||||
author?: MeAuthor
|
||||
}
|
||||
|
||||
export type OnUnauthorized = 'redirect' | 'passthrough'
|
||||
@@ -129,7 +136,7 @@ export function useApi() {
|
||||
|
||||
if (response.status === 403) {
|
||||
redirectUnverified(onForbidden)
|
||||
throw new ApiError(403)
|
||||
throw new ApiError(403, undefined, await readErrorBody(response))
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
@@ -165,6 +172,7 @@ export function useApi() {
|
||||
email?: string
|
||||
display_name?: string | null
|
||||
role?: string
|
||||
author?: MeAuthor | null
|
||||
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
|
||||
return {
|
||||
id: body.id,
|
||||
@@ -173,6 +181,7 @@ export function useApi() {
|
||||
email: body.email,
|
||||
displayName: body.display_name ?? '',
|
||||
role: body.role as UserRole | undefined,
|
||||
...(body.author ? { author: body.author } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
import { reassignQuery } from '@/lib/directory'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||
import type { SocialType } from '@/lib/socialLinks'
|
||||
|
||||
export interface SocialLink {
|
||||
type: SocialType
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
bio: string
|
||||
avatar_id: number | null
|
||||
website: string | null
|
||||
social_links: SocialLink[]
|
||||
user_id: number | null
|
||||
is_linked: boolean
|
||||
article_count: number
|
||||
user_email?: string
|
||||
}
|
||||
|
||||
export interface AuthorPage {
|
||||
items: Author[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface AuthorListQuery {
|
||||
q: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface NewAuthor {
|
||||
name: string
|
||||
slug: string
|
||||
bio: string
|
||||
avatar_id: number | null
|
||||
website: string | null
|
||||
social_links: SocialLink[]
|
||||
user_id: number | null
|
||||
}
|
||||
|
||||
export type AuthorPatch = Partial<NewAuthor>
|
||||
|
||||
export type ReassignTarget = number | 'none' | undefined
|
||||
|
||||
const OPTION_PAGE_SIZE = 100
|
||||
|
||||
export function authorListPath(query: AuthorListQuery) {
|
||||
const parts = [
|
||||
...(query.q ? [`q=${encodeURIComponent(query.q)}`] : []),
|
||||
`page=${query.page}`,
|
||||
`size=${query.size}`,
|
||||
]
|
||||
return `/v1/author/?${parts.join('&')}`
|
||||
}
|
||||
|
||||
export function useAuthors() {
|
||||
const { request } = useApi()
|
||||
const { listLinkableUsers: listUsersWithoutAuthor } = useUsers()
|
||||
|
||||
function listAuthors(query: AuthorListQuery): Promise<AuthorPage> {
|
||||
return request<AuthorPage>(authorListPath(query))
|
||||
}
|
||||
|
||||
async function listAllAuthors(): Promise<Author[]> {
|
||||
const all: Author[] = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
do {
|
||||
const response = await listAuthors({ q: '', page, size: OPTION_PAGE_SIZE })
|
||||
all.push(...response.items)
|
||||
total = response.total
|
||||
page += 1
|
||||
if (response.items.length === 0) {
|
||||
break
|
||||
}
|
||||
} while (all.length < total)
|
||||
return all
|
||||
}
|
||||
|
||||
async function listLinkableUsers(): Promise<LinkableUser[]> {
|
||||
const authors = await listAllAuthors()
|
||||
return listUsersWithoutAuthor(new Set(authors.map((author) => author.user_id)))
|
||||
}
|
||||
|
||||
function createAuthor(author: NewAuthor): Promise<Author> {
|
||||
return request<Author>('/v1/author/', jsonInit('POST', author))
|
||||
}
|
||||
|
||||
function updateAuthor(id: number, patch: AuthorPatch): Promise<Author> {
|
||||
return request<Author>(`/v1/author/${id}`, jsonInit('PATCH', patch))
|
||||
}
|
||||
|
||||
function deleteAuthor(id: number, reassignTo: ReassignTarget): Promise<void> {
|
||||
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return {
|
||||
listAuthors,
|
||||
listAllAuthors,
|
||||
listLinkableUsers,
|
||||
createAuthor,
|
||||
updateAuthor,
|
||||
deleteAuthor,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
export const SKELETON_DELAY_MS = 150
|
||||
|
||||
export function useDelayedSkeleton(delay = SKELETON_DELAY_MS) {
|
||||
const visible = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function stop() {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop()
|
||||
timer = setTimeout(() => {
|
||||
visible.value = true
|
||||
}, delay)
|
||||
}
|
||||
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
return { visible, start, stop }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { onBeforeUnmount, ref, type Ref } from 'vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { isSessionError, statusOf } from '@/lib/directoryErrors'
|
||||
|
||||
export type CollectionState = 'loading' | 'ready' | 'error' | 'forbidden'
|
||||
|
||||
export interface LoadOptions<T> {
|
||||
accept?: (result: T) => boolean
|
||||
}
|
||||
|
||||
export function useDirectoryCollection<T, A extends unknown[]>(
|
||||
fetcher: (...args: A) => Promise<T>,
|
||||
) {
|
||||
const state = ref<CollectionState>('loading')
|
||||
const data = ref<T | null>(null) as Ref<T | null>
|
||||
const skeleton = useDelayedSkeleton()
|
||||
let ticket = 0
|
||||
let alive = true
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
alive = false
|
||||
})
|
||||
|
||||
async function load(args: A, options: LoadOptions<T> = {}) {
|
||||
ticket += 1
|
||||
const current = ticket
|
||||
const first = data.value === null
|
||||
if (first) {
|
||||
state.value = 'loading'
|
||||
skeleton.start()
|
||||
}
|
||||
try {
|
||||
const result = await fetcher(...args)
|
||||
if (!alive || current !== ticket) {
|
||||
return 'stale' as const
|
||||
}
|
||||
if (options.accept && !options.accept(result)) {
|
||||
return 'rejected' as const
|
||||
}
|
||||
data.value = result
|
||||
state.value = 'ready'
|
||||
return 'ready' as const
|
||||
} catch (error) {
|
||||
if (!alive || current !== ticket || isSessionError(error)) {
|
||||
return 'stale' as const
|
||||
}
|
||||
data.value = null
|
||||
state.value = statusOf(error) === 403 ? 'forbidden' : 'error'
|
||||
return 'failed' as const
|
||||
} finally {
|
||||
if (alive && current === ticket) {
|
||||
skeleton.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { state, data, showSkeleton: skeleton.visible, load }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
|
||||
export function useDirectoryDelete<T>() {
|
||||
const target = ref<T | null>(null) as Ref<T | null>
|
||||
const pending = ref(false)
|
||||
const notice = ref<DirectoryNotice | null>(null)
|
||||
|
||||
function open(item: T) {
|
||||
notice.value = null
|
||||
target.value = item
|
||||
}
|
||||
|
||||
function close() {
|
||||
target.value = null
|
||||
notice.value = null
|
||||
}
|
||||
|
||||
async function confirm(
|
||||
run: (item: T) => Promise<void>,
|
||||
onFailure: (error: unknown, item: T) => Promise<void> | void,
|
||||
) {
|
||||
const item = target.value
|
||||
if (!item || pending.value) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
notice.value = null
|
||||
try {
|
||||
await run(item)
|
||||
} catch (error) {
|
||||
await onFailure(error, item)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { target, pending, notice, open, close, confirm }
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { t } from '@/i18n'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import type { FailureContext, FailureKind } from '@/lib/directoryErrors'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
|
||||
type ToastColor = 'success' | 'info' | 'error'
|
||||
|
||||
const ICONS: Record<ToastColor, string> = {
|
||||
success: 'i-lucide-circle-check',
|
||||
info: 'i-lucide-info',
|
||||
error: 'i-lucide-circle-x',
|
||||
}
|
||||
|
||||
export function useDirectoryToasts() {
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
function show(id: string, color: ToastColor, title: () => string, description?: () => string) {
|
||||
toast.add(() => ({
|
||||
id,
|
||||
color,
|
||||
icon: ICONS[color],
|
||||
get title() {
|
||||
return title()
|
||||
},
|
||||
get description() {
|
||||
return description?.()
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function siteGone() {
|
||||
show(
|
||||
'site-gone',
|
||||
'info',
|
||||
() => t('That site no longer exists'),
|
||||
() => t('It was deleted somewhere else, so we removed it from the list.'),
|
||||
)
|
||||
}
|
||||
|
||||
function authorGone() {
|
||||
show(
|
||||
'author-gone',
|
||||
'info',
|
||||
() => t('That author no longer exists'),
|
||||
() => t('It was deleted somewhere else, so we removed it from the list.'),
|
||||
)
|
||||
}
|
||||
|
||||
function ownerOnly() {
|
||||
show(
|
||||
'site-owner-only',
|
||||
'error',
|
||||
() => t('Only the owner can manage sites'),
|
||||
() => t('Ask the owner of this site.'),
|
||||
)
|
||||
}
|
||||
|
||||
function defaultSiteBlocked() {
|
||||
show('site-default-blocked', 'error', () => copy('defaultSiteBlocked'))
|
||||
}
|
||||
|
||||
function authorRights() {
|
||||
show(
|
||||
'author-rights',
|
||||
'error',
|
||||
() => t('Only an owner or admin can change authors'),
|
||||
() => t('You can still edit your own author profile.'),
|
||||
)
|
||||
}
|
||||
|
||||
function siteDefaultChanged(name: string) {
|
||||
show(
|
||||
'site-default-changed',
|
||||
'success',
|
||||
() => t('Default site changed'),
|
||||
() => t('"{0}" is now the default site.', [name]),
|
||||
)
|
||||
}
|
||||
|
||||
function siteDeleted(count: number, destination: string) {
|
||||
show(
|
||||
'site-deleted',
|
||||
'success',
|
||||
() => t('Site deleted'),
|
||||
count > 0
|
||||
? () =>
|
||||
count === 1
|
||||
? t('1 article moved to {0}.', [destination])
|
||||
: t('{0} articles moved to {1}.', [count, destination])
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function authorDeleted(count: number, destination: string | null) {
|
||||
show(
|
||||
'author-deleted',
|
||||
'success',
|
||||
() => t('Author deleted'),
|
||||
count > 0
|
||||
? () => {
|
||||
if (destination === null) {
|
||||
return count === 1
|
||||
? t('1 article no longer has a byline.')
|
||||
: t('{0} articles no longer have a byline.', [count])
|
||||
}
|
||||
return count === 1
|
||||
? t('1 article now credits {0}.', [destination])
|
||||
: t('{0} articles now credit {1}.', [count, destination])
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
|
||||
function userUnlinked(name: string) {
|
||||
show(
|
||||
'author-unlinked',
|
||||
'success',
|
||||
() => t('User unlinked'),
|
||||
() => t('{0} is now a guest author.', [name]),
|
||||
)
|
||||
}
|
||||
|
||||
function report(kind: FailureKind, context: FailureContext) {
|
||||
const handlers: Partial<Record<FailureKind, () => void>> = {
|
||||
forbidden: ownerOnly,
|
||||
defaultSite: defaultSiteBlocked,
|
||||
rights: authorRights,
|
||||
gone: context === 'site' ? siteGone : authorGone,
|
||||
}
|
||||
const handler = handlers[kind]
|
||||
handler?.()
|
||||
return handler !== undefined
|
||||
}
|
||||
|
||||
return {
|
||||
report,
|
||||
siteGone,
|
||||
authorGone,
|
||||
ownerOnly,
|
||||
defaultSiteBlocked,
|
||||
authorRights,
|
||||
siteDefaultChanged,
|
||||
siteDeleted,
|
||||
authorDeleted,
|
||||
userUnlinked,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
export function useDirectoryView() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
let opener: HTMLElement | null = null
|
||||
let followed = ''
|
||||
let ticket = 0
|
||||
let alive = true
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
alive = false
|
||||
})
|
||||
|
||||
function rememberOpener(element?: HTMLElement | null) {
|
||||
opener =
|
||||
element ??
|
||||
(document.activeElement instanceof HTMLElement ? document.activeElement : null)
|
||||
}
|
||||
|
||||
async function restoreFocus() {
|
||||
await nextTick()
|
||||
if (opener?.isConnected) {
|
||||
opener.focus()
|
||||
}
|
||||
opener = null
|
||||
}
|
||||
|
||||
function editParam() {
|
||||
const value = route.query.edit
|
||||
return typeof value === 'string' ? value : Array.isArray(value) ? (value[0] ?? '') : ''
|
||||
}
|
||||
|
||||
function dropEditParam() {
|
||||
followed = ''
|
||||
if (route.query.edit !== undefined) {
|
||||
router.replace({
|
||||
query: Object.fromEntries(
|
||||
Object.entries(route.query).filter(([key]) => key !== 'edit'),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function leavePanel() {
|
||||
dropEditParam()
|
||||
return restoreFocus()
|
||||
}
|
||||
|
||||
async function followEditLink<T>(
|
||||
lookup: (value: string) => T | null | Promise<T | null>,
|
||||
open: (item: T) => void,
|
||||
missing: () => void,
|
||||
) {
|
||||
const value = editParam()
|
||||
if (!value || followed === value) {
|
||||
return
|
||||
}
|
||||
followed = value
|
||||
ticket += 1
|
||||
const current = ticket
|
||||
const item = await lookup(value)
|
||||
if (!alive || current !== ticket) {
|
||||
return
|
||||
}
|
||||
if (item === null) {
|
||||
missing()
|
||||
dropEditParam()
|
||||
return
|
||||
}
|
||||
open(item)
|
||||
}
|
||||
|
||||
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import { reassignQuery } from '@/lib/directory'
|
||||
|
||||
export type SiteRobots = 'index' | 'noindex'
|
||||
|
||||
export interface Site {
|
||||
id: number
|
||||
name: string
|
||||
key: string
|
||||
base_url: string
|
||||
default_social_image_id: number | null
|
||||
default_robots: SiteRobots
|
||||
locale: string | null
|
||||
is_default: boolean
|
||||
article_count: number
|
||||
}
|
||||
|
||||
export interface NewSite {
|
||||
name: string
|
||||
key: string
|
||||
base_url: string
|
||||
default_robots: SiteRobots
|
||||
locale?: string
|
||||
default_social_image_id?: number
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export interface SitePatch {
|
||||
name?: string
|
||||
base_url?: string
|
||||
default_robots?: SiteRobots
|
||||
locale?: string | null
|
||||
default_social_image_id?: number | null
|
||||
}
|
||||
|
||||
export function useSites() {
|
||||
const { request } = useApi()
|
||||
|
||||
function listSites(): Promise<Site[]> {
|
||||
return request<Site[]>('/v1/site/')
|
||||
}
|
||||
|
||||
function createSite(site: NewSite): Promise<Site> {
|
||||
return request<Site>('/v1/site/', jsonInit('POST', site))
|
||||
}
|
||||
|
||||
function updateSite(id: number, patch: SitePatch): Promise<Site> {
|
||||
return request<Site>(`/v1/site/${id}`, jsonInit('PATCH', patch))
|
||||
}
|
||||
|
||||
function setDefaultSite(id: number): Promise<Site> {
|
||||
return request<Site>(`/v1/site/${id}/default`, { method: 'PATCH' })
|
||||
}
|
||||
|
||||
function deleteSite(id: number, reassignTo?: number): Promise<void> {
|
||||
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { slugify } from '@/lib/directory'
|
||||
|
||||
export type SlugMode = 'empty' | 'auto' | 'manual' | 'saved'
|
||||
|
||||
export function useSlugField(max: number, saved?: string) {
|
||||
const source = ref('')
|
||||
const override = ref<string | null>(saved ?? null)
|
||||
const isSaved = saved !== undefined
|
||||
|
||||
const value = computed(() => override.value ?? slugify(source.value, max))
|
||||
const mode = computed<SlugMode>(() => {
|
||||
if (override.value !== null) {
|
||||
return isSaved && override.value === saved ? 'saved' : 'manual'
|
||||
}
|
||||
return source.value.trim() ? 'auto' : 'empty'
|
||||
})
|
||||
|
||||
function setSource(text: string) {
|
||||
source.value = text
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
override.value = value.value
|
||||
}
|
||||
|
||||
function setValue(next: string) {
|
||||
override.value = next
|
||||
}
|
||||
|
||||
function reset() {
|
||||
override.value = isSaved ? slugify(source.value, max) : null
|
||||
}
|
||||
|
||||
return { source, value, mode, setSource, unlock, setValue, reset }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||
|
||||
export type UserListItem =
|
||||
| {
|
||||
@@ -50,6 +51,17 @@ export function useUsers() {
|
||||
return request<UserListItem[]>('/v1/user/')
|
||||
}
|
||||
|
||||
async function listLinkableUsers(
|
||||
takenUserIds: ReadonlySet<number | null>,
|
||||
): Promise<LinkableUser[]> {
|
||||
const users = await listUsers()
|
||||
return users.flatMap((user) =>
|
||||
user.kind === 'user' && !takenUserIds.has(user.id)
|
||||
? [{ id: user.id, label: user.display_name || user.username, email: user.email }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function inviteUser(email: string): Promise<CreatedInvite> {
|
||||
return request<CreatedInvite>('/v1/user/invite', {
|
||||
method: 'POST',
|
||||
@@ -66,5 +78,5 @@ export function useUsers() {
|
||||
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, { method: 'POST' })
|
||||
}
|
||||
|
||||
return { listUsers, inviteUser, cancelInvite, renewInvite }
|
||||
return { listUsers, listLinkableUsers, inviteUser, cancelInvite, renewInvite }
|
||||
}
|
||||
Reference in new issue
Block a user