From 224025b136f3f9d296f0fefb0ccf694754070ba4 Mon Sep 17 00:00:00 2001 From: Isaac S Date: Fri, 29 May 2026 01:49:08 -0400 Subject: [PATCH] v5 gallery: fuzzy ranked search + materials/location facets Replace the naive substring search in GalleryShell with match-sorter for typo-tolerant, relevance-ranked results. Key order encodes weight: name > category/sub/tags/materials > location/zone/ppe/training > description. Empty query preserves the catalog's existing order. Add single-select Materials and Location facet filters, derived from the loaded catalog like categories. All facets combine with AND, applied before the fuzzy match. Localize the two new filter labels across all 12 message files under the gallery namespace. Co-Authored-By: Claude Opus 4.8 (1M context) --- v5/messages/ar.json | 2 + v5/messages/en.json | 2 + v5/messages/es.json | 2 + v5/messages/fr.json | 2 + v5/messages/he.json | 2 + v5/messages/hi.json | 2 + v5/messages/ja.json | 2 + v5/messages/ko.json | 2 + v5/messages/pt-BR.json | 2 + v5/messages/ru.json | 2 + v5/messages/tr.json | 2 + v5/messages/zh-CN.json | 2 + v5/package-lock.json | 26 +++++++ v5/package.json | 1 + v5/src/components/GalleryShell.tsx | 107 +++++++++++++++++++++++------ 15 files changed, 137 insertions(+), 21 deletions(-) diff --git a/v5/messages/ar.json b/v5/messages/ar.json index d170708..16c21e5 100644 --- a/v5/messages/ar.json +++ b/v5/messages/ar.json @@ -21,6 +21,8 @@ "searchAria": "البحث في المخزون", "category": "الفئة:", "training": "التدريب:", + "materials": "المواد:", + "location": "الموقع:", "viewModeLabel": "وضع العرض", "grid": "[ شبكة ]", "table": "[ جدول ]", diff --git a/v5/messages/en.json b/v5/messages/en.json index ff3be98..5f359df 100644 --- a/v5/messages/en.json +++ b/v5/messages/en.json @@ -21,6 +21,8 @@ "searchAria": "Search inventory", "category": "CATEGORY:", "training": "TRAINING:", + "materials": "MATERIALS:", + "location": "LOCATION:", "viewModeLabel": "View mode", "grid": "[ GRID ]", "table": "[ TABLE ]", diff --git a/v5/messages/es.json b/v5/messages/es.json index 3171a72..f9a7d58 100644 --- a/v5/messages/es.json +++ b/v5/messages/es.json @@ -21,6 +21,8 @@ "searchAria": "Buscar en el inventario", "category": "CATEGORÍA:", "training": "FORMACIÓN:", + "materials": "MATERIALES:", + "location": "UBICACIÓN:", "viewModeLabel": "Modo de vista", "grid": "[ CUADRÍCULA ]", "table": "[ TABLA ]", diff --git a/v5/messages/fr.json b/v5/messages/fr.json index 669b760..2568804 100644 --- a/v5/messages/fr.json +++ b/v5/messages/fr.json @@ -21,6 +21,8 @@ "searchAria": "Rechercher dans l'inventaire", "category": "CATÉGORIE :", "training": "FORMATION :", + "materials": "MATÉRIAUX :", + "location": "EMPLACEMENT :", "viewModeLabel": "Mode d'affichage", "grid": "[ GRILLE ]", "table": "[ TABLEAU ]", diff --git a/v5/messages/he.json b/v5/messages/he.json index 93198cb..0befa99 100644 --- a/v5/messages/he.json +++ b/v5/messages/he.json @@ -21,6 +21,8 @@ "searchAria": "חיפוש במלאי", "category": "קטגוריה:", "training": "הכשרה:", + "materials": "חומרים:", + "location": "מיקום:", "viewModeLabel": "מצב תצוגה", "grid": "[ רשת ]", "table": "[ טבלה ]", diff --git a/v5/messages/hi.json b/v5/messages/hi.json index 8a947bd..bb6a007 100644 --- a/v5/messages/hi.json +++ b/v5/messages/hi.json @@ -21,6 +21,8 @@ "searchAria": "इन्वेंटरी खोजें", "category": "श्रेणी:", "training": "प्रशिक्षण:", + "materials": "सामग्री:", + "location": "स्थान:", "viewModeLabel": "व्यू मोड", "grid": "[ ग्रिड ]", "table": "[ तालिका ]", diff --git a/v5/messages/ja.json b/v5/messages/ja.json index 3c0c0a7..38308e2 100644 --- a/v5/messages/ja.json +++ b/v5/messages/ja.json @@ -21,6 +21,8 @@ "searchAria": "在庫を検索", "category": "カテゴリ:", "training": "トレーニング:", + "materials": "素材:", + "location": "場所:", "viewModeLabel": "表示モード", "grid": "[ グリッド ]", "table": "[ テーブル ]", diff --git a/v5/messages/ko.json b/v5/messages/ko.json index 630567a..1950d6a 100644 --- a/v5/messages/ko.json +++ b/v5/messages/ko.json @@ -21,6 +21,8 @@ "searchAria": "재고 검색", "category": "카테고리:", "training": "교육:", + "materials": "재료:", + "location": "위치:", "viewModeLabel": "보기 모드", "grid": "[ 그리드 ]", "table": "[ 테이블 ]", diff --git a/v5/messages/pt-BR.json b/v5/messages/pt-BR.json index 8ad29ac..42be387 100644 --- a/v5/messages/pt-BR.json +++ b/v5/messages/pt-BR.json @@ -21,6 +21,8 @@ "searchAria": "Buscar no inventário", "category": "CATEGORIA:", "training": "TREINAMENTO:", + "materials": "MATERIAIS:", + "location": "LOCALIZAÇÃO:", "viewModeLabel": "Modo de visualização", "grid": "[ GRADE ]", "table": "[ TABELA ]", diff --git a/v5/messages/ru.json b/v5/messages/ru.json index 73fc270..38e4721 100644 --- a/v5/messages/ru.json +++ b/v5/messages/ru.json @@ -21,6 +21,8 @@ "searchAria": "Поиск по инвентарю", "category": "КАТЕГОРИЯ:", "training": "ОБУЧЕНИЕ:", + "materials": "МАТЕРИАЛЫ:", + "location": "РАСПОЛОЖЕНИЕ:", "viewModeLabel": "Режим просмотра", "grid": "[ СЕТКА ]", "table": "[ ТАБЛИЦА ]", diff --git a/v5/messages/tr.json b/v5/messages/tr.json index 1749086..633b91e 100644 --- a/v5/messages/tr.json +++ b/v5/messages/tr.json @@ -21,6 +21,8 @@ "searchAria": "Envanterde ara", "category": "KATEGORİ:", "training": "EĞİTİM:", + "materials": "MALZEMELER:", + "location": "KONUM:", "viewModeLabel": "Görünüm modu", "grid": "[ IZGARA ]", "table": "[ TABLO ]", diff --git a/v5/messages/zh-CN.json b/v5/messages/zh-CN.json index 53bbcc0..0a8768b 100644 --- a/v5/messages/zh-CN.json +++ b/v5/messages/zh-CN.json @@ -21,6 +21,8 @@ "searchAria": "搜索库存", "category": "类别:", "training": "培训:", + "materials": "材料:", + "location": "位置:", "viewModeLabel": "视图模式", "grid": "[ 网格 ]", "table": "[ 表格 ]", diff --git a/v5/package-lock.json b/v5/package-lock.json index ebd20d5..c5d395a 100644 --- a/v5/package-lock.json +++ b/v5/package-lock.json @@ -11,6 +11,7 @@ "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/react": "^3.0.97", "ai": "^6.0.95", + "match-sorter": "^8.3.0", "next": "16.1.6", "next-intl": "^4.13.0", "react": "19.2.3", @@ -315,6 +316,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -5893,6 +5903,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/match-sorter": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-8.3.0.tgz", + "integrity": "sha512-8Py1GbZi5zsclYSFcPAH4H5xfTbeD0bOREA7qP/t8bW4MbOSlPl8sbqHOedEV7O+Bxyvxm6xs/v6BXJGe+JDNA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.8", + "remove-accents": "0.5.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7591,6 +7611,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", diff --git a/v5/package.json b/v5/package.json index 486f936..86c7f6d 100644 --- a/v5/package.json +++ b/v5/package.json @@ -16,6 +16,7 @@ "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/react": "^3.0.97", "ai": "^6.0.95", + "match-sorter": "^8.3.0", "next": "16.1.6", "next-intl": "^4.13.0", "react": "19.2.3", diff --git a/v5/src/components/GalleryShell.tsx b/v5/src/components/GalleryShell.tsx index 42bf02b..1aba9ad 100644 --- a/v5/src/components/GalleryShell.tsx +++ b/v5/src/components/GalleryShell.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; +import { matchSorter } from "match-sorter"; import type { MakerLabTool } from "./catalog-types"; import { TechnicalFrame } from "./TechnicalFrame"; import { ToolCard } from "./ToolCard"; @@ -17,11 +18,30 @@ const TRAINING_LEVELS = [ { value: "Advanced", key: "trainingAdvanced" }, ] as const; +// Ranked, typo-tolerant search keys. match-sorter ranks earlier keys above +// later ones when match quality ties, so key order doubles as relevance +// weight: name first, then the structured metadata (category / tags / +// materials), then the free-text description last. +const SEARCH_KEYS: ReadonlyArray = [ + "name", + "category", + "categorySub", + "tags", + "materials", + "location", + "zone", + "ppe", + "trainingLevel", + "description", +]; + export function GalleryShell({ tools }: GalleryShellProps) { const t = useTranslations("gallery"); const [query, setQuery] = useState(""); const [category, setCategory] = useState(null); const [training, setTraining] = useState(null); + const [material, setMaterial] = useState(null); + const [location, setLocation] = useState(null); const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); const categories = useMemo( @@ -29,32 +49,39 @@ export function GalleryShell({ tools }: GalleryShellProps) { [tools] ); + // Facet options are derived from the loaded catalog, mirroring `categories`. + const materials = useMemo( + () => Array.from(new Set(tools.flatMap((tool) => tool.materials))).sort(), + [tools] + ); + + const locations = useMemo( + () => Array.from(new Set(tools.map((tool) => tool.location).filter(Boolean))).sort(), + [tools] + ); + const filteredTools = useMemo(() => { - const normalizedQuery = query.trim().toLowerCase(); - - return tools.filter((tool) => { - const searchable = [ - tool.name, - tool.category, - tool.categorySub, - tool.location, - tool.zone, - tool.trainingLevel, - tool.description, - ...tool.ppe, - ...tool.materials, - ...tool.tags, - ] - .join(" ") - .toLowerCase(); - - const matchesQuery = !normalizedQuery || searchable.includes(normalizedQuery); + // Apply facet filters first; each dimension is single-select and they + // combine with AND across dimensions. + const faceted = tools.filter((tool) => { const matchesCategory = !category || tool.category === category; const matchesTraining = !training || tool.trainingLevel === training; + const matchesMaterial = !material || tool.materials.includes(material); + const matchesLocation = !location || tool.location === location; - return matchesQuery && matchesCategory && matchesTraining; + return matchesCategory && matchesTraining && matchesMaterial && matchesLocation; }); - }, [category, query, tools, training]); + + const normalizedQuery = query.trim(); + + // Empty query preserves the catalog's existing order (show all). + if (!normalizedQuery) { + return faceted; + } + + // Fuzzy, ranked match across the weighted keys (typo tolerant). + return matchSorter(faceted, normalizedQuery, { keys: SEARCH_KEYS.slice() }); + }, [category, location, material, query, tools, training]); return (
@@ -116,6 +143,44 @@ export function GalleryShell({ tools }: GalleryShellProps) { +
+ {t("materials")} +
+ {materials.map((materialName) => ( + + ))} +
+
+ +
+ {t("location")} +
+ {locations.map((locationName) => ( + + ))} +
+
+