diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8b9974c4..bc5a6b8c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "axios": "^1.13.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.23.24", "lucide-react": "^0.553.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -2848,6 +2849,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3429,6 +3457,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4440,6 +4483,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6341c6e2..48ec10df 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "axios": "^1.13.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "^12.23.24", "lucide-react": "^0.553.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98028ed3..bc2399d5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,366 +1,58 @@ -import { useState, useCallback, useEffect } from 'react'; -import { QueryClient, QueryClientProvider, useQuery, useMutation } from '@tanstack/react-query'; -import axios from 'axios'; -import { Search, Clock, Globe, Chrome, Compass, Sparkles, TrendingUp, RefreshCw, Globe2, Layers, Star } from 'lucide-react'; -import { format } from 'date-fns'; -import { clsx } from 'clsx'; +/** + * Master-Class Command Palette App + * Replaces the old search interface with the new CommandPalette + */ -const queryClient = new QueryClient(); -const API_BASE = 'http://localhost:3000/api'; +import { useState, useEffect } from 'react'; +import { CommandPalette } from './CommandPalette'; +import { Sparkles } from 'lucide-react'; -interface SearchResult { - url: string; - title?: string; - visit_time: string; - visit_count: number; - relevance_score: number; - browser_source: string; - domain: string; - related_urls: string[]; -} - -interface SearchResponse { - results: SearchResult[]; - total: number; - query_time_ms: number; -} - -function SearchInterface() { - const [query, setQuery] = useState(''); - const [searchTerm, setSearchTerm] = useState(''); - const [selectedBrowsers, setSelectedBrowsers] = useState([]); - const [wsConnection, setWsConnection] = useState(null); - const [realtimeResults, setRealtimeResults] = useState([]); +function App() { + const [isOpen, setIsOpen] = useState(true); // Start open for demo, can be toggled with Cmd+K - // Connect WebSocket for real-time search + // Keyboard shortcut: Cmd+K or Ctrl+K to toggle useEffect(() => { - const ws = new WebSocket('ws://localhost:3000/ws'); - - ws.onopen = () => { - console.log('WebSocket connected'); - setWsConnection(ws); - }; - - ws.onmessage = (event) => { - const data = JSON.parse(event.data); - if (data.type === 'search_results') { - setRealtimeResults(data.results); + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setIsOpen((prev) => !prev); + } + if (e.key === 'Escape' && isOpen) { + setIsOpen(false); } }; - ws.onerror = (error) => { - console.error('WebSocket error:', error); - }; - - return () => { - ws.close(); - }; - }, []); - - // Search query - const { data: searchData, isLoading, refetch } = useQuery({ - queryKey: ['search', searchTerm, selectedBrowsers], - queryFn: async () => { - if (!searchTerm) return null; - const response = await axios.post(`${API_BASE}/search`, { - query: searchTerm, - limit: 50, - browsers: selectedBrowsers.length > 0 ? selectedBrowsers : undefined, - }); - return response.data; - }, - enabled: !!searchTerm, - }); - - // Suggestions query - const { data: suggestions } = useQuery({ - queryKey: ['suggestions', query], - queryFn: async () => { - if (query.length < 2) return []; - const response = await axios.get(`${API_BASE}/suggest`, { - params: { query } - }); - return response.data.suggestions; - }, - enabled: query.length >= 2, - }); - - // Popular URLs - const { data: popularUrls } = useQuery({ - queryKey: ['popular'], - queryFn: async () => { - const response = await axios.get(`${API_BASE}/popular`); - return response.data.popular; - }, - }); - - // Domains - const { data: domains } = useQuery({ - queryKey: ['domains'], - queryFn: async () => { - const response = await axios.get(`${API_BASE}/domains`); - return response.data.domains; - }, - }); - - // Index mutation - const indexMutation = useMutation({ - mutationFn: async () => { - const response = await axios.post(`${API_BASE}/index`); - return response.data; - }, - onSuccess: () => { - queryClient.invalidateQueries(); - }, - }); - - const handleSearch = useCallback((e: React.FormEvent) => { - e.preventDefault(); - setSearchTerm(query); - - // Send via WebSocket for real-time results - if (wsConnection && wsConnection.readyState === WebSocket.OPEN) { - wsConnection.send(JSON.stringify({ - query, - limit: 50, - browsers: selectedBrowsers.length > 0 ? selectedBrowsers : undefined, - })); - } - }, [query, selectedBrowsers, wsConnection]); - - const toggleBrowser = (browser: string) => { - setSelectedBrowsers(prev => - prev.includes(browser) - ? prev.filter(b => b !== browser) - : [...prev, browser] - ); - }; - - const getBrowserIcon = (browser: string) => { - switch (browser.toLowerCase()) { - case 'chrome': return ; - case 'safari': return ; - case 'arc': return ; - case 'comet': return ; - case 'thorium': return ; - default: return ; - } - }; - - const results = realtimeResults.length > 0 ? realtimeResults : searchData?.results || []; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); return (
- {/* Header */} -
-
-
-
- -

