191 lines
5.1 KiB
Vue
191 lines
5.1 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, ref, watchPostEffect } from 'vue'
|
|
import { RouterLink } from 'vue-router'
|
|
import FieldCell from '@/components/FieldCell.vue'
|
|
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
|
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
|
|
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
|
|
import { fieldTypeSpec } from '@/lib/fieldTypes'
|
|
import { mediaOf } from '@/lib/mediaField'
|
|
import { summariesOf } from '@/lib/relationField'
|
|
import { entryPath } from '@/lib/routePaths'
|
|
|
|
const ID_WIDTH = 'w-16.5'
|
|
const NARROW_WIDTH = 'w-23'
|
|
const FIELD_WIDTH = 'w-33'
|
|
const WIDE_WIDTH = 'w-50'
|
|
const CREATED_WIDTH = 'w-30.5'
|
|
const UPDATED_WIDTH = 'w-40.5'
|
|
const UPDATED_BY_WIDTH = 'w-37'
|
|
const FIELD_PREFIX = 'field:'
|
|
|
|
const props = defineProps<{
|
|
typeKey: string
|
|
entries: ContentEntry[]
|
|
fields: BuiltinField[]
|
|
sort: string
|
|
names: Record<number, string>
|
|
caption: string
|
|
}>()
|
|
|
|
const root = ref<HTMLElement | null>(null)
|
|
const sorted = computed(() => sortParts(props.sort))
|
|
|
|
const HEADER = 'h-9 py-0 font-medium text-muted'
|
|
|
|
function widthOf(field: BuiltinField, index: number) {
|
|
if (index === 0) {
|
|
return ''
|
|
}
|
|
const spec = fieldTypeSpec(field.type)
|
|
if (spec.wideColumn) {
|
|
return WIDE_WIDTH
|
|
}
|
|
return spec.narrowColumn ? NARROW_WIDTH : FIELD_WIDTH
|
|
}
|
|
|
|
function column(id: string, header: string, width: string, edge = 'px-1.5') {
|
|
return {
|
|
id,
|
|
header,
|
|
meta: { class: { th: `${width} ${edge} ${HEADER}`, td: `${width} ${edge}` } },
|
|
}
|
|
}
|
|
|
|
const columns = computed(() => [
|
|
column('id', t('ID'), ID_WIDTH, 'pr-1.5 pl-3'),
|
|
...props.fields.map((field, index) =>
|
|
column(`${FIELD_PREFIX}${field.key}`, field.label, widthOf(field, index)),
|
|
),
|
|
column('created_at', t('Created'), CREATED_WIDTH),
|
|
column('updated_at', t('Updated'), UPDATED_WIDTH),
|
|
column('updated_by', t('Updated by'), UPDATED_BY_WIDTH, 'pr-3 pl-1.5'),
|
|
])
|
|
|
|
function fieldOf(columnId: string) {
|
|
return props.fields.find((field) => `${FIELD_PREFIX}${field.key}` === columnId)
|
|
}
|
|
|
|
function isRight(columnId: string) {
|
|
const field = fieldOf(columnId)
|
|
return field ? fieldTypeSpec(field.type).alignEnd : false
|
|
}
|
|
|
|
function sortIcon(columnId: string) {
|
|
if (sorted.value.field !== columnId) {
|
|
return undefined
|
|
}
|
|
return sorted.value.direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
|
|
}
|
|
|
|
function nameOf(entry: ContentEntry) {
|
|
return props.names[entry.updated_by]
|
|
}
|
|
|
|
watchPostEffect(() => {
|
|
const headers = root.value?.querySelectorAll('thead th') ?? []
|
|
headers.forEach((header, index) => {
|
|
const id = columns.value[index]?.id
|
|
if (
|
|
id &&
|
|
(ENTRY_SORT_FIELDS as readonly string[]).includes(id) &&
|
|
id === sorted.value.field
|
|
) {
|
|
header.setAttribute(
|
|
'aria-sort',
|
|
sorted.value.direction === 'desc' ? 'descending' : 'ascending',
|
|
)
|
|
} else {
|
|
header.removeAttribute('aria-sort')
|
|
}
|
|
})
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="root" class="border border-default bg-default">
|
|
<UTable
|
|
:data="entries"
|
|
:columns="columns"
|
|
:ui="{
|
|
base: 'table-fixed',
|
|
thead: 'bg-elevated',
|
|
separator: 'bg-(--ui-border)',
|
|
td: 'h-12 px-1.5 py-0 text-default',
|
|
}"
|
|
>
|
|
<template #caption>
|
|
<caption class="sr-only">
|
|
{{
|
|
caption
|
|
}}
|
|
</caption>
|
|
</template>
|
|
|
|
<template v-for="column in columns" :key="column.id" #[`${column.id}-header`]>
|
|
<span
|
|
class="flex items-center gap-1"
|
|
:class="isRight(column.id) ? 'justify-end' : ''"
|
|
>
|
|
{{ column.header }}
|
|
<UIcon
|
|
v-if="sortIcon(column.id)"
|
|
:name="sortIcon(column.id)!"
|
|
:data-sort-direction="sorted.direction"
|
|
class="size-3"
|
|
/>
|
|
</span>
|
|
</template>
|
|
|
|
<template #id-cell="{ row }">
|
|
<span class="font-mono text-xs text-muted">{{ row.original.id }}</span>
|
|
</template>
|
|
|
|
<template
|
|
v-for="(field, index) in fields"
|
|
:key="field.key"
|
|
#[`${FIELD_PREFIX}${field.key}-cell`]="{ row }"
|
|
>
|
|
<RouterLink
|
|
v-if="index === 0"
|
|
:to="entryPath(typeKey, row.original.id)"
|
|
data-testid="entry-link"
|
|
class="flex min-w-0 font-medium text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
>
|
|
<FieldCell
|
|
:field="field"
|
|
:value="row.original.data[field.key]"
|
|
:image="mediaOf(row.original, field.key)"
|
|
:relations="summariesOf(row.original, field.key)"
|
|
class="min-w-0"
|
|
/>
|
|
</RouterLink>
|
|
<FieldCell
|
|
v-else
|
|
:field="field"
|
|
:value="row.original.data[field.key]"
|
|
:image="mediaOf(row.original, field.key)"
|
|
:relations="summariesOf(row.original, field.key)"
|
|
/>
|
|
</template>
|
|
|
|
<template #created_at-cell="{ row }">
|
|
<span class="text-muted">{{ formatEntryDate(row.original.created_at) }}</span>
|
|
</template>
|
|
|
|
<template #updated_at-cell="{ row }">
|
|
<span class="text-muted">{{ formatEntryDate(row.original.updated_at) }}</span>
|
|
</template>
|
|
|
|
<template #updated_by-cell="{ row }">
|
|
<span v-if="nameOf(row.original)" class="block truncate">{{
|
|
nameOf(row.original)
|
|
}}</span>
|
|
<span v-else class="text-muted">{{ EMPTY_TEXT }}</span>
|
|
</template>
|
|
</UTable>
|
|
</div>
|
|
</template>
|