diff --git a/i18n/locales/de.json b/i18n/locales/de.json index fdb18dbb..59bfea6a 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -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", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 867d7d00..14e9f05b 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -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", diff --git a/layers/projects/components/ProjectFilters.vue b/layers/projects/components/ProjectFilters.vue new file mode 100644 index 00000000..a6e35429 --- /dev/null +++ b/layers/projects/components/ProjectFilters.vue @@ -0,0 +1,92 @@ + + + diff --git a/layers/projects/utils/projectFilter.ts b/layers/projects/utils/projectFilter.ts new file mode 100644 index 00000000..810ba569 --- /dev/null +++ b/layers/projects/utils/projectFilter.ts @@ -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() + 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 { + const query: Record = {} + 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 +} diff --git a/pages/projects/index.vue b/pages/projects/index.vue index 8390a8cb..db74b436 100644 --- a/pages/projects/index.vue +++ b/pages/projects/index.vue @@ -3,6 +3,8 @@ import { definePageMeta } from '#imports' const { t, locale } = useI18n() const site = useSiteConfig() +const route = useRoute() +const router = useRouter() definePageMeta({ layout: 'default' @@ -10,10 +12,40 @@ definePageMeta({ 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(() => { + 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'), @@ -61,7 +93,19 @@ useSchemaOrg(computed(() => {
-