- Add semantic search API endpoint with mock data and debounced search - Create useSearch hook for managing search state and API calls - Implement SearchResults component with rich item display - Add ImageDropzone component with drag-and-drop file upload support - Create upload API endpoint for file and URL processing - Build ItemDetailsModal with comprehensive metadata display and actions - Add Badge UI component to design system - Integrate search functionality into main interface with real-time results - Support multiple image formats with validation and size limits - Add item management actions (share, download, copy, favorite, edit, delete) Core features now working: ✅ Real-time semantic search with debouncing ✅ Image upload via drag-and-drop or URL ✅ Detailed item view with metadata and actions ✅ Responsive UI with proper loading states and error handling
176 lines
4.0 KiB
TypeScript
176 lines
4.0 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
|
|
export interface SearchResult {
|
|
id: string;
|
|
type: 'image' | 'video' | 'link' | 'note';
|
|
title: string;
|
|
description: string;
|
|
url: string;
|
|
tags: string[];
|
|
source: string;
|
|
dateAdded: string;
|
|
collection: string;
|
|
thumbnail: string;
|
|
}
|
|
|
|
export interface SearchResponse {
|
|
results: SearchResult[];
|
|
total: number;
|
|
query: string;
|
|
searchTime?: string;
|
|
message?: string;
|
|
}
|
|
|
|
export interface SearchState {
|
|
query: string;
|
|
results: SearchResult[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
hasSearched: boolean;
|
|
total: number;
|
|
searchTime: string | null;
|
|
}
|
|
|
|
export interface SearchOptions {
|
|
type?: 'image' | 'video' | 'link' | 'note' | 'all';
|
|
limit?: number;
|
|
}
|
|
|
|
export function useSearch() {
|
|
const [state, setState] = useState<SearchState>({
|
|
query: '',
|
|
results: [],
|
|
isLoading: false,
|
|
error: null,
|
|
hasSearched: false,
|
|
total: 0,
|
|
searchTime: null,
|
|
});
|
|
|
|
const debounceTimeoutRef = useRef<NodeJS.Timeout>();
|
|
const abortControllerRef = useRef<AbortController>();
|
|
|
|
const search = useCallback(async (
|
|
query: string,
|
|
options: SearchOptions = {}
|
|
) => {
|
|
// Clear previous timeout
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
|
|
// Cancel previous request
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
|
|
// Create new abort controller
|
|
abortControllerRef.current = new AbortController();
|
|
|
|
// Debounce search to avoid too many API calls
|
|
return new Promise<SearchResponse>((resolve, reject) => {
|
|
debounceTimeoutRef.current = setTimeout(async () => {
|
|
if (!query.trim()) {
|
|
setState(prev => ({
|
|
...prev,
|
|
query: '',
|
|
results: [],
|
|
isLoading: false,
|
|
error: null,
|
|
hasSearched: false,
|
|
total: 0,
|
|
searchTime: null,
|
|
}));
|
|
resolve({ results: [], total: 0, query: '' });
|
|
return;
|
|
}
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
query: query.trim(),
|
|
isLoading: true,
|
|
error: null,
|
|
}));
|
|
|
|
try {
|
|
const searchParams = new URLSearchParams({
|
|
q: query.trim(),
|
|
type: options.type || 'all',
|
|
limit: (options.limit || 20).toString(),
|
|
});
|
|
|
|
const response = await fetch(`/api/search?${searchParams}`, {
|
|
signal: abortControllerRef.current?.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Search failed: ${response.statusText}`);
|
|
}
|
|
|
|
const data: SearchResponse = await response.json();
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
results: data.results,
|
|
isLoading: false,
|
|
hasSearched: true,
|
|
total: data.total,
|
|
searchTime: data.searchTime || null,
|
|
error: null,
|
|
}));
|
|
|
|
resolve(data);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
// Request was cancelled, don't update state
|
|
return;
|
|
}
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Search failed';
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
isLoading: false,
|
|
error: errorMessage,
|
|
}));
|
|
|
|
reject(error);
|
|
}
|
|
}, 300); // 300ms debounce
|
|
});
|
|
}, []);
|
|
|
|
// Clear search results
|
|
const clearSearch = useCallback(() => {
|
|
setState({
|
|
query: '',
|
|
results: [],
|
|
isLoading: false,
|
|
error: null,
|
|
hasSearched: false,
|
|
total: 0,
|
|
searchTime: null,
|
|
});
|
|
}, []);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimeoutRef.current) {
|
|
clearTimeout(debounceTimeoutRef.current);
|
|
}
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
...state,
|
|
search,
|
|
clearSearch,
|
|
};
|
|
}
|