feat: add Unsplash search hook and API proxy routes

- Implemented `useUnsplashSearch` hook for searching Unsplash photos with debounce.
- Created Unsplash API client functions for searching photos and triggering downloads.
- Added proxy routes for Unsplash API to handle search requests and background image uploads.
- Introduced accessibility utilities for WCAG compliance checks and motion preference management.
- Developed theme sharing module for encoding and decoding theme configurations as base64url strings.
This commit is contained in:
catlog22
2026-02-08 20:01:28 +08:00
parent 87daccdc48
commit 166211dcd4
52 changed files with 5798 additions and 142 deletions

View File

@@ -56,11 +56,16 @@ import {
type CodexLensWorkspaceStatus,
type CodexLensSearchParams,
type CodexLensSearchResponse,
type CodexLensFileSearchResponse,
type CodexLensSymbolSearchResponse,
type CodexLensIndexesResponse,
type CodexLensIndexingStatusResponse,
type CodexLensSemanticInstallResponse,
type CodexLensWatcherStatusResponse,
type CodexLensLspStatusResponse,
type CodexLensSemanticSearchParams,
type CodexLensSemanticSearchResponse,
fetchCodexLensLspStatus,
semanticSearchCodexLens,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -83,6 +88,8 @@ export const codexLensKeys = {
search: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'search', params] as const,
filesSearch: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'filesSearch', params] as const,
symbolSearch: (params: Pick<CodexLensSearchParams, 'query' | 'limit'>) => [...codexLensKeys.all, 'symbolSearch', params] as const,
lspStatus: () => [...codexLensKeys.all, 'lspStatus'] as const,
semanticSearch: (params: CodexLensSemanticSearchParams) => [...codexLensKeys.all, 'semanticSearch', params] as const,
watcher: () => [...codexLensKeys.all, 'watcher'] as const,
};
@@ -1288,10 +1295,18 @@ export function useCodexLensSearch(params: CodexLensSearchParams, options: UseCo
};
}
export interface UseCodexLensFileSearchReturn {
data: CodexLensFileSearchResponse | undefined;
files: string[] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for file search using CodexLens
*/
export function useCodexLensFilesSearch(params: CodexLensSearchParams, options: UseCodexLensSearchOptions = {}): UseCodexLensSearchReturn {
export function useCodexLensFilesSearch(params: CodexLensSearchParams, options: UseCodexLensSearchOptions = {}): UseCodexLensFileSearchReturn {
const { enabled = false } = options;
const query = useQuery({
@@ -1308,7 +1323,7 @@ export function useCodexLensFilesSearch(params: CodexLensSearchParams, options:
return {
data: query.data,
results: query.data?.results,
files: query.data?.files,
isLoading: query.isLoading,
error: query.error,
refetch,
@@ -1357,6 +1372,98 @@ export function useCodexLensSymbolSearch(
};
}
// ========== LSP / Semantic Search Hooks ==========
export interface UseCodexLensLspStatusOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensLspStatusReturn {
data: CodexLensLspStatusResponse | undefined;
available: boolean;
semanticAvailable: boolean;
vectorIndex: boolean;
modes: string[];
strategies: string[];
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for checking CodexLens LSP/semantic search availability
*/
export function useCodexLensLspStatus(options: UseCodexLensLspStatusOptions = {}): UseCodexLensLspStatusReturn {
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
const query = useQuery({
queryKey: codexLensKeys.lspStatus(),
queryFn: fetchCodexLensLspStatus,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
available: query.data?.available ?? false,
semanticAvailable: query.data?.semantic_available ?? false,
vectorIndex: query.data?.vector_index ?? false,
modes: query.data?.modes ?? [],
strategies: query.data?.strategies ?? [],
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensSemanticSearchOptions {
enabled?: boolean;
}
export interface UseCodexLensSemanticSearchReturn {
data: CodexLensSemanticSearchResponse | undefined;
results: CodexLensSemanticSearchResponse['results'] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for semantic search using CodexLens Python API
*/
export function useCodexLensSemanticSearch(
params: CodexLensSemanticSearchParams,
options: UseCodexLensSemanticSearchOptions = {}
): UseCodexLensSemanticSearchReturn {
const { enabled = false } = options;
const query = useQuery({
queryKey: codexLensKeys.semanticSearch(params),
queryFn: () => semanticSearchCodexLens(params),
enabled,
staleTime: STALE_TIME_SHORT,
retry: 1,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
results: query.data?.results,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
// ========== File Watcher Hooks ==========
export interface UseCodexLensWatcherOptions {

View File

@@ -3,7 +3,7 @@
// ========================================
// Convenient hook for theme management with multi-color scheme support
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import {
useAppStore,
selectTheme,
@@ -13,8 +13,16 @@ import {
selectGradientLevel,
selectEnableHoverGlow,
selectEnableBackgroundAnimation,
selectMotionPreference,
selectThemeSlots,
selectActiveSlotId,
selectDeletedSlotBuffer,
} from '../stores/appStore';
import type { Theme, ColorScheme, GradientLevel } from '../types/store';
import type { Theme, ColorScheme, GradientLevel, MotionPreference, StyleTier, ThemeSlot, ThemeSlotId, BackgroundConfig, BackgroundEffects, BackgroundMode, UnsplashAttribution } from '../types/store';
import { resolveMotionPreference } from '../lib/accessibility';
import { THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '../lib/theme';
import { encodeTheme, decodeTheme } from '../lib/themeShare';
import type { ImportResult } from '../lib/themeShare';
export interface UseThemeReturn {
/** Current theme preference ('light', 'dark', 'system') */
@@ -35,6 +43,10 @@ export interface UseThemeReturn {
enableHoverGlow: boolean;
/** Whether background gradient animation is enabled */
enableBackgroundAnimation: boolean;
/** User's motion preference setting */
motionPreference: MotionPreference;
/** Resolved motion preference (true = reduce motion) */
resolvedMotion: boolean;
/** Set theme preference */
setTheme: (theme: Theme) => void;
/** Set color scheme */
@@ -49,6 +61,46 @@ export interface UseThemeReturn {
setEnableHoverGlow: (enabled: boolean) => void;
/** Set background animation enabled */
setEnableBackgroundAnimation: (enabled: boolean) => void;
/** Set motion preference */
setMotionPreference: (pref: MotionPreference) => void;
/** Current style tier ('soft', 'standard', 'high-contrast') */
styleTier: StyleTier;
/** Set style tier */
setStyleTier: (tier: StyleTier) => void;
/** All theme slots */
themeSlots: ThemeSlot[];
/** Currently active slot ID */
activeSlotId: ThemeSlotId;
/** Currently active slot object */
activeSlot: ThemeSlot | undefined;
/** Buffer holding recently deleted slot for undo */
deletedSlotBuffer: ThemeSlot | null;
/** Whether user can add more slots (below THEME_SLOT_LIMIT) */
canAddSlot: boolean;
/** Switch to a different theme slot */
setActiveSlot: (slotId: ThemeSlotId) => void;
/** Copy current slot to a new slot */
copySlot: () => void;
/** Rename a slot */
renameSlot: (slotId: ThemeSlotId, name: string) => void;
/** Delete a slot (moves to deletedSlotBuffer for undo) */
deleteSlot: (slotId: ThemeSlotId) => void;
/** Undo the last slot deletion */
undoDeleteSlot: () => void;
/** Export current active slot as a shareable theme code string */
exportThemeCode: () => string;
/** Decode and validate an imported theme code string */
importThemeCode: (code: string) => ImportResult;
/** Current background configuration for the active slot */
backgroundConfig: BackgroundConfig;
/** Set full background config */
setBackgroundConfig: (config: BackgroundConfig) => void;
/** Update a single background effect property */
updateBackgroundEffect: <K extends keyof BackgroundEffects>(key: K, value: BackgroundEffects[K]) => void;
/** Set background mode */
setBackgroundMode: (mode: BackgroundMode) => void;
/** Set background image URL and attribution */
setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => void;
}
/**
@@ -78,6 +130,7 @@ export function useTheme(): UseThemeReturn {
const gradientLevel = useAppStore(selectGradientLevel);
const enableHoverGlow = useAppStore(selectEnableHoverGlow);
const enableBackgroundAnimation = useAppStore(selectEnableBackgroundAnimation);
const motionPreference = useAppStore(selectMotionPreference);
const setThemeAction = useAppStore((state) => state.setTheme);
const setColorSchemeAction = useAppStore((state) => state.setColorScheme);
const setCustomHueAction = useAppStore((state) => state.setCustomHue);
@@ -85,6 +138,26 @@ export function useTheme(): UseThemeReturn {
const setGradientLevelAction = useAppStore((state) => state.setGradientLevel);
const setEnableHoverGlowAction = useAppStore((state) => state.setEnableHoverGlow);
const setEnableBackgroundAnimationAction = useAppStore((state) => state.setEnableBackgroundAnimation);
const setMotionPreferenceAction = useAppStore((state) => state.setMotionPreference);
const setStyleTierAction = useAppStore((state) => state.setStyleTier);
// Slot state
const themeSlots = useAppStore(selectThemeSlots);
const activeSlotId = useAppStore(selectActiveSlotId);
const deletedSlotBuffer = useAppStore(selectDeletedSlotBuffer);
// Background actions
const setBackgroundConfigAction = useAppStore((state) => state.setBackgroundConfig);
const updateBackgroundEffectAction = useAppStore((state) => state.updateBackgroundEffect);
const setBackgroundModeAction = useAppStore((state) => state.setBackgroundMode);
const setBackgroundImageAction = useAppStore((state) => state.setBackgroundImage);
// Slot actions
const setActiveSlotAction = useAppStore((state) => state.setActiveSlot);
const copySlotAction = useAppStore((state) => state.copySlot);
const renameSlotAction = useAppStore((state) => state.renameSlot);
const deleteSlotAction = useAppStore((state) => state.deleteSlot);
const undoDeleteSlotAction = useAppStore((state) => state.undoDeleteSlot);
const setTheme = useCallback(
(newTheme: Theme) => {
@@ -132,6 +205,85 @@ export function useTheme(): UseThemeReturn {
[setEnableBackgroundAnimationAction]
);
const setMotionPreference = useCallback(
(pref: MotionPreference) => {
setMotionPreferenceAction(pref);
},
[setMotionPreferenceAction]
);
const setStyleTier = useCallback(
(tier: StyleTier) => {
setStyleTierAction(tier);
},
[setStyleTierAction]
);
const resolvedMotion = resolveMotionPreference(motionPreference);
// Slot computed values
const activeSlot = useMemo(
() => themeSlots.find(s => s.id === activeSlotId),
[themeSlots, activeSlotId]
);
const canAddSlot = themeSlots.length < THEME_SLOT_LIMIT;
const styleTier = activeSlot?.styleTier ?? 'standard';
const backgroundConfig = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
// Slot callbacks
const setActiveSlot = useCallback(
(slotId: ThemeSlotId) => {
setActiveSlotAction(slotId);
},
[setActiveSlotAction]
);
const copySlot = useCallback(() => {
copySlotAction();
}, [copySlotAction]);
const renameSlot = useCallback(
(slotId: ThemeSlotId, name: string) => {
renameSlotAction(slotId, name);
},
[renameSlotAction]
);
const deleteSlot = useCallback(
(slotId: ThemeSlotId) => {
deleteSlotAction(slotId);
},
[deleteSlotAction]
);
const undoDeleteSlot = useCallback(() => {
undoDeleteSlotAction();
}, [undoDeleteSlotAction]);
const exportThemeCode = useCallback((): string => {
if (!activeSlot) {
// Fallback: build a minimal slot from current state
const fallbackSlot: ThemeSlot = {
id: activeSlotId,
name: '',
colorScheme,
customHue,
isCustomTheme,
gradientLevel,
enableHoverGlow,
enableBackgroundAnimation,
styleTier,
isDefault: false,
};
return encodeTheme(fallbackSlot);
}
return encodeTheme(activeSlot);
}, [activeSlot, activeSlotId, colorScheme, customHue, isCustomTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation, styleTier]);
const importThemeCode = useCallback((code: string): ImportResult => {
return decodeTheme(code);
}, []);
return {
theme,
resolvedTheme,
@@ -142,6 +294,8 @@ export function useTheme(): UseThemeReturn {
gradientLevel,
enableHoverGlow,
enableBackgroundAnimation,
motionPreference,
resolvedMotion,
setTheme,
setColorScheme,
setCustomHue,
@@ -149,5 +303,25 @@ export function useTheme(): UseThemeReturn {
setGradientLevel,
setEnableHoverGlow,
setEnableBackgroundAnimation,
setMotionPreference,
styleTier,
setStyleTier,
themeSlots,
activeSlotId,
activeSlot,
deletedSlotBuffer,
canAddSlot,
setActiveSlot,
copySlot,
renameSlot,
deleteSlot,
undoDeleteSlot,
exportThemeCode,
importThemeCode,
backgroundConfig,
setBackgroundConfig: setBackgroundConfigAction,
updateBackgroundEffect: updateBackgroundEffectAction,
setBackgroundMode: setBackgroundModeAction,
setBackgroundImage: setBackgroundImageAction,
};
}

View File

@@ -0,0 +1,26 @@
/**
* React Query hook for searching Unsplash photos with debounce.
*/
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { searchUnsplash } from '@/lib/unsplash';
import type { UnsplashSearchResult } from '@/lib/unsplash';
export function useUnsplashSearch(query: string, page = 1, perPage = 20) {
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => clearTimeout(timer);
}, [query]);
return useQuery<UnsplashSearchResult>({
queryKey: ['unsplash-search', debouncedQuery, page, perPage],
queryFn: () => searchUnsplash(debouncedQuery, page, perPage),
enabled: debouncedQuery.trim().length > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}

View File

@@ -340,6 +340,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Schedule reconnection with exponential backoff
// Define this first to avoid circular dependency
const scheduleReconnect = useCallback(() => {
// Don't reconnect after unmount
if (!mountedRef.current) return;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
@@ -363,7 +366,14 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
}, []); // No dependencies - uses connectRef and getStoreState()
const connect = useCallback(() => {
if (!enabled) return;
if (!enabled || !mountedRef.current) return;
// Close existing connection to avoid orphaned sockets
if (wsRef.current) {
wsRef.current.onclose = null; // Prevent onclose from triggering reconnect
wsRef.current.close();
wsRef.current = null;
}
// Construct WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -430,6 +440,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Connect on mount, cleanup on unmount
useEffect(() => {
// Reset mounted flag (needed after React Strict Mode remount)
mountedRef.current = true;
if (enabled) {
connect();
}
@@ -455,6 +468,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.onclose = null; // Prevent onclose from triggering orphaned reconnect
wsRef.current.close();
wsRef.current = null;
}