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

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

No files matched your search

+10 -3
View File
@@ -244,14 +244,19 @@ describe('useApi abort and blob handling', () => {
it('rethrows an AbortError without flagging the app offline', async () => {
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
await expect(useApi().request('/v1/x')).rejects.toMatchObject({ name: 'AbortError' })
await expect(useApi().request('/v1/x')).rejects.toMatchObject({
name: 'AbortError',
})
expect(isOffline.value).toBe(false)
})
it('requestBlob returns the response blob', async () => {
const blob = new Blob(['a,b'])
fetchMock.mockResolvedValue({ ...mockResponse(200), blob: async () => blob })
fetchMock.mockResolvedValue({
...mockResponse(200),
blob: async () => blob,
})
await expect(useApi().requestBlob('/v1/activity/export')).resolves.toBe(blob)
})
@@ -259,7 +264,9 @@ describe('useApi abort and blob handling', () => {
it('requestBlob reuses the 401 handling and redirects to login', async () => {
fetchMock.mockResolvedValue(mockResponse(401))
await expectApiError(useApi().requestBlob('/v1/activity/export'), { status: 401 })
await expectApiError(useApi().requestBlob('/v1/activity/export'), {
status: 401,
})
await settle()
expect(router.currentRoute.value.path).toBe('/login')
@@ -1,9 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import {
useArticleDraft,
readMirroredDraft,
} from '@/composables/useArticleDraft'
import { useArticleDraft, readMirroredDraft } from '@/composables/useArticleDraft'
function mockResponse(status: number, body: unknown = null) {
return {
@@ -63,9 +60,7 @@ describe('A successful save sets state saved and records the time', () => {
setActivePinia(createPinia())
global.fetch = vi
.fn()
.mockResolvedValue(
mockResponse(200, { id: 12, body: '<p>Changed</p>' }),
)
.mockResolvedValue(mockResponse(200, { id: 12, body: '<p>Changed</p>' }))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
@@ -29,7 +29,12 @@ describe('useArticles', () => {
})
it('sends the search text and the status when they are set', async () => {
await useArticles().listArticles({ page: 2, size: 50, q: 'plaster', status: 'draft' })
await useArticles().listArticles({
page: 2,
size: 50,
q: 'plaster',
status: 'draft',
})
expect(request).toHaveBeenCalledWith(
'/v1/article/?format=html&page=2&size=50&q=plaster&status=draft',
@@ -37,7 +42,11 @@ describe('useArticles', () => {
})
it('sends the sort when it is set', async () => {
await useArticles().listArticles({ page: 1, size: 25, sort: 'title:asc' })
await useArticles().listArticles({
page: 1,
size: 25,
sort: 'title:asc',
})
expect(request).toHaveBeenCalledWith(
'/v1/article/?format=html&page=1&size=25&sort=title%3Aasc',
@@ -45,7 +54,12 @@ describe('useArticles', () => {
})
it('omits an empty search and an empty status', async () => {
await useArticles().listArticles({ page: 3, size: 5, q: '', status: '' })
await useArticles().listArticles({
page: 3,
size: 5,
q: '',
status: '',
})
expect(request).toHaveBeenCalledWith('/v1/article/?format=html&page=3&size=5')
})
+25 -5
View File
@@ -77,7 +77,11 @@ describe('useMedia list state', () => {
request.mockResolvedValue(listBody([item(1)], { total: 312, page: 7 }))
const media = useMedia()
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 7, size: 50 })
const outcome = await media.load({
...DEFAULT_CONTROLS,
page: 7,
size: 50,
})
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=7&size=50')
expect(media.total.value).toBe(312)
@@ -116,7 +120,12 @@ describe('useMedia list state', () => {
request.mockResolvedValueOnce(listBody(second, { total: 130, page: 2 }))
const media = useMedia()
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 })
await media.load({
...DEFAULT_CONTROLS,
filter: 'duplicates',
page: 4,
size: 5,
})
expect(request).toHaveBeenCalledTimes(2)
expect(request).toHaveBeenNthCalledWith(
@@ -251,7 +260,13 @@ describe('useMedia list state', () => {
describe('useMedia requests', () => {
it('fetches the usage of an image', async () => {
const usage = [{ id: 10, title: 'How we price a bathroom renovation', published: true }]
const usage = [
{
id: 10,
title: 'How we price a bathroom renovation',
published: true,
},
]
request.mockResolvedValue(usage)
const media = useMedia()
@@ -298,7 +313,9 @@ describe('useMedia requests', () => {
request.mockResolvedValueOnce(undefined)
await media.deleteItem(1)
expect(request).toHaveBeenLastCalledWith('/v1/media/1', { method: 'DELETE' })
expect(request).toHaveBeenLastCalledWith('/v1/media/1', {
method: 'DELETE',
})
expect(media.items.value.map((entry) => entry.id)).toEqual([2])
})
@@ -316,7 +333,10 @@ describe('useMedia requests', () => {
it('copies the absolute address of the image', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
const media = useMedia()
await media.copyUrl(item(1, { url: '/media/north-wall.jpg' }))
@@ -17,8 +17,14 @@ function mountEdges() {
}
function layout(element: HTMLElement, scrollLeft: number) {
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 390 })
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 1100 })
Object.defineProperty(element, 'clientWidth', {
configurable: true,
value: 390,
})
Object.defineProperty(element, 'scrollWidth', {
configurable: true,
value: 1100,
})
element.scrollLeft = scrollLeft
element.dispatchEvent(new Event('scroll'))
}
@@ -53,8 +59,14 @@ describe('useScrollEdges', () => {
it('shows no fade when the content fits', async () => {
const { element, edges } = mountEdges()
Object.defineProperty(element, 'clientWidth', { configurable: true, value: 500 })
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: 500 })
Object.defineProperty(element, 'clientWidth', {
configurable: true,
value: 500,
})
Object.defineProperty(element, 'scrollWidth', {
configurable: true,
value: 500,
})
element.dispatchEvent(new Event('scroll'))
await nextTick()
+6 -1
View File
@@ -61,7 +61,12 @@ export function useActivity() {
return {
users: users.flatMap((item) =>
item.kind === 'user'
? [{ id: item.id, name: item.display_name || item.username }]
? [
{
id: item.id,
name: item.display_name || item.username,
},
]
: [],
),
keys: keys.map((key) => ({ id: key.id, name: key.name })),
+3 -1
View File
@@ -208,7 +208,9 @@ export function useActivityPage() {
const { size: current, ...rest } = route.query
const wanted = size.value === DEFAULT_PAGE_SIZE ? undefined : String(size.value)
if (current === wanted) return
router.replace({ query: wanted === undefined ? rest : { ...rest, size: wanted } })
router.replace({
query: wanted === undefined ? rest : { ...rest, size: wanted },
})
}
syncSizeQuery()
+152
View File
@@ -0,0 +1,152 @@
import { t } from '@/i18n'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
interface PublishHandlers {
onUpdated: (updated: Article) => void
onGone: () => void
retry: () => void
}
interface DeleteHandlers {
onDeleted: () => void
onGone: () => void
}
type ArticleRef = Pick<Article, 'id' | 'title'>
function deleteDescription(article: Pick<Article, 'title' | 'body'>) {
const count = bodyImageCount(article.body)
const imagesPhrase = count === 0 ? '' : t(' Its images stay in your library.')
return t("\u0022{0}\u0022 will be deleted.{1} This can't be undone.", [
article.title,
imagesPhrase,
])
}
function isNotFound(err: unknown) {
return err instanceof ApiError && err.status === 404
}
export function useArticleActions() {
const { request } = useApi()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
function announcePublished(article: ArticleRef, makeOnline: boolean) {
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return makeOnline ? t('Article published') : t('Article unpublished')
},
get description() {
return makeOnline
? t('\u0022{0}\u0022 is now online.', [article.title])
: t('\u0022{0}\u0022 is now offline.', [article.title])
},
}))
}
function announcePublishFailed(id: number, makeOnline: boolean, retry: () => void) {
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return makeOnline ? t("Couldn't publish") : t("Couldn't unpublish")
},
get description() {
return t("The server didn't respond. Nothing changed.")
},
duration: 0,
actions: [
{
get label() {
return t('Retry')
},
onClick: retry,
},
],
}))
}
function announceDeleted(article: ArticleRef) {
toast.add(() => ({
id: `article-deleted-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Article deleted')
},
get description() {
return t('\u0022{0}\u0022 is gone.', [article.title])
},
}))
}
async function publish(article: ArticleRef, makeOnline: boolean, handlers: PublishHandlers) {
try {
const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
handlers.onUpdated(updated)
announcePublished(updated, makeOnline)
} catch (err) {
if (isNotFound(err)) {
handlers.onGone()
return
}
announcePublishFailed(article.id, makeOnline, handlers.retry)
}
}
async function deleteArticle(article: ArticleRef, handlers: DeleteHandlers) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
handlers.onDeleted()
announceDeleted(article)
} catch (err) {
if (isNotFound(err)) {
handlers.onGone()
return
}
throw new Error(
t("Couldn't delete the article, so it's still there. Try again in a moment."),
)
}
}
function confirmDelete(article: Article, action: () => Promise<void>) {
return overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Delete this article?')
},
get description() {
return deleteDescription(article)
},
get confirmLabel() {
return t('Delete article')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Deleting…')
},
tone: 'danger',
action,
},
})
.open()
}
return { publish, deleteArticle, confirmDelete }
}
+136 -27
View File
@@ -1,33 +1,86 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import type { Article, Byline } from '@/composables/useArticles'
import { sameIds, toBylines } from '@/lib/articleMeta'
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
export type ArticleDraftBusinessState =
| 'clean'
| 'dirty'
| 'saving'
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
export type SaveOutcome =
| 'saved'
| 'error'
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
export type SaveOutcome = 'saved' | 'error' | 'not-found' | 'unauthorized'
| 'not-found'
| 'unauthorized'
| 'site-gone'
| 'author-gone'
export interface ArticleMeta {
siteId: number | null
authors: Byline[]
}
function draftStorageKey(articleId: number) {
return `renewcms:article-draft:${articleId}`
}
function metaStorageKey(articleId: number) {
return `renewcms:article-draft-meta:${articleId}`
}
function readStorage(key: string): string | null {
try {
return sessionStorage.getItem(key)
} catch {
return null
}
}
function writeStorage(key: string, value: string) {
try {
sessionStorage.setItem(key, value)
} catch {
return
}
}
function removeStorage(key: string) {
try {
sessionStorage.removeItem(key)
} catch {
return
}
}
function readMirroredMeta(articleId: number): ArticleMeta | null {
const raw = readStorage(metaStorageKey(articleId))
if (raw === null) {
return null
}
try {
const parsed = JSON.parse(raw) as {
site_id: number | null
authors: Byline[]
}
return { siteId: parsed.site_id, authors: toBylines(parsed.authors) }
} catch {
return null
}
}
export function readMirroredDraft(articleId: number): string | null {
return sessionStorage.getItem(draftStorageKey(articleId))
return readStorage(draftStorageKey(articleId))
}
export function useArticleDraft(articleId: number) {
const { request } = useApi()
const body = ref('')
const bodyEdited = ref(false)
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
const savedMeta = ref<ArticleMeta>({ siteId: null, authors: [] })
const businessState = ref<ArticleDraftBusinessState>('clean')
const savedAt = ref<Date | null>(null)
const offline = ref(
typeof navigator === 'undefined' ? false : !navigator.onLine,
)
const offline = ref(typeof navigator === 'undefined' ? false : !navigator.onLine)
function handleOffline() {
offline.value = true
@@ -51,44 +104,100 @@ export function useArticleDraft(articleId: number) {
offline.value ? 'offline' : businessState.value,
)
function siteChanged() {
return meta.value.siteId !== savedMeta.value.siteId
}
function authorsChanged() {
return !sameIds(meta.value.authors, savedMeta.value.authors)
}
function mirrorToStorage() {
sessionStorage.setItem(draftStorageKey(articleId), body.value)
if (bodyEdited.value) {
writeStorage(draftStorageKey(articleId), body.value)
}
if (siteChanged() || authorsChanged()) {
writeStorage(
metaStorageKey(articleId),
JSON.stringify({
site_id: meta.value.siteId,
authors: meta.value.authors,
}),
)
}
}
function clearStorage() {
sessionStorage.removeItem(draftStorageKey(articleId))
removeStorage(draftStorageKey(articleId))
removeStorage(metaStorageKey(articleId))
}
function load(initialBody: string, initialSavedAt: Date) {
function load(initialBody: string, initialSavedAt: Date, initialMeta?: ArticleMeta) {
const mirrored = readMirroredDraft(articleId)
const mirroredMeta = readMirroredMeta(articleId)
savedAt.value = initialSavedAt
if (mirrored !== null) {
body.value = mirrored
businessState.value = 'dirty'
return
}
body.value = initialBody
businessState.value = 'clean'
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
meta.value = mirroredMeta ?? savedMeta.value
bodyEdited.value = mirrored !== null
body.value = mirrored ?? initialBody
businessState.value = mirrored !== null || mirroredMeta !== null ? 'dirty' : 'clean'
}
function edit(newBody: string) {
body.value = newBody
bodyEdited.value = true
businessState.value = 'dirty'
}
function editMeta(next: Partial<ArticleMeta>) {
meta.value = { ...meta.value, ...next }
businessState.value = 'dirty'
}
function savePayload() {
return {
...(bodyEdited.value ? { body: body.value } : {}),
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
...(authorsChanged()
? { author_ids: meta.value.authors.map((author) => author.id) }
: {}),
}
}
function goneOutcome(err: unknown): SaveOutcome | null {
if (statusOf(err) !== 404) {
return null
}
const code = codeOf(err)
if (code === SITE_CODES.notFound) {
return 'site-gone'
}
return code === AUTHOR_CODES.notFound ? 'author-gone' : null
}
async function save(): Promise<SaveOutcome> {
businessState.value = 'saving'
const payload = savePayload()
try {
await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: body.value }),
})
if (Object.keys(payload).length > 0) {
await request<Article>(`/v1/article/${articleId}?format=html`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
businessState.value = 'saved'
savedAt.value = new Date()
savedMeta.value = meta.value
bodyEdited.value = false
clearStorage()
return 'saved'
} catch (err) {
const gone = goneOutcome(err)
if (gone) {
businessState.value = 'error'
return gone
}
if (err instanceof ApiError && err.status === 404) {
businessState.value = 'dirty'
return 'not-found'
@@ -103,5 +212,5 @@ export function useArticleDraft(articleId: number) {
}
}
return { body, state, savedAt, load, edit, save }
return { body, meta, state, savedAt, load, edit, editMeta, save }
}
+192
View File
@@ -0,0 +1,192 @@
import { nextTick, ref, type Ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { useArticleActions } from '@/composables/useArticleActions'
import type { Article, ArticleCounts } from '@/composables/useArticles'
import type { ArticleListQuery } from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
interface ArticleRowActionsState {
items: Ref<Article[]>
total: Ref<number>
counts: Ref<ArticleCounts>
shown: Ref<ArticleListQuery>
reload: (query: ArticleListQuery, mode: 'push' | 'replace') => void
}
export function useArticleRowActions({
items,
total,
counts,
shown,
reload,
}: ArticleRowActionsState) {
const actions = useArticleActions()
const toast = useLocalizedToast()
const busyIds = ref<Set<number>>(new Set())
function setBusy(id: number, busy: boolean) {
const next = new Set(busyIds.value)
if (busy) {
next.add(id)
} else {
next.delete(id)
}
busyIds.value = next
}
function shiftCounts(article: Article, delta: number) {
const key = article.is_online ? 'online' : 'draft'
counts.value = {
...counts.value,
all: Math.max(0, counts.value.all + delta),
[key]: Math.max(0, counts.value[key] + delta),
}
}
function detachRow(id: number) {
if (!items.value.some((article) => article.id === id)) {
return
}
items.value = items.value.filter((article) => article.id !== id)
total.value = Math.max(0, total.value - 1)
if (items.value.length === 0 && total.value > 0) {
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
reload({ ...shown.value, page }, 'replace')
}
}
function removeArticle(article: Article) {
shiftCounts(article, -1)
detachRow(article.id)
}
function replaceArticle(updated: Article, previous: Article) {
items.value = items.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
if (updated.is_online === previous.is_online) {
return
}
counts.value = {
...counts.value,
online: counts.value.online + (updated.is_online ? 1 : -1),
draft: counts.value.draft + (updated.is_online ? -1 : 1),
}
const filter = shown.value.status
if (filter && (filter === 'online') !== updated.is_online) {
detachRow(updated.id)
}
}
function notFoundToast(article: Article) {
return {
id: `article-not-found-${article.id}`,
color: 'info' as const,
icon: 'i-lucide-info',
get title() {
return t('That article no longer exists')
},
get description() {
return t('It was deleted somewhere else, so we removed it from the list.')
},
}
}
async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true)
try {
await actions.publish(article, makeOnline, {
onUpdated: (updated) => replaceArticle(updated, article),
onGone: () => goneFromList(article),
retry: () => setOnline(article, makeOnline),
})
} finally {
setBusy(article.id, false)
}
}
function findItem(id: number) {
return items.value.find((existing) => existing.id === id)
}
function handlePublish(id: number) {
const article = findItem(id)
if (article) {
setOnline(article, true)
}
}
function handleUnpublish(id: number) {
const article = findItem(id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = findItem(id)
if (!article) {
return
}
const url = `${window.location.origin}/v1/article/${id}`
try {
await navigator.clipboard.writeText(url)
toast.add(() => ({
id: `article-link-copied-${id}`,
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Link copied')
},
get description() {
return t('The API URL for \u0022{0}\u0022 is on your clipboard.', [
article.title,
])
},
}))
} catch {
toast.add(() => ({
id: `article-link-copy-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't copy the link")
},
}))
}
}
function goneFromList(article: Article) {
removeArticle(article)
toast.add(() => notFoundToast(article))
}
function performDelete(article: Article) {
return actions.deleteArticle(article, {
onDeleted: () => removeArticle(article),
onGone: () => goneFromList(article),
})
}
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
await nextTick()
const index = idsAtOpen.indexOf(deletedId)
const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0]
if (nextId != null) {
document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus()
return
}
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
}
async function handleDelete(article: Article) {
const idsAtOpen = items.value.map((existing) => existing.id)
const confirmed = await actions.confirmDelete(article, () => performDelete(article))
if (confirmed) {
focusRowOrHeading(idsAtOpen, article.id)
}
}
return { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete }
}
+9
View File
@@ -1,11 +1,16 @@
import { useApi } from '@/composables/useApi'
import type { Byline } from '@/lib/articleTypes'
import type { ArticleStatus } from '@/lib/articleListQuery'
export type { Byline }
export interface Article {
id: number
title: string
body: string
is_online: boolean
site_id?: number
authors?: Byline[]
created_at: string
updated_at: string
}
@@ -32,6 +37,7 @@ export interface ListArticlesParams {
q?: string
status?: ArticleStatus | ''
sort?: string
site?: string
}
export function useArticles() {
@@ -52,6 +58,9 @@ export function useArticles() {
if (params.sort) {
query.set('sort', params.sort)
}
if (params.site) {
query.set('site', params.site)
}
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
}
+24 -3
View File
@@ -24,6 +24,10 @@ export interface Author {
user_email?: string
}
export interface MyAuthor extends Author {
slug_adjusted: boolean
}
export interface AuthorPage {
items: Author[]
total: number
@@ -47,6 +51,12 @@ export interface NewAuthor {
user_id: number | null
}
export interface NewByline {
name: string
slug: string
user_id?: number
}
export type AuthorPatch = Partial<NewAuthor>
export type ReassignTarget = number | 'none' | undefined
@@ -75,7 +85,11 @@ export function useAuthors() {
let page = 1
let total = 0
do {
const response = await listAuthors({ q: '', page, size: OPTION_PAGE_SIZE })
const response = await listAuthors({
q: '',
page,
size: OPTION_PAGE_SIZE,
})
all.push(...response.items)
total = response.total
page += 1
@@ -91,16 +105,22 @@ export function useAuthors() {
return listUsersWithoutAuthor(new Set(authors.map((author) => author.user_id)))
}
function createAuthor(author: NewAuthor): Promise<Author> {
function createAuthor(author: NewAuthor | NewByline): Promise<Author> {
return request<Author>('/v1/author/', jsonInit('POST', author))
}
function createMyAuthor(): Promise<MyAuthor> {
return request<MyAuthor>('/v1/author/me', { method: 'POST' })
}
function updateAuthor(id: number, patch: AuthorPatch): Promise<Author> {
return request<Author>(`/v1/author/${id}`, jsonInit('PATCH', patch))
}
function deleteAuthor(id: number, reassignTo: ReassignTarget): Promise<void> {
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, {
method: 'DELETE',
})
}
return {
@@ -108,6 +128,7 @@ export function useAuthors() {
listAllAuthors,
listLinkableUsers,
createAuthor,
createMyAuthor,
updateAuthor,
deleteAuthor,
}
+1 -3
View File
@@ -7,9 +7,7 @@ export function useColorMode() {
return {
get preference(): ColorModePreference {
return store.value === 'auto'
? 'system'
: (store.value as ColorModePreference)
return store.value === 'auto' ? 'system' : (store.value as ColorModePreference)
},
set preference(value: ColorModePreference) {
store.value = value === 'system' ? 'auto' : value
+8 -1
View File
@@ -72,5 +72,12 @@ export function useDirectoryView() {
open(item)
}
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
return {
rememberOpener,
restoreFocus,
editParam,
dropEditParam,
leavePanel,
followEditLink,
}
}
+131
View File
@@ -0,0 +1,131 @@
import { ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ArticleEditor from '@/components/ArticleEditor.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { MediaItem } from '@/composables/useMedia'
import { bodyImageIds } from '@/lib/bodyImageIds'
import { formatSize } from '@/lib/mediaFormat'
import { useSeenImagesStore } from '@/stores/seenImages'
export function useEditorImages(body: () => string) {
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const seenImages = useSeenImagesStore()
const editorRef = ref<InstanceType<typeof ArticleEditor> | null>(null)
const imageUploaderOpen = ref(false)
const imageUploaderFile = ref<File | null>(null)
const imageIdsInArticle = ref<number[]>([])
function openImageUploader(file: File | null) {
imageUploaderFile.value = file
imageIdsInArticle.value = bodyImageIds(body())
imageUploaderOpen.value = true
}
function onRequestImage() {
openImageUploader(null)
}
function onEditorDrop(event: DragEvent) {
const file = event.dataTransfer?.files?.[0]
if (!file) {
return
}
openImageUploader(file)
}
function announceImageAdded() {
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Image added')
},
get description() {
return t('Save to keep it in the article.')
},
}))
}
function onImageUploaded(item: MediaItem) {
seenImages.remember([item])
editorRef.value?.insertImage(item.url, '', item.id)
announceImageAdded()
}
function onImageInserted(item: MediaItem, alt: string, src: string) {
seenImages.remember([item])
editorRef.value?.insertImage(src, alt, item.id)
announceImageAdded()
}
async function performRemoveImage(imageId: number) {
editorRef.value?.removeImage(imageId)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Image removed')
},
get description() {
return t("It's still in your library.")
},
}))
}
function removalPreview(imageId: number) {
const seen = seenImages.find(imageId)
return {
src: editorRef.value?.imageSrc(imageId) ?? '',
filename: seen?.filename,
size: seen ? formatSize(seen.size) : undefined,
}
}
async function onRequestRemoveImage(imageId: number) {
await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Remove this image from the article?')
},
get description() {
return t(
"It's removed from this article only. The file stays in your library and in any other article that uses it.",
)
},
get confirmLabel() {
return t('Remove image')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Removing…')
},
tone: 'neutral',
preview: removalPreview(imageId),
action: () => performRemoveImage(imageId),
},
})
.open()
}
function onImageUploaderClosed() {
imageUploaderFile.value = null
}
return {
editorRef,
imageUploaderOpen,
imageUploaderFile,
imageIdsInArticle,
onRequestImage,
onEditorDrop,
onImageUploaded,
onImageInserted,
onRequestRemoveImage,
onImageUploaderClosed,
}
}
+5 -1
View File
@@ -47,7 +47,11 @@ export function useImageVariantRows(imageId: number, onGone: () => void) {
if (cause && !item.generated) {
return { item, status: 'failed', cause }
}
return { item, status: item.generated ? 'generated' : 'missing', cause: null }
return {
item,
status: item.generated ? 'generated' : 'missing',
cause: null,
}
}),
)
const generatedCount = computed(() => items.value.filter((item) => item.generated).length)
+3 -1
View File
@@ -85,7 +85,9 @@ export function useImageVariants() {
}
function purgeVariant(name: string): Promise<PurgeResult> {
return request<PurgeResult>(`${variantPath(name)}/purge`, { method: 'POST' })
return request<PurgeResult>(`${variantPath(name)}/purge`, {
method: 'POST',
})
}
return {
+23 -5
View File
@@ -141,14 +141,26 @@ export function useMedia() {
async function requestList(controls: MediaControls) {
if (isFullSet(controls)) {
return { response: await requestFullSet(controls), page: 1, redirected: false }
return {
response: await requestFullSet(controls),
page: 1,
redirected: false,
}
}
const response = await requestPage(controls)
if (!isPastLastPage(response, controls.page, controls.size)) {
return { response, page: response.total === 0 ? 1 : controls.page, redirected: false }
return {
response,
page: response.total === 0 ? 1 : controls.page,
redirected: false,
}
}
const page = pageCountFor(response.total, controls.size)
return { response: await requestPage({ ...controls, page }), page, redirected: true }
return {
response: await requestPage({ ...controls, page }),
page,
redirected: true,
}
}
async function load(
@@ -227,7 +239,9 @@ export function useMedia() {
const outcomes = await Promise.all(
targets.map(async (item) => {
try {
await request<void>(`/v1/media/${item.id}`, { method: 'DELETE' })
await request<void>(`/v1/media/${item.id}`, {
method: 'DELETE',
})
return { item, outcome: 'deleted' as const }
} catch (error) {
const failure = classifyMediaError(error)
@@ -245,7 +259,11 @@ export function useMedia() {
bumpMediaRevision()
}
items.value = items.value.filter((entry) => !deleted.some((gone) => gone.id === entry.id))
return { deleted, failed: pick('failed'), handled: pick('handled').length > 0 }
return {
deleted,
failed: pick('failed'),
handled: pick('handled').length > 0,
}
}
async function mergeCopies(keepId: number, mergeIds: number[]) {
+10 -1
View File
@@ -125,5 +125,14 @@ export function useMediaCleanupSettings(onForbidden: () => void) {
clearSavedTimer()
})
return { settings, loadState, showSkeleton, saveStatus, saveFailure, load, refresh, save }
return {
settings,
loadState,
showSkeleton,
saveStatus,
saveFailure,
load,
refresh,
save,
}
}
+6 -2
View File
@@ -49,7 +49,9 @@ export function useMediaVariants() {
}
function generate(imageId: number, name: string) {
return request<MediaVariantItem>(namePath(imageId, name), { method: 'POST' })
return request<MediaVariantItem>(namePath(imageId, name), {
method: 'POST',
})
}
function generateAll(imageId: number) {
@@ -57,7 +59,9 @@ export function useMediaVariants() {
}
function purge(imageId: number, name: string) {
return request<PurgedVariant>(namePath(imageId, name), { method: 'DELETE' })
return request<PurgedVariant>(namePath(imageId, name), {
method: 'DELETE',
})
}
return { list, generate, generateAll, purge }
+79
View File
@@ -0,0 +1,79 @@
import { ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { useAuthors, type Author, type MyAuthor } from '@/composables/useAuthors'
import { AUTHOR_CODES, codeOf, isSessionError, statusOf } from '@/lib/directoryErrors'
import { useSessionStore } from '@/stores/session'
export type MyAuthorOutcome =
| { kind: 'created'; author: MyAuthor }
| { kind: 'exists' }
| { kind: 'failed' }
| { kind: 'handled' }
const HANDLED_STATUSES = [401, 403]
export function useMyAuthorProfile() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { createMyAuthor } = useAuthors()
const pending = ref(false)
function announceCreated(author: Author) {
toast.add(() => ({
id: 'author-profile-created',
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Author profile created')
},
get description() {
return t('{0} is now your byline.', [author.name])
},
}))
}
function announceFailed() {
toast.add(() => ({
id: 'author-profile-failed',
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't create your author profile")
},
get description() {
return t('Try again in a moment.')
},
}))
}
async function create(): Promise<MyAuthorOutcome> {
if (pending.value) {
return { kind: 'handled' }
}
pending.value = true
try {
const author = await createMyAuthor()
session.setAuthor({
id: author.id,
name: author.name,
slug: author.slug,
})
announceCreated(author)
return { kind: 'created', author }
} catch (error) {
if (statusOf(error) === 409 && codeOf(error) === AUTHOR_CODES.userLinked) {
await session.refresh()
return { kind: 'exists' }
}
if (isSessionError(error) || HANDLED_STATUSES.includes(statusOf(error))) {
return { kind: 'handled' }
}
return { kind: 'failed' }
} finally {
pending.value = false
}
}
return { pending, create, announceFailed }
}
+3 -1
View File
@@ -54,7 +54,9 @@ export function useSites() {
}
function deleteSite(id: number, reassignTo?: number): Promise<void> {
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, {
method: 'DELETE',
})
}
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
+6 -2
View File
@@ -49,11 +49,15 @@ export function useUpdate() {
}
function checkForUpdate(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update/check', { method: 'POST' })
return request<UpdateStatus>('/v1/system/update/check', {
method: 'POST',
})
}
function startUpdateDownload(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update/download', { method: 'POST' })
return request<UpdateStatus>('/v1/system/update/download', {
method: 'POST',
})
}
function requestRestart(kind: RestartKind): Promise<UpdateStatus> {
+17 -3
View File
@@ -57,7 +57,13 @@ export function useUsers() {
const users = await listUsers()
return users.flatMap((user) =>
user.kind === 'user' && !takenUserIds.has(user.id)
? [{ id: user.id, label: user.display_name || user.username, email: user.email }]
? [
{
id: user.id,
label: user.display_name || user.username,
email: user.email,
},
]
: [],
)
}
@@ -75,8 +81,16 @@ export function useUsers() {
}
function renewInvite(id: number): Promise<CreatedInvite> {
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, { method: 'POST' })
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, {
method: 'POST',
})
}
return { listUsers, listLinkableUsers, inviteUser, cancelInvite, renewInvite }
return {
listUsers,
listLinkableUsers,
inviteUser,
cancelInvite,
renewInvite,
}
}
+9 -1
View File
@@ -89,5 +89,13 @@ export function useVariantForm(mode: FormMode, initial: VariantFormValues) {
() => delete remote.quality,
)
return { values, dirty, errors, dimensionHelpHidden, onBlur, validateAll, showRemote }
return {
values,
dirty,
errors,
dimensionHelpHidden,
onBlur,
validateAll,
showRemote,
}
}