diff --git a/README.md b/README.md index 193cdc53..760e19f2 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ If you add or change queries run this command to generate new sqlx SQL files: cargo sqlx prepare --workspace ``` -Schema changes, including new indices, go into the `migrations` folder as SQL DDL scripts. +Schema changes, including new indices, go into the `migrations` folder as SQL DDL scripts. ### Testing diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index 422364ae..67a952d5 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -432,23 +432,37 @@ async fn get_block_heights( let mut query = sqlx::QueryBuilder::new( " WITH filtered_coins AS ( - SELECT cs.coin_id, cs.kind, - cats.ticker, - cats.name, - created_height as height + SELECT cs.coin_id, + cs.kind, + cats.ticker, + cats.name as cat_name, + dids.name as did_name, + nfts.name as nft_name, + cs.created_height as height FROM coin_states cs - LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id - LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id - WHERE created_height IS NOT NULL + LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id + LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id + LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id + LEFT JOIN dids ON did_coins.coin_id = dids.coin_id + LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id + LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id + WHERE cs.created_height IS NOT NULL UNION ALL - SELECT cs.coin_id, cs.kind, - cats.ticker, - cats.name, - spent_height as height + SELECT cs.coin_id, + cs.kind, + cats.ticker, + cats.name as cat_name, + dids.name as did_name, + nfts.name as nft_name, + cs.spent_height as height FROM coin_states cs - LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id - LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id - WHERE spent_height IS NOT NULL + LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id + LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id + LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id + LEFT JOIN dids ON did_coins.coin_id = dids.coin_id + LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id + LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id + WHERE cs.spent_height IS NOT NULL ), filtered_heights AS ( SELECT DISTINCT height @@ -476,7 +490,11 @@ async fn get_block_heights( query .push("ticker LIKE ") .push_bind(format!("%{}%", value)) - .push(" OR name LIKE ") + .push(" OR cat_name LIKE ") + .push_bind(format!("%{}%", value)) + .push(" OR did_name LIKE ") + .push_bind(format!("%{}%", value)) + .push(" OR nft_name LIKE ") .push_bind(format!("%{}%", value)) .push(")"); } diff --git a/crates/sage-database/src/primitives/nfts.rs b/crates/sage-database/src/primitives/nfts.rs index 873fecd5..161c4d8a 100644 --- a/crates/sage-database/src/primitives/nfts.rs +++ b/crates/sage-database/src/primitives/nfts.rs @@ -835,137 +835,94 @@ async fn nfts_by_metadata_hash( .collect() } -fn escape_fts_query(query: &str) -> String { - // First escape backslashes by doubling them - // Then escape quotes by doubling them - // Finally wrap in quotes to treat as literal string - let escaped = query.replace('\\', "\\\\").replace('"', "\"\""); - format!("\"{escaped}\"") -} - async fn search_nfts( conn: impl SqliteExecutor<'_>, params: NftSearchParams, limit: u32, offset: u32, ) -> Result<(Vec, u32)> { - let mut conditions = vec!["is_owned = 1"]; - - // Group filtering (Collection/DID) - match params.group { - Some(NftGroup::Collection(_)) => conditions.push("collection_id = ?"), - Some(NftGroup::NoCollection) => conditions.push("collection_id IS NULL"), - Some(NftGroup::MinterDid(_)) => conditions.push("minter_did = ?"), - Some(NftGroup::NoMinterDid) => conditions.push("minter_did IS NULL"), - Some(NftGroup::OwnerDid(_)) => conditions.push("owner_did = ?"), - Some(NftGroup::NoOwnerDid) => conditions.push("owner_did IS NULL"), - None => {} - } + let mut query = sqlx::QueryBuilder::new( + "SELECT launcher_id, + coin_id, + collection_id, + minter_did, + owner_did, + visible, + sensitive_content, + name, + is_owned, + created_height, + metadata_hash, + is_named, + is_pending, + COUNT(*) OVER() as total_count + FROM nfts + WHERE 1=1 + AND is_owned = 1 + ", + ); - // Visibility condition + // Add visibility condition if not including hidden NFTs if !params.include_hidden { - conditions.push("visible = 1"); + query.push(" AND visible = 1"); } - // Build base conditions - let where_clause = conditions.join(" AND "); - - // Common parts - let order_by = format!( - r"ORDER BY {visible_order} - is_pending DESC, - {sort_order}, - launcher_id ASC - LIMIT ? OFFSET ?", - visible_order = if params.include_hidden { - "visible DESC," - } else { - "" - }, - sort_order = match params.sort_mode { - NftSortMode::Recent => "created_height DESC", - NftSortMode::Name => "is_named DESC, name ASC", + // Add group filtering (Collection/DID) + if let Some(group) = ¶ms.group { + match group { + NftGroup::Collection(id) => { + query.push(" AND collection_id = "); + query.push_bind(id.as_ref()); + } + NftGroup::NoCollection => { + query.push(" AND collection_id IS NULL"); + } + NftGroup::MinterDid(id) => { + query.push(" AND minter_did = "); + query.push_bind(id.as_ref()); + } + NftGroup::NoMinterDid => { + query.push(" AND minter_did IS NULL"); + } + NftGroup::OwnerDid(id) => { + query.push(" AND owner_did = "); + query.push_bind(id.as_ref()); + } + NftGroup::NoOwnerDid => { + query.push(" AND owner_did IS NULL"); + } } - ); + } - // Choose index based on sort mode and group type - let index = match (params.sort_mode, ¶ms.group) { - // Collection grouping - (NftSortMode::Name, Some(NftGroup::Collection(_) | NftGroup::NoCollection)) => { - "nft_col_name" - } - (NftSortMode::Recent, Some(NftGroup::Collection(_) | NftGroup::NoCollection)) => { - "nft_col_recent" - } + // Add name search if present + if let Some(name_search) = ¶ms.name { + query.push(" AND name LIKE "); + query.push_bind(format!("%{}%", name_search)); + } - // Minter DID grouping - (NftSortMode::Name, Some(NftGroup::MinterDid(_) | NftGroup::NoMinterDid)) => { - "nft_minter_did_name" - } - (NftSortMode::Recent, Some(NftGroup::MinterDid(_) | NftGroup::NoMinterDid)) => { - "nft_minter_did_recent" - } + // Add ORDER BY clause based on sort_mode + query.push(" ORDER BY "); + + // Add visible DESC to sort order if including hidden NFTs + if params.include_hidden { + query.push("visible DESC, "); + } - // Owner DID grouping - (NftSortMode::Name, Some(NftGroup::OwnerDid(_) | NftGroup::NoOwnerDid)) => { - "nft_owner_did_name" + match params.sort_mode { + NftSortMode::Recent => { + query.push("is_pending DESC, created_height DESC, launcher_id ASC"); } - (NftSortMode::Recent, Some(NftGroup::OwnerDid(_) | NftGroup::NoOwnerDid)) => { - "nft_owner_did_recent" + NftSortMode::Name => { + query.push("is_pending DESC, is_named DESC, name ASC, launcher_id ASC"); } - - // Global sorting - (NftSortMode::Name, None) => "nft_name", - (NftSortMode::Recent, None) => "nft_recent", - }; - - // Construct query based on whether we're doing a name search - let query = if params.name.is_some() { - format!( - r" - WITH matched_names AS ( - SELECT launcher_id - FROM nft_name_fts - WHERE name MATCH ? || '*' - ORDER BY rank - ) - SELECT nfts.*, COUNT(*) OVER() as total_count - FROM nfts INDEXED BY {index} - INNER JOIN matched_names ON nfts.launcher_id = matched_names.launcher_id - WHERE {where_clause} - {order_by} - " - ) - } else { - format!( - r" - SELECT *, COUNT(*) OVER() as total_count - FROM nfts INDEXED BY {index} - WHERE {where_clause} - {order_by} - " - ) - }; - - // Execute query with bindings - let mut query = sqlx::query_as::<_, NftSearchRow>(&query); - - // Bind name search if present - if let Some(name_search) = params.name { - query = query.bind(escape_fts_query(&name_search)); } - // Bind group parameters if present - //if let Some(NftGroup::Collection(id) | NftGroup::MinterDid(id)) = ¶ms.group { - if let Some(NftGroup::Collection(id) | NftGroup::MinterDid(id) | NftGroup::OwnerDid(id)) = - ¶ms.group - { - query = query.bind(id.as_ref()); - } + query.push(" LIMIT ? OFFSET ?"); + + let query = query.build_query_as::(); - // Limit and offset - query = query.bind(limit); - query = query.bind(offset); + // Bind limit and offset + let query = query.bind(limit).bind(offset); let rows = query.fetch_all(conn).await?; let total_count = rows diff --git a/src/components/NftOptions.tsx b/src/components/NftOptions.tsx index 0a1dc9df..ca2d33a6 100644 --- a/src/components/NftOptions.tsx +++ b/src/components/NftOptions.tsx @@ -34,9 +34,9 @@ import { DropdownMenuTrigger, } from './ui/dropdown-menu'; import { Input } from './ui/input'; -import { Pagination } from './Pagination'; -import { CardSizeToggle } from './CardSizeToggle'; import { motion, AnimatePresence } from 'framer-motion'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useDebounce } from '@/hooks/useDebounce'; export interface NftOptionsProps { isCollection?: boolean; @@ -69,6 +69,34 @@ export function NftOptions({ const navigate = useNavigate(); const isFilteredView = Boolean(collection_id || owner_did || minter_did); const allowSearch = group === NftGroupMode.None || isFilteredView; + const [searchValue, setSearchValue] = useState(query ?? ''); + const debouncedSearch = useDebounce(searchValue, 400); + const prevSearchRef = useRef(query); + + useEffect(() => { + setSearchValue(query ?? ''); + }, [query]); + + useEffect(() => { + if (debouncedSearch !== query) { + const shouldResetPage = prevSearchRef.current !== debouncedSearch; + prevSearchRef.current = debouncedSearch; + + setParams({ + query: debouncedSearch || null, + ...(shouldResetPage && { page: 1 }), + }); + } + }, [debouncedSearch, query, setParams]); + + const handleInputChange = useCallback((value: string) => { + setSearchValue(value); + }, []); + + const handleClearSearch = useCallback(() => { + setSearchValue(''); + }, []); + const handleBack = () => { if (collection_id) { setParams({ group: NftGroupMode.Collection, page: 1 }); @@ -107,24 +135,24 @@ export function NftOptions({ aria-hidden='true' /> setParams({ query: e.target.value, page: 1 })} + onChange={(e) => handleInputChange(e.target.value)} className='w-full pl-8 pr-8' disabled={!allowSearch} aria-disabled={!allowSearch} /> - {query && ( + {searchValue && ( diff --git a/src/components/TransactionOptions.tsx b/src/components/TransactionOptions.tsx index f68dd50e..3d898848 100644 --- a/src/components/TransactionOptions.tsx +++ b/src/components/TransactionOptions.tsx @@ -19,6 +19,8 @@ import { SetTransactionParams, } from '@/hooks/useTransactionsParams'; import { motion, AnimatePresence } from 'framer-motion'; +import { useDebounce } from '@/hooks/useDebounce'; +import { useState, useEffect } from 'react'; const optionsPaginationVariants = { enter: { opacity: 1, y: 0 }, @@ -41,6 +43,18 @@ export function TransactionOptions({ renderPagination, }: TransactionOptionsProps) { const { search, ascending } = params; + const [searchValue, setSearchValue] = useState(search); + const debouncedSearch = useDebounce(searchValue, 400); + + useEffect(() => { + setSearchValue(search); + }, [search]); + + useEffect(() => { + if (debouncedSearch !== search) { + onParamsChange({ search: debouncedSearch, page: 1 }); + } + }, [debouncedSearch, search, onParamsChange]); return ( - {search && ( + {searchValue && ( diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..7316443c --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +/** + * A hook that debounces a value with a specified delay. + * @param value The value to debounce + * @param delay The delay in milliseconds (default: 300ms) + * @returns The debounced value + */ +export function useDebounce(value: T, delay: number = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +}