From 3e28d196be25771287ac401f8e62b69a22f60f00 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Tue, 12 May 2026 21:06:04 -0500 Subject: [PATCH 1/9] feat: Add Custom Mold feature inline to AddDiscForm --- CHANGELOG.md | 5 ++ package-lock.json | 4 +- package.json | 2 +- src/app/actions/molds.ts | 41 +++++++++++ src/components/AddDiscForm.tsx | 126 +++++++++++++++++++++++++++++++-- 5 files changed, 171 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b4b6a..2b03d36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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.6.0] - 2026-05-12 + +### Added +- **Custom Molds:** Added the ability to create Custom Molds directly inline from the "Add Disc" form. If a disc does not exist in the Discit API, users can now define the disc's name, brand, category, and flight numbers to add it to their vault. + ## [0.5.2] - 2026-05-07 ### Fixed diff --git a/package-lock.json b/package-lock.json index 911164b..0c67fba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "discvault", - "version": "0.1.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "discvault", - "version": "0.1.0", + "version": "0.6.0", "dependencies": { "@prisma/client": "^6.19.2", "@types/papaparse": "^5.5.2", diff --git a/package.json b/package.json index abcce67..73b8184 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "discvault", - "version": "0.5.0", + "version": "0.6.0", "private": true, "scripts": { "dev": "next dev -p 3001", diff --git a/src/app/actions/molds.ts b/src/app/actions/molds.ts index 06ae9e7..1e30437 100644 --- a/src/app/actions/molds.ts +++ b/src/app/actions/molds.ts @@ -96,3 +96,44 @@ export async function syncMolds() { revalidatePath('/') return { updatedCount, createdCount } } + +function getStability(turn: number, fade: number): string { + const score = turn + fade + if (score <= -2) return 'Understable' + if (score <= 0) return 'Stable' + if (score <= 2) return 'Overstable' + return 'Very Overstable' +} + +export async function createCustomMold(formData: FormData) { + const name = formData.get('name') as string + const brand = formData.get('brand') as string + const category = formData.get('category') as string + const speed = parseFloat(formData.get('speed') as string) + const glide = parseFloat(formData.get('glide') as string) + const turn = parseFloat(formData.get('turn') as string) + const fade = parseFloat(formData.get('fade') as string) + + if (!name || !brand || !category || isNaN(speed) || isNaN(glide) || isNaN(turn) || isNaN(fade)) { + throw new Error('All fields are required to create a custom mold.') + } + + const stability = getStability(turn, fade) + + const newMold = await prisma.mold.create({ + data: { + name, + brand, + category, + speed, + glide, + turn, + fade, + stability, + isCustom: true + } + }) + + revalidatePath('/settings') + return newMold +} diff --git a/src/components/AddDiscForm.tsx b/src/components/AddDiscForm.tsx index 1c9f4a4..35e4cb8 100644 --- a/src/components/AddDiscForm.tsx +++ b/src/components/AddDiscForm.tsx @@ -18,7 +18,8 @@ import { useState, useEffect, useCallback } from 'react' import { addDisc } from '@/app/actions/inventory' -import { Search, Loader2 } from 'lucide-react' +import { createCustomMold } from '@/app/actions/molds' +import { Search, Loader2, Plus } from 'lucide-react' import LocationPicker from '@/components/LocationPicker' import { type LocationNode } from '@/lib/locationTree' @@ -41,6 +42,49 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { const [loading, setLoading] = useState(false) const [isSearching, setIsSearching] = useState(false) const [selectedLocation, setSelectedLocation] = useState(null) + const [isCustomMode, setIsCustomMode] = useState(false) + const [isCreatingCustom, setIsCreatingCustom] = useState(false) + + const handleCreateCustomMold = async () => { + const brandInput = document.querySelector('input[name="customBrand"]') as HTMLInputElement + const nameInput = document.querySelector('input[name="customName"]') as HTMLInputElement + const categoryInput = document.querySelector('select[name="customCategory"]') as HTMLSelectElement + const speedInput = document.querySelector('input[name="customSpeed"]') as HTMLInputElement + const glideInput = document.querySelector('input[name="customGlide"]') as HTMLInputElement + const turnInput = document.querySelector('input[name="customTurn"]') as HTMLInputElement + const fadeInput = document.querySelector('input[name="customFade"]') as HTMLInputElement + + if (!brandInput.value || !nameInput.value || !speedInput.value || !glideInput.value || !turnInput.value || !fadeInput.value) { + alert("Please fill out all custom mold fields") + return + } + + setIsCreatingCustom(true) + const formData = new FormData() + formData.append('brand', brandInput.value) + formData.append('name', nameInput.value) + formData.append('category', categoryInput.value) + formData.append('speed', speedInput.value) + formData.append('glide', glideInput.value) + formData.append('turn', turnInput.value) + formData.append('fade', fadeInput.value) + + try { + const newMold = await createCustomMold(formData) + handleSelectMold({ + id: newMold.id, + name: newMold.name, + brand: newMold.brand, + category: newMold.category + }) + setIsCustomMode(false) + } catch (e) { + console.error(e) + alert("Failed to create custom mold") + } finally { + setIsCreatingCustom(false) + } + } const searchMolds = useCallback(async (q: string) => { if (q.length < 2) { @@ -98,7 +142,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { {loading && } - {isSearching && molds.length > 0 && ( + {isSearching && (
    {molds.map((mold) => (
  • {mold.brand} • {mold.category}
  • ))} +
  • { + setIsCustomMode(true) + setSelectedMold(null) + setIsSearching(false) + }} + className="px-5 py-4 hover:bg-slate-50 cursor-pointer flex items-center justify-center transition-all rounded-2xl border-2 border-dashed border-slate-200 mt-2 text-slate-500 font-bold gap-2" + > + Not finding your disc? Create Custom Mold +
)} - + + {isCustomMode && !selectedMold && ( +
+
+

+ Create Custom Mold +

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ )} +
@@ -241,7 +359,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) {
diff --git a/src/components/DiscDetailView.tsx b/src/components/DiscDetailView.tsx index fd48390..70caf3b 100644 --- a/src/components/DiscDetailView.tsx +++ b/src/components/DiscDetailView.tsx @@ -4,6 +4,7 @@ import { Edit3, ArrowLeft, MapPin, Zap } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/navigation' import DiscPreview from './DiscPreview' +import { SECONDARY_PATTERN_LABELS } from '@/lib/constants' interface DiscDetailViewProps { disc: { @@ -148,7 +149,7 @@ export default function DiscDetailView({ disc, categoryColors, vaultId, bagPaths
Secondary Pattern - {disc.secondaryPattern || '—'} + {disc.secondaryPattern ? (SECONDARY_PATTERN_LABELS[disc.secondaryPattern] || disc.secondaryPattern) : '—'}
Condition diff --git a/src/components/EditDiscForm.tsx b/src/components/EditDiscForm.tsx index 6e67276..f05b228 100644 --- a/src/components/EditDiscForm.tsx +++ b/src/components/EditDiscForm.tsx @@ -22,6 +22,7 @@ import { Trash2, ArrowLeft, Inbox } from 'lucide-react' import { useRouter } from 'next/navigation' import LocationPicker from '@/components/LocationPicker' import { type LocationNode } from '@/lib/locationTree' +import { SECONDARY_PATTERN_LABELS } from '@/lib/constants' interface Collection { id: string @@ -191,11 +192,9 @@ export default function EditDiscForm({ disc, collections }: Omit - - - - - + {Object.entries(SECONDARY_PATTERN_LABELS).map(([value, label]) => ( + + ))}
diff --git a/src/components/PreviewModal.tsx b/src/components/PreviewModal.tsx index 1943331..7a56324 100644 --- a/src/components/PreviewModal.tsx +++ b/src/components/PreviewModal.tsx @@ -20,6 +20,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { X } from 'lucide-react' import DiscPreview from './DiscPreview' import { useEffect } from 'react' +import { SECONDARY_PATTERN_LABELS } from '@/lib/constants' interface PreviewModalProps { isOpen: boolean @@ -105,7 +106,7 @@ export default function PreviewModal({ isOpen, onClose, disc }: PreviewModalProp {disc.secondaryPattern && (
- {disc.secondaryPattern} Pattern + {SECONDARY_PATTERN_LABELS[disc.secondaryPattern] || disc.secondaryPattern} Pattern
)} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 679be62..ced58a0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -5,3 +5,11 @@ export const DEFAULT_CATEGORY_COLORS: Record = { 'Putter': '#10b981', // emerald 'Utility': '#8b5cf6', // violet } + +export const SECONDARY_PATTERN_LABELS: Record = { + 'Halo': 'Halo/Overmold', + 'Burst': 'Center Burst', + 'Split': 'Half \'n Half', + 'Swirl': 'Swirly', + 'Speckled': 'Speckled' +} From 37cf0338bb3653c3700f9fba8d63c76328afeade Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Tue, 12 May 2026 21:22:48 -0500 Subject: [PATCH 3/9] fix: Resolve SVG rendering issues with translucent secondary patterns --- CHANGELOG.md | 1 + src/components/DiscPreview.tsx | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a095d13..6c65b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Secondary Patterns:** Standardized Secondary Pattern names (e.g. "Halo/Overmold") to consistently display friendly labels across both the editor forms and the detail views instead of the raw database values. +- **Disc Visuals:** Fixed an SVG rendering bug where translucent or "Clear" secondary colors in Half 'n Half and Halo patterns incorrectly showed the base disc color underneath them. ## [0.5.2] - 2026-05-07 diff --git a/src/components/DiscPreview.tsx b/src/components/DiscPreview.tsx index 787cf5b..9960630 100644 --- a/src/components/DiscPreview.tsx +++ b/src/components/DiscPreview.tsx @@ -128,12 +128,26 @@ export default function DiscPreview({ {isTuned && showTunedOutline && isHovered && } {/* Base Disc */} - + {pattern === 'Split' && secondaryColor ? ( + <> + + + + ) : ( + + )} {/* Secondary Patterns - Clipped to disc bounds */} {pattern === 'Halo' && secondaryColor && ( - + )} {pattern === 'Burst' && secondaryColor && ( From cce07e3af7ea9007e39a271da697f07d188e867d Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Tue, 12 May 2026 21:33:51 -0500 Subject: [PATCH 4/9] fix: Wrap long text in location picker selection button --- CHANGELOG.md | 1 + src/components/LocationPicker.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c65b82..bc2c282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **Secondary Patterns:** Standardized Secondary Pattern names (e.g. "Halo/Overmold") to consistently display friendly labels across both the editor forms and the detail views instead of the raw database values. - **Disc Visuals:** Fixed an SVG rendering bug where translucent or "Clear" secondary colors in Half 'n Half and Halo patterns incorrectly showed the base disc color underneath them. +- **Location Picker:** Fixed an issue where heavily nested location paths would be truncated after selection. The dropdown button now expands vertically to wrap long text. ## [0.5.2] - 2026-05-07 diff --git a/src/components/LocationPicker.tsx b/src/components/LocationPicker.tsx index 2369da7..8f7de50 100644 --- a/src/components/LocationPicker.tsx +++ b/src/components/LocationPicker.tsx @@ -74,14 +74,14 @@ export default function LocationPicker({ tree, value, onChange, className = '' } : 'border-slate-200 bg-slate-50 hover:border-indigo-200' }`} > - + {selected ? ( <> - {selected.inBag && } - {selected.path} + {selected.inBag && } + {selected.path} ) : ( - No location set + No location set )}
From cdba0e4512af7d04d10882ef6567fb5aad58895b Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Tue, 12 May 2026 22:30:23 -0500 Subject: [PATCH 5/9] feat: 0.6.0 precise color picker, custom molds, and layout fixes --- CHANGELOG.md | 3 +- prisma/schema.prisma | 2 + src/app/actions/inventory.ts | 10 ++ src/app/v/[vaultId]/bagcheck/page.tsx | 2 + src/components/AddDiscForm.tsx | 138 +++++++++++++++-------- src/components/BagCheckList.tsx | 4 + src/components/ColorInput.tsx | 129 +++++++++++++++++++++ src/components/DiscDetailView.tsx | 4 + src/components/DiscPreview.tsx | 8 +- src/components/EditDiscForm.tsx | 133 ++++++++++++++-------- src/components/FlightChart.tsx | 4 + src/components/InventoryInfiniteList.tsx | 4 + src/components/InventoryList.tsx | 4 + src/components/PreviewModal.tsx | 4 + src/lib/colorParser.ts | 33 ++++-- src/lib/constants.ts | 2 +- 16 files changed, 372 insertions(+), 112 deletions(-) create mode 100644 src/components/ColorInput.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index bc2c282..797c036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.6.0] - 2026-05-12 ### Added +- **Precise Color Picker & Live Preview:** Replaced the generic color text inputs in Add and Edit forms with an interactive Color Input component. This new component combines text search with a native hex color picker and opacity slider, providing accurate visual representation while maintaining searchability. The form now features a live-updating `DiscPreview` that reflects changes in real-time. Hex color overrides seamlessly propagate to inventory lists, bag checklists, and flight charts. - **Custom Molds:** Added the ability to create Custom Molds directly inline from the "Add Disc" form. If a disc does not exist in the Discit API, users can now define the disc's name, brand, category, and flight numbers to add it to their vault. ### Fixed -- **Secondary Patterns:** Standardized Secondary Pattern names (e.g. "Halo/Overmold") to consistently display friendly labels across both the editor forms and the detail views instead of the raw database values. +- **Secondary Patterns:** Standardized Secondary Pattern names (e.g. "Halo/Rim") to consistently display friendly labels across both the editor forms and the detail views instead of the raw database values. Fixed an issue where long pattern names would truncate in form inputs on smaller screens. - **Disc Visuals:** Fixed an SVG rendering bug where translucent or "Clear" secondary colors in Half 'n Half and Halo patterns incorrectly showed the base disc color underneath them. - **Location Picker:** Fixed an issue where heavily nested location paths would be truncated after selection. The dropdown button now expands vertically to wrap long text. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 833091c..7916447 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,6 +58,8 @@ model Inventory { userGlide Float? userTurn Float? secondaryColor String? + colorHex String? + secondaryColorHex String? secondaryPattern String? collection DiscCollection? @relation(fields: [collectionId], references: [id]) mold Mold @relation(fields: [moldId], references: [id]) diff --git a/src/app/actions/inventory.ts b/src/app/actions/inventory.ts index bfe2aa6..24a295c 100644 --- a/src/app/actions/inventory.ts +++ b/src/app/actions/inventory.ts @@ -52,6 +52,8 @@ export async function addDisc(formData: FormData) { const userGlide = parseUserFlight(formData.get('userGlide')) const userTurn = parseUserFlight(formData.get('userTurn')) const userFade = parseUserFlight(formData.get('userFade')) + const colorHex = (formData.get('colorHex') as string) || null + const secondaryColorHex = (formData.get('secondaryColorHex') as string) || null if (!moldId) throw new Error('Mold ID is required') @@ -63,6 +65,8 @@ export async function addDisc(formData: FormData) { weight, color, secondaryColor, + colorHex, + secondaryColorHex, secondaryPattern, plastic, stamp, @@ -103,6 +107,8 @@ export async function updateDisc(id: string, formData: FormData) { const userGlide = parseUserFlight(formData.get('userGlide')) const userTurn = parseUserFlight(formData.get('userTurn')) const userFade = parseUserFlight(formData.get('userFade')) + const colorHex = (formData.get('colorHex') as string) || null + const secondaryColorHex = (formData.get('secondaryColorHex') as string) || null try { await prisma.inventory.update({ @@ -112,6 +118,8 @@ export async function updateDisc(id: string, formData: FormData) { weight, color, secondaryColor, + colorHex, + secondaryColorHex, secondaryPattern, plastic, stamp, @@ -243,7 +251,9 @@ export async function importDiscs(records: any[], targetCollectionId?: string) { plastic: record.plastic || null, weight: parseSafeFloat(record.weight, NaN) || null, color: record.color || null, + colorHex: record.colorHex || null, secondaryColor: record.secondaryColor || null, + secondaryColorHex: record.secondaryColorHex || null, secondaryPattern: record.secondaryPattern || null, stamp: record.stamp || null, stampFoil: record.stampFoil || null, diff --git a/src/app/v/[vaultId]/bagcheck/page.tsx b/src/app/v/[vaultId]/bagcheck/page.tsx index 5aa00a2..0e9d3b6 100644 --- a/src/app/v/[vaultId]/bagcheck/page.tsx +++ b/src/app/v/[vaultId]/bagcheck/page.tsx @@ -81,7 +81,9 @@ export default async function BagCheckPage({ plastic: d.plastic, weight: d.weight, color: d.color, + colorHex: d.colorHex, secondaryColor: d.secondaryColor, + secondaryColorHex: d.secondaryColorHex, secondaryPattern: d.secondaryPattern, stampFoil: d.stampFoil, location: d.location, diff --git a/src/components/AddDiscForm.tsx b/src/components/AddDiscForm.tsx index 6bc89d5..ab41af4 100644 --- a/src/components/AddDiscForm.tsx +++ b/src/components/AddDiscForm.tsx @@ -23,6 +23,8 @@ import { Search, Loader2, Plus } from 'lucide-react' import LocationPicker from '@/components/LocationPicker' import { type LocationNode } from '@/lib/locationTree' import { SECONDARY_PATTERN_LABELS } from '@/lib/constants' +import ColorInput from './ColorInput' +import DiscPreview from './DiscPreview' interface Mold { id: string @@ -46,6 +48,18 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { const [isCustomMode, setIsCustomMode] = useState(false) const [isCreatingCustom, setIsCreatingCustom] = useState(false) + // Preview State + const [previewState, setPreviewState] = useState({ + color: '', + colorHex: null as string | null, + secondaryColor: '', + secondaryColorHex: null as string | null, + secondaryPattern: '', + stampFoil: '', + weight: '', + plastic: '' + }) + const handleCreateCustomMold = async () => { const brandInput = document.querySelector('input[name="customBrand"]') as HTMLInputElement const nameInput = document.querySelector('input[name="customName"]') as HTMLInputElement @@ -244,6 +258,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { step="0.1" name="weight" placeholder="175" + onChange={e => setPreviewState(s => ({ ...s, weight: e.target.value }))} className="w-full px-5 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" />
@@ -253,6 +268,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { type="text" name="plastic" placeholder="Star, Champion..." + onChange={e => setPreviewState(s => ({ ...s, plastic: e.target.value }))} className="w-full px-5 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" /> @@ -266,59 +282,83 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) { -
-
- - -
-
- - +
+
+
+ setPreviewState(s => ({ ...s, color: text, colorHex: hex }))} + /> + setPreviewState(s => ({ ...s, secondaryColor: text, secondaryColorHex: hex }))} + /> +
+ +
+
+ + +
+
+ + +
+
+ + setPreviewState(s => ({ ...s, stampFoil: e.target.value }))} + className="w-full px-3 py-4 bg-white border border-slate-200 rounded-2xl font-black text-sm text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" + /> +
+
-
- - + + {/* Live Preview */} +
+ {selectedMold || isCustomMode ? ( + + ) : ( +
+
+ Preview +
+

Select a mold to see preview

+
+ )}
-
-
- - -
-
- - -
-
+
diff --git a/src/components/BagCheckList.tsx b/src/components/BagCheckList.tsx index b9b4c6e..0ff6e76 100644 --- a/src/components/BagCheckList.tsx +++ b/src/components/BagCheckList.tsx @@ -33,7 +33,9 @@ interface BagCheckDisc { plastic: string | null weight: number | null color: string | null + colorHex: string | null secondaryColor: string | null + secondaryColorHex: string | null secondaryPattern: string | null stampFoil: string | null location: string | null @@ -292,7 +294,9 @@ export default function BagCheckList({ vaultName, bags, discs }: BagCheckListPro
void +} + +export default function ColorInput({ name, label, initialText, initialHex, onChange }: ColorInputProps) { + const [text, setText] = useState(initialText || '') + + // Track if the user has manually touched the color or opacity controls + const [isOverridden, setIsOverridden] = useState(!!initialHex) + + // Derive initial internal states + const [hex, setHex] = useState(() => { + if (initialHex) return initialHex.slice(0, 7) + return tinycolor(parseDiscColor(initialText || '')).toHexString() + }) + + const [opacity, setOpacity] = useState(() => { + if (initialHex && initialHex.length === 9) { + return parseInt(initialHex.slice(7, 9), 16) / 255 + } + if (initialHex) return 1 + return tinycolor(parseDiscColor(initialText || '')).getAlpha() + }) + + // When text changes (and not overridden), auto-update the swatch and opacity + useEffect(() => { + if (!isOverridden) { + const parsed = tinycolor(parseDiscColor(text)) + setHex(parsed.toHexString()) + setOpacity(parsed.getAlpha()) + } + }, [text, isOverridden]) + + // Use a ref for onChange to avoid infinite loops if it's passed as an inline function + const onChangeRef = useRef(onChange) + useEffect(() => { + onChangeRef.current = onChange + }, [onChange]) + + // Fire onChange whenever the derived output changes + useEffect(() => { + if (onChangeRef.current) { + const hex8 = isOverridden ? `${hex}${Math.round(opacity * 255).toString(16).padStart(2, '0')}` : null + onChangeRef.current(text, hex8) + } + }, [text, hex, opacity, isOverridden]) + + const hex8Output = isOverridden ? `${hex}${Math.round(opacity * 255).toString(16).padStart(2, '0')}` : '' + + return ( +
+
+ + +
+ +
+ setText(e.target.value)} + placeholder="Blue, Pink..." + className="w-full pl-5 pr-14 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm text-ellipsis overflow-hidden whitespace-nowrap" + /> + + {/* Color Picker Swatch */} +
+ { + setHex(e.target.value) + setIsOverridden(true) + }} + className="absolute -inset-2 w-12 h-12 cursor-pointer bg-transparent border-0 outline-none" + /> +
+
+ + {/* Opacity Slider */} +
+
+ Opacity + + {Math.round(opacity * 100)}% + +
+ { + setOpacity(parseFloat(e.target.value)) + setIsOverridden(true) + }} + className="w-full h-1.5 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-indigo-500" + /> +
+ + {/* Hidden input for the DB hex override */} + +
+ ) +} diff --git a/src/components/DiscDetailView.tsx b/src/components/DiscDetailView.tsx index 70caf3b..4e094ce 100644 --- a/src/components/DiscDetailView.tsx +++ b/src/components/DiscDetailView.tsx @@ -12,7 +12,9 @@ interface DiscDetailViewProps { collectionId: string | null weight: number | null color: string | null + colorHex: string | null secondaryColor?: string | null + secondaryColorHex?: string | null secondaryPattern?: string | null plastic: string | null stamp: string | null @@ -133,7 +135,9 @@ export default function DiscDetailView({ disc, categoryColors, vaultId, bagPaths {disc.color && ( (disc.userTurn !== null && disc.userTurn !== undefined ? String(disc.userTurn) : '') const [userFade, setUserFade] = useState(disc.userFade !== null && disc.userFade !== undefined ? String(disc.userFade) : '') const hasTuning = userGlide !== '' || userTurn !== '' || userFade !== '' + + // Preview State + const [previewState, setPreviewState] = useState({ + color: disc.color || '', + colorHex: disc.colorHex, + secondaryColor: disc.secondaryColor || '', + secondaryColorHex: disc.secondaryColorHex, + secondaryPattern: disc.secondaryPattern || '', + stampFoil: disc.stampFoil || '', + weight: disc.weight ? String(disc.weight) : '', + plastic: disc.plastic || '' + }) const currentTree = (() => { if (!selectedVaultId) return [] @@ -142,6 +158,7 @@ export default function EditDiscForm({ disc, collections }: Omit setPreviewState(s => ({ ...s, weight: e.target.value }))} className="w-full px-5 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" />
@@ -151,6 +168,7 @@ export default function EditDiscForm({ disc, collections }: Omit setPreviewState(s => ({ ...s, plastic: e.target.value }))} className="w-full px-5 py-4 bg-white border border-slate-200 rounded-2xl font-black text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" />
@@ -165,57 +183,74 @@ export default function EditDiscForm({ disc, collections }: Omit
-
-
- - -
-
- - -
-
- - -
-
+
+
+
+ setPreviewState(s => ({ ...s, color: text, colorHex: hex }))} + /> + setPreviewState(s => ({ ...s, secondaryColor: text, secondaryColorHex: hex }))} + /> +
-
-
- - +
+
+ + +
+
+ + +
+
+ + setPreviewState(s => ({ ...s, stampFoil: e.target.value }))} + className="w-full px-3 py-4 bg-white border border-slate-200 rounded-2xl font-black text-sm text-slate-900 focus:ring-4 focus:ring-indigo-100 focus:border-indigo-400 transition-all outline-none shadow-sm" + /> +
+
-
- - +
diff --git a/src/components/FlightChart.tsx b/src/components/FlightChart.tsx index d83aed9..fd53a63 100644 --- a/src/components/FlightChart.tsx +++ b/src/components/FlightChart.tsx @@ -35,7 +35,9 @@ interface Disc { fade: number } color?: string | null + colorHex?: string | null secondaryColor?: string | null + secondaryColorHex?: string | null secondaryPattern?: string | null stampFoil?: string | null plastic?: string | null @@ -336,7 +338,9 @@ export default function FlightChart({ discs, vaultId, defaultShowFlightPaths = f { + if (!isClear) return hex; + const c = tinycolor(hex).toRgb(); + return `rgba(${c.r}, ${c.g}, ${c.b}, 0.5)`; + } + + if (hexOverride) { + // If we have an 8-char hex, the alpha is embedded in the hex. + // If it's a 6-char hex and isClear, we apply 0.5 opacity. + const parsedHex = tinycolor(hexOverride); + if (parsedHex.isValid()) { + // If it already has an alpha < 1, just return its hex8 or rgba + if (parsedHex.getAlpha() < 1) { + return parsedHex.toRgbString(); + } + return applyClear(parsedHex.toHexString()); + } + } + // 1. Handle specific Disc Golf exact matches / overrides if (colorStr === 'glow' || colorStr === 'moonshine' || colorStr === 'eclipse') { return isClear ? 'rgba(220, 242, 181, 0.3)' : '#dcf2b5'; @@ -40,12 +59,6 @@ export function parseDiscColor(input: string | null | undefined): string { if (hasDark) colorStr = colorStr.replace('dark', '').trim(); if (hasDeep) colorStr = colorStr.replace('deep', '').trim(); - const applyClear = (hex: string) => { - if (!isClear) return hex; - const c = tinycolor(hex).toRgb(); - return `rgba(${c.r}, ${c.g}, ${c.b}, 0.5)`; - } - const applyModifiers = (color: tinycolor.Instance) => { if (hasNeon) color = color.saturate(100).lighten(10); if (hasLight) color = color.lighten(20); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ced58a0..810b165 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -7,7 +7,7 @@ export const DEFAULT_CATEGORY_COLORS: Record = { } export const SECONDARY_PATTERN_LABELS: Record = { - 'Halo': 'Halo/Overmold', + 'Halo': 'Halo/Rim', 'Burst': 'Center Burst', 'Split': 'Half \'n Half', 'Swirl': 'Swirly', From 748cabd5afb5e9a64b8d1822cc12486614661f4f Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Thu, 14 May 2026 19:38:08 -0500 Subject: [PATCH 6/9] fix: resolve speckled pattern hash issue and location picker z-index overlap --- CHANGELOG.md | 6 ++++++ src/components/AddDiscForm.tsx | 2 +- src/components/DiscPreview.tsx | 1 + src/components/EditDiscForm.tsx | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 797c036..9f3bb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.6.1] - 2026-05-14 + +### 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/components/AddDiscForm.tsx b/src/components/AddDiscForm.tsx index ab41af4..6ed1345 100644 --- a/src/components/AddDiscForm.tsx +++ b/src/components/AddDiscForm.tsx @@ -250,7 +250,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) {
)} -
+
{ hash = (hash * 16807) % 2147483647 diff --git a/src/components/EditDiscForm.tsx b/src/components/EditDiscForm.tsx index 4bf5232..1ec67fa 100644 --- a/src/components/EditDiscForm.tsx +++ b/src/components/EditDiscForm.tsx @@ -150,7 +150,7 @@ export default function EditDiscForm({ disc, collections }: Omit
-
+
Date: Thu, 14 May 2026 19:43:59 -0500 Subject: [PATCH 7/9] fix: explicitly trap DiscPreview z-index context to prevent overlaying the location picker during hover states --- src/components/AddDiscForm.tsx | 2 +- src/components/EditDiscForm.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/AddDiscForm.tsx b/src/components/AddDiscForm.tsx index 6ed1345..3bd1c9b 100644 --- a/src/components/AddDiscForm.tsx +++ b/src/components/AddDiscForm.tsx @@ -282,7 +282,7 @@ export default function AddDiscForm({ vaultId, tree }: AddDiscFormProps) {
-
+
-
+
Date: Thu, 14 May 2026 20:00:43 -0500 Subject: [PATCH 8/9] fix: use explicit navigation instead of router.back() to prevent history stack bugs on edit/detail flows --- src/components/DiscDetailView.tsx | 7 +++---- src/components/EditDiscForm.tsx | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) 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 */}
- + { 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 () => { From 29801704ca45e2ce84012a3a8e208acf235328c8 Mon Sep 17 00:00:00 2001 From: ChaoticGood007 Date: Thu, 14 May 2026 21:27:00 -0500 Subject: [PATCH 9/9] feat: Intelligent Autocomplete Search and Smart Stacking (v0.7.0) --- CHANGELOG.md | 7 +- src/app/v/[vaultId]/page.tsx | 139 +------------- src/app/v/all/page.tsx | 128 +------------ src/components/AdvancedSearch.tsx | 222 +++++++++++----------- src/components/AutocompleteSearch.tsx | 256 ++++++++++++++++++++++++++ src/components/DashboardToolbar.tsx | 193 ++++++++++--------- src/components/MobileFilterDrawer.tsx | 210 +++++++++------------ src/lib/searchParser.ts | 234 +++++++++++++++++++++++ 8 files changed, 814 insertions(+), 575 deletions(-) create mode 100644 src/components/AutocompleteSearch.tsx create mode 100644 src/lib/searchParser.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3bb53..2ffc725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,12 @@ 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.6.1] - 2026-05-14 +## [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. 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/AdvancedSearch.tsx b/src/components/AdvancedSearch.tsx index 7860a5e..b89c743 100644 --- a/src/components/AdvancedSearch.tsx +++ b/src/components/AdvancedSearch.tsx @@ -1,56 +1,25 @@ -/* - * Copyright 2026 ChaoticGood007 - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - 'use client' -import { useState } from 'react' -import { useRouter, useSearchParams, usePathname } from 'next/navigation' +import { useState, useEffect } from 'react' import { X, SlidersHorizontal, RotateCcw } from 'lucide-react' import dynamic from 'next/dynamic' +import { extractSearchTokens } from '@/lib/searchParser' import MultiSelectDropdown from './MultiSelectDropdown' const LocationTreePicker = dynamic(() => 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/MobileFilterDrawer.tsx b/src/components/MobileFilterDrawer.tsx index 0a653bb..91dca64 100644 --- a/src/components/MobileFilterDrawer.tsx +++ b/src/components/MobileFilterDrawer.tsx @@ -1,10 +1,9 @@ 'use client' import { useState, useEffect } from 'react' -import { useRouter, useSearchParams, usePathname } from 'next/navigation' -import { X, Check, RotateCcw, SlidersHorizontal, Package, Tag, Layers, Filter } from 'lucide-react' -import { type AdvancedFilters, ADVANCED_KEYS } from './AdvancedSearch' +import { X, RotateCcw, SlidersHorizontal, Package, Tag, Layers, Filter } from 'lucide-react' import dynamic from 'next/dynamic' +import { extractSearchTokens } from '@/lib/searchParser' import MultiSelectDropdown from './MultiSelectDropdown' const LocationTreePicker = dynamic(() => 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; +}