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
10 changes: 10 additions & 0 deletions i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,16 @@
"description": "Software von OneLiteFeather: Plugins und Werkzeuge, die wir selbst entwickeln und auf unserem Server einsetzen — mit Dokumentation, Quellcode und Downloads.",
"empty": "Noch keine Projekte veröffentlicht."
},
"filter": {
"audience_label": "Zielgruppe",
"audience": {
"all": "Alle"
},
"platform_label": "Plattform",
"count": "{shown} von {total} Projekten",
"reset": "Filter zurücksetzen",
"empty": "Kein Projekt passt zu diesem Filter."
},
"status": {
"active": "Aktiv",
"maintenance": "In Wartung",
Expand Down
10 changes: 10 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,16 @@
"description": "Software by OneLiteFeather: plugins and tools we build ourselves and run on our server — with documentation, source code and downloads.",
"empty": "No projects published yet."
},
"filter": {
"audience_label": "Audience",
"audience": {
"all": "All"
},
"platform_label": "Platform",
"count": "{shown} of {total} projects",
"reset": "Reset filters",
"empty": "No project matches this filter."
},
"status": {
"active": "Active",
"maintenance": "Maintained",
Expand Down
92 changes: 92 additions & 0 deletions layers/projects/components/ProjectFilters.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue'
import { PROJECT_AUDIENCES } from '../types'
import {
EMPTY_PROJECT_FILTER,
isProjectFilterActive,
togglePlatform,
type PlatformFacet,
type ProjectAudienceFilter,
type ProjectFilter,
} from '../utils/projectFilter'

const props = defineProps<{
filter: ProjectFilter
facets: PlatformFacet[]
shown: number
total: number
}>()

const emit = defineEmits<{
'update:filter': [filter: ProjectFilter]
}>()

const { t } = useI18n()

const audienceOptions = computed<Array<{ value: ProjectAudienceFilter, label: string }>>(() => [
{ value: 'all', label: t('projects.filter.audience.all') },
...PROJECT_AUDIENCES.map((audience) => ({
value: audience,
label: t(`projects.audience.label.${audience}`),
})),
])

const active = computed(() => isProjectFilterActive(props.filter))

const chooseAudience = (audience: ProjectAudienceFilter) => {
emit('update:filter', { ...props.filter, audience })
}

const choosePlatform = (platform: string) => {
emit('update:filter', togglePlatform(props.filter, platform))
}

const reset = () => {
emit('update:filter', EMPTY_PROJECT_FILTER)
}

const rowClass = 'flex flex-wrap items-center gap-2'
const labelClass = 'mr-1 text-label-large text-on-surface-variant'
</script>

<template>
<div class="space-y-4">
<div role="group" :aria-label="t('projects.filter.audience_label')" :class="rowClass">
<span :class="labelClass" aria-hidden="true">{{ t('projects.filter.audience_label') }}</span>
<M3Chip
v-for="option in audienceOptions"
:key="option.value"
kind="filter"
:label="option.label"
:selected="filter.audience === option.value"
@click="chooseAudience(option.value)"
/>
</div>

<div
v-if="facets.length"
role="group"
:aria-label="t('projects.filter.platform_label')"
:class="rowClass"
>
<span :class="labelClass" aria-hidden="true">{{ t('projects.filter.platform_label') }}</span>
<M3Chip
v-for="facet in facets"
:key="facet.platform"
kind="filter"
:label="`${facet.platform} (${facet.count})`"
:selected="filter.platforms.includes(facet.platform)"
@click="choosePlatform(facet.platform)"
/>
</div>

<div class="flex flex-wrap items-center justify-between gap-3">
<p aria-live="polite" class="text-body-medium text-on-surface-variant">
{{ t('projects.filter.count', { shown, total }) }}
</p>
<M3Button v-if="active" variant="text" @click="reset">
{{ t('projects.filter.reset') }}
</M3Button>
</div>
</div>
</template>
85 changes: 85 additions & 0 deletions layers/projects/utils/projectFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { PROJECT_AUDIENCES, type ProjectAudience, type ProjectSummary } from '../types'

export type ProjectAudienceFilter = 'all' | ProjectAudience

/** Platforms combine as "any of": a project matches when it runs on one of them. */
export interface ProjectFilter {
audience: ProjectAudienceFilter
platforms: string[]
}

export interface PlatformFacet {
platform: string
count: number
}

export const EMPTY_PROJECT_FILTER: ProjectFilter = { audience: 'all', platforms: [] }

// Explicit locale: server and browser must order the chips identically or
// hydration disagrees.
const comparePlatforms = (a: string, b: string): number => a.localeCompare(b, 'en')

export function isProjectFilterActive(filter: ProjectFilter): boolean {
return filter.audience !== 'all' || filter.platforms.length > 0
}

/** Each platform any project names, with how many projects name it, alphabetical. */
export function platformFacets(projects: readonly ProjectSummary[]): PlatformFacet[] {
const counts = new Map<string, number>()
for (const project of projects) {
const names = new Set((project.platforms ?? []).map((name) => name.trim()).filter(Boolean))
for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1)
}
return [...counts]
.map(([platform, count]) => ({ platform, count }))
.sort((a, b) => comparePlatforms(a.platform, b.platform))
}

