Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .contentrain/content/system/ui-strings/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,12 @@
"content.entry_count_filtered": "Showing {shown} of {matched} matches in {total} entries",
"content.entry_count_partial": "Showing {shown} of {total}",
"content.field_required": "This field is required",
"content.filter": "Filter",
"content.filter_clear": "Clear filters",
"content.filter_keys": "Filter keys or values...",
"content.filter_none": "This model has no fields that can be filtered.",
"content.filter_remove": "Remove filter {value}",
"content.filter_status": "Status",
"content.keep_editing": "Keep editing",
"content.key_column": "Key",
"content.keys_count": "keys",
Expand Down Expand Up @@ -312,6 +317,13 @@
"content.select_entry": "Select an entry",
"content.show_more": "Show more",
"content.slug_hint": "Lowercase letters, numbers, and hyphens only",
"content.sort": "Sort",
"content.sort_default": "Default order",
"content.sort_field_asc": "{field} ascending",
"content.sort_field_desc": "{field} descending",
"content.sort_title_asc": "Title A–Z",
"content.sort_title_desc": "Title Z–A",
"content.sort_updated": "Recently updated",
"content.stat_entries": "Entries",
"content.stat_locales": "Locales",
"content.stat_models": "Models",
Expand Down
157 changes: 157 additions & 0 deletions app/components/molecules/ContentFilterBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'radix-vue'
import type { FilterAxis, FilterSelection, SortOption } from '~/utils/content-filters'

/**
* Filter and sort controls for a collection listing.
*
* One button opening a popover, with the active filters shown as chips —
* rather than a row of dropdowns. The number of axes is derived from the model
* and so varies from one to many, and the panel is 280–640px wide: a fixed row
* of controls would overflow on some models and look empty on others. A single
* button costs the same whatever the model, and the chips keep "what is being
* filtered" visible without needing the room.
*/
const props = defineProps<{
axes: readonly FilterAxis[]
sortOptions: readonly SortOption[]
activeCount: number
}>()

const { t } = useContent()

const selection = defineModel<FilterSelection>('selection', { required: true })
const sort = defineModel<string>('sort', { required: true })

const open = ref(false)

function isSelected(axisId: string, value: string) {
return selection.value[axisId]?.includes(value) ?? false
}

function toggle(axisId: string, value: string) {
const current = selection.value[axisId] ?? []
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value]

// Rebuilt without the key rather than emptied: "no values selected" and "axis
// not filtered" are the same thing, and keeping both spellings around is how
// an active-filter count goes wrong.
const updated = Object.fromEntries(
Object.entries(selection.value).filter(([key]) => key !== axisId),
)
if (next.length > 0) updated[axisId] = next
selection.value = updated
}

function clearAll() {
selection.value = {}
}

/** Flattened for the chip row: one chip per selected value, not per axis. */
const activeChips = computed(() =>
props.axes.flatMap(axis =>
(selection.value[axis.id] ?? []).map(value => ({
axisId: axis.id,
value,
label: axis.options.find(o => o.value === value)?.label ?? value,
})),
),
)
</script>

<template>
<div class="space-y-2">
<div class="flex items-center gap-2">
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<button
type="button"
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-secondary-200 px-2 text-xs font-medium text-body transition-colors hover:bg-secondary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 dark:border-secondary-700 dark:text-secondary-300 dark:hover:bg-secondary-900"
>
<span class="icon-[annon--filter] size-3.5" aria-hidden="true" />
{{ t('content.filter') }}
<AtomsBadge v-if="activeCount > 0" variant="primary" size="sm">
{{ activeCount }}
</AtomsBadge>
</button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
align="start"
:side-offset="6"
:collision-padding="8"
class="z-50 max-h-[70vh] w-64 overflow-y-auto rounded-lg border border-secondary-200 bg-white p-3 shadow-lg dark:border-secondary-700 dark:bg-secondary-900"
>
<div v-for="axis in axes" :key="axis.id" class="mb-3 last:mb-0">
<p class="mb-1 text-[10px] font-medium uppercase tracking-wider text-muted">
{{ axis.label }}
</p>
<div class="space-y-0.5">
<button
v-for="option in axis.options"
:key="option.value"
type="button"
:aria-pressed="isSelected(axis.id, option.value)"
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-secondary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 dark:hover:bg-secondary-800"
:class="isSelected(axis.id, option.value)
? 'text-heading dark:text-secondary-100'
: 'text-body dark:text-secondary-300'"
@click="toggle(axis.id, option.value)"
>
<span
class="size-3.5 shrink-0"
:class="isSelected(axis.id, option.value)
? 'icon-[annon--check-circle] text-primary-500'
: 'icon-[annon--radio] text-disabled'"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
</button>
</div>
</div>

