From 037fe4f647019f6888777bf8ba30b5781de1a80d Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 27 May 2026 18:36:09 -0400 Subject: [PATCH 1/8] feat(community): convert table to filterable card grid - Replace table layout with responsive CSS Grid card layout - Cards support optional image field; fall back to cached og:image - Add scripts/fetch-og-images.mjs to fetch and validate og:image at build time (HEAD request confirms content-type: image/*); results committed to community/og-image-cache.json - Placeholder gradient (brand colors, 45% opacity) for imageless cards - Filter pills colored per group: Theme=navy, Tool=teal, Type=lime - Add date sort control (newest/oldest first) - Add Overture Maps brand palette as CSS custom properties in custom.css - Update CommunityTable tests for card-based DOM (testid, no table rows) - Fix active+hovered pill contrast (brightness filter, explicit bg restore) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- community/og-image-cache.json | 42 ++++ package.json | 1 + scripts/fetch-og-images.mjs | 120 +++++++++++ src/components/CommunityTable.jsx | 135 ++++++++---- src/components/CommunityTable.module.css | 192 +++++++++++++++++- .../__tests__/CommunityTable.test.jsx | 110 ++++++---- src/css/custom.css | 15 ++ 7 files changed, 529 insertions(+), 86 deletions(-) create mode 100644 community/og-image-cache.json create mode 100644 scripts/fetch-og-images.mjs diff --git a/community/og-image-cache.json b/community/og-image-cache.json new file mode 100644 index 000000000..b7fa8e38a --- /dev/null +++ b/community/og-image-cache.json @@ -0,0 +1,42 @@ +{ + "https://city2graph.net/examples/morphological_graph_from_overturemaps.html": "", + "https://docs.fused.io/blog/overture-tiles/": "https://fused-magic.s3.us-west-2.amazonaws.com/blog-assets/social_jennings.png", + "https://tech.marksblogg.com/asian-building-footprints-from-google-maps.html": "", + "https://www.crunchydata.com/blog/postgis-meets-duckdb-crunchy-bridge-for-analytics-goes-spatial": "https://imagedelivery.net/lPM0ntuwQfh8VQgJRu0mFg/5c4288f4-e78e-4a78-d288-17bbe1902300/public", + "https://chatgpt.com/g/g-onSLtzQQB-overture-maps-gpt": "", + "https://github.com/Krizz/fetch_overture": "https://opengraph.githubassets.com/737d862ed4e51eb12aadd77180cbd831171b2aac59a1858dd8d6bc1f39ce0667/Krizz/fetch_overture", + "https://overture-maps-docs.vercel.app/zh-Hant": "", + "https://whosonfirst.org/blog/2024/08/16/dedupe/": "https://www.whosonfirst.org/blog/2024/08/16/dedupe/images/219609_e312862475b94323_b.jpg", + "https://github.com/arthurgailes/overtureR": "https://opengraph.githubassets.com/2be9ce4e69cf7ff1541efa31dfbb438e1e631890a4ee47ddd431adac3b099bf3/arthurgailes/overtureR", + "https://supabase.com/blog/postgis-generate-vector-tiles": "https://supabase.com/images/blog/postgis_vector_tiles/overture_postgis_mvt.png", + "https://www.dbreunig.com/2024/06/25/using-duckdb-spatial-joins-to-map-overture-gers-ids-to-us-census-fips-codes.html": "https://www.dbreunig.com/img/denver_building_og.jpg", + "https://github.com/denironyx/overturemapsr": "https://opengraph.githubassets.com/4192b579bd027d1997292924c496b2183ab5367a828d3aab0809f163d079d41e/denironyx/overturemapsr", + "https://walker-data.com/posts/overture-buildings/": "", + "https://www.openstreetmap.org/user/Kshitijraj%20Sharma/diary": "", + "https://developmentseed.org/lonboard/latest/examples/overture-maps/": "https://developmentseed.org/lonboard/latest/assets/images/social/examples/overture-maps.png", + "https://carto.com/blog/overture-maps-data-now-on-the-cloud-use-it-with-carto": "https://carto.com/cdn.prod.website-files.com/63483ad423421bd16e7a7ae7/662fd131b47e1840271ad569_Overture%20Maps%20data%20now%20on%20the%20cloud%20how%20to%20use%20it%20with%20CARTO.webp", + "https://wherobots.com/overture-maps-data-cloud-native-geoparquet-apache-sedona/": "https://wherobots.com/wp-content/uploads/2024/04/Screenshot-2024-01-22-at-1.20.58PM.jpg", + "https://pypi.org/project/overturemapsdownloader/": "", + "https://docs.fused.io/basics/tutorials/overture/": "", + "https://community.esri.com/t5/arcgis-data-interoperability-blog/go-cloud-native-overture-geoparquet-from-object/ba-p/1371965": "", + "https://python.plainenglish.io/downloading-overture-map-foundations-buildings-data-using-apache-sedona-with-docker-python-and-473f5175f241": "", + "https://tech.marksblogg.com/tokyo-walking-tour-guide.html": "", + "https://msbarry.github.io/planetiler-overture-demo/#13.99/42.35625/-71.06989": "", + "https://engineering.tomtom.com/overture-transportation-network-linear-referencing/": "", + "https://www.spatialnode.net/articles/how-to-query-overture-maps-foundation-data-in-arcgis-pro-with-duck-dbc094f9": "https://storage.googleapis.com/spatialnodefiles/article_covers/7f690560-9989-4b73-a895-7a6b66c4d84fgroup35701.jpg", + "https://tech.marksblogg.com/overture-gis-data.html": "", + "https://www.esri.com/arcgis-blog/products/arcgis-online/mapping/enriching-overture-data-with-gers/": "", + "https://github.com/bdon/overture-tiles": "https://opengraph.githubassets.com/d81965506e1f642724e79393fcce9866185a4343922ea38910cbcdbf5af1c956/OvertureMaps/overture-tiles", + "https://www.openstreetmap.org/user/mikelmaron/diary/402600": "https://www.openstreetmap.org/assets/osm_logo_256-ed028f90468224a272961c380ecee0cfb73b8048b34f4b4b204b7f0d1097875d.png", + "https://community.esri.com/t5/geoanalytics-engine-blog/using-overture-maps-data-in-geoanalytics-engine/ba-p/1341493": "", + "https://open.gishub.org/open-buildings/": "", + "https://medium.com/@singh.tanya3298/lets-explore-overture-maps-3209c25d6c97": "", + "https://lyonwj.com/blog/importing-overture-maps-neo4j-aws-athena-spatial-sql-query": "https://lyonwj.com/static/images/overture-graph/import4.png", + "https://shi-works.github.io/Overture-Maps-Data-for-GIS/#16.18/35.680945/139.767552/-12.7/60": "", + "https://observablehq.com/d/9847c08c46f56ed6": "https://static.observableusercontent.com/thumbnail/93483d37715016640ac96554c8483a7b904059fe3f626729efb9e9d80603365b.jpg", + "https://medium.com/@dr.jiayu/harnessing-overture-maps-data-apache-sedonas-journey-from-parquet-to-geoparquet-d99f7767a499": "", + "https://www.postholer.com/articles/Overature-Cheat-Sheet": "", + "https://feyeandal.me/blog/access_overture_data_using_athena": "", + "https://til.simonwillison.net/overture-maps/overture-maps-parquet": "https://s3.amazonaws.com/til.simonwillison.net/41a6a07bd194e630fb59d653871c103a.jpg", + "https://beta.source.coop/repositories/cholmes/overture/description/": "" +} diff --git a/package.json b/package.json index a1b27a855..7c2444917 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.2.1", "private": true, "scripts": { + "fetch-og": "node scripts/fetch-og-images.mjs", "docusaurus": "docusaurus", "start": "npm run docusaurus start", "build": "npm run docusaurus build", diff --git a/scripts/fetch-og-images.mjs b/scripts/fetch-og-images.mjs new file mode 100644 index 000000000..4024760f1 --- /dev/null +++ b/scripts/fetch-og-images.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Fetches og:image metadata for community project entries that have no + * explicit `image` field and writes the results to community/og-image-cache.json. + * + * Run manually after adding new entries: + * npm run fetch-og + * + * The cache file is committed so CI builds never need to hit external URLs. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const entriesPath = resolve(root, 'community/community-projects.json'); +const cachePath = resolve(root, 'community/og-image-cache.json'); + +const FETCH_TIMEOUT_MS = 10_000; +const DELAY_BETWEEN_REQUESTS_MS = 300; + +const entries = JSON.parse(readFileSync(entriesPath, 'utf8')); + +let cache = {}; +try { + cache = JSON.parse(readFileSync(cachePath, 'utf8')); +} catch { + // cache doesn't exist yet — start fresh +} + +// Re-validate previously cached non-empty values + fetch missing ones +const needsValidation = entries.filter((e) => !e.image && cache[e.url]); +const needsFetch = entries.filter((e) => !e.image && !Object.hasOwn(cache, e.url)); + +if (needsValidation.length === 0 && needsFetch.length === 0) { + console.log('og-image cache is up to date. Nothing to fetch.'); + process.exit(0); +} + +if (needsValidation.length > 0) { + console.log(`Validating ${needsValidation.length} cached entries…`); + for (const entry of needsValidation) { + process.stdout.write(` ${entry.url} … `); + const valid = await isImageUrl(cache[entry.url]); + if (!valid) { + cache[entry.url] = ''; + console.log('✗ invalid, clearing'); + } else { + console.log('✓'); + } + await sleep(DELAY_BETWEEN_REQUESTS_MS); + } +} + +console.log(`Fetching og:image for ${needsFetch.length} entries…`); + +function extractOgImage(html) { + // Match in any attribute order + const match = html.match( + /]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i, + ) ?? html.match( + /]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i, + ); + return match?.[1] ?? null; +} + +async function fetchOgImage(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { 'User-Agent': 'OvertureMaps-docs-og-fetcher/1.0' }, + }); + if (!res.ok) return null; + const html = await res.text(); + const imgUrl = extractOgImage(html); + if (!imgUrl) return null; + return (await isImageUrl(imgUrl)) ? imgUrl : null; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +async function isImageUrl(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + method: 'HEAD', + signal: controller.signal, + headers: { 'User-Agent': 'OvertureMaps-docs-og-fetcher/1.0' }, + }); + const ct = res.headers.get('content-type') ?? ''; + return res.ok && ct.startsWith('image/'); + } catch { + return false; + } finally { + clearTimeout(timer); + } +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +for (const entry of needsFetch) { + process.stdout.write(` ${entry.url} … `); + const img = await fetchOgImage(entry.url); + cache[entry.url] = img ?? ''; + console.log(img ? '✓' : '(no og:image)'); + await sleep(DELAY_BETWEEN_REQUESTS_MS); +} + +writeFileSync(cachePath, JSON.stringify(cache, null, 2) + '\n', 'utf8'); +console.log(`\nCache written to community/og-image-cache.json`); diff --git a/src/components/CommunityTable.jsx b/src/components/CommunityTable.jsx index 7871a08fa..5d00cf008 100644 --- a/src/components/CommunityTable.jsx +++ b/src/components/CommunityTable.jsx @@ -1,5 +1,6 @@ import React, { useState, useMemo } from 'react'; import ENTRIES from '@site/community/community-projects.json'; +import OG_CACHE from '@site/community/og-image-cache.json'; import styles from './CommunityTable.module.css'; // Ordered groups for the filter UI @@ -7,19 +8,74 @@ const TAG_GROUPS = [ { label: 'Theme', tags: ['buildings', 'places', 'transportation', 'tiles', 'gers'], + colorClass: 'pillTheme', }, { label: 'Tool', tags: ['duckdb', 'postgis', 'python', 'r', 'spark', 'javascript', 'arcgis'], + colorClass: 'pillTool', }, { label: 'Type', tags: ['tutorial', 'library', 'visualization', 'analysis', 'tools', 'docs'], + colorClass: 'pillType', }, ]; +function ProjectCard({ entry, activeTags, onTagClick }) { + const image = entry.image || OG_CACHE[entry.url] || null; + return ( +
+ {image ? ( + + {entry.title} { e.currentTarget.parentElement.style.display = 'none'; }} /> + + ) : ( +
+ {entry.title} + +
+ + {entry.creatorUrl ? ( + + {entry.creator} + + ) : ( + entry.creator + )} + + {entry.release} +
+
+ {entry.tags.map((tag) => ( + + ))} +
+
+
+ ); +} + export default function CommunityTable() { const [activeTags, setActiveTags] = useState(new Set()); + const [sortOrder, setSortOrder] = useState('newest'); const toggle = (tag) => { setActiveTags((prev) => { @@ -34,10 +90,16 @@ export default function CommunityTable() { }; const filtered = useMemo(() => { - if (activeTags.size === 0) return ENTRIES; - const selectedTags = [...activeTags]; - return ENTRIES.filter((e) => selectedTags.every((t) => e.tags.includes(t))); - }, [activeTags]); + let results = activeTags.size === 0 + ? ENTRIES + : ENTRIES.filter((e) => [...activeTags].every((t) => e.tags.includes(t))); + + return [...results].sort((a, b) => { + const da = new Date(a.release); + const db = new Date(b.release); + return sortOrder === 'newest' ? db - da : da - db; + }); + }, [activeTags, sortOrder]); return (
@@ -49,7 +111,7 @@ export default function CommunityTable() {
))} {activeTags.size > 0 && ( -
+
@@ -68,45 +130,36 @@ export default function CommunityTable() {
)} +
+ Sort + {['newest', 'oldest'].map((order) => ( + + ))} +
- - - - - - - - - + {filtered.length === 0 ? ( +

No projects match the selected filters.

+ ) : ( +
{filtered.map((entry) => ( -
- - - - + ))} - -
ProjectCreatorData Release
- - {entry.title} - -
- {entry.tags.map((tag) => ( - - {tag} - - ))} -
-
- {entry.creatorUrl ? ( - - {entry.creator} - - ) : ( - entry.creator - )} - {entry.release}
+ + )} ); } diff --git a/src/components/CommunityTable.module.css b/src/components/CommunityTable.module.css index 7ce2c701a..68a1593a1 100644 --- a/src/components/CommunityTable.module.css +++ b/src/components/CommunityTable.module.css @@ -1,5 +1,7 @@ +/* ── Filter bar ── */ + .filterBar { - margin-bottom: 1.25rem; + margin-bottom: 1.5rem; display: flex; flex-direction: column; gap: 0.5rem; @@ -27,19 +29,44 @@ border-radius: 1rem; font-size: 0.75rem; cursor: pointer; - border: 1px solid var(--ifm-color-emphasis-300); - background-color: transparent; - color: var(--ifm-color-emphasis-700); + border: 1px solid var(--pill-color, var(--ifm-color-emphasis-300)); + background-color: var(--pill-bg, transparent); + color: var(--pill-text, var(--ifm-color-emphasis-700)); transition: all 0.15s; user-select: none; } +.pill:hover { + border-color: var(--pill-color, var(--ifm-color-primary)); + color: var(--pill-color, var(--ifm-color-primary)); + background-color: var(--pill-bg-hover, transparent); +} + .pillActive { - border-color: var(--ifm-color-primary); - background-color: var(--ifm-color-primary); + border-color: var(--pill-color, var(--ifm-color-primary)); + background-color: var(--pill-color, var(--ifm-color-primary)); color: #fff; } +.pillActive:hover { + background-color: var(--pill-color, var(--ifm-color-primary)); + border-color: var(--pill-color, var(--ifm-color-primary)); + color: #fff; + filter: brightness(0.88); +} + +/* Per-group color tokens — Theme: navy, Tool: teal, Type: lime */ +.pillTheme { --pill-color: var(--om-primary-navy); --pill-bg-hover: rgba(44,46,127,0.08); } +.pillTool { --pill-color: var(--om-secondary-teal-dark); --pill-bg-hover: rgba(5,165,175,0.1); } +.pillType { --pill-color: var(--om-accent-lime); --pill-bg-hover: rgba(150,201,61,0.12); } + +.filterStatus { + display: flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.25rem; +} + .clearBtn { font-size: 0.75rem; cursor: pointer; @@ -53,6 +80,135 @@ .count { font-size: 0.8rem; color: var(--ifm-color-emphasis-600); +} + +.sortBar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + padding-top: 0.5rem; + border-top: 1px solid var(--ifm-color-emphasis-200); + margin-top: 0.25rem; +} + +.empty { + color: var(--ifm-color-emphasis-600); + font-style: italic; + text-align: center; + padding: 2rem 0; +} + +/* ── Card grid ── */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; +} + +/* ── Individual card ── */ + +.card { + display: flex; + flex-direction: column; + border: 1px solid var(--ifm-color-emphasis-200); + border-radius: var(--ifm-card-border-radius, 0.5rem); + overflow: hidden; + background: var(--ifm-card-background-color, var(--ifm-background-surface-color)); + transition: box-shadow 0.15s, border-color 0.15s, transform 0.15s; +} + +.card:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); + border-color: var(--ifm-color-primary-light); + transform: translateY(-2px); +} + +.cardImageLink { + display: block; + overflow: hidden; + flex-shrink: 0; +} + +.cardImagePlaceholder { + display: block; + height: 160px; + background: linear-gradient( + 135deg, + rgba(44, 46, 127, 0.45) 0%, + rgba(64, 81, 204, 0.45) 40%, + rgba(14, 193, 189, 0.45) 70%, + rgba(78, 218, 216, 0.45) 100% + ); + flex-shrink: 0; +} + +.cardImagePlaceholder:hover { + opacity: 0.9; +} + +.cardImage { + width: 100%; + height: 160px; + object-fit: cover; + display: block; + transition: transform 0.2s; +} + +.cardImageLink:hover .cardImage { + transform: scale(1.03); +} + +.cardBody { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + flex: 1; +} + +.cardTitle { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.4; + color: var(--ifm-color-content); + text-decoration: none; +} + +.cardTitle:hover { + color: var(--ifm-color-primary); + text-decoration: underline; +} + +.cardMeta { + display: flex; + flex-direction: column; + gap: 0.15rem; + font-size: 0.8rem; + color: var(--ifm-color-emphasis-600); + margin-top: auto; +} + +.cardCreator a { + color: var(--ifm-color-emphasis-700); + text-decoration: none; +} + +.cardCreator a:hover { + color: var(--ifm-color-primary); + text-decoration: underline; +} + +.cardRelease { + font-size: 0.75rem; + color: var(--ifm-color-emphasis-500); +} + +.cardTags { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; margin-top: 0.25rem; } @@ -63,6 +219,26 @@ font-size: 0.7rem; background-color: var(--ifm-color-emphasis-100); color: var(--ifm-color-emphasis-700); - margin-right: 0.25rem; - margin-top: 0.15rem; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.12s; + user-select: none; +} + +.entryTag:hover { + border-color: var(--ifm-color-primary); + color: var(--ifm-color-primary); +} + +.entryTagActive { + background-color: var(--ifm-color-primary); + color: #fff; + border-color: var(--ifm-color-primary); +} + +.entryTagActive:hover { + background-color: var(--ifm-color-primary); + border-color: var(--ifm-color-primary); + color: #fff; + filter: brightness(0.88); } diff --git a/src/components/__tests__/CommunityTable.test.jsx b/src/components/__tests__/CommunityTable.test.jsx index 484954ccb..d333e7920 100644 --- a/src/components/__tests__/CommunityTable.test.jsx +++ b/src/components/__tests__/CommunityTable.test.jsx @@ -9,18 +9,10 @@ afterEach(cleanup); describe('CommunityTable', () => { describe('initial render', () => { - it('renders table headers', () => { - render(); - expect(screen.getByText('Project')).toBeInTheDocument(); - expect(screen.getByText('Creator')).toBeInTheDocument(); - expect(screen.getByText('Data Release')).toBeInTheDocument(); - }); - it('renders all entries by default', () => { render(); - const rows = screen.getAllByRole('row'); - // subtract header row - expect(rows.length - 1).toBe(ENTRIES.length); + const cards = screen.getAllByTestId('community-card'); + expect(cards).toHaveLength(ENTRIES.length); }); it('renders filter pill for every tag', () => { @@ -31,7 +23,10 @@ describe('CommunityTable', () => { 'tutorial', 'library', 'visualization', 'analysis', 'tools', 'docs', ]; for (const tag of expectedTags) { - expect(screen.getByRole('button', { name: tag })).toBeInTheDocument(); + // filter pills use aria-pressed; match by accessible name + const buttons = screen.getAllByRole('button', { name: tag }); + // at least the filter pill exists + expect(buttons.length).toBeGreaterThan(0); } }); @@ -70,65 +65,69 @@ describe('CommunityTable', () => { it('filters entries when a tag pill is clicked', () => { render(); const tag = 'duckdb'; - fireEvent.click(screen.getByRole('button', { name: tag })); + // click the filter-bar pill (aria-pressed button with exact name) + const pills = screen.getAllByRole('button', { name: tag }); + const filterPill = pills.find((b) => b.getAttribute('aria-pressed') !== null && b.closest('[class*="filterBar"]') !== null) + ?? pills[0]; + fireEvent.click(filterPill); const expectedCount = ENTRIES.filter((e) => e.tags.includes(tag)).length; - const rows = screen.getAllByRole('row'); - expect(rows.length - 1).toBe(expectedCount); + expect(screen.getAllByTestId('community-card')).toHaveLength(expectedCount); }); it('applies AND logic when multiple tags are selected', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'duckdb' })); - fireEvent.click(screen.getByRole('button', { name: 'tutorial' })); + const allDuckdb = screen.getAllByRole('button', { name: 'duckdb' }); + const allTutorial = screen.getAllByRole('button', { name: 'tutorial' }); + fireEvent.click(allDuckdb[0]); + fireEvent.click(allTutorial[0]); const expectedCount = ENTRIES.filter( (e) => e.tags.includes('duckdb') && e.tags.includes('tutorial'), ).length; - const rows = screen.getAllByRole('row'); - expect(rows.length - 1).toBe(expectedCount); + expect(screen.getAllByTestId('community-card')).toHaveLength(expectedCount); }); it('deselecting a tag restores the broader filtered set', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'duckdb' })); - fireEvent.click(screen.getByRole('button', { name: 'tutorial' })); - // deselect tutorial - fireEvent.click(screen.getByRole('button', { name: 'tutorial' })); + const allDuckdb = screen.getAllByRole('button', { name: 'duckdb' }); + const allTutorial = screen.getAllByRole('button', { name: 'tutorial' }); + fireEvent.click(allDuckdb[0]); + fireEvent.click(allTutorial[0]); + // deselect tutorial via filter pill (index 0) + fireEvent.click(allTutorial[0]); const expectedCount = ENTRIES.filter((e) => e.tags.includes('duckdb')).length; - const rows = screen.getAllByRole('row'); - expect(rows.length - 1).toBe(expectedCount); + expect(screen.getAllByTestId('community-card')).toHaveLength(expectedCount); }); it('deselecting all tags shows all entries again', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'duckdb' })); - fireEvent.click(screen.getByRole('button', { name: 'duckdb' })); + const pills = screen.getAllByRole('button', { name: 'duckdb' }); + fireEvent.click(pills[0]); + fireEvent.click(pills[0]); - const rows = screen.getAllByRole('row'); - expect(rows.length - 1).toBe(ENTRIES.length); + expect(screen.getAllByTestId('community-card')).toHaveLength(ENTRIES.length); }); it('shows clear-filters button when a tag is active', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'buildings' })); + fireEvent.click(screen.getAllByRole('button', { name: 'buildings' })[0]); expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(); }); it('clear-filters button resets to all entries', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'buildings' })); + fireEvent.click(screen.getAllByRole('button', { name: 'buildings' })[0]); fireEvent.click(screen.getByRole('button', { name: /clear filters/i })); - const rows = screen.getAllByRole('row'); - expect(rows.length - 1).toBe(ENTRIES.length); + expect(screen.getAllByTestId('community-card')).toHaveLength(ENTRIES.length); expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument(); }); it('shows entry count when filtered', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'duckdb' })); + fireEvent.click(screen.getAllByRole('button', { name: 'duckdb' })[0]); const expectedCount = ENTRIES.filter((e) => e.tags.includes('duckdb')).length; expect( @@ -138,10 +137,47 @@ describe('CommunityTable', () => { it('tag pills have keyboard-accessible attributes', () => { render(); - // role="button" makes pills keyboard-reachable. Full keydown→toggle - // behaviour requires @testing-library/user-event and is covered by the - // onClick tests above. - expect(screen.getByRole('button', { name: 'tiles' })).toBeInTheDocument(); + // filter-bar pills should have aria-pressed + const tilesPills = screen.getAllByRole('button', { name: 'tiles' }); + expect(tilesPills.length).toBeGreaterThan(0); + // at least one should be the filter-bar pill with aria-pressed + const filterPill = tilesPills.find((b) => b.hasAttribute('aria-pressed')); + expect(filterPill).toBeTruthy(); + }); + + it('clicking a tag on a card activates that tag filter', () => { + render(); + const tag = 'duckdb'; + // find the card-level tag button (there may be multiple; click the first card's tag) + const cardTagButtons = screen.getAllByRole('button', { name: tag }); + // click any one — they all call toggle(tag) + fireEvent.click(cardTagButtons[cardTagButtons.length - 1]); + + const expectedCount = ENTRIES.filter((e) => e.tags.includes(tag)).length; + expect(screen.getAllByTestId('community-card')).toHaveLength(expectedCount); + }); + }); + + describe('date sorting', () => { + it('defaults to newest-first order', () => { + render(); + expect(screen.getByRole('button', { name: 'Newest first' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Oldest first' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('clicking oldest-first reorders cards', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Oldest first' })); + expect(screen.getByRole('button', { name: 'Oldest first' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getAllByTestId('community-card')).toHaveLength(ENTRIES.length); + }); + + it('sort and filter work together', () => { + render(); + fireEvent.click(screen.getAllByRole('button', { name: 'duckdb' })[0]); + fireEvent.click(screen.getByRole('button', { name: 'Oldest first' })); + const expected = ENTRIES.filter((e) => e.tags.includes('duckdb')).length; + expect(screen.getAllByTestId('community-card')).toHaveLength(expected); }); }); }); diff --git a/src/css/custom.css b/src/css/custom.css index bf1f3a124..2a04f0a3d 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -68,6 +68,21 @@ --ifm-code-font-size: 90%; --ifm-font-family-base: Montserrat, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); + + /* ── Overture Maps brand palette ── */ + /* Primary */ + --om-primary-navy: #2C2E7F; + --om-primary-teal: #0EC1BD; + --om-primary-blue: #4051CC; + /* Secondary */ + --om-secondary-teal-dark: #05A5AF; + --om-secondary-cyan: #4EDAD8; + --om-secondary-green-teal: #00A790; + /* Accent */ + --om-accent-lime: #96C93D; + /* Neutral */ + --om-light: #FFFFFF; + --om-dark: #001A39; } [data-theme='light'] { From f427ed7a69d2646ef21576f7a78467cd9b4d3e66 Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 27 May 2026 18:38:23 -0400 Subject: [PATCH 2/8] docs: document og-image cache in README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 01b9d7c40..905cc55de 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ This repository uses [Docusaurus](https://docusaurus.io/) to publish the documen - `blog/`: Entries for the Overture engineering blog available at docs.overturemaps.org/blog - `community/`: The community page that showcases Overture data being used in the wild. + - `community-projects.json` - source data for all community project cards + - `og-image-cache.json` - cached `og:image` URLs for entries without an explicit `image` field (see [OG Image Cache](#og-image-cache) below) - `docs/`: The main documentation pages available at docs.overturemaps.org/. The sidebar for these pages is manually curated in the `sidebars.js` file. - `release-blog/`: Release notes for every Overture data release. The latest release is always available at - Notice there is no `schema reference` folder. See below. @@ -41,11 +43,31 @@ Now navigate to to see the live preview. - `npm run build` - Build the production site (also shows locale/translation warnings and broken link checks) - `npm run serve` - Serve the built site locally - `npm run deploy` - Deploy the site -- `npm run clear` - Clear the Docusaurus cache +- `npm run fetch-og` - Fetch and cache `og:image` metadata for community project entries (see [OG Image Cache](#og-image-cache) below) - `npm run swizzle` - Customize Docusaurus components by "ejecting" them for modification - `npm run write-translations` - Generate translation files for internationalization - `npm run write-heading-ids` - Auto-generate heading IDs for better linking +## OG Image Cache + +The community page displays project cards with images. Each entry in `community/community-projects.json` can include an optional `"image"` field. For entries without one, the site falls back to a cached `og:image` fetched from the project's URL. + +The cache lives in `community/og-image-cache.json` and is committed to the repo so CI builds never make external HTTP requests. + +**When to run it:** after adding or updating entries in `community-projects.json`. + +```shell +npm run fetch-og +``` + +The script (`scripts/fetch-og-images.mjs`): +1. Skips entries that already have an explicit `"image"` field +2. Re-validates any previously cached non-empty URLs via a HEAD request (`Content-Type: image/*`) and clears invalid ones +3. Fetches the HTML for uncached entries, extracts `og:image`, and validates the URL before writing it to the cache +4. Is idempotent - safe to re-run at any time + +Cards with no image (neither explicit nor cached) display a branded gradient placeholder. + ## LLM-Friendly Content Each production build generates [llmstxt.org](https://llmstxt.org)-standard files for use with LLMs and AI tools: From b532b1a056b673c95276ab52420fd4bcd3a5b34a Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 27 May 2026 18:43:23 -0400 Subject: [PATCH 3/8] fix(community): use React state for image error instead of direct DOM mutation Replaces onError DOM manipulation with useState(imageError) so React controls visibility. Fixes potential SSG hydration mismatches and phantom keyboard tab stops on hidden anchor elements. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- src/components/CommunityTable.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/CommunityTable.jsx b/src/components/CommunityTable.jsx index 5d00cf008..df995acfe 100644 --- a/src/components/CommunityTable.jsx +++ b/src/components/CommunityTable.jsx @@ -23,12 +23,13 @@ const TAG_GROUPS = [ ]; function ProjectCard({ entry, activeTags, onTagClick }) { + const [imageError, setImageError] = useState(false); const image = entry.image || OG_CACHE[entry.url] || null; return (
- {image ? ( + {image && !imageError ? ( - {entry.title} { e.currentTarget.parentElement.style.display = 'none'; }} /> + {entry.title} setImageError(true)} /> ) : ( with
- CommunityTable.module.css: dark text on lime active pills (WCAG contrast) - Tests: assert actual card order on oldest-first sort, not just count Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- README.md | 2 +- src/components/CommunityTable.jsx | 20 ++++++++++++++++--- src/components/CommunityTable.module.css | 5 ++++- .../__tests__/CommunityTable.test.jsx | 11 +++++++++- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 905cc55de..edb45b31c 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Now navigate to to see the live preview. The community page displays project cards with images. Each entry in `community/community-projects.json` can include an optional `"image"` field. For entries without one, the site falls back to a cached `og:image` fetched from the project's URL. -The cache lives in `community/og-image-cache.json` and is committed to the repo so CI builds never make external HTTP requests. +The cache lives in `community/og-image-cache.json` and is committed to the repository so CI builds never make external HTTP requests. **When to run it:** after adding or updating entries in `community-projects.json`. diff --git a/src/components/CommunityTable.jsx b/src/components/CommunityTable.jsx index df995acfe..93263b765 100644 --- a/src/components/CommunityTable.jsx +++ b/src/components/CommunityTable.jsx @@ -3,6 +3,20 @@ import ENTRIES from '@site/community/community-projects.json'; import OG_CACHE from '@site/community/og-image-cache.json'; import styles from './CommunityTable.module.css'; +const MONTHS = { + january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, + july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, +}; + +/** Parse "Month YYYY" strings to a UTC timestamp. Returns 0 for unrecognised formats. */ +function parseRelease(str) { + const [month, year] = (str ?? '').trim().toLowerCase().split(/\s+/); + const m = MONTHS[month]; + const y = parseInt(year, 10); + if (m === undefined || isNaN(y)) return 0; + return Date.UTC(y, m, 1); +} + // Ordered groups for the filter UI const TAG_GROUPS = [ { @@ -32,7 +46,7 @@ function ProjectCard({ entry, activeTags, onTagClick }) { {entry.title} setImageError(true)} /> ) : ( -
[...activeTags].every((t) => e.tags.includes(t))); return [...results].sort((a, b) => { - const da = new Date(a.release); - const db = new Date(b.release); + const da = parseRelease(a.release); + const db = parseRelease(b.release); return sortOrder === 'newest' ? db - da : da - db; }); }, [activeTags, sortOrder]); diff --git a/src/components/CommunityTable.module.css b/src/components/CommunityTable.module.css index 68a1593a1..ffce10489 100644 --- a/src/components/CommunityTable.module.css +++ b/src/components/CommunityTable.module.css @@ -58,7 +58,10 @@ /* Per-group color tokens — Theme: navy, Tool: teal, Type: lime */ .pillTheme { --pill-color: var(--om-primary-navy); --pill-bg-hover: rgba(44,46,127,0.08); } .pillTool { --pill-color: var(--om-secondary-teal-dark); --pill-bg-hover: rgba(5,165,175,0.1); } -.pillType { --pill-color: var(--om-accent-lime); --pill-bg-hover: rgba(150,201,61,0.12); } +/* Type uses lime — active state needs dark text for WCAG contrast */ +.pillType { --pill-color: var(--om-accent-lime); --pill-bg-hover: rgba(150,201,61,0.12); } +.pillType.pillActive { color: var(--om-dark); } +.pillType.pillActive:hover { color: var(--om-dark); } .filterStatus { display: flex; diff --git a/src/components/__tests__/CommunityTable.test.jsx b/src/components/__tests__/CommunityTable.test.jsx index d333e7920..1fd3ea893 100644 --- a/src/components/__tests__/CommunityTable.test.jsx +++ b/src/components/__tests__/CommunityTable.test.jsx @@ -169,7 +169,16 @@ describe('CommunityTable', () => { render(); fireEvent.click(screen.getByRole('button', { name: 'Oldest first' })); expect(screen.getByRole('button', { name: 'Oldest first' })).toHaveAttribute('aria-pressed', 'true'); - expect(screen.getAllByTestId('community-card')).toHaveLength(ENTRIES.length); + + // Oldest entry should appear first + const sorted = [...ENTRIES].sort((a, b) => { + const months = {january:0,february:1,march:2,april:3,may:4,june:5,july:6,august:7,september:8,october:9,november:10,december:11}; + const parse = (s) => { const [m,y]=(s??'').trim().toLowerCase().split(/\s+/); return Date.UTC(+y, months[m]??0, 1); }; + return parse(a.release) - parse(b.release); + }); + const cards = screen.getAllByTestId('community-card'); + const firstCardLink = cards[0].querySelector('a[href]'); + expect(firstCardLink).toHaveAttribute('href', sorted[0].url); }); it('sort and filter work together', () => { From 070b403b43acff9d540a8c9c88d6eb8303c491db Mon Sep 17 00:00:00 2001 From: John McCall Date: Wed, 27 May 2026 18:54:06 -0400 Subject: [PATCH 5/8] feat(community): color card tag badges to match filter group color Tags on cards now inherit the same color token (navy/teal/lime) as their corresponding filter-bar pill group, using a TAG_COLOR_CLASS lookup map derived from TAG_GROUPS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: John McCall --- src/components/CommunityTable.jsx | 10 ++++++--- src/components/CommunityTable.module.css | 26 ++++++++++++++++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/components/CommunityTable.jsx b/src/components/CommunityTable.jsx index 93263b765..20bde4b27 100644 --- a/src/components/CommunityTable.jsx +++ b/src/components/CommunityTable.jsx @@ -74,7 +74,7 @@ function ProjectCard({ entry, activeTags, onTagClick }) {