mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-06 16:31:12 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
26
ccw/frontend/src/hooks/useUnsplashSearch.ts
Normal file
26
ccw/frontend/src/hooks/useUnsplashSearch.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user