export function filterProjects(
projects: readonly ProjectSummary[],
filter: ProjectFilter,
): ProjectSummary[] {
return projects.filter((project) => {
if (filter.audience !== 'all' && project.audience !== filter.audience) return false
if (filter.platforms.length === 0) return true
return (project.platforms ?? []).some((platform) => filter.platforms.includes(platform))
})
}

export function togglePlatform(filter: ProjectFilter, platform: string): ProjectFilter {
const platforms = filter.platforms.includes(platform)
? filter.platforms.filter((selected) => selected !== platform)
: [...filter.platforms, platform]
return { ...filter, platforms }
}

/**
* Reads the filter from a query string. Unknown audiences and platforms that
* no project names are dropped, so a stale or hand-edited link shows the
* unfiltered list rather than an empty one.
*/
export function parseProjectFilter(
query: URLSearchParams,
knownPlatforms: readonly string[],
): ProjectFilter {
const audience = query.get('audience')
const known = new Set(knownPlatforms)
const platforms = [...new Set((query.get('platform') ?? '').split(',').map((name) => name.trim()))]
.filter((name) => known.has(name))
.sort(comparePlatforms)
return {
audience: (PROJECT_AUDIENCES as readonly string[]).includes(audience ?? '')
? (audience as ProjectAudience)
: 'all',
platforms,
}
}

/** The query parameters for a filter; defaults are left out so the unfiltered page has no query. */
export function serializeProjectFilter(filter: ProjectFilter): Record<string, string> {
const query: Record<string, string> = {}
if (filter.audience !== 'all') query.audience = filter.audience
const platforms = [...new Set(filter.platforms)].sort(comparePlatforms)
if (platforms.length) query.platform = platforms.join(',')
return query
}
48 changes: 46 additions & 2 deletions pages/projects/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,49 @@ import { definePageMeta } from '#imports'

const { t, locale } = useI18n()
const site = useSiteConfig()
const route = useRoute()
const router = useRouter()

definePageMeta({
layout: 'default'
})

const { projects } = await useProjectsOverview()

const facets = computed(() => platformFacets(projects.value))

// Read after mount only: the cached server render must not depend on the request
// (request-independent-render.spec.ts), so the server renders every project.
const hydrated = ref(false)
onMounted(() => {
hydrated.value = true
})

const filter = computed<ProjectFilter>(() => {
if (!hydrated.value) return EMPTY_PROJECT_FILTER
const query = new URL(route.fullPath, 'http://localhost').searchParams
return parseProjectFilter(query, facets.value.map((facet) => facet.platform))
})

const setFilter = (next: ProjectFilter) => {
router.replace({ query: serializeProjectFilter(next) })
}

const groups = computed(() => groupProjectsByAudience(projects.value))
// Same order the page shows, so the ItemList positions match what a reader sees.
const visibleGroups = computed(() => {
return groupProjectsByAudience(filterProjects(projects.value, filter.value))
})
const visibleCount = computed(() => {
return visibleGroups.value.reduce((sum, group) => sum + group.projects.length, 0)
})
// Every project, whatever the filter, in the order the unfiltered page shows,
// so the ItemList positions match what a reader sees.
const orderedProjects = computed(() => groups.value.flatMap((group) => group.projects))

const emptyClass
= 'rounded-large border border-dashed border-outline-variant p-8 text-center '
+ 'text-body-medium text-on-surface-variant'

usePageSeo({
title: t('projects.overview.title'),
description: t('projects.overview.description'),
Expand Down Expand Up @@ -61,7 +93,19 @@ useSchemaOrg(computed(() => {
</header>

<div v-if="projects.length" class="space-y-12">
<template v-for="group in groups" :key="group.audience">
<ProjectFilters
:filter="filter"
:facets="facets"
:shown="visibleCount"
:total="projects.length"
@update:filter="setFilter"
/>

<p v-if="!visibleCount" :class="emptyClass">
{{ t('projects.filter.empty') }}
</p>

<template v-for="group in visibleGroups" :key="group.audience">
<section
v-if="group.projects.length"
:aria-labelledby="`projects-${group.audience}`"
Expand Down
16 changes: 16 additions & 0 deletions tests/projects/pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ describe.each([
})
})

describe('project overview filter', () => {
it('builds the ItemList from every project, not from the filtered list', () => {
expect(overview).toMatch(/groups = computed\(\(\) =>\s*groupProjectsByAudience\(projects/)
expect(overview).toMatch(/orderedProjects = computed\(\(\) => groups\.value\.flatMap/)
})

it('writes the filter with router.replace, so every change stays out of the history', () => {
expect(overview).toContain('router.replace(')
expect(overview).not.toContain('router.push(')
})

it('reads the URL only after mount, so the cached server render stays the same for every query', () => {
expect(overview).toMatch(/onMounted\(\(\) => \{\s*hydrated\.value = true/)
})
})

describe('project detail page', () => {
it('lists links through ResourceList and maintainers through PersonLink', () => {
expect(detail).toContain('<ResourceList')
Expand Down
Loading
Loading