A gallery of fruits, vegetables, herbs, and spices - their colors, seasons, and the quiet stories behind them. Browse a hand-curated set, a frozen snapshot of Wikidata, or stream the full catalogue live.
Live demo: stalevski.github.io/produce-gallery
Built with Vite + React + TypeScript + Tailwind CSS.
Built AI-assisted, human-directed. Planned, generated, reviewed, and tested with AI tooling under my direction - the architecture, code review, and final decisions are mine.
Working on this across devices or with others? See
PROGRESS.mdfor current focus, backlog, known issues, and the decision log, andAGENTS.mdfor the operator's manual.
- Three data sources, switchable in the header:
- Curated - 114 hand-curated items across 9 categories with emoji art, origins, fun facts, seasons, botanical names, plant families, and food pairings
- Snapshot - a frozen ~1,870-item Wikidata dump bundled with the app; instant, offline-safe, regenerated by
npm run snapshot - Live - a fresh SPARQL query against the public Wikidata endpoint with photos from Wikimedia Commons
- 9 categories: fruit, vegetable, herb, spice, nut, seed, mushroom, legume, grain
- Click any card for a detail modal with botanical name, family, full description, food pairings, and a live-fetched Wikipedia lead paragraph
- Pagination with size selector (25 / 50 / 100 / 200 / All) and first/prev/next/last navigation
- 7-day localStorage cache for live Wikidata results, with a Refresh button to invalidate
- Background cache-warming: 5s after page load, the live Wikidata fetch runs invisibly so the first toggle to Live is instant
- Self-hosted fonts (Fraunces + Inter variable WOFF2) - zero third-party CDN dependency at runtime
- Search across name, color, origin, description, and trivia, with live match highlighting in card titles and descriptions
- Filter by category, season, color, and (in Snapshot or Live mode) has photo
- Sort alphabetical (A→Z, Z→A), by category, or random
- URL-encoded state: every filter, page, sort, source, and selected item is in the URL - links are shareable, browser refresh preserves view
- Keyboard shortcuts:
/focuses search,Esccloses the detail modal - Reset all chip appears when any filter is active
- Skeleton loading cards during the live Wikidata fetch and the snapshot chunk download
- Loading + error states with retry / fallback to snapshot or curated
- Per-card image fallback to category emoji
- Wikipedia link on each Wikidata-sourced item, plus the "this taxon is source of" bridge so common ingredients (rosemary, cinnamon, ginger) get their photos from the parent species
- Adaptive UI: filters that don't apply to the current dataset auto-hide (e.g., season/color in Live or Snapshot mode where data is sparse)
- Responsive: 1 → 2 → 3 → 4 columns from mobile to desktop
npm install
npm run devThen open the URL Vite prints (default http://localhost:5173).
npm run build
npm run previewsrc/
App.tsx # Page composition, three-tier source toggle, fetch lifecycle, URL state
main.tsx # React root
index.css # Tailwind entry + base styles
types.ts # ProduceItem / Category / Source / ImageAttribution types
data/
curated.ts # Hand-curated dataset (114 items, 9 categories)
category-qids.json # Wikidata QID per category, shared with the snapshot generator
wikidata-snapshot.json # Frozen Wikidata dump, regenerated by scripts/generate-snapshot.mjs
assets/fonts/ # Self-hosted variable WOFF2 (Fraunces + Inter)
services/
wikidata.ts # Live SPARQL queries + 7-day localStorage cache
wikipedia.ts # Lead-paragraph fetcher for the detail view
components/
ProduceCard.tsx # Card (clickable, with search highlight + photo fallback)
DetailView.tsx # Modal with botanical name, pairings, Wikipedia summary
FilterBar.tsx # Search + category/season/color/photo/sort + reset chip
Pagination.tsx # Size selector (25/50/100/200/All) + page navigation
SkeletonCard.tsx # Pulsing placeholders during snapshot/live Wikidata load
HighlightedText.tsx # Highlights search-query matches inside text
src/services/wikidata.ts runs nine SPARQL queries (one per category) against
https://query.wikidata.org/sparql. To capture both classification styles in
Wikidata's ontology - and to inherit images / Wikipedia articles from a source
taxon when the food entity itself lacks them - the query uses a hybrid path:
SELECT DISTINCT ?item ?itemLabel ?itemDescription
(SAMPLE(?fImage) AS ?image)
(SAMPLE(?fArticle) AS ?article)
WHERE {
{ ?item wdt:P279+ wd:${qid} . } # subclass-of+
UNION
{ ?item wdt:P31/wdt:P279* wd:${qid} . } # instance-of (any subclass)
OPTIONAL { ?item wdt:P18 ?dImg }
OPTIONAL {
?taxon wdt:P1672 ?item . # "this taxon is source of"
?taxon wdt:P18 ?tImg .
}
BIND(COALESCE(?dImg, ?tImg) AS ?fImage)
OPTIONAL {
?dArt schema:about ?item ;
schema:isPartOf <https://en.wikipedia.org/> .
}
OPTIONAL {
?taxon2 wdt:P1672 ?item .
?tArt schema:about ?taxon2 ;
schema:isPartOf <https://en.wikipedia.org/> .
}
BIND(COALESCE(?dArt, ?tArt) AS ?fArticle)
FILTER(BOUND(?fImage) || BOUND(?fArticle))
SERVICE wikibase:label { bd:serviceParam wikibase:language "en" }
}
GROUP BY ?item ?itemLabel ?itemDescription
LIMIT 800Categories use these QIDs:
| Category | QID | Notes |
|---|---|---|
| fruit | Q3314483 | edible fruit |
| vegetable | Q11004 | vegetable |
| spice | Q42527 | spice |
| herb | Q207123 | herb |
| nut | Q11009 | nut |
| seed | Q40763 | seed |
| mushroom | Q654236 | edible mushroom |
| legume | Q12034432 | legume (fruit type) |
| grain | Q12117 | cereal |
Results are deduplicated by Wikidata ID, sorted alphabetically, and cached in
localStorage under produce-gallery:wikidata:v6 for 7 days. The cache is also
warmed in the background 5 seconds after page load, so the first toggle to
Wikidata mode is instant.
The current view is fully encoded in the URL search params. Open one of these in a fresh tab:
| Example URL | What it does |
|---|---|
/?q=basil |
Search for "basil" |
/?cat=spice&sort=random |
Spices in random order |
/?src=snapshot |
Bundled Wikidata snapshot |
/?src=wikidata&photo=1 |
Live Wikidata, only items with photos |
/?cat=herb&item=rosemary |
Herbs filter, with the rosemary detail modal open |
Append a new entry to src/data/curated.ts matching the ProduceItem shape:
{
id: "fennel",
name: "Fennel",
emoji: "\u{1F33F}",
category: "vegetable",
color: "Green",
colorHex: "#9FBE6F",
seasons: ["autumn", "winter"],
origin: "Mediterranean",
description: "...",
funFact: "...",
botanicalName: "Foeniculum vulgare",
family: "Apiaceae",
pairings: ["orange", "olive oil", "fish"],
}The filter chips for color are derived automatically from the dataset, and the new item will appear in the appropriate category.