diff --git a/CHANGELOG.md b/CHANGELOG.md index 797c036..e33f944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-05-14 + +### Added +- **Intelligent Autocomplete Search:** Replaced the standard search bar with a powerful, context-aware autocomplete engine. The search bar now provides instant, keyboard-accessible suggestions for syntax keys (e.g., `brand:`, `speed:`) and scans your database to suggest available values as you type. +- **Powerful Search Syntax:** Replaced the legacy dropdown filter system with a unified, GitHub-style text search parser. The main search bar is now the single source of truth for inventory discovery, supporting complex tokens (e.g., `brand:Innova`, `speed:>9`, `plastic:"Star"`). The Advanced Filter and Mobile Filter UI have been repurposed into a "Query Builder" that seamlessly constructs and appends these tokens to your search string. +- **Smart Stacking for Bags:** The "Bags" dropdown toggle now features intelligent "Smart Stacking." When toggling a bag or pocket on, the system automatically clears out redundant parent or child location tokens to keep your query clean and focused while still supporting multi-bag searches. + +### Fixed +- **Disc Visuals:** Fixed a bug where the 'Speckled' secondary pattern would fail to render due to a hash calculation error. +- **Location Picker:** Fixed a z-index issue where the Location Picker dropdown menu in the Add/Edit forms would render behind the live Disc Preview. ## [0.6.0] - 2026-05-12 ### Added diff --git a/src/app/v/[vaultId]/page.tsx b/src/app/v/[vaultId]/page.tsx index 625c8b2..e670e81 100644 --- a/src/app/v/[vaultId]/page.tsx +++ b/src/app/v/[vaultId]/page.tsx @@ -27,6 +27,7 @@ import FloatingSearchButton from "@/components/FloatingSearchButton" import FilterPreserver from "@/components/FilterPreserver" import { getCategoryColors, getLocationTree } from "@/app/actions/settings" import { flattenTree } from "@/lib/locationTree" +import { parseSearchQuery } from "@/lib/searchParser" type SortField = 'name' | 'brand' | 'category' | 'speed' | 'glide' | 'turn' | 'fade' | 'createdAt' | 'plastic' | 'weight' | 'color' | 'stamp' | 'stampFoil' | 'location' | 'condition' | 'ink'; @@ -44,8 +45,6 @@ export default async function VaultDashboard({ const { vaultId } = await params const searchP = await searchParams - const category = typeof searchP.category === 'string' ? searchP.category : undefined - const brand = typeof searchP.brand === 'string' ? searchP.brand : undefined const view = typeof searchP.view === 'string' ? searchP.view : 'cards' const cookieStore = await cookies() const defaultSortBy = cookieStore.get('discVaultSortBy')?.value || 'createdAt' @@ -58,32 +57,12 @@ export default async function VaultDashboard({ const colsParam = typeof searchP.cols === 'string' ? searchP.cols.split(',') : cookieCols const visibleCols = colsParam.includes('name') ? colsParam : ['name', ...colsParam] const searchQuery = typeof searchP.search === 'string' ? searchP.search : undefined - const inBagParam = typeof searchP.inBag === 'string' ? searchP.inBag : undefined const userFlightCookie = cookieStore.get('discVaultUserFlightNum')?.value === 'true' const useUserFlightNumbers = typeof searchP.useUserFlightNumbers === 'string' ? searchP.useUserFlightNumbers === 'true' : userFlightCookie - const minSpeed = searchP.minSpeed ? parseFloat(searchP.minSpeed as string) : undefined - const maxSpeed = searchP.maxSpeed ? parseFloat(searchP.maxSpeed as string) : undefined - const minGlide = searchP.minGlide ? parseFloat(searchP.minGlide as string) : undefined - const maxGlide = searchP.maxGlide ? parseFloat(searchP.maxGlide as string) : undefined - const minTurn = searchP.minTurn ? parseFloat(searchP.minTurn as string) : undefined - const maxTurn = searchP.maxTurn ? parseFloat(searchP.maxTurn as string) : undefined - const minFade = searchP.minFade ? parseFloat(searchP.minFade as string) : undefined - const maxFade = searchP.maxFade ? parseFloat(searchP.maxFade as string) : undefined - const minWeight = searchP.minWeight ? parseFloat(searchP.minWeight as string) : undefined - const maxWeight = searchP.maxWeight ? parseFloat(searchP.maxWeight as string) : undefined - const minCond = searchP.minCond ? parseInt(searchP.minCond as string) : undefined - const maxCond = searchP.maxCond ? parseInt(searchP.maxCond as string) : undefined - const inkFilter = searchP.ink as string | undefined - const plasticFilter = typeof searchP.plastic === 'string' ? searchP.plastic : undefined - const colorFilter = typeof searchP.color === 'string' ? searchP.color : undefined - const stampFilter = typeof searchP.stamp === 'string' ? searchP.stamp : undefined - const stampFoilFilter = typeof searchP.stampFoil === 'string' ? searchP.stampFoil : undefined - const locationsFilter = typeof searchP.locations === 'string' ? searchP.locations.split(',').filter(Boolean) : [] - const pageSize = 24 const page = typeof searchP.page === 'string' ? Math.max(1, parseInt(searchP.page)) : 1 @@ -99,93 +78,7 @@ export default async function VaultDashboard({ return { mold: { [field]: order } } } - const andConditions: any[] = [] - - if (searchQuery) { - andConditions.push({ - OR: [ - { mold: { name: { contains: searchQuery } } }, - { mold: { brand: { contains: searchQuery } } }, - { mold: { category: { contains: searchQuery } } }, - { plastic: { contains: searchQuery } }, - { color: { contains: searchQuery } }, - { stamp: { contains: searchQuery } }, - { stampFoil: { contains: searchQuery } }, - { location: { contains: searchQuery } }, - { notes: { contains: searchQuery } }, - ] - }) - } - - const addNullableMultiSelect = (field: string, filterStr?: string) => { - if (!filterStr) return; - const values = filterStr.split(',').filter(Boolean); - if (values.length === 0) return; - const hasNotDefined = values.includes('Not Defined'); - const valid = values.filter(v => v !== 'Not Defined'); - if (hasNotDefined) { - const orConditions: any[] = [{ [field]: null }, { [field]: '' }]; - if (valid.length > 0) { - orConditions.push({ [field]: { in: valid } }); - } - andConditions.push({ OR: orConditions }); - } else { - andConditions.push({ [field]: { in: values } }); - } - } - - addNullableMultiSelect('plastic', plasticFilter); - addNullableMultiSelect('color', colorFilter); - addNullableMultiSelect('stamp', stampFilter); - addNullableMultiSelect('stampFoil', stampFoilFilter); - - // Bag / Location logic - if (inBagParam === 'true') { - if (bagPaths.length > 0) { - andConditions.push({ - OR: bagPaths.flatMap(p => [ - { location: p }, - { location: { startsWith: p + '/' } } - ]) - }) - } else { - andConditions.push({ id: 'none' }) - } - } else if (inBagParam === 'false') { - if (bagPaths.length > 0) { - andConditions.push({ - NOT: { OR: bagPaths.flatMap(p => [ - { location: p }, - { location: { startsWith: p + '/' } } - ])} - }) - } - } else if (inBagParam) { - andConditions.push({ - OR: [ - { location: inBagParam }, - { location: { startsWith: inBagParam + '/' } } - ] - }) - } else if (locationsFilter.length > 0) { - andConditions.push({ location: { in: locationsFilter } }) - } - - const whereClause: any = { - collectionId: vaultId, - weight: (minWeight !== undefined || maxWeight !== undefined) ? { gte: minWeight, lte: maxWeight } : undefined, - condition: (minCond !== undefined || maxCond !== undefined) ? { gte: minCond, lte: maxCond } : undefined, - ink: inkFilter === 'none' ? { equals: 'None' } : inkFilter === 'exists' ? { not: 'None' } : undefined, - mold: { - brand: brand ? { in: brand.split(',') } : undefined, - category: category ? { in: category.split(',') } : undefined, - speed: (minSpeed !== undefined || maxSpeed !== undefined) ? { gte: minSpeed, lte: maxSpeed } : undefined, - glide: (minGlide !== undefined || maxGlide !== undefined) ? { gte: minGlide, lte: maxGlide } : undefined, - turn: (minTurn !== undefined || maxTurn !== undefined) ? { gte: minTurn, lte: maxTurn } : undefined, - fade: (minFade !== undefined || maxFade !== undefined) ? { gte: minFade, lte: maxFade } : undefined, - }, - AND: andConditions.length > 0 ? andConditions : undefined, - } + const whereClause = parseSearchQuery(searchQuery || '', vaultId, bagPaths); const [ inventoryBrands, inventoryCategories, @@ -219,7 +112,7 @@ export default async function VaultDashboard({ const locationsList = flatLocs.map(f => f.value) - const activeFiltersCount = (category ? category.split(',').length : 0) + (brand ? brand.split(',').length : 0) + (searchQuery ? 1 : 0) + (inBagParam ? 1 : 0) + Object.values(searchP).filter(v => v !== undefined && v !== '').length - (searchP.view ? 1 : 0) - (searchP.sortBy ? 1 : 0) - (searchP.sortOrder ? 1 : 0) - (searchP.cols ? 1 : 0) - (searchP.page ? 1 : 0) + const activeFiltersCount = (searchQuery ? 1 : 0) + Object.values(searchP).filter(v => v !== undefined && v !== '').length - (searchP.view ? 1 : 0) - (searchP.sortBy ? 1 : 0) - (searchP.sortOrder ? 1 : 0) - (searchP.cols ? 1 : 0) - (searchP.page ? 1 : 0) @@ -237,30 +130,14 @@ export default async function VaultDashboard({ collections={collections as any} availableLocations={locationsList} currentCollectionIds={[vaultId]} - currentCategory={category} - currentBrand={brand} currentView={view} searchQuery={searchQuery} - currentBag={inBagParam} bagOptions={bagOptions} - advancedFilters={{ - minSpeed, maxSpeed, - minGlide, maxGlide, - minTurn, maxTurn, - minFade, maxFade, - minWeight, maxWeight, - minCond, maxCond, - ink: inkFilter, - plastic: plasticFilter, - color: colorFilter, - stamp: stampFilter, - locations: locationsFilter.length ? locationsFilter.join(',') : undefined, - }} - sortBy={sortBy} - sortOrder={sortOrder} - visibleColumns={visibleCols} - useUserFlightNumbers={useUserFlightNumbers} - /> + sortBy={sortBy} + sortOrder={sortOrder} + visibleColumns={visibleCols} + useUserFlightNumbers={useUserFlightNumbers} + />
diff --git a/src/app/v/all/page.tsx b/src/app/v/all/page.tsx index 0809a98..f00ebea 100644 --- a/src/app/v/all/page.tsx +++ b/src/app/v/all/page.tsx @@ -25,6 +25,7 @@ import InventoryInfiniteList from "@/components/InventoryInfiniteList" import FilterPreserver from "@/components/FilterPreserver" import { getLocationTree } from "@/app/actions/settings" import { flattenTree, type LocationNode } from "@/lib/locationTree" +import { parseSearchQuery } from "@/lib/searchParser" type SortField = 'name' | 'brand' | 'category' | 'speed' | 'glide' | 'turn' | 'fade' | 'createdAt' | 'plastic' | 'weight' | 'color' | 'stamp' | 'stampFoil' | 'location' | 'condition' | 'ink'; type SortOrder = 'asc' | 'desc'; @@ -38,8 +39,6 @@ export default async function AllVaultsDashboard({ }) { const searchP = await searchParams - const category = typeof searchP.category === 'string' ? searchP.category : undefined - const brand = typeof searchP.brand === 'string' ? searchP.brand : undefined const view = typeof searchP.view === 'string' ? searchP.view : 'cards' const cookieStore = await cookies() const defaultSortBy = cookieStore.get('discVaultSortBy')?.value || 'createdAt' @@ -52,7 +51,6 @@ export default async function AllVaultsDashboard({ const colsParam = typeof searchP.cols === 'string' ? searchP.cols.split(',') : cookieCols const visibleCols = colsParam.includes('name') ? colsParam : ['name', ...colsParam] const searchQuery = typeof searchP.search === 'string' ? searchP.search : undefined - const inBagParam = typeof searchP.inBag === 'string' ? searchP.inBag : undefined const selectedCollectionIds = typeof searchP.collections === 'string' ? searchP.collections.split(',') : [] const userFlightCookie = cookieStore.get('discVaultUserFlightNum')?.value === 'true' @@ -60,25 +58,6 @@ export default async function AllVaultsDashboard({ ? searchP.useUserFlightNumbers === 'true' : userFlightCookie - const minSpeed = searchP.minSpeed ? parseFloat(searchP.minSpeed as string) : undefined - const maxSpeed = searchP.maxSpeed ? parseFloat(searchP.maxSpeed as string) : undefined - const minGlide = searchP.minGlide ? parseFloat(searchP.minGlide as string) : undefined - const maxGlide = searchP.maxGlide ? parseFloat(searchP.maxGlide as string) : undefined - const minTurn = searchP.minTurn ? parseFloat(searchP.minTurn as string) : undefined - const maxTurn = searchP.maxTurn ? parseFloat(searchP.maxTurn as string) : undefined - const minFade = searchP.minFade ? parseFloat(searchP.minFade as string) : undefined - const maxFade = searchP.maxFade ? parseFloat(searchP.maxFade as string) : undefined - const minWeight = searchP.minWeight ? parseFloat(searchP.minWeight as string) : undefined - const maxWeight = searchP.maxWeight ? parseFloat(searchP.maxWeight as string) : undefined - const minCond = searchP.minCond ? parseInt(searchP.minCond as string) : undefined - const maxCond = searchP.maxCond ? parseInt(searchP.maxCond as string) : undefined - const inkFilter = searchP.ink as string | undefined - const plasticFilter = typeof searchP.plastic === 'string' ? searchP.plastic : undefined - const colorFilter = typeof searchP.color === 'string' ? searchP.color : undefined - const stampFilter = typeof searchP.stamp === 'string' ? searchP.stamp : undefined - const stampFoilFilter = typeof searchP.stampFoil === 'string' ? searchP.stampFoil : undefined - const locationsFilter = typeof searchP.locations === 'string' ? searchP.locations.split(',').filter(Boolean) : [] - const pageSize = 24 const page = typeof searchP.page === 'string' ? Math.max(1, parseInt(searchP.page)) : 1 @@ -89,45 +68,7 @@ export default async function AllVaultsDashboard({ return { mold: { [field]: order } } } - const andConditions: any[] = [] - - if (searchQuery) { - andConditions.push({ - OR: [ - { mold: { name: { contains: searchQuery } } }, - { mold: { brand: { contains: searchQuery } } }, - { mold: { category: { contains: searchQuery } } }, - { plastic: { contains: searchQuery } }, - { color: { contains: searchQuery } }, - { stamp: { contains: searchQuery } }, - { stampFoil: { contains: searchQuery } }, - { location: { contains: searchQuery } }, - { notes: { contains: searchQuery } }, - ] - }) - } - - const addNullableMultiSelect = (field: string, filterStr?: string) => { - if (!filterStr) return; - const values = filterStr.split(',').filter(Boolean); - if (values.length === 0) return; - const hasNotDefined = values.includes('Not Defined'); - const valid = values.filter(v => v !== 'Not Defined'); - if (hasNotDefined) { - const orConditions: any[] = [{ [field]: null }, { [field]: '' }]; - if (valid.length > 0) { - orConditions.push({ [field]: { in: valid } }); - } - andConditions.push({ OR: orConditions }); - } else { - andConditions.push({ [field]: { in: values } }); - } - } - addNullableMultiSelect('plastic', plasticFilter); - addNullableMultiSelect('color', colorFilter); - addNullableMultiSelect('stamp', stampFilter); - addNullableMultiSelect('stampFoil', stampFoilFilter); const baseColWhere = selectedCollectionIds.length > 0 ? { collectionId: { in: selectedCollectionIds } } : {} @@ -144,52 +85,9 @@ export default async function AllVaultsDashboard({ const bagOptions = Array.from(new Map(allBags.map(b => [b.value, b])).values()) const bagPaths = bagOptions.map(b => b.value) - // Bag / Location logic - if (inBagParam === 'true') { - if (bagPaths.length > 0) { - andConditions.push({ - OR: bagPaths.flatMap(p => [ - { location: p }, - { location: { startsWith: p + '/' } } - ]) - }) - } else { - andConditions.push({ id: 'none' }) - } - } else if (inBagParam === 'false') { - if (bagPaths.length > 0) { - andConditions.push({ - NOT: { OR: bagPaths.flatMap(p => [ - { location: p }, - { location: { startsWith: p + '/' } } - ])} - }) - } - } else if (inBagParam) { - andConditions.push({ - OR: [ - { location: inBagParam }, - { location: { startsWith: inBagParam + '/' } } - ] - }) - } else if (locationsFilter.length > 0) { - andConditions.push({ location: { in: locationsFilter } }) - } - - const whereClause: any = { - collectionId: selectedCollectionIds.length > 0 ? { in: selectedCollectionIds } : undefined, - weight: (minWeight !== undefined || maxWeight !== undefined) ? { gte: minWeight, lte: maxWeight } : undefined, - condition: (minCond !== undefined || maxCond !== undefined) ? { gte: minCond, lte: maxCond } : undefined, - ink: inkFilter === 'none' ? { equals: 'None' } : inkFilter === 'exists' ? { not: 'None' } : undefined, - mold: { - brand: brand ? { in: brand.split(',') } : undefined, - category: category ? { in: category.split(',') } : undefined, - speed: (minSpeed !== undefined || maxSpeed !== undefined) ? { gte: minSpeed, lte: maxSpeed } : undefined, - glide: (minGlide !== undefined || maxGlide !== undefined) ? { gte: minGlide, lte: maxGlide } : undefined, - turn: (minTurn !== undefined || maxTurn !== undefined) ? { gte: minTurn, lte: maxTurn } : undefined, - fade: (minFade !== undefined || maxFade !== undefined) ? { gte: minFade, lte: maxFade } : undefined, - }, - AND: andConditions.length > 0 ? andConditions : undefined, + const whereClause: any = parseSearchQuery(searchQuery || '', 'all', bagPaths); + if (selectedCollectionIds.length > 0) { + whereClause.collectionId = { in: selectedCollectionIds }; } const [ @@ -222,7 +120,7 @@ export default async function AllVaultsDashboard({ const stampFoilsList = ['Not Defined', ...Array.from(new Set(inventoryStampFoils.map((i: any) => i.stampFoil))).filter(Boolean).sort() as string[]] const locationsList = Array.from(new Set(bagOptions.map(b => b.value))).sort() // Fallback or aggregate list - const activeFiltersCount = (category ? category.split(',').length : 0) + (brand ? brand.split(',').length : 0) + (searchQuery ? 1 : 0) + (inBagParam ? 1 : 0) + selectedCollectionIds.length + Object.values(searchP).filter(v => v !== undefined && v !== '').length - (searchP.view ? 1 : 0) - (searchP.sortBy ? 1 : 0) - (searchP.sortOrder ? 1 : 0) - (searchP.cols ? 1 : 0) - (searchP.page ? 1 : 0) - (searchP.collections ? 1 : 0) + const activeFiltersCount = (searchQuery ? 1 : 0) + selectedCollectionIds.length + Object.values(searchP).filter(v => v !== undefined && v !== '').length - (searchP.view ? 1 : 0) - (searchP.sortBy ? 1 : 0) - (searchP.sortOrder ? 1 : 0) - (searchP.cols ? 1 : 0) - (searchP.page ? 1 : 0) - (searchP.collections ? 1 : 0) return (
@@ -242,25 +140,9 @@ export default async function AllVaultsDashboard({ collections={collections as any} availableLocations={locationsList} currentCollectionIds={selectedCollectionIds} - currentCategory={category} - currentBrand={brand} currentView={view} searchQuery={searchQuery} - currentBag={inBagParam} bagOptions={bagOptions} - advancedFilters={{ - minSpeed, maxSpeed, - minGlide, maxGlide, - minTurn, maxTurn, - minFade, maxFade, - minWeight, maxWeight, - minCond, maxCond, - ink: inkFilter, - plastic: plasticFilter, - color: colorFilter, - stamp: stampFilter, - locations: locationsFilter.length ? locationsFilter.join(',') : undefined, - }} sortBy={sortBy} sortOrder={sortOrder} visibleColumns={visibleCols} diff --git a/src/components/AddDiscForm.tsx b/src/components/AddDiscForm.tsx index ab41af4..3bd1c9b 100644 --- a/src/components/AddDiscForm.tsx +++ b/src/components/AddDiscForm.tsx @@ -250,7 +250,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) {
)} -
+
-
+
import('./LocationTreePicker'), { ssr: false }) -export interface AdvancedFilters { - minSpeed?: number | string - maxSpeed?: number | string - minGlide?: number | string - maxGlide?: number | string - minTurn?: number | string - maxTurn?: number | string - minFade?: number | string - maxFade?: number | string - minWeight?: number | string - maxWeight?: number | string - minCond?: number | string - maxCond?: number | string - ink?: string - plastic?: string - color?: string - stamp?: string - stampFoil?: string - // locations stored as comma-separated string in URL - locations?: string +interface AdvancedSearchProps { + categories: string[] + brands: string[] + plastics: string[] + colors: string[] + stamps: string[] + stampFoils: string[] + availableLocations: string[] + initialQuery?: string + onAppendSearch: (query: string) => void + onClose: () => void } -export const ADVANCED_KEYS: (keyof AdvancedFilters)[] = [ - 'minSpeed', 'maxSpeed', 'minGlide', 'maxGlide', 'minTurn', 'maxTurn', - 'minFade', 'maxFade', 'minWeight', 'maxWeight', 'minCond', 'maxCond', - 'ink', 'plastic', 'color', 'stamp', 'stampFoil', 'locations' -] - const allowNumeric = (e: React.KeyboardEvent) => { if (['Backspace','Delete','Tab','Enter','ArrowLeft','ArrowRight','-','.'].includes(e.key)) return if (!/[\d]/.test(e.key)) e.preventDefault() @@ -59,53 +28,70 @@ const allowNumeric = (e: React.KeyboardEvent) => { const inputCls = "w-full px-3 py-2 bg-slate-50 border border-slate-100 rounded-xl text-xs font-black outline-none focus:ring-2 focus:ring-indigo-100 focus:border-indigo-200 transition-all text-slate-900 placeholder:text-slate-300 placeholder:font-medium" const labelCls = "text-[10px] font-black text-slate-400 uppercase tracking-widest" -interface AdvancedSearchProps { - filters: AdvancedFilters - availableLocations: string[] - plastics: string[] - colors: string[] - stamps: string[] - stampFoils: string[] - onClose: () => void -} - export default function AdvancedSearch({ - filters, availableLocations, + categories, brands, plastics, colors, stamps, stampFoils, - onClose + availableLocations, + initialQuery, + onAppendSearch, onClose }: AdvancedSearchProps) { - const router = useRouter() - const searchParams = useSearchParams() - const pathname = usePathname() - const [localFilters, setLocalFilters] = useState(filters) + const [localState, setLocalState] = useState>({}) - // Parse current selected locations from comma-separated string - const selectedLocations = localFilters.locations ? localFilters.locations.split(',').filter(Boolean) : [] + useEffect(() => { + setLocalState(extractSearchTokens(initialQuery || '')) + }, [initialQuery]) - const handleUpdate = (key: keyof AdvancedFilters, value: string | number | undefined) => { - setLocalFilters(prev => ({ ...prev, [key]: value })) + const handleUpdate = (key: string, value: any) => { + setLocalState(prev => ({ ...prev, [key]: value })) } const applyFilters = () => { - const params = new URLSearchParams(searchParams.toString()) - Object.entries(localFilters).forEach(([key, value]) => { - if (value !== undefined && value !== '') { - params.set(key, value.toString()) - } else { - params.delete(key) + const tokens: string[] = [] + + const addRange = (minKey: string, maxKey: string, field: string) => { + const min = localState[minKey]; + const max = localState[maxKey]; + if (min && max) tokens.push(`${field}:${min}-${max}`) + else if (min) tokens.push(`${field}:>=${min}`) + else if (max) tokens.push(`${field}:<=${max}`) + } + + addRange('minSpeed', 'maxSpeed', 'speed') + addRange('minGlide', 'maxGlide', 'glide') + addRange('minTurn', 'maxTurn', 'turn') + addRange('minFade', 'maxFade', 'fade') + addRange('minWeight', 'maxWeight', 'weight') + addRange('minCond', 'maxCond', 'condition') + + const addMulti = (key: string, field: string) => { + if (localState[key]) { + localState[key].split(',').filter(Boolean).forEach((val: string) => { + tokens.push(`${field}:"${val}"`) + }) } - }) - params.set('page', '1') - router.push(`${pathname}?${params.toString()}`) - onClose() - } + } + + addMulti('category', 'category') + addMulti('brand', 'brand') + addMulti('plastic', 'plastic') + addMulti('color', 'color') + addMulti('stamp', 'stamp') + addMulti('stampFoil', 'foil') + + if (localState.locations) { + localState.locations.split(',').filter(Boolean).forEach((val: string) => { + tokens.push(`location:"${val}"`) + }) + } + + if (localState.ink && localState.ink !== 'any') { + tokens.push(`ink:${localState.ink}`) + } - const resetFilters = () => { - const params = new URLSearchParams(searchParams.toString()) - ADVANCED_KEYS.forEach(k => params.delete(k)) - router.push(`${pathname}?${params.toString()}`) - setLocalFilters({}) + if (tokens.length > 0) { + onAppendSearch(tokens.join(' ')) + } onClose() } @@ -114,16 +100,44 @@ export default function AdvancedSearch({
-

Advanced Filters

+

Query Builder

-
- {/* Flight Numbers */} +
+ {/* Core Properties */}
+

Core Properties

+
+
+ + handleUpdate('category', vals.length ? vals.join(',') : undefined)} + placeholder="All Categories" + /> +
+
+ + handleUpdate('brand', vals.length ? vals.join(',') : undefined)} + placeholder="All Brands" + align="right" + /> +
+
+
+ + {/* Flight Numbers */} +

Flight Numbers

{([ @@ -131,17 +145,17 @@ export default function AdvancedSearch({ ['Glide', 'minGlide', 'maxGlide'], ['Turn', 'minTurn', 'maxTurn'], ['Fade', 'minFade', 'maxFade'], - ] as [string, keyof AdvancedFilters, keyof AdvancedFilters][]).map(([label, minKey, maxKey]) => ( + ] as [string, string, string][]).map(([label, minKey, maxKey]) => (
handleUpdate(minKey, e.target.value !== '' ? e.target.value : undefined)} className={inputCls} /> handleUpdate(maxKey, e.target.value !== '' ? e.target.value : undefined)} className={inputCls} />
@@ -157,17 +171,17 @@ export default function AdvancedSearch({ {([ ['Weight (g)', 'minWeight', 'maxWeight'], ['Condition', 'minCond', 'maxCond'], - ] as [string, keyof AdvancedFilters, keyof AdvancedFilters][]).map(([label, minKey, maxKey]) => ( + ] as [string, string, string][]).map(([label, minKey, maxKey]) => (
handleUpdate(minKey, e.target.value !== '' ? e.target.value : undefined)} className={inputCls} /> handleUpdate(maxKey, e.target.value !== '' ? e.target.value : undefined)} className={inputCls} />
@@ -178,7 +192,7 @@ export default function AdvancedSearch({ handleUpdate('plastic', vals.length ? vals.join(',') : undefined)} placeholder="All Plastics" /> @@ -188,9 +202,10 @@ export default function AdvancedSearch({ handleUpdate('color', vals.length ? vals.join(',') : undefined)} placeholder="All Colors" + align="right" />
@@ -198,7 +213,7 @@ export default function AdvancedSearch({ handleUpdate('stamp', vals.length ? vals.join(',') : undefined)} placeholder="All Stamps" /> @@ -208,9 +223,10 @@ export default function AdvancedSearch({ handleUpdate('stampFoil', vals.length ? vals.join(',') : undefined)} placeholder="All Foils" + align="right" />
@@ -218,14 +234,14 @@ export default function AdvancedSearch({ {availableLocations.length > 0 && (
- {selectedLocations.length > 0 && ( + {(localState.locations ? localState.locations.split(',').filter(Boolean) : []).length > 0 && (
- {selectedLocations.map(loc => ( + {(localState.locations ? localState.locations.split(',').filter(Boolean) : []).map((loc: string) => ( {loc.split('/').pop()} ))} @@ -233,8 +249,8 @@ export default function AdvancedSearch({ )} setLocalFilters(prev => ({ ...prev, locations: locs.length ? locs.join(',') : undefined }))} + selectedLocations={(localState.locations ? localState.locations.split(',').filter(Boolean) : [])} + onChange={(locs) => setLocalState(prev => ({ ...prev, locations: locs.length ? locs.join(',') : undefined }))} />
)} @@ -249,7 +265,7 @@ export default function AdvancedSearch({
-
diff --git a/src/components/AutocompleteSearch.tsx b/src/components/AutocompleteSearch.tsx new file mode 100644 index 0000000..76eebdc --- /dev/null +++ b/src/components/AutocompleteSearch.tsx @@ -0,0 +1,256 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' +import { Search } from 'lucide-react' + +const SYNTAX_KEYS = [ + { key: 'brand:', desc: 'Filter by brand (e.g. brand:innova)' }, + { key: 'mold:', desc: 'Filter by mold (e.g. mold:buzzz)' }, + { key: 'category:', desc: 'Filter by category (e.g. category:distance)' }, + { key: 'plastic:', desc: 'Filter by plastic (e.g. plastic:star)' }, + { key: 'color:', desc: 'Filter by color (e.g. color:blue)' }, + { key: 'stamp:', desc: 'Filter by stamp (e.g. stamp:stock)' }, + { key: 'foil:', desc: 'Filter by foil type' }, + { key: 'speed:', desc: 'Filter by speed (e.g. speed:>9, speed:9-11)' }, + { key: 'glide:', desc: 'Filter by glide' }, + { key: 'turn:', desc: 'Filter by turn' }, + { key: 'fade:', desc: 'Filter by fade' }, + { key: 'weight:', desc: 'Filter by weight (e.g. weight:170-175)' }, + { key: 'condition:', desc: 'Filter by condition (e.g. condition:10)' }, + { key: 'location:', desc: 'Filter by location (e.g. location:main)' }, + { key: 'bag:', desc: 'In bag? (e.g. bag:true)' }, +] + +interface AutocompleteSearchProps { + value: string + onChange: (val: string) => void + categories: string[] + brands: string[] + plastics: string[] + colors: string[] + stamps: string[] + stampFoils: string[] + availableLocations: string[] +} + +interface Suggestion { + value: string + label: string + desc?: string +} + +export default function AutocompleteSearch({ + value, + onChange, + categories, + brands, + plastics, + colors, + stamps, + stampFoils, + availableLocations +}: AutocompleteSearchProps) { + const [isOpen, setIsOpen] = useState(false) + const [suggestions, setSuggestions] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(0) + + const containerRef = useRef(null) + const inputRef = useRef(null) + + // Map prefix to its corresponding array for value suggestions + const arrayMap: Record = { + brand: brands, + company: brands, + category: categories, + type: categories, + plastic: plastics, + color: colors, + stamp: stamps, + foil: stampFoils, + stampfoil: stampFoils, + location: availableLocations, + } + + // Parses the query up to the cursor to find the current active token being typed + const getCurrentTokenInfo = () => { + if (!inputRef.current) return null + const cursor = inputRef.current.selectionStart || value.length + const textBeforeCursor = value.substring(0, cursor) + + // Find the start of the current token (split by spaces, but ignoring quotes - simplified here) + const parts = textBeforeCursor.split(/\s+/) + const currentToken = parts[parts.length - 1] + const tokenStartIdx = textBeforeCursor.lastIndexOf(currentToken) + + return { currentToken, tokenStartIdx, tokenEndIdx: cursor } + } + + useEffect(() => { + if (!isOpen) return + + const info = getCurrentTokenInfo() + if (!info) return + + const { currentToken } = info + + if (!currentToken) { + // Empty state: show all keys + setSuggestions(SYNTAX_KEYS.map(k => ({ value: k.key, label: k.key, desc: k.desc }))) + setSelectedIndex(0) + return + } + + if (currentToken.includes(':')) { + // Suggesting values for a key + const [prefix, partialValue] = currentToken.split(':') + const lowerPrefix = prefix.toLowerCase() + + if (lowerPrefix === 'bag' || lowerPrefix === 'inbag') { + const bools = ['true', 'false'] + const matches = bools.filter(b => b.startsWith(partialValue.toLowerCase())) + setSuggestions(matches.map(m => ({ value: `${prefix}:${m}`, label: m }))) + } else if (arrayMap[lowerPrefix]) { + const options = arrayMap[lowerPrefix] + const lowerPartial = partialValue.toLowerCase() + const matches = options.filter(opt => opt.toLowerCase().includes(lowerPartial)) + setSuggestions(matches.map(m => ({ value: `${prefix}:"${m}"`, label: m }))) + } else { + setSuggestions([]) + } + } else { + // Typing a key prefix + const lowerToken = currentToken.toLowerCase() + const matches = SYNTAX_KEYS.filter(k => k.key.startsWith(lowerToken)) + + // If no matches, assume they are just doing a plain text search + if (matches.length === 0) { + if (currentToken.length <= 2) { + setSuggestions(SYNTAX_KEYS.map(k => ({ value: k.key, label: k.key, desc: k.desc }))) + setSelectedIndex(-1) + return + } else { + setSuggestions([]) + } + } else { + // Show all matches in scrollable dropdown + setSuggestions(matches.map(k => ({ value: k.key, label: k.key, desc: k.desc }))) + } + } + setSelectedIndex(0) + }, [value, isOpen]) + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + // Ensure the selected item is always scrolled into view + useEffect(() => { + if (isOpen && suggestions.length > 0) { + const el = document.getElementById(`suggestion-${selectedIndex}`) + if (el) { + el.scrollIntoView({ block: 'nearest' }) + } + } + }, [selectedIndex, isOpen, suggestions.length]) + + const applySuggestion = (suggestion: Suggestion) => { + const info = getCurrentTokenInfo() + if (!info) return + + const { tokenStartIdx, tokenEndIdx } = info + const before = value.substring(0, tokenStartIdx) + const after = value.substring(tokenEndIdx) + + // If the suggestion doesn't end in a colon, it's a completed value (or full text token), so add a space + const suffix = suggestion.value.endsWith(':') ? '' : ' ' + const newValue = before + suggestion.value + suffix + after + + onChange(newValue) + + if (suggestion.value.endsWith(':')) { + // Keep dropdown open for the value + inputRef.current?.focus() + } else { + setIsOpen(false) + } + + // Set cursor position after the newly inserted token + setTimeout(() => { + if (inputRef.current) { + const newPos = tokenStartIdx + suggestion.value.length + suffix.length + inputRef.current.setSelectionRange(newPos, newPos) + inputRef.current.focus() + } + }, 0) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen || suggestions.length === 0) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(prev => (prev + 1) % suggestions.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(prev => (prev - 1 + suggestions.length) % suggestions.length) + } else if (e.key === 'Enter' || e.key === 'Tab' || e.key === ' ') { + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + e.preventDefault() + applySuggestion(suggestions[selectedIndex]) + } + } else if (e.key === 'Escape') { + setIsOpen(false) + } + } + + return ( +
+ onChange(e.target.value)} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + className="w-full pl-9 pr-4 py-2 border border-slate-200 rounded-xl text-xs font-black focus:ring-4 focus:ring-indigo-100 outline-none bg-slate-50 text-slate-900 transition-all placeholder:text-slate-400 placeholder:font-medium" + /> + + + {isOpen && suggestions.length > 0 && ( +
+
+ {suggestions.map((s, idx) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/src/components/DashboardToolbar.tsx b/src/components/DashboardToolbar.tsx index 8d88bd7..7e2aa61 100644 --- a/src/components/DashboardToolbar.tsx +++ b/src/components/DashboardToolbar.tsx @@ -20,9 +20,11 @@ import { useRouter, useSearchParams, usePathname } from 'next/navigation' import { Filter, LayoutGrid, List, Columns, Check, Search, Inbox, ChevronDown, Settings, SlidersHorizontal, ArrowUpDown, ArrowDown, ArrowUp, Briefcase } from 'lucide-react' import Link from 'next/link' import { useState, useRef, useEffect, useCallback } from 'react' -import AdvancedSearch, { type AdvancedFilters } from './AdvancedSearch' +import AdvancedSearch from './AdvancedSearch' import MobileFilterDrawer from './MobileFilterDrawer' +import AutocompleteSearch from './AutocompleteSearch' import MultiSelectDropdown from './MultiSelectDropdown' +import { extractSearchTokens } from '@/lib/searchParser' const SORT_OPTIONS = [ { id: 'createdAt', label: 'Date Added' }, @@ -52,13 +54,9 @@ interface DashboardToolbarProps { collections: Collection[] availableLocations: string[] currentCollectionIds: string[] - currentCategory?: string - currentBrand?: string currentView: string searchQuery?: string - currentBag?: string bagOptions?: { label: string, value: string }[] - advancedFilters: AdvancedFilters sortBy: string sortOrder: string visibleColumns: string[] @@ -92,13 +90,9 @@ export default function DashboardToolbar({ collections, availableLocations, currentCollectionIds, - currentCategory, - currentBrand, currentView, searchQuery, - currentBag, bagOptions = [], - advancedFilters, sortBy, sortOrder, visibleColumns, @@ -209,12 +203,53 @@ export default function DashboardToolbar({ updateParams({ collections: newIds.length > 0 ? newIds.join(',') : null }) } - const activeAdvancedCount = Object.values(advancedFilters).filter(v => v !== undefined && v !== '').length - - const currentCategoryArray = currentCategory ? currentCategory.split(',').filter(Boolean) : [] - const currentBrandArray = currentBrand ? currentBrand.split(',').filter(Boolean) : [] - - const activeFilterCount = currentCategoryArray.length + currentBrandArray.length + (currentBag ? 1 : 0) + activeAdvancedCount + const parsedSearch = extractSearchTokens(localSearch) + const isBagYes = parsedSearch.bag === 'yes' || parsedSearch.bag === 'true' || parsedSearch.bag === '1' + const isBagNo = parsedSearch.bag === 'no' || parsedSearch.bag === 'false' || parsedSearch.bag === '0' + const selectedBagLocations = parsedSearch.locations ? parsedSearch.locations.split(',') : [] + + const toggleBagToken = (type: 'yes' | 'no' | 'location', value?: string) => { + let current = localSearch + + if (type === 'location' && value) { + const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + // Allow flexible whitespace around slashes and in general + const flexValue = escapedValue.replace(/\\? /g, '\\s*').replace(/\//g, '\\s*\\/\\s*') + + const regex1 = new RegExp(`location:"${flexValue}"`, 'gi') + const regex2 = new RegExp(`location:${value.replace(/\s+/g, '')}`, 'gi') + + if (regex1.test(current) || regex2.test(current)) { + current = current.replace(regex1, '').replace(regex2, '') + } else { + // Smart Stacking: remove any parents or children before adding + const toRemove = selectedBagLocations.filter(loc => { + const normLoc = loc.replace(/\s+/g, '').toLowerCase() + const normVal = value.replace(/\s+/g, '').toLowerCase() + return normVal.startsWith(normLoc + '/') || normLoc.startsWith(normVal + '/') + }) + + toRemove.forEach(locToRemove => { + const escaped = locToRemove.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const flex = escaped.replace(/\\? /g, '\\s*').replace(/\//g, '\\s*\\/\\s*') + const r1 = new RegExp(`location:"${flex}"`, 'gi') + const r2 = new RegExp(`location:${locToRemove.replace(/\s+/g, '')}`, 'gi') + current = current.replace(r1, '').replace(r2, '') + }) + + current = `${current} location:"${value}"` + } + } else { + current = current.replace(/bag:(yes|no|true|false|1|0)/gi, '').replace(/inbag:(yes|no|true|false|1|0)/gi, '') + if (type === 'yes' && !isBagYes) current = `${current} bag:yes` + else if (type === 'no' && !isBagNo) current = `${current} bag:no` + } + + setLocalSearch(current.replace(/\s+/g, ' ').trim()) + setShowBagSelector(false) + } + + const activeFilterCount = localSearch ? 1 : 0 const isAllMode = pathname === '/v/all' return ( @@ -288,118 +323,99 @@ export default function DashboardToolbar({ {/* Main Toolbar */}
-
+
- Filter + Search
-
- setLocalSearch(e.target.value)} - className="w-full pl-9 pr-4 py-2 border border-slate-200 rounded-xl text-xs font-black focus:ring-4 focus:ring-indigo-100 outline-none bg-slate-50 text-slate-900 transition-all placeholder:text-slate-400 placeholder:font-medium" - /> - -
+
- {bagOptions.length > 0 &&
} - {bagOptions.map(bag => ( - - ))} + {bagOptions.map(bag => { + const isActive = selectedBagLocations.some(l => + l.replace(/\s+/g, '').toLowerCase() === bag.value.replace(/\s+/g, '').toLowerCase() + ) + return ( + + ) + })}
{showAdvanced && ( setLocalSearch(prev => (prev ? prev + ' ' + str : str))} onClose={() => setShowAdvanced(false)} /> )}
- -
- updateParams({ category: vals.length ? vals.join(',') : null })} - placeholder="Categories" - /> -
- -
- updateParams({ brand: vals.length ? vals.join(',') : null })} - placeholder="Brands" - /> -
) diff --git a/src/components/DiscDetailView.tsx b/src/components/DiscDetailView.tsx index 4e094ce..33c7e50 100644 --- a/src/components/DiscDetailView.tsx +++ b/src/components/DiscDetailView.tsx @@ -195,14 +195,13 @@ export default function DiscDetailView({ disc, categoryColors, vaultId, bagPaths {/* Action Buttons */}
- + { hash = (hash * 16807) % 2147483647 diff --git a/src/components/EditDiscForm.tsx b/src/components/EditDiscForm.tsx index 4bf5232..c3cc90b 100644 --- a/src/components/EditDiscForm.tsx +++ b/src/components/EditDiscForm.tsx @@ -98,7 +98,8 @@ export default function EditDiscForm({ disc, collections }: Omit { await updateDisc(disc.id, formData) - router.back() + const targetVault = formData.get('collectionId') as string || 'all' + router.push(`/v/${targetVault}/inventory/${disc.id}`) } const handleDelete = async () => { @@ -150,7 +151,7 @@ export default function EditDiscForm({ disc, collections }: Omit
-
+
-
+
import('./LocationTreePicker'), { ssr: false }) @@ -18,11 +17,8 @@ interface MobileFilterDrawerProps { stamps: string[] stampFoils: string[] availableLocations: string[] - currentCategory?: string - currentBrand?: string - currentBag?: string - bagOptions: { label: string, value: string }[] - advancedFilters: AdvancedFilters + initialQuery?: string + onAppendSearch: (str: string) => void } const allowNumeric = (e: React.KeyboardEvent) => { @@ -43,29 +39,16 @@ export default function MobileFilterDrawer({ stamps, stampFoils, availableLocations, - currentCategory, - currentBrand, - currentBag, - bagOptions, - advancedFilters, + initialQuery, + onAppendSearch }: MobileFilterDrawerProps) { - const router = useRouter() - const searchParams = useSearchParams() - const pathname = usePathname() - - const [localCategory, setLocalCategory] = useState(currentCategory) - const [localBrand, setLocalBrand] = useState(currentBrand) - const [localBag, setLocalBag] = useState(currentBag) - const [localAdvanced, setLocalAdvanced] = useState(advancedFilters) + const [localState, setLocalState] = useState>({}) const [prevIsOpen, setPrevIsOpen] = useState(isOpen) if (isOpen !== prevIsOpen) { setPrevIsOpen(isOpen) if (isOpen) { - setLocalCategory(currentCategory) - setLocalBrand(currentBrand) - setLocalBag(currentBag) - setLocalAdvanced(advancedFilters) + setLocalState(extractSearchTokens(initialQuery || '')) } } @@ -80,57 +63,64 @@ export default function MobileFilterDrawer({ if (!isOpen) return null - const handleUpdateAdvanced = (key: keyof AdvancedFilters, value: string | number | undefined) => { - setLocalAdvanced(prev => ({ ...prev, [key]: value })) - } - - const selectedLocations = localAdvanced.locations ? localAdvanced.locations.split(',').filter(Boolean) : [] - - const toggleLocation = (loc: string) => { - const next = selectedLocations.includes(loc) - ? selectedLocations.filter(l => l !== loc) - : [...selectedLocations, loc] - setLocalAdvanced(prev => ({ ...prev, locations: next.length ? next.join(',') : undefined })) + const handleUpdate = (key: string, value: any) => { + setLocalState(prev => ({ ...prev, [key]: value })) } const applyFilters = () => { - const params = new URLSearchParams(searchParams.toString()) - - if (localCategory) params.set('category', localCategory) - else params.delete('category') - - if (localBrand) params.set('brand', localBrand) - else params.delete('brand') + const tokens: string[] = [] + + const addRange = (minKey: string, maxKey: string, field: string) => { + const min = localState[minKey]; + const max = localState[maxKey]; + if (min && max) tokens.push(`${field}:${min}-${max}`) + else if (min) tokens.push(`${field}:>=${min}`) + else if (max) tokens.push(`${field}:<=${max}`) + } - if (localBag) params.set('inBag', localBag) - else params.delete('inBag') + addRange('minSpeed', 'maxSpeed', 'speed') + addRange('minGlide', 'maxGlide', 'glide') + addRange('minTurn', 'maxTurn', 'turn') + addRange('minFade', 'maxFade', 'fade') + addRange('minWeight', 'maxWeight', 'weight') + addRange('minCond', 'maxCond', 'condition') - ADVANCED_KEYS.forEach(key => { - const val = localAdvanced[key] - if (val !== undefined && val !== '') { - params.set(key, val.toString()) - } else { - params.delete(key) + const addMulti = (key: string, field: string) => { + if (localState[key]) { + localState[key].split(',').filter(Boolean).forEach((val: string) => { + tokens.push(`${field}:"${val}"`) + }) } - }) + } + + addMulti('category', 'category') + addMulti('brand', 'brand') + addMulti('plastic', 'plastic') + addMulti('color', 'color') + addMulti('stamp', 'stamp') + addMulti('stampFoil', 'foil') + + if (localState.locations) { + localState.locations.split(',').filter(Boolean).forEach((val: string) => { + tokens.push(`location:"${val}"`) + }) + } - params.set('page', '1') - router.push(`${pathname}?${params.toString()}`) + if (localState.ink && localState.ink !== 'any') { + tokens.push(`ink:${localState.ink}`) + } + + if (tokens.length > 0) { + onAppendSearch(tokens.join(' ')) + } onClose() } const resetFilters = () => { - setLocalCategory(undefined) - setLocalBrand(undefined) - setLocalBag(undefined) - setLocalAdvanced({}) + setLocalState({}) } - const activeCount = - (localCategory ? 1 : 0) + - (localBrand ? 1 : 0) + - (localBag ? 1 : 0) + - Object.values(localAdvanced).filter(v => v !== undefined && v !== '').length + const activeCount = Object.keys(localState).length return (
@@ -138,7 +128,7 @@ export default function MobileFilterDrawer({
-

Filters

+

Query Builder

{activeCount > 0 && ( {activeCount} Active @@ -152,40 +142,6 @@ export default function MobileFilterDrawer({ {/* Scrollable Content */}
- {/* Quick Filters */} -
-
- - Bag Select -
-
- - - {bagOptions.map(bag => ( - - ))} -
-
- {/* Categories */}
@@ -195,8 +151,8 @@ export default function MobileFilterDrawer({ setLocalCategory(vals.length ? vals.join(',') : undefined)} + selectedValues={(localState.category || '').split(',').filter(Boolean)} + onChange={(vals) => handleUpdate('category', vals.length ? vals.join(',') : undefined)} placeholder="All Categories" />
@@ -210,8 +166,8 @@ export default function MobileFilterDrawer({ setLocalBrand(vals.length ? vals.join(',') : undefined)} + selectedValues={(localState.brand || '').split(',').filter(Boolean)} + onChange={(vals) => handleUpdate('brand', vals.length ? vals.join(',') : undefined)} placeholder="All Brands" />
@@ -220,22 +176,22 @@ export default function MobileFilterDrawer({ {availableLocations.length > 0 && (
- + Location - {selectedLocations.length > 0 && ( + {(localState.locations ? localState.locations.split(',').filter(Boolean) : []).length > 0 && ( - {selectedLocations.length} selected + {(localState.locations ? localState.locations.split(',').filter(Boolean) : []).length} selected )}
- {selectedLocations.length > 0 && ( + {(localState.locations ? localState.locations.split(',').filter(Boolean) : []).length > 0 && (
- {selectedLocations.map(loc => ( + {(localState.locations ? localState.locations.split(',').filter(Boolean) : []).map((loc: string) => ( {loc.split('/').pop()} ))} @@ -243,8 +199,8 @@ export default function MobileFilterDrawer({ )} setLocalAdvanced(prev => ({ ...prev, locations: locs.length ? locs.join(',') : undefined }))} + selectedLocations={(localState.locations ? localState.locations.split(',').filter(Boolean) : [])} + onChange={(locs) => handleUpdate('locations', locs.length ? locs.join(',') : undefined)} className="shadow-sm" />
@@ -264,18 +220,18 @@ export default function MobileFilterDrawer({ ['Fade', 'minFade', 'maxFade'], ['Weight (g)', 'minWeight', 'maxWeight'], ['Condition', 'minCond', 'maxCond'], - ] as [string, keyof AdvancedFilters, keyof AdvancedFilters][]).map(([label, minKey, maxKey]) => ( + ] as [string, string, string][]).map(([label, minKey, maxKey]) => (
handleUpdateAdvanced(minKey, e.target.value ? parseFloat(e.target.value) : undefined)} + value={localState[minKey] ?? ''} onKeyDown={allowNumeric} + onChange={(e) => handleUpdate(minKey, e.target.value ? parseFloat(e.target.value) : undefined)} className={inputCls} /> handleUpdateAdvanced(maxKey, e.target.value ? parseFloat(e.target.value) : undefined)} + value={localState[maxKey] ?? ''} onKeyDown={allowNumeric} + onChange={(e) => handleUpdate(maxKey, e.target.value ? parseFloat(e.target.value) : undefined)} className={inputCls} />
@@ -287,8 +243,8 @@ export default function MobileFilterDrawer({ handleUpdateAdvanced('plastic', vals.length ? vals.join(',') : undefined)} + selectedValues={(localState.plastic as string || '').split(',').filter(Boolean)} + onChange={(vals) => handleUpdate('plastic', vals.length ? vals.join(',') : undefined)} placeholder="All Plastics" />
@@ -298,8 +254,8 @@ export default function MobileFilterDrawer({ handleUpdateAdvanced('color', vals.length ? vals.join(',') : undefined)} + selectedValues={(localState.color as string || '').split(',').filter(Boolean)} + onChange={(vals) => handleUpdate('color', vals.length ? vals.join(',') : undefined)} placeholder="All Colors" />
@@ -309,8 +265,8 @@ export default function MobileFilterDrawer({ handleUpdateAdvanced('stamp', vals.length ? vals.join(',') : undefined)} + selectedValues={(localState.stamp as string || '').split(',').filter(Boolean)} + onChange={(vals) => handleUpdate('stamp', vals.length ? vals.join(',') : undefined)} placeholder="All Stamps" />
@@ -320,8 +276,8 @@ export default function MobileFilterDrawer({ handleUpdateAdvanced('stampFoil', vals.length ? vals.join(',') : undefined)} + selectedValues={(localState.stampFoil as string || '').split(',').filter(Boolean)} + onChange={(vals) => handleUpdate('stampFoil', vals.length ? vals.join(',') : undefined)} placeholder="All Foils" />
@@ -332,9 +288,9 @@ export default function MobileFilterDrawer({
{['any', 'none', 'exists'].map((option) => (
diff --git a/src/lib/searchParser.ts b/src/lib/searchParser.ts new file mode 100644 index 0000000..9b7b56c --- /dev/null +++ b/src/lib/searchParser.ts @@ -0,0 +1,234 @@ +import { Prisma } from "@prisma/client" + +const FIELD_ALIASES: Record = { + mold: 'mold.name', + name: 'mold.name', + disc: 'mold.name', + category: 'mold.category', + type: 'mold.category', + brand: 'mold.brand', + company: 'mold.brand', + secpattern: 'secondaryPattern', + pattern: 'secondaryPattern', + design: 'secondaryPattern', + plastic: 'plastic', + color: 'color', + stamp: 'stamp', + foil: 'stampFoil', + stampfoil: 'stampFoil', + weight: 'weight', + speed: 'mold.speed', + glide: 'mold.glide', + turn: 'mold.turn', + fade: 'mold.fade', + condition: 'condition', + location: 'location', +} + +function parseNumericOperator(val: string) { + // e.g. "9-11" + const rangeMatch = val.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/); + if (rangeMatch) return { gte: parseFloat(rangeMatch[1]), lte: parseFloat(rangeMatch[2]) } + + // e.g. ">9", "<=10", "=0" + const opMatch = val.match(/^(>=|<=|>|<|=)?(-?\d+(?:\.\d+)?)$/); + if (!opMatch) return null; + const num = parseFloat(opMatch[2]) + switch(opMatch[1]) { + case '>': return { gt: num } + case '<': return { lt: num } + case '>=': return { gte: num } + case '<=': return { lte: num } + case '=': return { equals: num } + default: return { equals: num } + } +} + +function parseStringWildcard(val: string) { + const starts = val.startsWith('*') + const ends = val.endsWith('*') + const clean = val.replace(/^\*|\*$/g, '') + if (starts && ends) return { contains: clean } + if (starts) return { endsWith: clean } + if (ends) return { startsWith: clean } + // default exact match behaviour for fielded queries without wildcards + return { startsWith: clean, endsWith: clean } +} + +export function parseSearchQuery(query: string, vaultId: string | null = null, bagPaths: string[] = []): Prisma.InventoryWhereInput { + const where: any = {}; + if (vaultId && vaultId !== 'all') { + where.collectionId = vaultId; + } + + if (!query || !query.trim()) return where; + + const exact: string[] = []; + const fields: { key: string, val: string }[] = []; + const fuzzy: string[] = []; + + // Match: key:"value", key:value, "exact", or fuzzy + const regex = /(?:([a-zA-Z]+):)?"([^"]+)"|(?:([a-zA-Z]+):)([^\s]+)|"([^"]+)"|([^\s]+)/g; + let match; + while ((match = regex.exec(query)) !== null) { + if (match[1] && match[2]) { + fields.push({ key: match[1].toLowerCase(), val: match[2] }); + } else if (match[3] && match[4]) { + fields.push({ key: match[3].toLowerCase(), val: match[4] }); + } else if (match[5]) { + exact.push(match[5]); + } else if (match[6]) { + fuzzy.push(match[6]); + } + } + + const andConditions: any[] = []; + + const fieldGroups: Record = {}; + + for (const f of fields) { + // Special handling for inbag + if (f.key === 'inbag' || f.key === 'bag') { + const isTrue = f.val.toLowerCase() === 'true' || f.val === '1' || f.val.toLowerCase() === 'yes'; + if (isTrue) { + if (bagPaths.length > 0) { + andConditions.push({ + OR: bagPaths.flatMap(p => [ + { location: p }, + { location: { startsWith: p + '/' } } + ]) + }) + } else { + andConditions.push({ id: 'none' }) + } + } else { + if (bagPaths.length > 0) { + andConditions.push({ + NOT: { OR: bagPaths.flatMap(p => [ + { location: p }, + { location: { startsWith: p + '/' } } + ])} + }) + } + } + continue; + } + + const dbFieldPath = FIELD_ALIASES[f.key]; + if (!dbFieldPath) { + fuzzy.push(`${f.key}:${f.val}`); + continue; + } + + const isNumeric = ['mold.speed', 'mold.glide', 'mold.turn', 'mold.fade', 'weight', 'condition'].includes(dbFieldPath); + let condition: any; + + if (isNumeric) { + const numCond = parseNumericOperator(f.val); + if (numCond) { + condition = numCond; + } else { + // failed to parse numeric, just treat it as fuzzy string for robustness? + } + } else { + condition = parseStringWildcard(f.val); + } + + if (condition) { + if (!fieldGroups[dbFieldPath]) fieldGroups[dbFieldPath] = []; + fieldGroups[dbFieldPath].push(condition); + } + } + + for (const [path, conds] of Object.entries(fieldGroups)) { + const parts = path.split('.'); + const orList = conds.map(c => { + if (parts.length === 2) { + return { [parts[0]]: { [parts[1]]: c } }; + } + return { [path]: c }; + }); + andConditions.push(orList.length > 1 ? { OR: orList } : orList[0]); + } + + for (const e of exact) { + // strict exact match on mold.name + andConditions.push({ mold: { name: { startsWith: e, endsWith: e } } }); + } + + if (fuzzy.length > 0) { + const fuzzyStr = fuzzy.join(' '); + andConditions.push({ + OR: [ + { mold: { name: { contains: fuzzyStr } } }, + { mold: { brand: { contains: fuzzyStr } } }, + { mold: { category: { contains: fuzzyStr } } }, + { plastic: { contains: fuzzyStr } }, + { color: { contains: fuzzyStr } }, + { stamp: { contains: fuzzyStr } }, + { stampFoil: { contains: fuzzyStr } }, + { location: { contains: fuzzyStr } }, + { notes: { contains: fuzzyStr } }, + ] + }); + } + + if (andConditions.length > 0) { + where.AND = andConditions; + } + + return where; +} + +export function extractSearchTokens(query: string): Record { + const state: Record = {}; + if (!query) return state; + + const regex = /(?:([a-zA-Z]+):)?"([^"]+)"|(?:([a-zA-Z]+):)([^\s]+)/g; + let match; + + const multi: Record = {}; + + while ((match = regex.exec(query)) !== null) { + const key = (match[1] || match[3])?.toLowerCase(); + const val = match[2] || match[4]; + if (!key || !val) continue; + + if (['speed','glide','turn','fade','weight','condition','cond'].includes(key)) { + const field = key === 'cond' ? 'Cond' : key.charAt(0).toUpperCase() + key.slice(1); + const rangeMatch = val.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/); + if (rangeMatch) { + state[`min${field}`] = rangeMatch[1]; + state[`max${field}`] = rangeMatch[2]; + } else { + const opMatch = val.match(/^(>=|<=|>|<|=)?(-?\d+(?:\.\d+)?)$/); + if (opMatch) { + if (opMatch[1] === '>=' || opMatch[1] === '>') state[`min${field}`] = opMatch[2]; + else if (opMatch[1] === '<=' || opMatch[1] === '<') state[`max${field}`] = opMatch[2]; + else if (!opMatch[1] || opMatch[1] === '=') { + state[`min${field}`] = opMatch[2]; + state[`max${field}`] = opMatch[2]; + } + } + } + } else { + let normalizedKey = key; + if (key === 'foil') normalizedKey = 'stampFoil'; + if (key === 'location') normalizedKey = 'locations'; + + const cleanVal = val.replace(/^\*|\*$/g, ''); + if (!multi[normalizedKey]) multi[normalizedKey] = []; + multi[normalizedKey].push(cleanVal); + } + } + + for (const [k, v] of Object.entries(multi)) { + if (k === 'ink') { + state[k] = v[0]; + } else { + state[k] = v.join(','); + } + } + + return state; +}