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
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user