<p v-if="axes.length === 0" class="text-xs text-muted">
{{ t('content.filter_none') }}
</p>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>

<!-- Sort. A plain select: one choice from a list, and it has to survive a
280px panel. -->
<AtomsFormSelect
v-model="sort"
size="sm"
class="min-w-0 flex-1"
:label="t('content.sort')"
:options="[...sortOptions]"
/>
</div>

<!-- Active filters. Removable one at a time, because the alternative is
reopening the popover to find which value did the narrowing. -->
<div v-if="activeChips.length > 0" class="flex flex-wrap items-center gap-1">
<button
v-for="chip in activeChips"
:key="`${chip.axisId}:${chip.value}`"
type="button"
class="flex max-w-full items-center gap-1 rounded-full bg-primary-50 py-0.5 pl-2 pr-1 text-[11px] text-primary-600 transition-colors hover:bg-primary-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 dark:bg-primary-500/15 dark:text-primary-300"
:aria-label="t('content.filter_remove', { value: chip.label })"
@click="toggle(chip.axisId, chip.value)"
>
<span class="min-w-0 truncate">{{ chip.label }}</span>
<span class="icon-[annon--cross] size-3 shrink-0" aria-hidden="true" />
</button>
<button
type="button"
class="rounded px-1.5 py-0.5 text-[11px] font-medium text-muted transition-colors hover:text-body focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50 dark:hover:text-secondary-100"
@click="clearAll"
>
{{ t('common.clear_all') }}
</button>
</div>
</div>
</template>
140 changes: 133 additions & 7 deletions app/components/organisms/ContentCollectionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,81 @@ const visibleCount = ref(PAGE_SIZE)

const allIds = computed(() => Object.keys(props.content))

// ── Filter + sort ──────────────────────────────────────────
// The axes come from the model, so they differ per model and cannot be
// hardcoded. The full definition comes from the brain rather than the injected
// meta, which carries only id/name/kind — `title_field` and `fields` are needed
// here to title a sort and to derive the axes.
const modelDefinition = computed(() =>
brain.models.value.find(m => m.id === props.modelId) ?? null,
)

const filterSelection = ref<FilterSelection>({})
const sortBy = ref<string>(SORT_DEFAULT)

/**
* Human labels for relation targets, so a relation axis reads as titles rather
* than as `f3a81c09d24e`. Loaded when the model changes, not per render.
*/
const relationLabels = ref<Record<string, Record<string, string>>>({})

async function loadRelationLabels() {
const model = modelDefinition.value
const fields = (model?.fields ?? {}) as Record<string, { type?: string, model?: string | string[] }>
const next: Record<string, Record<string, string>> = {}

for (const [fieldId, def] of Object.entries(fields)) {
if (def?.type !== 'relation' && def?.type !== 'relations') continue
const targets = Array.isArray(def.model) ? def.model : def.model ? [def.model] : []
const labels: Record<string, string> = {}

for (const targetId of targets) {
const targetModel = brain.models.value.find(m => m.id === targetId) ?? null
const result = await brain.queryContent(targetId, props.locale ?? 'en')
const data = result?.data as Record<string, Record<string, unknown>> | Array<Record<string, unknown>> | null
if (Array.isArray(data)) {
for (const doc of data) {
const slug = doc.slug as string
if (slug) labels[slug] = resolveEntryTitle(doc, targetModel, slug)
}
}
else if (data) {
for (const [ref, entry] of Object.entries(data)) labels[ref] = resolveEntryTitle(entry, targetModel, ref)
}
}

if (Object.keys(labels).length > 0) next[fieldId] = labels
}

relationLabels.value = next
}

const filterAxes = computed(() => deriveFilterAxes({
model: modelDefinition.value,
content: props.content,
meta: props.meta as Record<string, { status?: string, updated_at?: string }> | null,
relationLabels: relationLabels.value,
t,
}))

const sortOptions = computed(() => deriveSortOptions({
model: modelDefinition.value,
meta: props.meta as Record<string, { status?: string, updated_at?: string }> | null,
hasStatusAxis: filterAxes.value.some(a => a.id === STATUS_AXIS_ID),
t,
}))

const activeFilterCount = computed(() =>
Object.values(filterSelection.value).reduce((sum, values) => sum + values.length, 0),
)