- Fast Browser Search -

-
+ {/* Fallback UI when palette is closed */} + {!isOpen && ( +
+
+ +

+ Fast Browser Search +

+

+ Press ⌘K or Ctrl+K to open the command palette +

-
+ )} -
- {/* Search Bar */} -
-
- - setQuery(e.target.value)} - placeholder="Search your browsing history..." - className="w-full pl-12 pr-4 py-4 text-lg bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500" - autoFocus - /> -
- - {/* Suggestions */} - {suggestions && suggestions.length > 0 && ( -
- {suggestions.map((suggestion: string) => ( - - ))} -
- )} -
- - {/* Browser Filters */} -
- {['Chrome', 'Safari', 'Arc', 'Comet', 'Genspark', 'Thorium'].map(browser => ( - - ))} -
- -
- {/* Search Results */} -
- {isLoading && ( -
-
-
- )} - - {searchData && ( -
- Found {searchData.total} results in {searchData.query_time_ms}ms -
- )} - -
- {results.map((result, index) => ( -
- -

- {result.title || result.url} -

-

- {result.url} -

-
- -
-
- - - {format(new Date(result.visit_time), 'MMM d, yyyy')} - - - {getBrowserIcon(result.browser_source)} - {result.browser_source} - - {result.visit_count} visits -
-
- - {result.related_urls && result.related_urls.length > 0 && ( -
-

Related:

-
- {result.related_urls.slice(0, 3).map(url => ( - - {new URL(url).hostname} - - ))} -
-
- )} -
- ))} -
-
- - {/* Sidebar */} -
- {/* Popular URLs */} - {popularUrls && popularUrls.length > 0 && ( -
-

- - Most Visited -

-
- {popularUrls.slice(0, 5).map((item: any) => ( - -
{new URL(item.url).hostname}
-
{item.visits} visits
-
- ))} -
-
- )} - - {/* Top Domains */} - {domains && domains.length > 0 && ( -
-

- - Top Domains -

-
- {domains.slice(0, 10).map((domain: string) => ( - - ))} -
-
- )} -
-
-
+ {/* Command Palette */} + {isOpen && setIsOpen(false)} />}
); } -function App() { - return ( - - - - ); -} - -export default App +export default App; diff --git a/frontend/src/CommandPalette.tsx b/frontend/src/CommandPalette.tsx new file mode 100644 index 00000000..14cf04f8 --- /dev/null +++ b/frontend/src/CommandPalette.tsx @@ -0,0 +1,545 @@ +/** + * CommandPalette Component + * Master-class UI with Framer Motion physics, ghost text, and micro-interactions + */ + +import { useEffect, useRef, useState } from 'react'; +import { motion, AnimatePresence, LayoutGroup, useMotionValue, useSpring, useTransform } from 'framer-motion'; +import { Search, Clock, Globe, Chrome, Compass, Sparkles, TrendingUp, X, ArrowRight, Check, AlertCircle } from 'lucide-react'; +import { format } from 'date-fns'; +import { useCommandEngine } from './useCommandEngine'; +import { easings, durations } from './tokens'; +import { recentSearches } from './api'; + +// Browser icon mapping +const getBrowserIcon = (browser: string) => { + switch (browser.toLowerCase()) { + case 'chrome': return Chrome; + case 'safari': return Globe; + case 'arc': return Compass; + default: return Globe; + } +}; + +// Ghost Input Component (inline autocomplete) +function GhostInput({ + input, + suggestions +}: { + input: string; + suggestions: string[] +}) { + const ghostText = suggestions.find(s => + s.toLowerCase().startsWith(input.toLowerCase()) && s !== input + ); + + if (!ghostText || !input) return null; + + return ( + + {ghostText.slice(input.length)} + + ); +} + +// Result Row Component +function ResultRow({ + result, + index, + isSelected, + onSelect, + onNavigate +}: { + result: any; + index: number; + isSelected: boolean; + onSelect: () => void; + onNavigate: () => void; +}) { + const BrowserIcon = getBrowserIcon(result.browser_source); + const [isHovered, setIsHovered] = useState(false); + + return ( + setIsHovered(true)} + onHoverEnd={() => setIsHovered(false)} + onClick={onNavigate} + onMouseEnter={onSelect} + className="relative cursor-pointer" + > + {/* Selection Highlight with layoutId for fluid morphing */} + {isSelected && ( + + )} + +
+ {/* Icon with micro-interaction */} + + + + + {/* Content */} +
+
+

