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 */}
-
-
-
-
-
- {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]) => (
@@ -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;
+}