mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 09:43:26 +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:
336
ccw/frontend/src/lib/accessibility.ts
Normal file
336
ccw/frontend/src/lib/accessibility.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
// ========================================
|
||||
// Accessibility Utilities
|
||||
// ========================================
|
||||
// WCAG 2.1 contrast checking and motion preference management
|
||||
// for the theme system. Operates on HSL 'H S% L%' format strings.
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/** User preference for animation behavior */
|
||||
export type MotionPreference = 'system' | 'reduce' | 'enable';
|
||||
|
||||
/** Result of evaluating one critical color pair against WCAG thresholds */
|
||||
export interface ContrastResult {
|
||||
/** Foreground CSS variable name (e.g. '--text') */
|
||||
fgVar: string;
|
||||
/** Background CSS variable name (e.g. '--bg') */
|
||||
bgVar: string;
|
||||
/** Computed contrast ratio (e.g. 4.52) */
|
||||
ratio: number;
|
||||
/** Required minimum contrast ratio for this pair */
|
||||
required: number;
|
||||
/** Whether the pair passes WCAG AA */
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
/** A single suggested fix to improve contrast, with visual distance metric */
|
||||
export interface FixSuggestion {
|
||||
/** Which variable to adjust ('fg' or 'bg') */
|
||||
target: 'fg' | 'bg';
|
||||
/** Original HSL value */
|
||||
original: string;
|
||||
/** Suggested replacement HSL value */
|
||||
suggested: string;
|
||||
/** Resulting contrast ratio after applying the fix */
|
||||
resultRatio: number;
|
||||
/** Visual distance from original (lower = less visible change) */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
// ========== Critical Color Pairs ==========
|
||||
|
||||
/**
|
||||
* Whitelist of critical UI color pairs to check for WCAG AA compliance.
|
||||
* Each entry: [foreground variable, background variable, required ratio]
|
||||
*
|
||||
* - 4.5:1 for normal text (WCAG AA)
|
||||
* - 3.0:1 for large text / UI components (WCAG AA)
|
||||
*/
|
||||
export const CRITICAL_COLOR_PAIRS: ReadonlyArray<[string, string, number]> = [
|
||||
// Text on backgrounds (4.5:1 - normal text)
|
||||
['--text', '--bg', 4.5],
|
||||
['--text', '--surface', 4.5],
|
||||
['--text-secondary', '--bg', 4.5],
|
||||
['--text-secondary', '--surface', 4.5],
|
||||
['--muted-text', '--muted', 4.5],
|
||||
['--muted-text', '--bg', 4.5],
|
||||
|
||||
// Tertiary/disabled text (3:1 - large text threshold)
|
||||
['--text-tertiary', '--bg', 3.0],
|
||||
['--text-disabled', '--bg', 3.0],
|
||||
|
||||
// Accent/interactive on backgrounds (3:1 - UI component threshold)
|
||||
['--accent', '--bg', 3.0],
|
||||
['--accent', '--surface', 3.0],
|
||||
|
||||
// Semantic text on semantic light backgrounds (4.5:1)
|
||||
['--success-text', '--success-light', 4.5],
|
||||
['--warning-text', '--warning-light', 4.5],
|
||||
['--error-text', '--error-light', 4.5],
|
||||
['--info-text', '--info-light', 4.5],
|
||||
|
||||
// Foreground on primary/destructive buttons (4.5:1)
|
||||
['--primary-foreground', '--primary', 4.5],
|
||||
['--destructive-foreground', '--destructive', 4.5],
|
||||
|
||||
// Text on muted surface (4.5:1)
|
||||
['--text', '--muted', 4.5],
|
||||
];
|
||||
|
||||
// ========== HSL Parsing and Color Conversion ==========
|
||||
|
||||
/**
|
||||
* Parse 'H S% L%' format HSL string into numeric [h, s, l] values.
|
||||
* h in degrees (0-360), s and l as fractions (0-1).
|
||||
*/
|
||||
function parseHSL(hslString: string): [number, number, number] | null {
|
||||
const trimmed = hslString.trim();
|
||||
// Match patterns: "220 60% 65%" or "220 60% 65"
|
||||
const match = trimmed.match(/^([\d.]+)\s+([\d.]+)%?\s+([\d.]+)%?$/);
|
||||
if (!match) return null;
|
||||
const h = parseFloat(match[1]);
|
||||
const s = parseFloat(match[2]) / 100;
|
||||
const l = parseFloat(match[3]) / 100;
|
||||
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSL values to linear sRGB [R, G, B] in 0-1 range.
|
||||
* Then apply sRGB linearization per WCAG 2.1 spec.
|
||||
*/
|
||||
function hslToLinearRGB(h: number, s: number, l: number): [number, number, number] {
|
||||
// HSL to sRGB conversion
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const hPrime = h / 60;
|
||||
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
|
||||
let r1: number, g1: number, b1: number;
|
||||
if (hPrime < 1) { r1 = c; g1 = x; b1 = 0; }
|
||||
else if (hPrime < 2) { r1 = x; g1 = c; b1 = 0; }
|
||||
else if (hPrime < 3) { r1 = 0; g1 = c; b1 = x; }
|
||||
else if (hPrime < 4) { r1 = 0; g1 = x; b1 = c; }
|
||||
else if (hPrime < 5) { r1 = x; g1 = 0; b1 = c; }
|
||||
else { r1 = c; g1 = 0; b1 = x; }
|
||||
|
||||
const rSRGB = r1 + m;
|
||||
const gSRGB = g1 + m;
|
||||
const bSRGB = b1 + m;
|
||||
|
||||
// sRGB linearization per WCAG 2.1
|
||||
const linearize = (v: number): number => {
|
||||
if (v <= 0.04045) return v / 12.92;
|
||||
return Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
|
||||
return [linearize(rSRGB), linearize(gSRGB), linearize(bSRGB)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse 'H S% L%' format HSL string, convert to sRGB,
|
||||
* compute WCAG 2.1 relative luminance.
|
||||
*
|
||||
* Formula: L = 0.2126*R + 0.7152*G + 0.0722*B
|
||||
* with sRGB linearization applied.
|
||||
*
|
||||
* @param hslString - HSL string in 'H S% L%' format (e.g. '220 60% 65%')
|
||||
* @returns Relative luminance (0-1), or -1 if parsing fails
|
||||
*/
|
||||
export function hslToRelativeLuminance(hslString: string): number {
|
||||
const parsed = parseHSL(hslString);
|
||||
if (!parsed) return -1;
|
||||
const [h, s, l] = parsed;
|
||||
const [rLin, gLin, bLin] = hslToLinearRGB(h, s, l);
|
||||
// Round to 4-decimal precision to avoid floating-point edge cases
|
||||
return Math.round((0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin) * 10000) / 10000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute WCAG contrast ratio from two relative luminance values.
|
||||
* Returns ratio in format X:1 (just the X number).
|
||||
*
|
||||
* @param l1 - Relative luminance of first color
|
||||
* @param l2 - Relative luminance of second color
|
||||
* @returns Contrast ratio (always >= 1)
|
||||
*/
|
||||
export function getContrastRatio(l1: number, l2: number): number {
|
||||
const lighter = Math.max(l1, l2);
|
||||
const darker = Math.min(l1, l2);
|
||||
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100;
|
||||
}
|
||||
|
||||
// ========== Theme Contrast Checking ==========
|
||||
|
||||
/**
|
||||
* Evaluate all critical color pairs in a generated theme against WCAG AA thresholds.
|
||||
* Only checks pairs where both variables exist in the provided vars map.
|
||||
*
|
||||
* @param vars - Record of CSS variable names to HSL values (e.g. from generateThemeFromHue)
|
||||
* @returns Array of ContrastResult for each evaluated pair
|
||||
*/
|
||||
export function checkThemeContrast(vars: Record<string, string>): ContrastResult[] {
|
||||
const results: ContrastResult[] = [];
|
||||
|
||||
for (const [fgVar, bgVar, required] of CRITICAL_COLOR_PAIRS) {
|
||||
const fgValue = vars[fgVar];
|
||||
const bgValue = vars[bgVar];
|
||||
if (!fgValue || !bgValue) continue;
|
||||
|
||||
const fgLum = hslToRelativeLuminance(fgValue);
|
||||
const bgLum = hslToRelativeLuminance(bgValue);
|
||||
if (fgLum < 0 || bgLum < 0) continue;
|
||||
|
||||
const ratio = getContrastRatio(fgLum, bgLum);
|
||||
// Use 0.01 tolerance buffer for borderline cases
|
||||
const passed = ratio >= (required - 0.01);
|
||||
|
||||
results.push({ fgVar, bgVar, ratio, required, passed });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ========== Contrast Fix Generation ==========
|
||||
|
||||
/**
|
||||
* Reconstruct 'H S% L%' string from components.
|
||||
*/
|
||||
function toHSLString(h: number, s: number, l: number): string {
|
||||
const clampedL = Math.max(0, Math.min(100, Math.round(l * 10) / 10));
|
||||
const clampedS = Math.max(0, Math.min(100, Math.round(s * 10) / 10));
|
||||
return `${Math.round(h)} ${clampedS}% ${clampedL}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 2-3 lightness-adjusted alternatives that achieve target contrast ratio.
|
||||
* Preserves hue and saturation, only adjusts lightness.
|
||||
* Sorted by minimal visual change (distance).
|
||||
*
|
||||
* @param fgVar - Foreground CSS variable name
|
||||
* @param bgVar - Background CSS variable name
|
||||
* @param currentVars - Current theme variable values
|
||||
* @param targetRatio - Target contrast ratio to achieve
|
||||
* @returns Array of 2-3 FixSuggestion sorted by distance (ascending)
|
||||
*/
|
||||
export function generateContrastFix(
|
||||
fgVar: string,
|
||||
bgVar: string,
|
||||
currentVars: Record<string, string>,
|
||||
targetRatio: number
|
||||
): FixSuggestion[] {
|
||||
const fgValue = currentVars[fgVar];
|
||||
const bgValue = currentVars[bgVar];
|
||||
if (!fgValue || !bgValue) return [];
|
||||
|
||||
const fgParsed = parseHSL(fgValue);
|
||||
const bgParsed = parseHSL(bgValue);
|
||||
if (!fgParsed || !bgParsed) return [];
|
||||
|
||||
const suggestions: FixSuggestion[] = [];
|
||||
|
||||
// Strategy 1: Adjust foreground lightness (darken or lighten)
|
||||
const bgLum = hslToRelativeLuminance(bgValue);
|
||||
const fgSuggestions = findLightnessForContrast(
|
||||
fgParsed[0], fgParsed[1], fgParsed[2], bgLum, targetRatio
|
||||
);
|
||||
for (const newL of fgSuggestions) {
|
||||
const suggested = toHSLString(fgParsed[0], fgParsed[1] * 100, newL * 100);
|
||||
const newFgLum = hslToRelativeLuminance(suggested);
|
||||
if (newFgLum < 0) continue;
|
||||
const resultRatio = getContrastRatio(newFgLum, bgLum);
|
||||
if (resultRatio >= targetRatio - 0.01) {
|
||||
suggestions.push({
|
||||
target: 'fg',
|
||||
original: fgValue,
|
||||
suggested,
|
||||
resultRatio,
|
||||
distance: Math.abs(newL - fgParsed[2]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Adjust background lightness
|
||||
const fgLum = hslToRelativeLuminance(fgValue);
|
||||
const bgSuggestions = findLightnessForContrast(
|
||||
bgParsed[0], bgParsed[1], bgParsed[2], fgLum, targetRatio
|
||||
);
|
||||
for (const newL of bgSuggestions) {
|
||||
const suggested = toHSLString(bgParsed[0], bgParsed[1] * 100, newL * 100);
|
||||
const newBgLum = hslToRelativeLuminance(suggested);
|
||||
if (newBgLum < 0) continue;
|
||||
const resultRatio = getContrastRatio(fgLum, newBgLum);
|
||||
if (resultRatio >= targetRatio - 0.01) {
|
||||
suggestions.push({
|
||||
target: 'bg',
|
||||
original: bgValue,
|
||||
suggested,
|
||||
resultRatio,
|
||||
distance: Math.abs(newL - bgParsed[2]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by distance (minimal visual change first) and take up to 3
|
||||
suggestions.sort((a, b) => a.distance - b.distance);
|
||||
return suggestions.slice(0, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find lightness values that achieve target contrast against a reference luminance.
|
||||
* Searches in both lighter and darker directions from current lightness.
|
||||
* Returns up to 2 candidates (one lighter, one darker if found).
|
||||
*/
|
||||
function findLightnessForContrast(
|
||||
h: number,
|
||||
s: number,
|
||||
currentL: number,
|
||||
refLum: number,
|
||||
targetRatio: number
|
||||
): number[] {
|
||||
const candidates: number[] = [];
|
||||
const step = 0.01;
|
||||
|
||||
// Search darker direction (decreasing lightness)
|
||||
for (let l = currentL - step; l >= 0; l -= step) {
|
||||
const hsl = toHSLString(h, s * 100, l * 100);
|
||||
const lum = hslToRelativeLuminance(hsl);
|
||||
if (lum < 0) continue;
|
||||
const ratio = getContrastRatio(lum, refLum);
|
||||
if (ratio >= targetRatio) {
|
||||
candidates.push(l);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Search lighter direction (increasing lightness)
|
||||
for (let l = currentL + step; l <= 1; l += step) {
|
||||
const hsl = toHSLString(h, s * 100, l * 100);
|
||||
const lum = hslToRelativeLuminance(hsl);
|
||||
if (lum < 0) continue;
|
||||
const ratio = getContrastRatio(lum, refLum);
|
||||
if (ratio >= targetRatio) {
|
||||
candidates.push(l);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
// ========== Motion Preference ==========
|
||||
|
||||
/**
|
||||
* Resolve user preference to actual reduced-motion boolean.
|
||||
* 'system' checks matchMedia, 'reduce' returns true, 'enable' returns false.
|
||||
*
|
||||
* @param pref - User's motion preference setting
|
||||
* @returns true if motion should be reduced, false otherwise
|
||||
*/
|
||||
export function resolveMotionPreference(pref: MotionPreference): boolean {
|
||||
if (pref === 'reduce') return true;
|
||||
if (pref === 'enable') return false;
|
||||
// 'system' - check OS preference
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
@@ -3239,7 +3239,7 @@ function buildCcwMcpServerConfig(config: {
|
||||
if (config.enabledTools && config.enabledTools.length > 0) {
|
||||
env.CCW_ENABLED_TOOLS = config.enabledTools.join(',');
|
||||
} else {
|
||||
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question';
|
||||
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
|
||||
}
|
||||
|
||||
if (config.projectRoot) {
|
||||
@@ -3352,7 +3352,7 @@ export async function installCcwMcp(
|
||||
projectPath?: string
|
||||
): Promise<CcwMcpConfig> {
|
||||
const serverConfig = buildCcwMcpServerConfig({
|
||||
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'],
|
||||
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
|
||||
});
|
||||
|
||||
if (scope === 'project' && projectPath) {
|
||||
@@ -3853,7 +3853,7 @@ export interface CodexLensGpuListResponse {
|
||||
}
|
||||
|
||||
/**
|
||||
* Model info
|
||||
* Model info (normalized from CLI output)
|
||||
*/
|
||||
export interface CodexLensModel {
|
||||
profile: string;
|
||||
@@ -3863,10 +3863,26 @@ export interface CodexLensModel {
|
||||
size?: string;
|
||||
installed: boolean;
|
||||
cache_path?: string;
|
||||
/** Original HuggingFace model name */
|
||||
model_name?: string;
|
||||
/** Model description */
|
||||
description?: string;
|
||||
/** Use case description */
|
||||
use_case?: string;
|
||||
/** Embedding dimensions */
|
||||
dimensions?: number;
|
||||
/** Whether this model is recommended */
|
||||
recommended?: boolean;
|
||||
/** Model source: 'predefined' | 'discovered' */
|
||||
source?: string;
|
||||
/** Estimated size in MB */
|
||||
estimated_size_mb?: number;
|
||||
/** Actual size in MB (when installed) */
|
||||
actual_size_mb?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model list response
|
||||
* Model list response (normalized)
|
||||
*/
|
||||
export interface CodexLensModelsResponse {
|
||||
success: boolean;
|
||||
@@ -4067,9 +4083,43 @@ export async function uninstallCodexLens(): Promise<CodexLensUninstallResponse>
|
||||
|
||||
/**
|
||||
* Fetch CodexLens models list
|
||||
* Normalizes the CLI response format to match the frontend interface.
|
||||
* CLI returns: { success, result: { models: [{ model_name, estimated_size_mb, ... }] } }
|
||||
* Frontend expects: { success, models: [{ name, size, type, backend, ... }] }
|
||||
*/
|
||||
export async function fetchCodexLensModels(): Promise<CodexLensModelsResponse> {
|
||||
return fetchApi<CodexLensModelsResponse>('/api/codexlens/models');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = await fetchApi<any>('/api/codexlens/models');
|
||||
|
||||
// Handle nested result structure from CLI
|
||||
const rawModels = raw?.result?.models ?? raw?.models ?? [];
|
||||
|
||||
const models: CodexLensModel[] = rawModels.map((m: Record<string, unknown>) => ({
|
||||
profile: (m.profile as string) || '',
|
||||
name: (m.model_name as string) || (m.name as string) || (m.profile as string) || '',
|
||||
type: (m.type as 'embedding' | 'reranker') || 'embedding',
|
||||
backend: (m.source as string) || 'fastembed',
|
||||
size: m.installed && m.actual_size_mb
|
||||
? `${(m.actual_size_mb as number).toFixed(0)} MB`
|
||||
: m.estimated_size_mb
|
||||
? `~${m.estimated_size_mb} MB`
|
||||
: undefined,
|
||||
installed: (m.installed as boolean) ?? false,
|
||||
cache_path: m.cache_path as string | undefined,
|
||||
model_name: m.model_name as string | undefined,
|
||||
description: m.description as string | undefined,
|
||||
use_case: m.use_case as string | undefined,
|
||||
dimensions: m.dimensions as number | undefined,
|
||||
recommended: m.recommended as boolean | undefined,
|
||||
source: m.source as string | undefined,
|
||||
estimated_size_mb: m.estimated_size_mb as number | undefined,
|
||||
actual_size_mb: m.actual_size_mb as number | null | undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: raw?.success ?? true,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4240,6 +4290,17 @@ export interface CodexLensSymbolSearchResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens file search response (returns file paths only)
|
||||
*/
|
||||
export interface CodexLensFileSearchResponse {
|
||||
success: boolean;
|
||||
query?: string;
|
||||
count?: number;
|
||||
files: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform content search using CodexLens
|
||||
*/
|
||||
@@ -4257,7 +4318,7 @@ export async function searchCodexLens(params: CodexLensSearchParams): Promise<Co
|
||||
/**
|
||||
* Perform file search using CodexLens
|
||||
*/
|
||||
export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensSearchResponse> {
|
||||
export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensFileSearchResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('query', params.query);
|
||||
if (params.limit) queryParams.append('limit', String(params.limit));
|
||||
@@ -4265,7 +4326,7 @@ export async function searchFilesCodexLens(params: CodexLensSearchParams): Promi
|
||||
if (params.max_content_length) queryParams.append('max_content_length', String(params.max_content_length));
|
||||
if (params.extra_files_count) queryParams.append('extra_files_count', String(params.extra_files_count));
|
||||
|
||||
return fetchApi<CodexLensSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`);
|
||||
return fetchApi<CodexLensFileSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4279,6 +4340,84 @@ export async function searchSymbolCodexLens(params: Pick<CodexLensSearchParams,
|
||||
return fetchApi<CodexLensSymbolSearchResponse>(`/api/codexlens/symbol?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// ========== CodexLens LSP / Semantic Search API ==========
|
||||
|
||||
/**
|
||||
* CodexLens LSP status response
|
||||
*/
|
||||
export interface CodexLensLspStatusResponse {
|
||||
available: boolean;
|
||||
semantic_available: boolean;
|
||||
vector_index: boolean;
|
||||
project_count?: number;
|
||||
embeddings?: Record<string, unknown>;
|
||||
modes?: string[];
|
||||
strategies?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens semantic search params (Python API)
|
||||
*/
|
||||
export type CodexLensSemanticSearchMode = 'fusion' | 'vector' | 'structural';
|
||||
export type CodexLensFusionStrategy = 'rrf' | 'staged' | 'binary' | 'hybrid' | 'dense_rerank';
|
||||
|
||||
export interface CodexLensSemanticSearchParams {
|
||||
query: string;
|
||||
path?: string;
|
||||
mode?: CodexLensSemanticSearchMode;
|
||||
fusion_strategy?: CodexLensFusionStrategy;
|
||||
vector_weight?: number;
|
||||
structural_weight?: number;
|
||||
keyword_weight?: number;
|
||||
kind_filter?: string[];
|
||||
limit?: number;
|
||||
include_match_reason?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens semantic search result
|
||||
*/
|
||||
export interface CodexLensSemanticSearchResult {
|
||||
name?: string;
|
||||
kind?: string;
|
||||
file_path?: string;
|
||||
score?: number;
|
||||
match_reason?: string;
|
||||
range?: { start_line: number; end_line: number };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* CodexLens semantic search response
|
||||
*/
|
||||
export interface CodexLensSemanticSearchResponse {
|
||||
success: boolean;
|
||||
results?: CodexLensSemanticSearchResult[];
|
||||
query?: string;
|
||||
mode?: string;
|
||||
fusion_strategy?: string;
|
||||
count?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CodexLens LSP status
|
||||
*/
|
||||
export async function fetchCodexLensLspStatus(): Promise<CodexLensLspStatusResponse> {
|
||||
return fetchApi<CodexLensLspStatusResponse>('/api/codexlens/lsp/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform semantic search using CodexLens Python API
|
||||
*/
|
||||
export async function semanticSearchCodexLens(params: CodexLensSemanticSearchParams): Promise<CodexLensSemanticSearchResponse> {
|
||||
return fetchApi<CodexLensSemanticSearchResponse>('/api/codexlens/lsp/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== CodexLens Index Management API ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,126 @@
|
||||
* @module colorGenerator
|
||||
*/
|
||||
|
||||
import type { StyleTier } from '../types/store';
|
||||
|
||||
// ========== Style Tier System ==========
|
||||
|
||||
/** Per-tier adjustment factors for saturation, lightness, and contrast */
|
||||
export interface StyleTierCoefficients {
|
||||
saturationScale: number;
|
||||
lightnessOffset: { light: number; dark: number };
|
||||
contrastBoost: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Style tier coefficient definitions.
|
||||
* - soft: reduced saturation, lighter feel, lower contrast
|
||||
* - standard: identity transform (no change)
|
||||
* - high-contrast: boosted saturation, sharper text/background separation
|
||||
*/
|
||||
export const STYLE_TIER_COEFFICIENTS: Record<StyleTier, StyleTierCoefficients> = {
|
||||
soft: {
|
||||
saturationScale: 0.6,
|
||||
lightnessOffset: { light: 5, dark: -3 },
|
||||
contrastBoost: 0.9,
|
||||
},
|
||||
standard: {
|
||||
saturationScale: 1.0,
|
||||
lightnessOffset: { light: 0, dark: 0 },
|
||||
contrastBoost: 1.0,
|
||||
},
|
||||
'high-contrast': {
|
||||
saturationScale: 1.3,
|
||||
lightnessOffset: { light: -5, dark: 3 },
|
||||
contrastBoost: 1.2,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse 'H S% L%' format HSL string into numeric components.
|
||||
* H in degrees (0-360), S and L as percentages (0-100).
|
||||
*
|
||||
* @param hslString - HSL string in 'H S% L%' format (e.g. '220 60% 65%')
|
||||
* @returns Parsed components or null if parsing fails
|
||||
*/
|
||||
export function parseHSL(hslString: string): { h: number; s: number; l: number } | null {
|
||||
const trimmed = hslString.trim();
|
||||
const match = trimmed.match(/^([\d.]+)\s+([\d.]+)%?\s+([\d.]+)%?$/);
|
||||
if (!match) return null;
|
||||
const h = parseFloat(match[1]);
|
||||
const s = parseFloat(match[2]);
|
||||
const l = parseFloat(match[3]);
|
||||
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format numeric HSL values back to 'H S% L%' string.
|
||||
* Values are clamped to valid ranges.
|
||||
*
|
||||
* @param h - Hue in degrees (0-360)
|
||||
* @param s - Saturation as percentage (0-100)
|
||||
* @param l - Lightness as percentage (0-100)
|
||||
* @returns Formatted HSL string
|
||||
*/
|
||||
export function formatHSL(h: number, s: number, l: number): string {
|
||||
const clampedS = Math.max(0, Math.min(100, Math.round(s * 10) / 10));
|
||||
const clampedL = Math.max(0, Math.min(100, Math.round(l * 10) / 10));
|
||||
return `${Math.round(h)} ${clampedS}% ${clampedL}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply style tier coefficients to a set of CSS variable values.
|
||||
* Adjusts saturation and lightness per tier, preserving hue.
|
||||
*
|
||||
* Processing pipeline per variable:
|
||||
* 1. Scale saturation: s * saturationScale
|
||||
* 2. Apply contrast boost: stretch lightness from midpoint (50%)
|
||||
* 3. Apply lightness offset (mode-specific)
|
||||
* 4. Clamp to valid ranges
|
||||
*
|
||||
* Standard tier is an identity transform (returns input unchanged).
|
||||
*
|
||||
* @param vars - Record of CSS variable names to HSL values in 'H S% L%' format
|
||||
* @param tier - Style tier to apply
|
||||
* @param mode - Current theme mode
|
||||
* @returns Modified CSS variables record
|
||||
*/
|
||||
export function applyStyleTier(
|
||||
vars: Record<string, string>,
|
||||
tier: StyleTier,
|
||||
mode: 'light' | 'dark'
|
||||
): Record<string, string> {
|
||||
if (tier === 'standard') return vars;
|
||||
|
||||
const coeffs = STYLE_TIER_COEFFICIENTS[tier];
|
||||
const offset = mode === 'light' ? coeffs.lightnessOffset.light : coeffs.lightnessOffset.dark;
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [varName, value] of Object.entries(vars)) {
|
||||
const parsed = parseHSL(value);
|
||||
if (!parsed) {
|
||||
result[varName] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Apply saturation scaling
|
||||
let s = parsed.s * coeffs.saturationScale;
|
||||
s = Math.max(0, Math.min(100, s));
|
||||
|
||||
// 2. Apply contrast boost (stretch lightness from midpoint)
|
||||
let l = 50 + (parsed.l - 50) * coeffs.contrastBoost;
|
||||
|
||||
// 3. Apply lightness offset
|
||||
l = l + offset;
|
||||
l = Math.max(0, Math.min(100, l));
|
||||
|
||||
result[varName] = formatHSL(parsed.h, s, l);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete theme from a single hue value
|
||||
*
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Defines available color schemes and theme modes for the CCW application
|
||||
*/
|
||||
|
||||
import type { ThemeSlot, ThemeSlotId, BackgroundEffects, BackgroundConfig } from '../types/store';
|
||||
|
||||
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
export type ThemeId = `${ThemeMode}-${ColorScheme}`;
|
||||
@@ -112,3 +114,62 @@ export const DEFAULT_THEME: Theme = {
|
||||
mode: 'light',
|
||||
name: '经典蓝 · 浅色'
|
||||
};
|
||||
|
||||
// ========== Background Defaults ==========
|
||||
|
||||
export const DEFAULT_BACKGROUND_EFFECTS: BackgroundEffects = {
|
||||
blur: 0,
|
||||
darkenOpacity: 0,
|
||||
saturation: 100,
|
||||
enableFrostedGlass: false,
|
||||
enableGrain: false,
|
||||
enableVignette: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_BACKGROUND_CONFIG: BackgroundConfig = {
|
||||
mode: 'gradient-only',
|
||||
imageUrl: null,
|
||||
attribution: null,
|
||||
effects: DEFAULT_BACKGROUND_EFFECTS,
|
||||
};
|
||||
|
||||
// ========== Theme Slot Constants ==========
|
||||
|
||||
/** Maximum number of theme slots a user can have */
|
||||
export const THEME_SLOT_LIMIT = 3;
|
||||
|
||||
/** Default theme slot with preset values */
|
||||
export const DEFAULT_SLOT: ThemeSlot = {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
colorScheme: 'blue',
|
||||
customHue: null,
|
||||
isCustomTheme: false,
|
||||
gradientLevel: 'standard',
|
||||
enableHoverGlow: true,
|
||||
enableBackgroundAnimation: false,
|
||||
styleTier: 'standard',
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create a new empty theme slot with default values.
|
||||
*
|
||||
* @param id - Slot identifier
|
||||
* @param name - Display name for the slot
|
||||
* @returns A new ThemeSlot with default theme values
|
||||
*/
|
||||
export function createEmptySlot(id: ThemeSlotId, name: string): ThemeSlot {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
colorScheme: 'blue',
|
||||
customHue: null,
|
||||
isCustomTheme: false,
|
||||
gradientLevel: 'standard',
|
||||
enableHoverGlow: true,
|
||||
enableBackgroundAnimation: false,
|
||||
styleTier: 'standard',
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
327
ccw/frontend/src/lib/themeShare.ts
Normal file
327
ccw/frontend/src/lib/themeShare.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Theme Sharing Module
|
||||
* Encodes/decodes theme configurations as compact base64url strings
|
||||
* for copy-paste sharing between users.
|
||||
*
|
||||
* Format: 'ccw{version}:{base64url_payload}'
|
||||
* Payload uses short field names for compactness:
|
||||
* v=version, c=colorScheme, h=customHue, t=styleTier,
|
||||
* g=gradientLevel, w=enableHoverGlow, a=enableBackgroundAnimation
|
||||
* bm=backgroundMode, bi=backgroundImageUrl, bp=photographerName,
|
||||
* bu=photographerUrl, bpu=photoUrl, be=backgroundEffects
|
||||
*
|
||||
* @module themeShare
|
||||
*/
|
||||
|
||||
import type { ThemeSlot, BackgroundConfig, BackgroundEffects } from '../types/store';
|
||||
import type { ColorScheme, GradientLevel, StyleTier, BackgroundMode } from '../types/store';
|
||||
import { DEFAULT_BACKGROUND_EFFECTS } from './theme';
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/** Current share format version. Bump when payload schema changes. */
|
||||
export const SHARE_VERSION = 2;
|
||||
|
||||
/** Maximum encoded string length accepted for import */
|
||||
const MAX_ENCODED_LENGTH = 800;
|
||||
|
||||
/** Version prefix pattern: 'ccw' followed by version number and colon */
|
||||
const PREFIX_PATTERN = /^ccw(\d+):(.+)$/;
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/** Serializable theme state for encoding/decoding */
|
||||
export interface ThemeSharePayload {
|
||||
version: number;
|
||||
colorScheme: ColorScheme;
|
||||
customHue: number | null;
|
||||
styleTier: StyleTier;
|
||||
gradientLevel: GradientLevel;
|
||||
enableHoverGlow: boolean;
|
||||
enableBackgroundAnimation: boolean;
|
||||
backgroundConfig?: BackgroundConfig;
|
||||
}
|
||||
|
||||
/** Compact wire format using short keys for smaller base64 output */
|
||||
interface CompactPayload {
|
||||
v: number;
|
||||
c: string;
|
||||
h: number | null;
|
||||
t: string;
|
||||
g: string;
|
||||
w: boolean;
|
||||
a: boolean;
|
||||
// v2 background fields (optional)
|
||||
bm?: string;
|
||||
bi?: string;
|
||||
bp?: string;
|
||||
bu?: string;
|
||||
bpu?: string;
|
||||
be?: CompactEffects;
|
||||
}
|
||||
|
||||
/** Compact background effects */
|
||||
interface CompactEffects {
|
||||
b: number; // blur
|
||||
d: number; // darkenOpacity
|
||||
s: number; // saturation
|
||||
f: boolean; // enableFrostedGlass
|
||||
g: boolean; // enableGrain
|
||||
v: boolean; // enableVignette
|
||||
}
|
||||
|
||||
/** Result of decoding and validating an import string */
|
||||
export type ImportResult =
|
||||
| { ok: true; payload: ThemeSharePayload; warning?: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Version compatibility check result */
|
||||
export interface VersionCheckResult {
|
||||
compatible: boolean;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
// ========== Validation Constants ==========
|
||||
|
||||
const VALID_COLOR_SCHEMES: readonly string[] = ['blue', 'green', 'orange', 'purple'];
|
||||
const VALID_STYLE_TIERS: readonly string[] = ['soft', 'standard', 'high-contrast'];
|
||||
const VALID_GRADIENT_LEVELS: readonly string[] = ['off', 'standard', 'enhanced'];
|
||||
const VALID_BACKGROUND_MODES: readonly string[] = ['gradient-only', 'image-only', 'image-gradient'];
|
||||
|
||||
// ========== Encoding ==========
|
||||
|
||||
/**
|
||||
* Encode a theme slot into a compact URL-safe base64 string with version prefix.
|
||||
*
|
||||
* Output format: 'ccw2:{base64url}'
|
||||
* The base64url payload contains JSON with short keys for compactness.
|
||||
* Background fields are only included when mode != gradient-only.
|
||||
*
|
||||
* @param slot - Theme slot to encode
|
||||
* @returns Encoded theme string (typically under 300 characters)
|
||||
*/
|
||||
export function encodeTheme(slot: ThemeSlot): string {
|
||||
const compact: CompactPayload = {
|
||||
v: SHARE_VERSION,
|
||||
c: slot.colorScheme,
|
||||
h: slot.customHue,
|
||||
t: slot.styleTier,
|
||||
g: slot.gradientLevel,
|
||||
w: slot.enableHoverGlow,
|
||||
a: slot.enableBackgroundAnimation,
|
||||
};
|
||||
|
||||
// Only include background fields when mode != gradient-only
|
||||
const bg = slot.backgroundConfig;
|
||||
if (bg && bg.mode !== 'gradient-only') {
|
||||
compact.bm = bg.mode;
|
||||
if (bg.imageUrl) compact.bi = bg.imageUrl;
|
||||
if (bg.attribution) {
|
||||
compact.bp = bg.attribution.photographerName;
|
||||
compact.bu = bg.attribution.photographerUrl;
|
||||
compact.bpu = bg.attribution.photoUrl;
|
||||
}
|
||||
compact.be = {
|
||||
b: bg.effects.blur,
|
||||
d: bg.effects.darkenOpacity,
|
||||
s: bg.effects.saturation,
|
||||
f: bg.effects.enableFrostedGlass,
|
||||
g: bg.effects.enableGrain,
|
||||
v: bg.effects.enableVignette,
|
||||
};
|
||||
}
|
||||
|
||||
const json = JSON.stringify(compact);
|
||||
|
||||
// Use TextEncoder for consistent UTF-8 handling
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(json);
|
||||
|
||||
// Convert bytes to binary string for btoa
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
// Base64 encode, then make URL-safe
|
||||
const base64 = btoa(binary);
|
||||
const base64url = base64
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
return `ccw${SHARE_VERSION}:${base64url}`;
|
||||
}
|
||||
|
||||
// ========== Decoding ==========
|
||||
|
||||
/**
|
||||
* Decode and validate a theme share string.
|
||||
* Checks version compatibility and validates all field types/ranges.
|
||||
* Invalid input never causes side effects.
|
||||
* Handles both v1 (no background) and v2 (with background) payloads.
|
||||
*
|
||||
* @param encoded - The encoded theme string to decode
|
||||
* @returns ImportResult with decoded payload on success, or error message on failure
|
||||
*/
|
||||
export function decodeTheme(encoded: string): ImportResult {
|
||||
// Guard: reject empty input
|
||||
if (!encoded || typeof encoded !== 'string') {
|
||||
return { ok: false, error: 'empty_input' };
|
||||
}
|
||||
|
||||
const trimmed = encoded.trim();
|
||||
|
||||
// Guard: reject strings exceeding max length
|
||||
if (trimmed.length > MAX_ENCODED_LENGTH) {
|
||||
return { ok: false, error: 'too_long' };
|
||||
}
|
||||
|
||||
// Extract version and payload from prefix
|
||||
const prefixMatch = trimmed.match(PREFIX_PATTERN);
|
||||
if (!prefixMatch) {
|
||||
return { ok: false, error: 'invalid_format' };
|
||||
}
|
||||
|
||||
const prefixVersion = parseInt(prefixMatch[1], 10);
|
||||
const base64url = prefixMatch[2];
|
||||
|
||||
// Restore standard base64 from URL-safe variant
|
||||
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Add padding if needed
|
||||
const remainder = base64.length % 4;
|
||||
if (remainder === 2) base64 += '==';
|
||||
else if (remainder === 3) base64 += '=';
|
||||
|
||||
// Decode base64 to bytes
|
||||
let json: string;
|
||||
try {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
// Use TextDecoder for consistent UTF-8 handling
|
||||
const decoder = new TextDecoder();
|
||||
json = decoder.decode(bytes);
|
||||
} catch {
|
||||
return { ok: false, error: 'decode_failed' };
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
let compact: unknown;
|
||||
try {
|
||||
compact = JSON.parse(json);
|
||||
} catch {
|
||||
return { ok: false, error: 'parse_failed' };
|
||||
}
|
||||
|
||||
// Validate object shape
|
||||
if (!compact || typeof compact !== 'object' || Array.isArray(compact)) {
|
||||
return { ok: false, error: 'invalid_payload' };
|
||||
}
|
||||
|
||||
const obj = compact as Record<string, unknown>;
|
||||
|
||||
// Extract and validate version
|
||||
const payloadVersion = typeof obj.v === 'number' ? obj.v : prefixVersion;
|
||||
|
||||
// Check version compatibility
|
||||
const versionCheck = isVersionCompatible(payloadVersion);
|
||||
if (!versionCheck.compatible) {
|
||||
return { ok: false, error: 'incompatible_version' };
|
||||
}
|
||||
|
||||
// Validate colorScheme
|
||||
if (typeof obj.c !== 'string' || !VALID_COLOR_SCHEMES.includes(obj.c)) {
|
||||
return { ok: false, error: 'invalid_field' };
|
||||
}
|
||||
|
||||
// Validate customHue
|
||||
if (obj.h !== null && (typeof obj.h !== 'number' || !isFinite(obj.h))) {
|
||||
return { ok: false, error: 'invalid_field' };
|
||||
}
|
||||
|
||||
// Validate styleTier
|
||||
if (typeof obj.t !== 'string' || !VALID_STYLE_TIERS.includes(obj.t)) {
|
||||
return { ok: false, error: 'invalid_field' };
|
||||
}
|
||||
|
||||
// Validate gradientLevel
|
||||
if (typeof obj.g !== 'string' || !VALID_GRADIENT_LEVELS.includes(obj.g)) {
|
||||
return { ok: false, error: 'invalid_field' };
|
||||
}
|
||||
|
||||
// Validate booleans
|
||||
if (typeof obj.w !== 'boolean' || typeof obj.a !== 'boolean') {
|
||||
return { ok: false, error: 'invalid_field' };
|
||||
}
|
||||
|
||||
const payload: ThemeSharePayload = {
|
||||
version: payloadVersion,
|
||||
colorScheme: obj.c as ColorScheme,
|
||||
customHue: obj.h as number | null,
|
||||
styleTier: obj.t as StyleTier,
|
||||
gradientLevel: obj.g as GradientLevel,
|
||||
enableHoverGlow: obj.w,
|
||||
enableBackgroundAnimation: obj.a,
|
||||
};
|
||||
|
||||
// Decode v2 background fields (optional — v1 payloads simply lack them)
|
||||
if (typeof obj.bm === 'string' && VALID_BACKGROUND_MODES.includes(obj.bm)) {
|
||||
const effects: BackgroundEffects = { ...DEFAULT_BACKGROUND_EFFECTS };
|
||||
|
||||
// Parse compact effects
|
||||
if (obj.be && typeof obj.be === 'object' && !Array.isArray(obj.be)) {
|
||||
const be = obj.be as Record<string, unknown>;
|
||||
if (typeof be.b === 'number') effects.blur = be.b;
|
||||
if (typeof be.d === 'number') effects.darkenOpacity = be.d;
|
||||
if (typeof be.s === 'number') effects.saturation = be.s;
|
||||
if (typeof be.f === 'boolean') effects.enableFrostedGlass = be.f;
|
||||
if (typeof be.g === 'boolean') effects.enableGrain = be.g;
|
||||
if (typeof be.v === 'boolean') effects.enableVignette = be.v;
|
||||
}
|
||||
|
||||
payload.backgroundConfig = {
|
||||
mode: obj.bm as BackgroundMode,
|
||||
imageUrl: typeof obj.bi === 'string' ? obj.bi : null,
|
||||
attribution: (typeof obj.bp === 'string' && typeof obj.bu === 'string' && typeof obj.bpu === 'string')
|
||||
? { photographerName: obj.bp, photographerUrl: obj.bu, photoUrl: obj.bpu }
|
||||
: null,
|
||||
effects,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
payload,
|
||||
warning: versionCheck.warning,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Version Compatibility ==========
|
||||
|
||||
/**
|
||||
* Check if a payload version is within +/-2 of the current SHARE_VERSION.
|
||||
* Versions outside range are incompatible. Versions within range but
|
||||
* not equal get a warning that accuracy may vary.
|
||||
*
|
||||
* @param payloadVersion - Version number from the decoded payload
|
||||
* @returns Compatibility result with optional warning
|
||||
*/
|
||||
export function isVersionCompatible(payloadVersion: number): VersionCheckResult {
|
||||
const diff = Math.abs(payloadVersion - SHARE_VERSION);
|
||||
|
||||
if (diff > 2) {
|
||||
return { compatible: false };
|
||||
}
|
||||
|
||||
if (diff > 0) {
|
||||
return {
|
||||
compatible: true,
|
||||
warning: 'version_mismatch',
|
||||
};
|
||||
}
|
||||
|
||||
return { compatible: true };
|
||||
}
|
||||
102
ccw/frontend/src/lib/unsplash.ts
Normal file
102
ccw/frontend/src/lib/unsplash.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Unsplash API Client
|
||||
* Frontend functions to search Unsplash via the backend proxy.
|
||||
*/
|
||||
|
||||
export interface UnsplashPhoto {
|
||||
id: string;
|
||||
thumbUrl: string;
|
||||
smallUrl: string;
|
||||
regularUrl: string;
|
||||
photographer: string;
|
||||
photographerUrl: string;
|
||||
photoUrl: string;
|
||||
blurHash: string | null;
|
||||
downloadLocation: string;
|
||||
}
|
||||
|
||||
export interface UnsplashSearchResult {
|
||||
photos: UnsplashPhoto[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search Unsplash photos via backend proxy.
|
||||
*/
|
||||
export async function searchUnsplash(
|
||||
query: string,
|
||||
page = 1,
|
||||
perPage = 20
|
||||
): Promise<UnsplashSearchResult> {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/unsplash/search?${params}`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Unsplash search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local image as background.
|
||||
* Sends raw binary to avoid base64 overhead.
|
||||
*/
|
||||
export async function uploadBackgroundImage(file: File): Promise<{ url: string; filename: string }> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': file.type,
|
||||
'X-Filename': file.name,
|
||||
};
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/background/upload', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger Unsplash download event (required by API guidelines).
|
||||
*/
|
||||
export async function triggerUnsplashDownload(downloadLocation: string): Promise<void> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
|
||||
await fetch('/api/unsplash/download', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ downloadLocation }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user