/**
* Search runs against the brain's index, not the rendered rows — otherwise it
* would only ever find what is already on screen, which is the opposite of what
* it is for. The index holds every entry, so a match on page twenty is found
* without paging there.
*/
const matchedIds = computed(() => {
const searchedIds = computed(() => {
if (!searchQuery.value.trim()) return allIds.value
if (!searchIds.value) return []
// Intersect rather than trust the index: it is rebuilt per sync, so it can
Expand All @@ -73,6 +141,30 @@ const matchedIds = computed(() => {
return searchIds.value.filter(id => present.has(id))
})

/**
* Search and filter compose as an intersection, then the result is ordered.
*
* Both work over the whole model — search through the index, filters through
* the in-memory payload — so neither is limited to the rows already rendered.
* The page limit is applied last, to whatever survived.
*/
const matchedIds = computed(() => {
const filtered = applyFilters(
searchedIds.value,
props.content,
props.meta as Record<string, { status?: string, updated_at?: string }> | null,
filterAxes.value,
filterSelection.value,
)
return sortIds(
filtered,
props.content,
props.meta as Record<string, { status?: string, updated_at?: string }> | null,
sortBy.value,
modelDefinition.value,
)
})

/**
* An entry named by `?entry=` — the palette's search result — is pulled to the
* front and opened. Otherwise "go to this entry" would drop someone at the top
Expand Down Expand Up @@ -139,13 +231,23 @@ onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})

// A different model or locale is a different list; the old query and page
// position mean nothing there.
// A different model or locale is a different list; the old query, filters and
// page position mean nothing there — one model's `category` is not another's.
watch(() => [props.modelId, props.locale], () => {
searchQuery.value = ''
searchIds.value = null
visibleCount.value = PAGE_SIZE
})
filterSelection.value = {}
sortBy.value = SORT_DEFAULT
relationLabels.value = {}
void loadRelationLabels()
}, { immediate: true })

// Paging restarts whenever the surviving set changes, so page two of the old
// filter is never page two of the new one.
watch([filterSelection, sortBy], () => {
visibleCount.value = PAGE_SIZE
}, { deep: true })

const getFieldType = inject(getFieldTypeKey, () => 'string')
const getEntryTitle = inject(getEntryTitleKey, (_e: Record<string, unknown>, f: string) => f)
Expand Down Expand Up @@ -254,7 +356,7 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value:
<div>
<!-- Search. Runs against the brain's index, so it finds entries this list
has not rendered — the whole point at 1000 articles. -->
<div class="sticky top-0 z-10 border-b border-secondary-100 bg-white px-5 py-2.5 dark:border-secondary-800 dark:bg-secondary-950">
<div class="sticky top-0 z-10 space-y-2 border-b border-secondary-100 bg-white px-5 py-2.5 dark:border-secondary-800 dark:bg-secondary-950">
<AtomsFormInput
v-model="searchQuery"
type="search"
Expand All @@ -263,6 +365,16 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value:
:placeholder="t('content.search_entries')"
:aria-label="t('content.search_entries')"
/>
<!-- Filters live behind one button. The axis count is derived from the
model, so a row of dropdowns would overflow some models and look
empty on others; a button costs the same for every model. -->
<MoleculesContentFilterBar
v-model:selection="filterSelection"
v-model:sort="sortBy"
:axes="filterAxes"
:sort-options="sortOptions"
:active-count="activeFilterCount"
/>
</div>

<div class="divide-y divide-secondary-100 dark:divide-secondary-800">
Expand Down Expand Up @@ -385,17 +497,31 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value:
:title="t('common.loading')"
:description="t('content.searching_description')"
/>
<!-- "Nothing matched" and "this model is empty" are different facts, and
only one of them has a way out. -->
<AtomsEmptyState
v-else-if="searchQuery.trim()"
v-else-if="searchQuery.trim() || activeFilterCount > 0"
icon="icon-[annon--search]"
:title="t('content.no_matches_title')"
:description="t('content.no_matches_description')"
>
<template #action>
<AtomsBaseButton v-if="activeFilterCount > 0" variant="ghost" size="sm" @click="filterSelection = {}">
{{ t('content.filter_clear') }}
</AtomsBaseButton>
</template>
</AtomsEmptyState>
<AtomsEmptyState
v-else
icon="icon-[annon--file-text]"
:title="t('content.no_entries')"
:description="t('content.no_content_description')"
/>
</div>

<div class="flex items-center gap-3 border-t border-secondary-200 px-5 py-3 dark:border-secondary-800">
<span class="min-w-0 flex-1 truncate text-xs text-muted">
<template v-if="searchQuery.trim()">
<template v-if="searchQuery.trim() || activeFilterCount > 0">
{{ t('content.entry_count_filtered', { shown: visibleEntries.length, matched: matchedIds.length, total: allIds.length }) }}
</template>
<template v-else-if="hasMore">
Expand Down
Loading
Loading