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

+95 -265
View File
@@ -3,22 +3,21 @@ import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleTable from '@/components/ArticleTable.vue'
import ArticleCardList from '@/components/ArticleCardList.vue'
import EmptyState from '@/components/EmptyState.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import { ApiError, useApi } from '@/composables/useApi'
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
import { useArticleRowActions } from '@/composables/useArticleRowActions'
import {
useArticles,
type Article,
type ArticleCounts,
type ArticleStatus,
} from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
import { useSites, type Site } from '@/composables/useSites'
import {
ARTICLE_PAGE_SIZES,
effectiveSort,
@@ -43,12 +42,11 @@ type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const { listArticles } = useArticles()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const { listSites } = useSites()
const items = ref<Article[]>([])
const sites = ref<Site[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
@@ -62,13 +60,13 @@ function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
const desired = ref<ArticleListQuery>(shown.value)
const sitesSettled = ref(false)
const hasLoaded = ref(false)
const loading = ref(true)
const loadFailed = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<Notice | null>(null)
const showSkeleton = ref(false)
const busyIds = ref<Set<number>>(new Set())
const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
@@ -115,6 +113,11 @@ const tabItems = computed(() => [
},
])
const severalSites = computed(() => sites.value.length > 1)
const siteName = computed(
() => sites.value.find((site) => site.key === shown.value.site)?.name ?? shown.value.site,
)
const settled = computed(() => hasLoaded.value && !loadFailed.value)
const skeletonActive = computed(() => loading.value && showSkeleton.value)
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
@@ -132,6 +135,9 @@ const emptyVariant = computed(() => {
if (shown.value.status === 'online') {
return 'no-online' as const
}
if (shown.value.site) {
return 'no-site' as const
}
return 'no-articles' as const
})
@@ -191,10 +197,15 @@ async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notic
response.total > 0 &&
query.page > lastPage
) {
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
await load({ ...query, page: lastPage }, 'replace', {
kind: 'range',
})
return
}
const effective = { ...query, page: response.total === 0 ? 1 : query.page }
const effective = {
...query,
page: response.total === 0 ? 1 : query.page,
}
items.value = response.items
total.value = response.total
counts.value = response.counts
@@ -240,8 +251,22 @@ function changeStatus(status: '' | ArticleStatus) {
load({ ...desired.value, status, page: 1 }, 'push')
}
function changeSite(site: string) {
if (site === desired.value.site) {
return
}
load({ ...desired.value, site, page: 1 }, 'push')
}
function changeSort(field: ArticleSortField) {
load({ ...desired.value, sort: nextSort(desired.value.sort, field), page: 1 }, 'push')
load(
{
...desired.value,
sort: nextSort(desired.value.sort, field),
page: 1,
},
'push',
)
}
function changeSearch(value: string) {
@@ -252,6 +277,19 @@ function changeSearch(value: string) {
load({ ...desired.value, q, page: 1 }, 'replace')
}
async function fetchSites() {
try {
sites.value = await listSites()
} catch {
sites.value = []
}
}
function usableSite(query: ArticleListQuery): ArticleListQuery {
const known = severalSites.value && sites.value.some((site) => site.key === query.site)
return query.site && !known ? { ...query, site: '' } : query
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
@@ -266,7 +304,10 @@ watch(
}
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
syncedTarget = null
const next = ownReplace ? parseArticleQuery(route.query) : parseWithStoredSize(route.query)
const parsed = ownReplace
? parseArticleQuery(route.query)
: parseWithStoredSize(route.query)
const next = sitesSettled.value ? usableSite(parsed) : parsed
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
@@ -279,249 +320,14 @@ watch(
},
)
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))
load({ ...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 {
const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
replaceArticle(updated, article)
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])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article)
toast.add(() => notFoundToast(article))
return
}
toast.add(() => ({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.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: () => 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 deleteDescription(article: Article) {
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,
])
}
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article)
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])
},
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article)
toast.add(() => notFoundToast(article))
return
}
throw new Error(
t("Couldn't delete the article, so it's still there. Try again in a moment."),
)
}
}
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 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: () => performDelete(article),
},
})
.open()
if (confirmed) {
focusRowOrHeading(idsAtOpen, article.id)
}
}
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
useArticleRowActions({
items,
total,
counts,
shown,
reload: load,
})
function openNewArticleModal() {
newArticleModalOpen.value = true
@@ -541,6 +347,10 @@ function clearSearch() {
}
function onEmptyAction() {
if (emptyVariant.value === 'no-site') {
changeSite('')
return
}
if (emptyVariant.value === 'no-results') {
clearSearch()
return
@@ -552,7 +362,18 @@ function onEmptyAction() {
openNewArticleModal()
}
onMounted(() => load(shown.value, 'replace'))
onMounted(async () => {
const siteRequest = fetchSites().finally(() => {
sitesSettled.value = true
})
if (shown.value.site) {
await siteRequest
const usable = usableSite(shown.value)
shown.value = usable
desired.value = usable
}
load(shown.value, 'replace')
})
onBeforeUnmount(() => {
requestToken++
@@ -595,14 +416,22 @@ onBeforeUnmount(() => {
:content="false"
:ui="{ list: 'gap-5', trigger: 'px-0' }"
/>
<UInput
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by title')"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<UInput
v-model="searchInput"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by title')"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
<ArticleSiteFilter
v-if="severalSites"
:model-value="desired.site"
:sites="sites"
@update:model-value="changeSite"
/>
</div>
</div>
<UAlert
@@ -638,7 +467,7 @@ onBeforeUnmount(() => {
<EmptyState
v-if="emptyVariant"
:variant="emptyVariant"
:query="shown.q"
:query="emptyVariant === 'no-site' ? siteName : shown.q"
@action="onEmptyAction"
/>
<template v-else>
@@ -647,6 +476,7 @@ onBeforeUnmount(() => {
:loading="skeletonActive"
:skeleton-rows="skeletonRows"
:sort="effectiveSort(shown.sort)"
:sites="severalSites ? sites : []"
:busy-ids="busyIds"
@sort="changeSort"
@publish="handlePublish"