726 lines
22 KiB
Vue
726 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { t, translate } from '@/i18n'
|
|
|
|
import {
|
|
computed,
|
|
nextTick,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
watch,
|
|
type ComponentPublicInstance,
|
|
} from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import ApiSearchPalette from '@/components/ApiSearchPalette.vue'
|
|
import CodeSnippet from '@/components/CodeSnippet.vue'
|
|
import CodeText from '@/components/CodeText.vue'
|
|
import DashboardShell from '@/components/DashboardShell.vue'
|
|
import EndpointCard from '@/components/EndpointCard.vue'
|
|
import IntegrationImages from '@/components/IntegrationImages.vue'
|
|
import SectionIndex from '@/components/SectionIndex.vue'
|
|
import SessionOnlyGroup from '@/components/SessionOnlyGroup.vue'
|
|
import {
|
|
API_GROUPS,
|
|
ERROR_ROWS,
|
|
FIELD_TYPES_ANCHOR,
|
|
ERROR_SAMPLE,
|
|
PUBLIC_AUTH_ROWS,
|
|
SESSION_GROUPS,
|
|
authExamples,
|
|
type ApiGroup,
|
|
type ErrorRow,
|
|
} from '@/data/apiCatalogue'
|
|
import { SECTION_TITLE, endpointAnchor, groupsAroundImages } from '@/lib/apiReference'
|
|
import { buildSearchIndex, type SearchEntry } from '@/lib/apiSearch'
|
|
|
|
const SPY_OFFSET_PX = 120
|
|
|
|
const route = useRoute()
|
|
const origin = computed(() => window.location.origin)
|
|
|
|
const root = ref<HTMLElement | null>(null)
|
|
const activeSection = ref('auth')
|
|
const authTab = ref('curl')
|
|
const baseUrlTabs = computed(() => [{ k: 'base', label: t('Base URL'), code: origin.value }])
|
|
const errorSampleTabs = computed(() => [
|
|
{ k: 'error', label: t('Error response · text/plain'), code: ERROR_SAMPLE },
|
|
])
|
|
|
|
const sessionRouteCount = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
|
|
|
|
const { before: groupsBeforeImages, after: groupsAfterImages } = groupsAroundImages(API_GROUPS)
|
|
|
|
function groupSection(group: ApiGroup) {
|
|
return { id: group.key, label: translate(group.title) }
|
|
}
|
|
|
|
const sections = computed(() => [
|
|
{ id: 'auth', label: t('Authentication') },
|
|
...groupsBeforeImages.map(groupSection),
|
|
{ id: 'images', label: t('Images and variants') },
|
|
...groupsAfterImages.map(groupSection),
|
|
{ id: 'session', label: t('Session only') },
|
|
{ id: 'errors', label: t('Errors') },
|
|
{ id: 'notes', label: t('Notes') },
|
|
])
|
|
|
|
const authKeyCopy = computed(() =>
|
|
t(
|
|
'Send `Authorization: Bearer rcms_…`. Requests act as the user who created the key, with that user\'s role as it is now. A key never has more rights than its creator. A route that needs a permission answers 403 "missing_permission" to a key whose creator\'s role lacks it.',
|
|
),
|
|
)
|
|
|
|
const authItems = computed(() => {
|
|
const examples = authExamples(origin.value)
|
|
return [
|
|
{ k: 'curl', label: 'curl', code: examples.curl },
|
|
{ k: 'fetch', label: 'fetch', code: examples.fetch },
|
|
]
|
|
})
|
|
|
|
const authNotes = computed(() => [
|
|
t(
|
|
'A key can\'t create, list or revoke API keys, verify or cancel a registration, or call anything under `/v1/system`. Those routes answer 403 "This action needs a real session." An invalid, unknown, expired or revoked key answers 401, even on public routes, and so does a key whose user was deleted or unverified. Routes that need a permission answer 403 "missing_permission" to a key when the role of its creator does not hold it.',
|
|
),
|
|
t(
|
|
'Each key can make 120 requests per minute. Beyond that the API answers 429 with a `Retry-After` header.',
|
|
),
|
|
])
|
|
|
|
const notes = computed(() => [
|
|
t(
|
|
'Images in `body` use paths like `/static/uploadedImages/<uuid>.png`. Prefix them with the base URL before rendering.',
|
|
),
|
|
t(
|
|
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.",
|
|
),
|
|
t(
|
|
'Article routes accept `?format=markdown`, `?format=html` or `?format=json` to choose how `body` is returned. With `json`, `body` is the stored JSON structure. Articles have `body_format: markdown` by default.',
|
|
),
|
|
])
|
|
|
|
const errorColumns = computed(() => [
|
|
{
|
|
accessorKey: 'status',
|
|
get header() {
|
|
return t('Status')
|
|
},
|
|
meta: { class: { th: 'w-17 lg:w-19', td: 'w-17 lg:w-19' } },
|
|
},
|
|
{
|
|
accessorKey: 'meaning',
|
|
get header() {
|
|
return t('Meaning')
|
|
},
|
|
meta: { class: { th: 'lg:w-53', td: 'lg:w-53' } },
|
|
},
|
|
{
|
|
accessorKey: 'messages',
|
|
get header() {
|
|
return t('Message')
|
|
},
|
|
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
|
|
},
|
|
])
|
|
|
|
interface MessagePart {
|
|
text: string
|
|
code: boolean
|
|
}
|
|
|
|
function messageParts(row: ErrorRow) {
|
|
const parts: MessagePart[] = []
|
|
const push = (text: string, code: boolean) => {
|
|
if (parts.length > 0) {
|
|
parts.push({ text: ' / ', code: false })
|
|
}
|
|
parts.push({ text, code })
|
|
}
|
|
row.messages.forEach((message) => push(`"${message}"`, false))
|
|
row.codes?.forEach((code) => push(code, true))
|
|
return parts
|
|
}
|
|
|
|
const fieldTypeColumns = computed(() => [
|
|
{
|
|
accessorKey: 'type',
|
|
get header() {
|
|
return t('Type')
|
|
},
|
|
meta: { class: { th: 'w-22', td: 'w-22' } },
|
|
},
|
|
{
|
|
accessorKey: 'value',
|
|
get header() {
|
|
return t('Value in an entry')
|
|
},
|
|
meta: { class: { th: 'lg:w-1/2', td: 'lg:w-1/2' } },
|
|
},
|
|
{
|
|
accessorKey: 'rules',
|
|
get header() {
|
|
return t('Rules')
|
|
},
|
|
meta: { class: { th: 'max-lg:hidden', td: 'max-lg:hidden' } },
|
|
},
|
|
])
|
|
|
|
const pageBlocks = [
|
|
...groupsBeforeImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
|
|
{ kind: 'images' as const, key: 'images', group: null },
|
|
...groupsAfterImages.map((group) => ({ kind: 'group' as const, key: group.key, group })),
|
|
]
|
|
|
|
function snippetTabs(group: ApiGroup) {
|
|
return group.snippet
|
|
? [
|
|
{
|
|
k: group.snippet.key,
|
|
label: translate(group.snippet.label),
|
|
code: group.snippet.code,
|
|
},
|
|
]
|
|
: []
|
|
}
|
|
|
|
function routeCount(count: number) {
|
|
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
|
|
}
|
|
|
|
function scrollParent(element: HTMLElement) {
|
|
let current = element.parentElement
|
|
while (current) {
|
|
const overflow = getComputedStyle(current).overflowY
|
|
if (overflow === 'auto' || overflow === 'scroll') {
|
|
return current
|
|
}
|
|
current = current.parentElement
|
|
}
|
|
return null
|
|
}
|
|
|
|
function sectionElement(id: string) {
|
|
return root.value?.querySelector<HTMLElement>(`[data-anchor="${id}"]`) ?? null
|
|
}
|
|
|
|
function scrollToElement(element: HTMLElement | null) {
|
|
const parent = element ? scrollParent(element) : null
|
|
if (!element || !parent) {
|
|
element?.scrollIntoView?.({ block: 'start' })
|
|
return
|
|
}
|
|
const margin = parseFloat(getComputedStyle(element).scrollMarginTop) || 0
|
|
const offset = element.getBoundingClientRect().top - parent.getBoundingClientRect().top
|
|
parent.scrollTo({ top: parent.scrollTop + offset - margin })
|
|
}
|
|
|
|
function goTo(id: string) {
|
|
activeSection.value = id
|
|
scrollToElement(sectionElement(id))
|
|
}
|
|
|
|
function updateActive() {
|
|
const element = root.value
|
|
const parent = element ? scrollParent(element) : null
|
|
if (!parent) {
|
|
return
|
|
}
|
|
const limit = parent.getBoundingClientRect().top + SPY_OFFSET_PX
|
|
let current = sections.value[0]!.id
|
|
let currentTop = Number.NEGATIVE_INFINITY
|
|
for (const section of sections.value) {
|
|
const top = sectionElement(section.id)?.getBoundingClientRect().top
|
|
if (top !== undefined && top <= limit && top > currentTop) {
|
|
current = section.id
|
|
currentTop = top
|
|
}
|
|
}
|
|
activeSection.value = current
|
|
}
|
|
|
|
interface RevealableCard {
|
|
reveal: () => Promise<void>
|
|
}
|
|
|
|
const FLASH_MS = 1500
|
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
|
|
|
const searchOpen = ref(false)
|
|
const searchData = computed(() => buildSearchIndex())
|
|
const flashedCard = ref<string | null>(null)
|
|
const flashMode = ref<'animated' | 'static'>('animated')
|
|
const cards = new Map<string, RevealableCard>()
|
|
let searchOpener: HTMLElement | null = null
|
|
let skipFocusRestore = false
|
|
let flashTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
function setCard(id: string, card: Element | ComponentPublicInstance | null) {
|
|
if (card) {
|
|
cards.set(id, card as unknown as RevealableCard)
|
|
} else {
|
|
cards.delete(id)
|
|
}
|
|
}
|
|
|
|
function openSearch(trigger?: HTMLElement | null) {
|
|
const active = document.activeElement
|
|
searchOpener = trigger ?? (active instanceof HTMLElement ? active : null)
|
|
searchOpen.value = true
|
|
}
|
|
|
|
watch(searchOpen, async (value) => {
|
|
if (value) {
|
|
return
|
|
}
|
|
await nextTick()
|
|
if (skipFocusRestore) {
|
|
skipFocusRestore = false
|
|
return
|
|
}
|
|
searchOpener?.focus()
|
|
})
|
|
|
|
function isEditable(target: EventTarget | null) {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false
|
|
}
|
|
const field = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|
|
return (
|
|
field ||
|
|
target.isContentEditable === true ||
|
|
target.closest('[contenteditable=""], [contenteditable="true"]') !== null
|
|
)
|
|
}
|
|
|
|
function onShortcut(event: KeyboardEvent) {
|
|
if (searchOpen.value || event.defaultPrevented || event.isComposing) {
|
|
return
|
|
}
|
|
const withoutAlt = !event.altKey
|
|
const chord =
|
|
(event.ctrlKey || event.metaKey) &&
|
|
withoutAlt &&
|
|
!event.shiftKey &&
|
|
event.key.toLowerCase() === 'k'
|
|
const slash =
|
|
event.key === '/' &&
|
|
withoutAlt &&
|
|
!event.ctrlKey &&
|
|
!event.metaKey &&
|
|
!isEditable(event.target)
|
|
if (chord || slash) {
|
|
event.preventDefault()
|
|
openSearch()
|
|
}
|
|
}
|
|
|
|
function writeHash(anchor: string) {
|
|
const { pathname, search } = window.location
|
|
window.history.replaceState(window.history.state, '', `${pathname}${search}#${anchor}`)
|
|
}
|
|
|
|
function startFlash(id: string) {
|
|
clearTimeout(flashTimer)
|
|
flashMode.value = window.matchMedia?.(REDUCED_MOTION_QUERY).matches ? 'static' : 'animated'
|
|
flashedCard.value = id
|
|
flashTimer = setTimeout(() => {
|
|
flashedCard.value = null
|
|
}, FLASH_MS)
|
|
}
|
|
|
|
function revealCard(id: string, sectionId: string) {
|
|
activeSection.value = sectionId
|
|
void cards.get(id)?.reveal()
|
|
scrollToElement(sectionElement(endpointAnchor(id)))
|
|
writeHash(endpointAnchor(id))
|
|
startFlash(id)
|
|
}
|
|
|
|
function focusSection(id: string) {
|
|
goTo(id)
|
|
writeHash(id)
|
|
const heading = sectionElement(id)?.querySelector<HTMLElement>('h2')
|
|
heading?.setAttribute('tabindex', '-1')
|
|
heading?.focus({ preventScroll: true })
|
|
}
|
|
|
|
async function onSearchSelect(entry: SearchEntry) {
|
|
skipFocusRestore = true
|
|
await nextTick()
|
|
if (entry.kind === 'endpoint' && entry.cardId) {
|
|
revealCard(entry.cardId, entry.sectionId)
|
|
return
|
|
}
|
|
focusSection(entry.sectionId)
|
|
}
|
|
|
|
function revealSharedCard(anchor: string) {
|
|
const group = API_GROUPS.find((item) =>
|
|
item.endpoints.some((endpoint) => endpointAnchor(endpoint.id) === anchor),
|
|
)
|
|
if (group) {
|
|
revealCard(anchor.slice('ep-'.length), group.key)
|
|
}
|
|
}
|
|
|
|
let scroller: HTMLElement | null = null
|
|
|
|
onMounted(() => {
|
|
scroller = root.value ? scrollParent(root.value) : null
|
|
scroller?.addEventListener('scroll', updateActive, { passive: true })
|
|
document.addEventListener('keydown', onShortcut)
|
|
const target = route.hash.slice(1)
|
|
if (sections.value.some((section) => section.id === target)) {
|
|
nextTick(() => goTo(target))
|
|
} else if (target.startsWith('ep-')) {
|
|
nextTick(() => revealSharedCard(target))
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
scroller?.removeEventListener('scroll', updateActive)
|
|
document.removeEventListener('keydown', onShortcut)
|
|
clearTimeout(flashTimer)
|
|
})
|
|
|
|
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
|
|
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
|
|
const AUTH_CARD = 'flex flex-col gap-2.5 border border-default bg-default p-4'
|
|
const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
|
</script>
|
|
|
|
<template>
|
|
<DashboardShell compact-padding :mobile-title="t('Integration')">
|
|
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
|
|
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="flex min-w-0 flex-col gap-1.5">
|
|
<UBreadcrumb
|
|
:items="[
|
|
{
|
|
get label() {
|
|
return t('Integration')
|
|
},
|
|
},
|
|
]"
|
|
color="neutral"
|
|
:ui="{
|
|
link: 'text-sm font-normal text-muted',
|
|
linkLabel: 'text-muted',
|
|
list: 'gap-1',
|
|
}"
|
|
/>
|
|
<h1 class="text-xl font-semibold text-highlighted">
|
|
{{ t('API reference') }}
|
|
</h1>
|
|
<p class="text-base text-muted">
|
|
{{ t('Everything a developer needs to build on RenewCMS.') }}
|
|
</p>
|
|
</div>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
:aria-label="t('Search the API reference')"
|
|
aria-keyshortcuts="/ Control+K Meta+K"
|
|
aria-haspopup="dialog"
|
|
class="hidden h-9 w-80 shrink-0 justify-start gap-2 rounded-none border border-accented bg-default py-0 pr-2 pl-3 text-base font-normal text-muted ring-0 hover:border-muted hover:bg-default focus-visible:outline-primary lg:inline-flex"
|
|
@click="openSearch($event.currentTarget as HTMLElement)"
|
|
>
|
|
<UIcon name="i-lucide-search" class="size-4 shrink-0 text-muted" />
|
|
<span class="min-w-0 flex-1 truncate text-left">{{
|
|
t('Search the API reference…')
|
|
}}</span>
|
|
<UKbd
|
|
value="/"
|
|
class="h-5 min-w-5 justify-center rounded-none border border-accented bg-elevated px-1.5 font-mono normal-case text-xs font-medium text-toned ring-0"
|
|
/>
|
|
</UButton>
|
|
</div>
|
|
|
|
<p class="max-w-180 text-base text-pretty text-toned">
|
|
{{
|
|
t(
|
|
'RenewCMS is headless: it stores your content and your own code displays it. There are two ways in. Public read-only access lets a website read online articles. API keys let a script or service manage articles, media and users, and manage content types and entries, without the back-office.',
|
|
)
|
|
}}
|
|
</p>
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
<span class="font-medium text-highlighted">{{ t('Base URL') }}</span>
|
|
<CodeSnippet :tabs="baseUrlTabs" />
|
|
</div>
|
|
|
|
<section
|
|
data-anchor="auth"
|
|
aria-labelledby="h-auth"
|
|
:class="[SECTION_CLASS, 'gap-3.5']"
|
|
>
|
|
<h2 id="h-auth" :class="SECTION_TITLE">
|
|
{{ t('Authentication') }}
|
|
</h2>
|
|
<div class="grid items-start gap-3 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
|
|
<div :class="AUTH_CARD">
|
|
<div class="flex items-center gap-2">
|
|
<span :class="CARD_TITLE">{{ t('Public') }}</span>
|
|
<UBadge
|
|
:label="t('No key')"
|
|
color="neutral"
|
|
variant="outline"
|
|
class="h-5 rounded-none border border-accented px-1.5 py-0 text-xs font-normal text-toned ring-0"
|
|
/>
|
|
</div>
|
|
<span class="text-pretty text-toned">{{
|
|
t(
|
|
'No key needed. Two read-only routes, and only online articles are returned.',
|
|
)
|
|
}}</span>
|
|
<div
|
|
class="flex flex-col gap-1 font-mono text-xs font-medium text-highlighted"
|
|
>
|
|
<span>GET /v1/article/</span>
|
|
<span>GET /v1/article/{id}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div :class="[AUTH_CARD, 'min-w-0']">
|
|
<div class="flex items-center gap-2">
|
|
<span :class="CARD_TITLE">{{ t('API key') }}</span>
|
|
<UBadge
|
|
:label="t('Full access')"
|
|
color="success"
|
|
variant="soft"
|
|
class="h-5 rounded-none bg-(--rc-success-soft) px-1.5 py-0 text-xs font-medium text-success"
|
|
/>
|
|
</div>
|
|
<span class="text-pretty text-toned"
|
|
><CodeText :text="authKeyCopy"
|
|
/></span>
|
|
<CodeSnippet v-model:active="authTab" :tabs="authItems" />
|
|
<span class="text-pretty text-muted"
|
|
><CodeText
|
|
:text="
|
|
t(
|
|
'Keys start with `rcms_` and are shown in full once, when you create them.',
|
|
)
|
|
"
|
|
/></span>
|
|
<UButton
|
|
to="/integration/api-keys"
|
|
variant="link"
|
|
trailing-icon="i-lucide-arrow-right"
|
|
class="min-h-11 self-start px-0 lg:min-h-7"
|
|
>
|
|
{{ t('Manage API keys') }}
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-col gap-2.5 text-base text-toned">
|
|
<div v-for="note in authNotes" :key="note" class="flex gap-2.5">
|
|
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
|
|
<span class="text-pretty"><CodeText :text="note" /></span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<template v-for="block in pageBlocks" :key="block.key">
|
|
<IntegrationImages v-if="block.kind === 'images'" :origin="origin" />
|
|
<section
|
|
v-else
|
|
:data-anchor="block.group.key"
|
|
:aria-labelledby="`h-${block.group.key}`"
|
|
:class="SECTION_CLASS"
|
|
>
|
|
<div class="flex flex-wrap items-baseline gap-2.5">
|
|
<h2 :id="`h-${block.group.key}`" :class="SECTION_TITLE">
|
|
{{ translate(block.group.title) }}
|
|
</h2>
|
|
<span class="text-muted">{{
|
|
routeCount(block.group.endpoints.length)
|
|
}}</span>
|
|
</div>
|
|
<span class="text-pretty text-toned">{{
|
|
translate(block.group.description)
|
|
}}</span>
|
|
<div v-if="block.group.note" class="flex gap-2.5 text-base text-toned">
|
|
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
|
|
<span class="text-pretty"
|
|
><CodeText :text="translate(block.group.note)"
|
|
/></span>
|
|
</div>
|
|
<div
|
|
v-if="block.group.fieldTypes"
|
|
:data-anchor="FIELD_TYPES_ANCHOR"
|
|
class="flex scroll-mt-16 flex-col gap-1.5 lg:scroll-mt-8"
|
|
>
|
|
<span class="font-semibold text-highlighted">{{
|
|
t('Field types')
|
|
}}</span>
|
|
<UTable
|
|
:data="block.group.fieldTypes"
|
|
:columns="fieldTypeColumns"
|
|
:ui="{
|
|
root: 'border border-default bg-default',
|
|
base: 'table-fixed',
|
|
thead: 'bg-elevated [&>tr]:border-t-0',
|
|
separator: 'hidden',
|
|
th: 'px-2.5 py-1.5 text-sm font-medium text-muted',
|
|
td: 'px-2.5 py-2 align-top text-sm whitespace-normal text-toned',
|
|
tbody: 'divide-y-0',
|
|
tr: 'border-t border-default',
|
|
}"
|
|
>
|
|
<template #type-cell="{ row }">
|
|
<span class="font-mono text-xs font-medium text-highlighted">{{
|
|
row.original.type
|
|
}}</span>
|
|
</template>
|
|
<template #value-cell="{ row }">
|
|
<div class="flex flex-col gap-0.5">
|
|
<span class="text-pretty"
|
|
><CodeText :text="translate(row.original.value)"
|
|
/></span>
|
|
<span class="text-pretty lg:hidden"
|
|
><CodeText :text="translate(row.original.rules)"
|
|
/></span>
|
|
</div>
|
|
</template>
|
|
<template #rules-cell="{ row }">
|
|
<span class="text-pretty"
|
|
><CodeText :text="translate(row.original.rules)"
|
|
/></span>
|
|
</template>
|
|
</UTable>
|
|
</div>
|
|
<CodeSnippet v-if="block.group.snippet" :tabs="snippetTabs(block.group)" />
|
|
<div class="border border-t-0 border-default bg-default">
|
|
<EndpointCard
|
|
v-for="endpoint in block.group.endpoints"
|
|
:key="endpoint.id"
|
|
:ref="(card) => setCard(endpoint.id, card)"
|
|
:endpoint="endpoint"
|
|
:origin="origin"
|
|
:flash="flashedCard === endpoint.id ? flashMode : null"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<section data-anchor="session" aria-labelledby="h-session" :class="SECTION_CLASS">
|
|
<div class="flex flex-wrap items-baseline gap-2.5">
|
|
<h2 id="h-session" :class="SECTION_TITLE">
|
|
{{ t('Session only') }}
|
|
</h2>
|
|
<span class="text-muted">{{
|
|
t('{0} routes, not available to API keys', [sessionRouteCount])
|
|
}}</span>
|
|
</div>
|
|
<SessionOnlyGroup :groups="SESSION_GROUPS" :public-rows="PUBLIC_AUTH_ROWS" />
|
|
</section>
|
|
|
|
<section data-anchor="errors" aria-labelledby="h-errors" :class="SECTION_CLASS">
|
|
<h2 id="h-errors" :class="SECTION_TITLE">
|
|
{{ t('Errors') }}
|
|
</h2>
|
|
<span class="text-pretty text-toned">{{
|
|
t(
|
|
'Every error is a plain-text body with a single sentence. The status code is in the HTTP status line, and the Content-Type is text/plain.',
|
|
)
|
|
}}</span>
|
|
<UTable
|
|
:data="ERROR_ROWS"
|
|
:columns="errorColumns"
|
|
:ui="{
|
|
root: 'border border-default bg-default',
|
|
base: 'table-fixed',
|
|
thead: 'bg-elevated [&>tr]:border-t-0',
|
|
separator: 'hidden',
|
|
th: 'px-3 py-2 text-sm font-medium text-muted',
|
|
td: 'px-3 py-2.5 align-top text-sm whitespace-normal text-toned',
|
|
tbody: 'divide-y-0',
|
|
tr: 'border-t border-default',
|
|
}"
|
|
>
|
|
<template #status-cell="{ row }">
|
|
<span class="font-mono text-sm font-medium text-highlighted">{{
|
|
row.original.status
|
|
}}</span>
|
|
</template>
|
|
<template #meaning-cell="{ row }">
|
|
<div class="flex flex-col gap-0.5">
|
|
<span class="font-medium text-highlighted">{{
|
|
translate(row.original.meaning)
|
|
}}</span>
|
|
<div class="flex flex-col gap-0.5 lg:hidden">
|
|
<span class="text-pretty text-toned"
|
|
><template
|
|
v-for="(part, index) in messageParts(row.original)"
|
|
:key="index"
|
|
><code
|
|
v-if="part.code"
|
|
class="font-mono text-xs text-highlighted"
|
|
>{{ part.text }}</code
|
|
><template v-else>{{ part.text }}</template></template
|
|
></span
|
|
>
|
|
<span
|
|
v-if="row.original.note"
|
|
class="text-pretty text-muted"
|
|
data-testid="error-note"
|
|
><CodeText :text="translate(row.original.note)"
|
|
/></span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #messages-cell="{ row }">
|
|
<div class="flex flex-col gap-0.5">
|
|
<span class="text-pretty"
|
|
><template
|
|
v-for="(part, index) in messageParts(row.original)"
|
|
:key="index"
|
|
><code
|
|
v-if="part.code"
|
|
class="font-mono text-xs text-highlighted"
|
|
>{{ part.text }}</code
|
|
><template v-else>{{ part.text }}</template></template
|
|
></span
|
|
>
|
|
<span
|
|
v-if="row.original.note"
|
|
class="text-pretty text-muted"
|
|
data-testid="error-note"
|
|
><CodeText :text="translate(row.original.note)"
|
|
/></span>
|
|
</div>
|
|
</template>
|
|
</UTable>
|
|
<CodeSnippet :tabs="errorSampleTabs" />
|
|
</section>
|
|
|
|
<section data-anchor="notes" aria-labelledby="h-notes" :class="SECTION_CLASS">
|
|
<h2 id="h-notes" :class="SECTION_TITLE">
|
|
{{ t('Notes') }}
|
|
</h2>
|
|
<div class="flex flex-col gap-2.5 text-base text-toned">
|
|
<div v-for="note in notes" :key="note" class="flex gap-2.5">
|
|
<UIcon name="i-lucide-info" :class="NOTE_ICON" />
|
|
<span class="text-pretty"><CodeText :text="note" /></span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<SectionIndex
|
|
:sections="sections"
|
|
:active="activeSection"
|
|
@select="goTo"
|
|
@search="openSearch"
|
|
/>
|
|
<ApiSearchPalette
|
|
v-model:open="searchOpen"
|
|
:index="searchData"
|
|
@select="onSearchSelect"
|
|
/>
|
|
</div>
|
|
</DashboardShell>
|
|
</template>
|