+ {result.title || new URL(result.url).hostname} +

+ {isSelected && ( + + + + )} +
+

+ {result.url} +

+
+ + + {format(new Date(result.visit_time), 'MMM d')} + + {result.visit_count} visits + {result.relevance_score > 0 && ( + + {Math.round(result.relevance_score * 100)}% match + + )} +
+
+ + {/* Arrow indicator */} + + + +
+
+ ); +} + +// Preview Pane Component +function PreviewPane({ + result, + isVisible +}: { + result: any | null; + isVisible: boolean; +}) { + if (!result) return null; + + const BrowserIcon = getBrowserIcon(result.browser_source); + + return ( + + {isVisible && result && ( + +
+
+
+ + {result.browser_source} +
+

+ {result.title || new URL(result.url).hostname} +

+ + {result.url} + +
+ +
+
+ Visits + {result.visit_count} +
+
+ Last visited + + {format(new Date(result.visit_time), 'MMM d, yyyy')} + +
+ {result.relevance_score > 0 && ( +
+ Relevance + + {Math.round(result.relevance_score * 100)}% + +
+ )} +
+ + {result.related_urls && result.related_urls.length > 0 && ( +
+

Related

+
+ {result.related_urls.slice(0, 5).map((url: string) => ( + + {new URL(url).hostname} + + ))} +
+
+ )} +
+
+ )} +
+ ); +} + +// Main CommandPalette Component +export function CommandPalette({ onClose }: { onClose?: () => void }) { + const { state, actions } = useCommandEngine(); + const inputRef = useRef(null); + const containerRef = useRef(null); + const [showPreview, setShowPreview] = useState(false); + const lastInputTimeRef = useRef(Date.now()); + + // Physics-based input animation based on typing velocity + const inputScale = useMotionValue(1); + const inputGlow = useMotionValue(0); + const scaleSpring = useSpring(inputScale, { stiffness: 400, damping: 30 }); + const glowSpring = useSpring(inputGlow, { stiffness: 300, damping: 25 }); + const glowOpacity = useTransform(glowSpring, [0, 1], [0, 0.3]); + + // Track typing velocity + useEffect(() => { + if (state.input.length > 0) { + const now = Date.now(); + const timeDelta = now - lastInputTimeRef.current; + const velocity = timeDelta > 0 ? 1000 / timeDelta : 0; + lastInputTimeRef.current = now; + + // Trigger input animation + inputScale.set(1 + velocity * 0.01); + inputGlow.set(Math.min(velocity * 0.1, 1)); + } else { + inputScale.set(1); + inputGlow.set(0); + } + }, [state.input, inputScale, inputGlow]); + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + actions.selectNext(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + actions.selectPrev(); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (state.results[state.selection]) { + actions.navigateToResult(state.selection); + } + } else if (e.key === 'Escape') { + if (state.input) { + actions.clear(); + } else if (onClose) { + onClose(); + } + inputRef.current?.blur(); + } else if (e.key === 'Tab' && state.results[state.selection]) { + e.preventDefault(); + setShowPreview(!showPreview); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [state.selection, state.results, actions, showPreview]); + + // Auto-focus input + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Get suggestions for ghost text + const suggestions = useRef([]); + useEffect(() => { + if (state.input.length >= 2) { + // Simple suggestion based on popular/recent + const all = [ + ...state.popular.map(p => p.url), + ...state.recent.map(r => r.query), + ]; + suggestions.current = Array.from(new Set(all)).slice(0, 10); + } else { + suggestions.current = []; + } + }, [state.input, state.popular, state.recent]); + + const selectedResult = state.results[state.selection] || null; + + return ( +
{ + // Close on backdrop click + if (e.target === e.currentTarget && onClose) { + onClose(); + } + }} + > + + {/* Input Container with Physics */} +
+ + + actions.setInput(e.target.value)} + placeholder="Search your browsing history..." + className="w-full pl-12 pr-10 py-3 bg-transparent text-base text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none" + /> + + {state.input && ( + + + + )} + + + {/* Status Bar */} + {(state.isLoading || state.queryTime !== null || state.error) && ( + + {state.isLoading && ( + + + + + Searching... + + )} + {state.queryTime !== null && !state.isLoading && ( + Found {state.results.length} results in {state.queryTime}ms + )} + {state.error && ( + + + {state.error} + + )} + + )} +
+ + {/* Results Container with LayoutGroup for FLIP animations */} +
+ + + {state.input.trim() ? ( + // Search Results + state.results.length > 0 ? ( + + {state.results.map((result, index) => ( + actions.selectIndex(index)} + onNavigate={() => actions.navigateToResult(index)} + /> + ))} + + ) : !state.isLoading ? ( + +

No results found

+

Try a different search term

+
+ ) : null + ) : ( + // Initial State: Popular + Recent + + {state.recent.length > 0 && ( +
+

+ Recent Searches +

+
+ {state.recent.slice(0, 5).map((item, index) => ( + { + actions.setInput(item.query); + actions.search(item.query); + }} + className="w-full text-left px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm text-gray-900 dark:text-gray-100 flex items-center justify-between" + > + + + {item.query} + + {item.resultCount && ( + + {item.resultCount} results + + )} + + ))} +
+
+ )} + + {state.popular.length > 0 && ( +
+

+ + Most Visited +

+
+ {state.popular.slice(0, 10).map((item, index) => ( + { + window.open(item.url, '_blank', 'noopener,noreferrer'); + // Add to recent + recentSearches.add(item.url, 1); + }} + className="w-full text-left px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm text-gray-900 dark:text-gray-100 flex items-center justify-between" + > + {new URL(item.url).hostname} + + {item.visits} visits + + + ))} +
+
+ )} +
+ )} +
+
+
+ + {/* Preview Pane */} + +
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 00000000..4ae8ef38 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,210 @@ +/** + * API Layer with Rust Type Definitions + * Strict type safety for all backend endpoints + */ + +import axios from 'axios'; + +const API_BASE = 'http://localhost:3000/api'; + +// Rust-compatible types matching backend +export interface SearchResult { + url: string; + title?: string; + visit_time: string; + visit_count: number; + relevance_score: number; + browser_source: string; + domain: string; + related_urls: string[]; +} + +export interface SearchRequest { + query: string; + limit?: number; + browsers?: string[]; + use_semantic?: boolean; +} + +export interface SearchResponse { + results: SearchResult[]; + total: number; + query_time_ms: number; +} + +export interface PopularUrl { + url: string; + visits: number; + title?: string; + domain: string; +} + +export interface PopularResponse { + popular: PopularUrl[]; +} + +export interface SuggestResponse { + suggestions: string[]; +} + +export interface DomainsResponse { + domains: string[]; +} + +export interface RelatedResponse { + related: string[]; +} + +// API Client +class ApiClient { + private baseUrl: string; + + constructor(baseUrl: string = API_BASE) { + this.baseUrl = baseUrl; + } + + /** + * Search with optional semantic search + */ + async search(request: SearchRequest): Promise { + const response = await axios.post( + `${this.baseUrl}/search`, + request + ); + return response.data; + } + + /** + * Semantic search (alternative endpoint) + */ + async semanticSearch(request: SearchRequest): Promise { + const response = await axios.post( + `${this.baseUrl}/semantic/search`, + { ...request, use_semantic: true } + ); + return response.data; + } + + /** + * Get search suggestions + */ + async getSuggestions(query: string): Promise { + if (query.length < 2) return []; + const response = await axios.get( + `${this.baseUrl}/suggest`, + { params: { query } } + ); + return response.data.suggestions; + } + + /** + * Get popular URLs + */ + async getPopular(limit: number = 20): Promise { + const response = await axios.get( + `${this.baseUrl}/popular`, + { params: { limit } } + ); + return response.data.popular; + } + + /** + * Get all indexed domains + */ + async getDomains(): Promise { + const response = await axios.get( + `${this.baseUrl}/domains` + ); + return response.data.domains; + } + + /** + * Get related URLs for a given URL + */ + async getRelated(url: string, limit: number = 10): Promise { + const response = await axios.get( + `${this.baseUrl}/related`, + { params: { url, limit } } + ); + return response.data.related; + } + + /** + * Trigger re-indexing + */ + async index(): Promise { + await axios.post(`${this.baseUrl}/index`); + } + + /** + * Health check + */ + async health(): Promise { + try { + const response = await axios.get(`${this.baseUrl.replace('/api', '')}/health`); + return response.status === 200; + } catch { + return false; + } + } +} + +// Singleton instance +export const api = new ApiClient(); + +// LocalStorage helpers for recent searches +const RECENT_SEARCHES_KEY = 'fast-browser-search:recent'; +const MAX_RECENT = 10; + +export interface RecentSearch { + query: string; + timestamp: number; + resultCount?: number; +} + +export const recentSearches = { + /** + * Get recent searches from localStorage + */ + get(): RecentSearch[] { + try { + const stored = localStorage.getItem(RECENT_SEARCHES_KEY); + if (!stored) return []; + const items = JSON.parse(stored) as RecentSearch[]; + // Sort by timestamp, most recent first + return items.sort((a, b) => b.timestamp - a.timestamp).slice(0, MAX_RECENT); + } catch { + return []; + } + }, + + /** + * Add a search to recent history + */ + add(query: string, resultCount?: number): void { + try { + const items = this.get(); + // Remove duplicates + const filtered = items.filter(item => item.query !== query); + // Add new item at the beginning + const updated = [ + { query, timestamp: Date.now(), resultCount }, + ...filtered, + ].slice(0, MAX_RECENT); + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated)); + } catch { + // Ignore localStorage errors + } + }, + + /** + * Clear recent searches + */ + clear(): void { + try { + localStorage.removeItem(RECENT_SEARCHES_KEY); + } catch { + // Ignore localStorage errors + } + }, +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c95..adfd2ef3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,19 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + * { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + font-family: 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: subpixel-antialiased; + -moz-osx-font-smoothing: auto; + } +} diff --git a/frontend/src/tokens.ts b/frontend/src/tokens.ts new file mode 100644 index 00000000..2edcbaca --- /dev/null +++ b/frontend/src/tokens.ts @@ -0,0 +1,164 @@ +/** + * Master-Class Design Tokens + * Physics-based, snappy animations with semantic color system + */ + +// Physics: Snappy bezier curves for ultra-fast, responsive interactions +export const easings = { + snappy: [0.18, 0.9, 0.22, 1] as const, + spring: [0.34, 1.56, 0.64, 1] as const, + smooth: [0.4, 0, 0.2, 1] as const, + bounce: [0.68, -0.55, 0.265, 1.55] as const, +} as const; + +// Timing: Ultra-fast for instant feedback +export const durations = { + instant: 100, + fast: 150, + normal: 200, + slow: 300, + slower: 500, +} as const; + +// Semantic Colors: Light & Dark modes +export const colors = { + light: { + background: { + primary: '#ffffff', + secondary: '#f8f9fa', + tertiary: '#f1f3f5', + overlay: 'rgba(0, 0, 0, 0.4)', + }, + surface: { + primary: '#ffffff', + elevated: '#ffffff', + hover: '#f8f9fa', + active: '#e9ecef', + }, + text: { + primary: '#1a1a1a', + secondary: '#6b7280', + tertiary: '#9ca3af', + inverse: '#ffffff', + }, + border: { + default: '#e5e7eb', + focus: '#3b82f6', + hover: '#d1d5db', + }, + accent: { + primary: '#3b82f6', + hover: '#2563eb', + active: '#1d4ed8', + glow: 'rgba(59, 130, 246, 0.3)', + }, + highlight: { + default: '#eff6ff', + hover: '#dbeafe', + active: '#bfdbfe', + }, + }, + dark: { + background: { + primary: '#0f172a', + secondary: '#1e293b', + tertiary: '#334155', + overlay: 'rgba(0, 0, 0, 0.6)', + }, + surface: { + primary: '#1e293b', + elevated: '#334155', + hover: '#334155', + active: '#475569', + }, + text: { + primary: '#f8fafc', + secondary: '#cbd5e1', + tertiary: '#94a3b8', + inverse: '#0f172a', + }, + border: { + default: '#334155', + focus: '#60a5fa', + hover: '#475569', + }, + accent: { + primary: '#60a5fa', + hover: '#3b82f6', + active: '#2563eb', + glow: 'rgba(96, 165, 250, 0.3)', + }, + highlight: { + default: '#1e3a5f', + hover: '#1e40af', + active: '#1e3a8a', + }, + }, +} as const; + +// Typography +export const typography = { + fontFamily: { + sans: ['Inter', 'SF Pro Display', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + mono: ['SF Mono', 'Monaco', 'Cascadia Code', 'monospace'], + }, + fontSize: { + xs: '0.75rem', + sm: '0.875rem', + base: '1rem', + lg: '1.125rem', + xl: '1.25rem', + '2xl': '1.5rem', + '3xl': '1.875rem', + }, + fontWeight: { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, + }, + lineHeight: { + tight: 1.2, + normal: 1.5, + relaxed: 1.75, + }, +} as const; + +// Spacing +export const spacing = { + xs: '0.25rem', + sm: '0.5rem', + md: '1rem', + lg: '1.5rem', + xl: '2rem', + '2xl': '3rem', +} as const; + +// Shadows +export const shadows = { + sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', + md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', + lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', + xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', + glow: '0 0 20px rgba(59, 130, 246, 0.3)', +} as const; + +// Z-index layers +export const zIndex = { + base: 0, + dropdown: 100, + overlay: 200, + modal: 300, + tooltip: 400, +} as const; + +// Command Palette specific +export const commandPalette = { + width: '640px', + maxHeight: '600px', + borderRadius: '12px', + itemHeight: '48px', + itemPadding: '12px 16px', + inputHeight: '56px', + previewWidth: '400px', +} as const; diff --git a/frontend/src/useCommandEngine.ts b/frontend/src/useCommandEngine.ts new file mode 100644 index 00000000..b866b3fa --- /dev/null +++ b/frontend/src/useCommandEngine.ts @@ -0,0 +1,285 @@ +/** + * useCommandEngine Hook + * Master-class logic layer with hybrid state, smart data fusion, and instant cache + */ + +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { api, recentSearches } from './api'; +import type { SearchResult, PopularUrl, RecentSearch } from './api'; + +// LRU Cache for instant back-navigation +class LRUCache { + private capacity: number; + private cache: Map; + + constructor(capacity: number = 50) { + this.capacity = capacity; + this.cache = new Map(); + } + + get(key: K): V | undefined { + if (!this.cache.has(key)) return undefined; + const value = this.cache.get(key); + if (value === undefined) return undefined; + // Move to end (most recently used) + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.cache.size >= this.capacity) { + // Remove least recently used (first item) + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + this.cache.set(key, value); + } + + clear(): void { + this.cache.clear(); + } + + size(): number { + return this.cache.size; + } +} + +export interface CommandState { + input: string; + results: SearchResult[]; + popular: PopularUrl[]; + recent: RecentSearch[]; + selection: number; + isLoading: boolean; + error: string | null; + queryTime: number | null; +} + +export interface CommandActions { + setInput: (value: string) => void; + search: (query: string) => Promise; + selectNext: () => void; + selectPrev: () => void; + selectIndex: (index: number) => void; + clear: () => void; + navigateToResult: (index: number) => void; +} + +export interface UseCommandEngineReturn { + state: CommandState; + actions: CommandActions; +} + +export function useCommandEngine(): UseCommandEngineReturn { + // Hybrid State + const [input, setInput] = useState(''); + const [results, setResults] = useState([]); + const [popular, setPopular] = useState([]); + const [recent, setRecent] = useState([]); + const [selection, setSelection] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [queryTime, setQueryTime] = useState(null); + + // Instant Cache: LRU Map for query -> results + const cacheRef = useRef(new LRUCache()); + + // Search history for back-navigation + const historyRef = useRef([]); + const historyIndexRef = useRef(-1); + + interface SearchResponse { + results: SearchResult[]; + queryTime: number; + } + + // Smart Data Fusion: Parallel fetch popular + recent on mount + useEffect(() => { + let cancelled = false; + + const loadInitialData = async () => { + try { + // Parallel fetch + const [popularData, recentData] = await Promise.all([ + api.getPopular(20), + Promise.resolve(recentSearches.get()), + ]); + + if (!cancelled) { + setPopular(popularData); + setRecent(recentData); + } + } catch (err) { + if (!cancelled) { + console.error('Failed to load initial data:', err); + } + } + }; + + loadInitialData(); + + return () => { + cancelled = true; + }; + }, []); + + // Search function with cache and history + const search = useCallback(async (query: string) => { + if (!query.trim()) { + setResults([]); + setQueryTime(null); + return; + } + + // Check cache first + const cached = cacheRef.current.get(query); + if (cached) { + setResults(cached.results); + setQueryTime(cached.queryTime); + setSelection(0); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await api.search({ + query, + limit: 50, + use_semantic: true, // Use semantic search by default + }); + + const searchResponse: SearchResponse = { + results: response.results, + queryTime: response.query_time_ms, + }; + + // Cache the results + cacheRef.current.set(query, searchResponse); + + // Update state + setResults(response.results); + setQueryTime(response.query_time_ms); + setSelection(0); + + // Add to recent searches + recentSearches.add(query, response.total); + + // Update history for back-navigation + historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1); + historyRef.current.push(query); + historyIndexRef.current = historyRef.current.length - 1; + + // Reload recent searches + setRecent(recentSearches.get()); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Search failed'; + setError(errorMessage); + setResults([]); + setQueryTime(null); + } finally { + setIsLoading(false); + } + }, []); + + // Debounced search on input change + const searchTimeoutRef = useRef(undefined); + useEffect(() => { + if (searchTimeoutRef.current !== undefined) { + clearTimeout(searchTimeoutRef.current); + } + + if (input.trim()) { + searchTimeoutRef.current = window.setTimeout(() => { + search(input); + }, 300) as unknown as number; // 300ms debounce + } else { + setResults([]); + setQueryTime(null); + } + + return () => { + if (searchTimeoutRef.current !== undefined) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, [input, search]); + + // Selection navigation + const selectNext = useCallback(() => { + setSelection((prev) => { + const maxIndex = results.length - 1; + return prev < maxIndex ? prev + 1 : prev; + }); + }, [results.length]); + + const selectPrev = useCallback(() => { + setSelection((prev) => (prev > 0 ? prev - 1 : 0)); + }, []); + + const selectIndex = useCallback((index: number) => { + if (index >= 0 && index < results.length) { + setSelection(index); + } + }, [results.length]); + + // Navigate to result (open URL) + const navigateToResult = useCallback((index: number) => { + if (index >= 0 && index < results.length) { + const result = results[index]; + window.open(result.url, '_blank', 'noopener,noreferrer'); + + // Add to recent searches + recentSearches.add(result.url, 1); + setRecent(recentSearches.get()); + } + }, [results]); + + // Clear function + const clear = useCallback(() => { + setInput(''); + setResults([]); + setSelection(0); + setError(null); + setQueryTime(null); + historyRef.current = []; + historyIndexRef.current = -1; + }, []); + + // Actions object + const actions: CommandActions = useMemo( + () => ({ + setInput, + search, + selectNext, + selectPrev, + selectIndex, + clear, + navigateToResult, + }), + [search, selectNext, selectPrev, selectIndex, clear, navigateToResult] + ); + + // State object + const state: CommandState = useMemo( + () => ({ + input, + results, + popular, + recent, + selection, + isLoading, + error, + queryTime, + }), + [input, results, popular, recent, selection, isLoading, error, queryTime] + ); + + return { state, actions }; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 89a305e0..e12410d3 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -5,7 +5,49 @@ export default { "./src/**/*.{js,ts,jsx,tsx}", ], theme: { - extend: {}, + extend: { + colors: { + // Light mode colors + 'bg-primary': '#ffffff', + 'bg-secondary': '#f8f9fa', + 'bg-tertiary': '#f1f3f5', + 'surface-primary': '#ffffff', + 'surface-elevated': '#ffffff', + 'surface-hover': '#f8f9fa', + 'surface-active': '#e9ecef', + 'text-primary': '#1a1a1a', + 'text-secondary': '#6b7280', + 'text-tertiary': '#9ca3af', + 'border-default': '#e5e7eb', + 'border-focus': '#3b82f6', + 'border-hover': '#d1d5db', + 'accent-primary': '#3b82f6', + 'accent-hover': '#2563eb', + 'accent-active': '#1d4ed8', + 'highlight-default': '#eff6ff', + 'highlight-hover': '#dbeafe', + 'highlight-active': '#bfdbfe', + }, + fontFamily: { + sans: ['Inter', 'SF Pro Display', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], + mono: ['SF Mono', 'Monaco', 'Cascadia Code', 'monospace'], + }, + transitionTimingFunction: { + 'snappy': 'cubic-bezier(0.18, 0.9, 0.22, 1)', + 'spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)', + 'bounce': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', + }, + transitionDuration: { + 'instant': '100ms', + 'fast': '150ms', + 'normal': '200ms', + 'slow': '300ms', + 'slower': '500ms', + }, + boxShadow: { + 'glow': '0 0 20px rgba(59, 130, 246, 0.3)', + }, + }, }, plugins: [], } \ No newline at end of file