mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +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:
@@ -259,7 +259,8 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
|
||||
|
||||
// Build CLI arguments based on index type
|
||||
// Use 'index init' subcommand (new CLI structure)
|
||||
const args = ['index', 'init', targetPath, '--json'];
|
||||
// --force flag ensures full reindex (not incremental)
|
||||
const args = ['index', 'init', targetPath, '--force', '--json'];
|
||||
if (resolvedIndexType === 'normal') {
|
||||
args.push('--no-embeddings');
|
||||
} else {
|
||||
@@ -380,8 +381,10 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
|
||||
}
|
||||
}
|
||||
|
||||
// Build CLI arguments for incremental update using 'index update' subcommand
|
||||
const args = ['index', 'update', targetPath, '--json'];
|
||||
// Build CLI arguments for incremental update using 'index init' without --force
|
||||
// 'index init' defaults to incremental mode (skip unchanged files)
|
||||
// 'index update' is only for single-file updates in hooks
|
||||
const args = ['index', 'init', targetPath, '--json'];
|
||||
if (resolvedIndexType === 'normal') {
|
||||
args.push('--no-embeddings');
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
executeCodexLens,
|
||||
installSemantic,
|
||||
} from '../../../tools/codex-lens.js';
|
||||
import { getCodexLensPython } from '../../../utils/codexlens-path.js';
|
||||
import { spawn } from 'child_process';
|
||||
import type { GpuMode } from '../../../tools/codex-lens.js';
|
||||
import { loadLiteLLMApiConfig, getAvailableModelsForType, getProvider, getAllProviders } from '../../../config/litellm-api-config-manager.js';
|
||||
import {
|
||||
@@ -19,6 +21,86 @@ import { extractJSON } from './utils.js';
|
||||
import { getDefaultTool } from '../../../tools/claude-cli-tools.js';
|
||||
import { getCodexLensDataDir } from '../../../utils/codexlens-path.js';
|
||||
|
||||
/**
|
||||
* Execute CodexLens Python API call directly (bypasses CLI for richer API access).
|
||||
*/
|
||||
async function executeCodexLensPythonAPI(
|
||||
apiFunction: string,
|
||||
args: Record<string, unknown>,
|
||||
timeout: number = 60000
|
||||
): Promise<{ success: boolean; results?: unknown; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const pythonScript = `
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import is_dataclass, asdict
|
||||
from codexlens.api import ${apiFunction}
|
||||
|
||||
def to_serializable(obj):
|
||||
if obj is None:
|
||||
return None
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
return asdict(obj)
|
||||
if isinstance(obj, list):
|
||||
return [to_serializable(item) for item in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {key: to_serializable(value) for key, value in obj.items()}
|
||||
if isinstance(obj, tuple):
|
||||
return tuple(to_serializable(item) for item in obj)
|
||||
return obj
|
||||
|
||||
try:
|
||||
args = ${JSON.stringify(args)}
|
||||
result = ${apiFunction}(**args)
|
||||
output = to_serializable(result)
|
||||
print(json.dumps({"success": True, "result": output}))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
sys.exit(1)
|
||||
`;
|
||||
|
||||
const pythonPath = getCodexLensPython();
|
||||
const child = spawn(pythonPath, ['-c', pythonScript], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
try {
|
||||
const errorData = JSON.parse(stderr || stdout);
|
||||
resolve({ success: false, error: errorData.error || 'Unknown error' });
|
||||
} catch {
|
||||
resolve({ success: false, error: stderr || stdout || `Process exited with code ${code}` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
resolve({ success: data.success, results: data.result, error: data.error });
|
||||
} catch (err) {
|
||||
resolve({ success: false, error: `Failed to parse output: ${(err as Error).message}` });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({ success: false, error: `Failed to execute: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
|
||||
|
||||
@@ -928,5 +1010,154 @@ except Exception as e:
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LSP / SEMANTIC SEARCH API ENDPOINTS
|
||||
// ============================================================
|
||||
|
||||
// API: LSP Status - Check if LSP/semantic search capabilities are available
|
||||
if (pathname === '/api/codexlens/lsp/status') {
|
||||
try {
|
||||
const venvStatus = await checkVenvStatus();
|
||||
if (!venvStatus.ready) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
available: false,
|
||||
semantic_available: false,
|
||||
vector_index: false,
|
||||
error: 'CodexLens not installed'
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check semantic deps and vector index availability in parallel
|
||||
const [semanticStatus, workspaceResult] = await Promise.all([
|
||||
checkSemanticStatus(),
|
||||
executeCodexLens(['status', '--json'])
|
||||
]);
|
||||
|
||||
let hasVectorIndex = false;
|
||||
let projectCount = 0;
|
||||
let embeddingsInfo: Record<string, unknown> = {};
|
||||
|
||||
if (workspaceResult.success) {
|
||||
try {
|
||||
const status = extractJSON(workspaceResult.output ?? '');
|
||||
if (status.success !== false && status.result) {
|
||||
projectCount = status.result.projects_count || 0;
|
||||
embeddingsInfo = status.result.embeddings || {};
|
||||
// Check if any projects have embeddings
|
||||
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
|
||||
} else if (status.projects_count !== undefined) {
|
||||
projectCount = status.projects_count || 0;
|
||||
embeddingsInfo = status.embeddings || {};
|
||||
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
|
||||
}
|
||||
} catch {
|
||||
// Parse failed
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
available: semanticStatus.available && hasVectorIndex,
|
||||
semantic_available: semanticStatus.available,
|
||||
vector_index: hasVectorIndex,
|
||||
project_count: projectCount,
|
||||
embeddings: embeddingsInfo,
|
||||
modes: ['fusion', 'vector', 'structural'],
|
||||
strategies: ['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'],
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
available: false,
|
||||
semantic_available: false,
|
||||
vector_index: false,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: LSP Semantic Search - Advanced semantic search via Python API
|
||||
if (pathname === '/api/codexlens/lsp/search' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const {
|
||||
query,
|
||||
path: projectPath,
|
||||
mode = 'fusion',
|
||||
fusion_strategy = 'rrf',
|
||||
vector_weight = 0.5,
|
||||
structural_weight = 0.3,
|
||||
keyword_weight = 0.2,
|
||||
kind_filter,
|
||||
limit = 20,
|
||||
include_match_reason = false,
|
||||
} = body as {
|
||||
query?: unknown;
|
||||
path?: unknown;
|
||||
mode?: unknown;
|
||||
fusion_strategy?: unknown;
|
||||
vector_weight?: unknown;
|
||||
structural_weight?: unknown;
|
||||
keyword_weight?: unknown;
|
||||
kind_filter?: unknown;
|
||||
limit?: unknown;
|
||||
include_match_reason?: unknown;
|
||||
};
|
||||
|
||||
const resolvedQuery = typeof query === 'string' ? query.trim() : '';
|
||||
if (!resolvedQuery) {
|
||||
return { success: false, error: 'Query parameter is required', status: 400 };
|
||||
}
|
||||
|
||||
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
|
||||
const resolvedMode = typeof mode === 'string' && ['fusion', 'vector', 'structural'].includes(mode) ? mode : 'fusion';
|
||||
const resolvedStrategy = typeof fusion_strategy === 'string' &&
|
||||
['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'].includes(fusion_strategy) ? fusion_strategy : 'rrf';
|
||||
const resolvedVectorWeight = typeof vector_weight === 'number' ? vector_weight : 0.5;
|
||||
const resolvedStructuralWeight = typeof structural_weight === 'number' ? structural_weight : 0.3;
|
||||
const resolvedKeywordWeight = typeof keyword_weight === 'number' ? keyword_weight : 0.2;
|
||||
const resolvedLimit = typeof limit === 'number' ? limit : 20;
|
||||
const resolvedIncludeReason = typeof include_match_reason === 'boolean' ? include_match_reason : false;
|
||||
|
||||
// Build Python API call args
|
||||
const apiArgs: Record<string, unknown> = {
|
||||
project_root: targetPath,
|
||||
query: resolvedQuery,
|
||||
mode: resolvedMode,
|
||||
vector_weight: resolvedVectorWeight,
|
||||
structural_weight: resolvedStructuralWeight,
|
||||
keyword_weight: resolvedKeywordWeight,
|
||||
fusion_strategy: resolvedStrategy,
|
||||
limit: resolvedLimit,
|
||||
include_match_reason: resolvedIncludeReason,
|
||||
};
|
||||
|
||||
if (Array.isArray(kind_filter) && kind_filter.length > 0) {
|
||||
apiArgs.kind_filter = kind_filter;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeCodexLensPythonAPI('semantic_search', apiArgs);
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
results: result.results,
|
||||
query: resolvedQuery,
|
||||
mode: resolvedMode,
|
||||
fusion_strategy: resolvedStrategy,
|
||||
count: Array.isArray(result.results) ? result.results.length : 0,
|
||||
};
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Semantic search failed', status: 500 };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1197,7 +1197,7 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
// Parse enabled tools from request body
|
||||
const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0
|
||||
? (body.enabledTools as string[]).join(',')
|
||||
: 'write_file,edit_file,read_file,core_memory,ask_question';
|
||||
: 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
|
||||
|
||||
// Generate CCW MCP server config
|
||||
// Use cmd /c on Windows to inherit Claude Code's working directory
|
||||
|
||||
@@ -648,5 +648,104 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Test multi-page ask_question popup (for development testing)
|
||||
if (pathname === '/api/test/ask-question-multi' && req.method === 'GET') {
|
||||
try {
|
||||
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
|
||||
|
||||
const compositeId = `multi-${Date.now()}`;
|
||||
const surfaceId = `question-${compositeId}`;
|
||||
|
||||
const testSurface = {
|
||||
surfaceId,
|
||||
components: [
|
||||
// Page 0: Select question
|
||||
{ id: 'page-0-title', page: 0, component: { Text: { text: { literalString: 'Which framework?' }, usageHint: 'h3' } } },
|
||||
{ id: 'page-0-radio-group', page: 0, component: {
|
||||
RadioGroup: {
|
||||
options: [
|
||||
{ label: { literalString: 'React' }, value: 'React', description: { literalString: 'UI library' } },
|
||||
{ label: { literalString: 'Vue' }, value: 'Vue', description: { literalString: 'Progressive framework' } },
|
||||
{ label: { literalString: 'Other' }, value: '__other__', description: { literalString: 'Provide a custom answer' } },
|
||||
],
|
||||
onChange: { actionId: 'select', parameters: { questionId: 'Framework' } },
|
||||
},
|
||||
}},
|
||||
// Page 1: Multi-select question
|
||||
{ id: 'page-1-title', page: 1, component: { Text: { text: { literalString: 'Which features?' }, usageHint: 'h3' } } },
|
||||
{ id: 'page-1-checkbox-0', page: 1, component: {
|
||||
Checkbox: { label: { literalString: 'Auth' }, description: { literalString: 'Authentication' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Auth' } }, checked: { literalBoolean: false } },
|
||||
}},
|
||||
{ id: 'page-1-checkbox-1', page: 1, component: {
|
||||
Checkbox: { label: { literalString: 'Cache' }, description: { literalString: 'Caching layer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Cache' } }, checked: { literalBoolean: false } },
|
||||
}},
|
||||
{ id: 'page-1-checkbox-other', page: 1, component: {
|
||||
Checkbox: { label: { literalString: 'Other' }, description: { literalString: 'Provide a custom answer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: '__other__' } }, checked: { literalBoolean: false } },
|
||||
}},
|
||||
],
|
||||
initialState: {
|
||||
questionId: compositeId,
|
||||
questionType: 'multi-question',
|
||||
pages: [
|
||||
{ index: 0, questionId: 'Framework', title: 'Which framework?', type: 'select' },
|
||||
{ index: 1, questionId: 'Features', title: 'Which features?', type: 'multi-select' },
|
||||
],
|
||||
totalPages: 2,
|
||||
},
|
||||
displayMode: 'popup' as const,
|
||||
};
|
||||
|
||||
const sentCount = a2uiWebSocketHandler.sendSurface(testSurface);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
message: 'Multi-page test popup sent',
|
||||
sentToClients: sentCount,
|
||||
surfaceId,
|
||||
compositeId,
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to send test popup', details: String(err) }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: A2UI answer broker — retrieve answers stored for MCP polling
|
||||
if (pathname === '/api/a2ui/answer' && req.method === 'GET') {
|
||||
const questionId = url.searchParams.get('questionId');
|
||||
const isComposite = url.searchParams.get('composite') === 'true';
|
||||
|
||||
if (!questionId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'questionId is required' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
|
||||
|
||||
if (isComposite) {
|
||||
const answers = a2uiWebSocketHandler.getResolvedMultiAnswer(questionId);
|
||||
if (answers) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ pending: false, answers }));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ pending: true }));
|
||||
}
|
||||
} else {
|
||||
const answer = a2uiWebSocketHandler.getResolvedAnswer(questionId);
|
||||
if (answer) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ pending: false, answer }));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ pending: true }));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
238
ccw/src/core/routes/unsplash-routes.ts
Normal file
238
ccw/src/core/routes/unsplash-routes.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Unsplash Proxy Routes & Background Image Upload
|
||||
* Proxies Unsplash API requests to keep API key server-side.
|
||||
* API key is read from process.env.UNSPLASH_ACCESS_KEY.
|
||||
* Also handles local background image upload and serving.
|
||||
*/
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { homedir } from 'os';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
const UNSPLASH_API = 'https://api.unsplash.com';
|
||||
|
||||
// Background upload config
|
||||
const UPLOADS_DIR = join(homedir(), '.ccw', 'uploads', 'backgrounds');
|
||||
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
const EXT_MAP: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
};
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
function getAccessKey(): string | undefined {
|
||||
return process.env.UNSPLASH_ACCESS_KEY;
|
||||
}
|
||||
|
||||
interface UnsplashPhoto {
|
||||
id: string;
|
||||
urls: { thumb: string; small: string; regular: string };
|
||||
user: { name: string; links: { html: string } };
|
||||
links: { html: string; download_location: string };
|
||||
blur_hash: string | null;
|
||||
}
|
||||
|
||||
interface UnsplashSearchResult {
|
||||
results: UnsplashPhoto[];
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export async function handleBackgroundRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res } = ctx;
|
||||
|
||||
// POST /api/background/upload
|
||||
if (pathname === '/api/background/upload' && req.method === 'POST') {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (!ALLOWED_TYPES.has(contentType)) {
|
||||
res.writeHead(415, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unsupported image type. Only JPEG, PNG, WebP, GIF allowed.' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_UPLOAD_SIZE) {
|
||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File too large. Maximum size is 10MB.' }));
|
||||
return true;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const ext = EXT_MAP[contentType] || 'bin';
|
||||
const filename = `${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`;
|
||||
|
||||
mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
writeFileSync(join(UPLOADS_DIR, filename), buffer);
|
||||
|
||||
const url = `/api/background/uploads/${filename}`;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ url, filename }));
|
||||
} catch (err) {
|
||||
console.error('[background] Upload error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Upload failed' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/background/uploads/:filename
|
||||
if (pathname.startsWith('/api/background/uploads/') && req.method === 'GET') {
|
||||
const filename = pathname.slice('/api/background/uploads/'.length);
|
||||
|
||||
// Security: reject path traversal
|
||||
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid filename' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = join(UPLOADS_DIR, filename);
|
||||
if (!existsSync(filePath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'File not found' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
||||
|
||||
try {
|
||||
const data = readFileSync(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mime,
|
||||
'Content-Length': data.length,
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
});
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
console.error('[background] Serve error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to read file' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleUnsplashRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res } = ctx;
|
||||
|
||||
// GET /api/unsplash/search?query=...&page=1&per_page=20
|
||||
if (pathname === '/api/unsplash/search' && req.method === 'GET') {
|
||||
const accessKey = getAccessKey();
|
||||
if (!accessKey) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const query = url.searchParams.get('query') || '';
|
||||
const page = url.searchParams.get('page') || '1';
|
||||
const perPage = url.searchParams.get('per_page') || '20';
|
||||
|
||||
if (!query.trim()) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing query parameter' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiUrl = `${UNSPLASH_API}/search/photos?query=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&orientation=landscape`;
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: { Authorization: `Client-ID ${accessKey}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const status = response.status;
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `Unsplash API error: ${status}` }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as UnsplashSearchResult;
|
||||
|
||||
// Return simplified data
|
||||
const photos = data.results.map((photo) => ({
|
||||
id: photo.id,
|
||||
thumbUrl: photo.urls.thumb,
|
||||
smallUrl: photo.urls.small,
|
||||
regularUrl: photo.urls.regular,
|
||||
photographer: photo.user.name,
|
||||
photographerUrl: photo.user.links.html,
|
||||
photoUrl: photo.links.html,
|
||||
blurHash: photo.blur_hash,
|
||||
downloadLocation: photo.links.download_location,
|
||||
}));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
photos,
|
||||
total: data.total,
|
||||
totalPages: data.total_pages,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[unsplash] Search error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to search Unsplash' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/unsplash/download — trigger download event (Unsplash API requirement)
|
||||
if (pathname === '/api/unsplash/download' && req.method === 'POST') {
|
||||
const accessKey = getAccessKey();
|
||||
if (!accessKey) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||
const downloadLocation = body.downloadLocation;
|
||||
|
||||
if (!downloadLocation) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Missing downloadLocation' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Trigger download event (Unsplash API guideline)
|
||||
await fetch(downloadLocation, {
|
||||
headers: { Authorization: `Client-ID ${accessKey}` },
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
} catch (err) {
|
||||
console.error('[unsplash] Download trigger error:', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Failed to trigger download' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user