diff --git a/README.md b/README.md index 01b9d7c40..edb45b31c 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 repository 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: 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..20bde4b27 100644 --- a/src/components/CommunityTable.jsx +++ b/src/components/CommunityTable.jsx @@ -1,25 +1,100 @@ 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'; +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 = [ { 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', }, ]; -export default function CommunityTable() { - const [activeTags, setActiveTags] = useState(new Set()); +function ProjectCard({ entry, activeTags, onTagClick }) { + const [imageError, setImageError] = useState(false); + const image = entry.image || OG_CACHE[entry.url] || null; + return ( +
+ {image && !imageError ? ( + + {entry.title} setImageError(true)} /> + + ) : ( +
+ )} +
+ + {entry.title} + +
+ + {entry.creatorUrl ? ( + + {entry.creator} + + ) : ( + entry.creator + )} + + {entry.release} +
+
+ {entry.tags.map((tag) => ( + + ))} +
+
+
+ ); +} + +/** Map every tag to the colorClass of its group for use on card tag badges. */ +const TAG_COLOR_CLASS = Object.fromEntries( + TAG_GROUPS.flatMap(({ tags, colorClass }) => tags.map((tag) => [tag, colorClass])), +); + +export default function CommunityTable() { const [activeTags, setActiveTags] = useState(new Set()); + const [sortOrder, setSortOrder] = useState('newest'); const toggle = (tag) => { setActiveTags((prev) => { @@ -34,10 +109,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 = parseRelease(a.release); + const db = parseRelease(b.release); + return sortOrder === 'newest' ? db - da : da - db; + }); + }, [activeTags, sortOrder]); return (
@@ -49,7 +130,7 @@ export default function CommunityTable() {
))} {activeTags.size > 0 && ( -
+
@@ -68,45 +149,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..83ea7c6e8 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,45 @@ 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); } +/* Type uses primary bright blue — distinct from navy and teal */ +.pillType { --pill-color: var(--om-primary-blue); --pill-bg-hover: rgba(64,81,204,0.08); } + +.filterStatus { + display: flex; + align-items: center; + gap: 0.25rem; + margin-top: 0.25rem; +} + .clearBtn { font-size: 0.75rem; cursor: pointer; @@ -53,6 +81,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; } @@ -62,7 +219,29 @@ border-radius: 0.75rem; 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; + color: var(--pill-color, var(--ifm-color-emphasis-700)); + border: 1px solid var(--pill-color, transparent); + cursor: pointer; + transition: all 0.12s; + user-select: none; +} + +.entryTag:hover { + background-color: var(--pill-bg-hover, rgba(0,0,0,0.05)); + color: var(--pill-color, var(--ifm-color-primary)); +} + +.entryTagActive { + background-color: var(--pill-color, var(--ifm-color-primary)); + color: #fff; + border-color: var(--pill-color, var(--ifm-color-primary)); } + +.entryTagActive: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); +} + +/* (No Type-group text overrides needed — white on #00A790 is WCAG AA compliant) */ diff --git a/src/components/__tests__/CommunityTable.test.jsx b/src/components/__tests__/CommunityTable.test.jsx index 484954ccb..1fd3ea893 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,56 @@ 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'); + + // 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', () => { + 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'] {