mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37: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:
@@ -3,9 +3,9 @@
|
||||
// ========================================
|
||||
// Centered popup dialog for A2UI surfaces with minimalist design
|
||||
// Used for displayMode: 'popup' surfaces (e.g., ask_question)
|
||||
// Supports markdown content parsing
|
||||
// Supports markdown content parsing and multi-page navigation
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -30,7 +30,14 @@ interface A2UIPopupCardProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type QuestionType = 'confirm' | 'select' | 'multi-select' | 'input' | 'unknown';
|
||||
type QuestionType = 'confirm' | 'select' | 'multi-select' | 'input' | 'multi-question' | 'unknown';
|
||||
|
||||
interface PageMeta {
|
||||
index: number;
|
||||
questionId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
@@ -73,6 +80,37 @@ function isActionButton(component: SurfaceComponent): boolean {
|
||||
return 'Button' in comp;
|
||||
}
|
||||
|
||||
// ========== "Other" Text Input Component ==========
|
||||
|
||||
interface OtherInputProps {
|
||||
visible: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function OtherInput({ visible, value, onChange, placeholder }: OtherInputProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 animate-in fade-in-0 slide-in-from-top-1 duration-200">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || 'Enter your answer...'}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-sm rounded-md border border-border',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent',
|
||||
'transition-colors'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Markdown Component ==========
|
||||
|
||||
interface MarkdownContentProps {
|
||||
@@ -114,15 +152,21 @@ function MarkdownContent({ content, className }: MarkdownContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
// ========== Single-Page Popup (Legacy) ==========
|
||||
|
||||
export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
|
||||
function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
|
||||
// Detect question type
|
||||
const questionType = useMemo(() => detectQuestionType(surface), [surface]);
|
||||
|
||||
// "Other" option state
|
||||
const [otherSelected, setOtherSelected] = useState(false);
|
||||
const [otherText, setOtherText] = useState('');
|
||||
|
||||
const questionId = (surface.initialState as any)?.questionId as string | undefined;
|
||||
|
||||
// Extract title, message, and description from surface components
|
||||
const titleComponent = surface.components.find(
|
||||
(c) => c.id === 'title' && 'Text' in (c.component as any)
|
||||
@@ -171,9 +215,33 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
|
||||
[surface, actionButtons]
|
||||
);
|
||||
|
||||
// Handle "Other" text change
|
||||
const handleOtherTextChange = useCallback(
|
||||
(value: string) => {
|
||||
setOtherText(value);
|
||||
if (questionId) {
|
||||
sendA2UIAction('input-change', surface.surfaceId, {
|
||||
questionId: `__other__:${questionId}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, questionId]
|
||||
);
|
||||
|
||||
// Handle A2UI actions
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
// Track "Other" selection state
|
||||
if (actionId === 'select' && params?.value === '__other__') {
|
||||
setOtherSelected(true);
|
||||
} else if (actionId === 'select' && params?.value !== '__other__') {
|
||||
setOtherSelected(false);
|
||||
}
|
||||
if (actionId === 'toggle' && params?.value === '__other__') {
|
||||
setOtherSelected((prev) => !prev);
|
||||
}
|
||||
|
||||
// Send action to backend via WebSocket
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
|
||||
@@ -211,6 +279,9 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
|
||||
}
|
||||
}, [questionType]);
|
||||
|
||||
// Check if this question type supports "Other" input
|
||||
const hasOtherOption = questionType === 'select' || questionType === 'multi-select';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
@@ -269,6 +340,14 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
|
||||
) : (
|
||||
<A2UIRenderer surface={bodySurface} onAction={handleAction} />
|
||||
)}
|
||||
{/* "Other" text input — shown when Other is selected */}
|
||||
{hasOtherOption && (
|
||||
<OtherInput
|
||||
visible={otherSelected}
|
||||
value={otherText}
|
||||
onChange={handleOtherTextChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -291,4 +370,308 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Multi-Page Popup ==========
|
||||
|
||||
function MultiPagePopup({ surface, onClose }: A2UIPopupCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
|
||||
|
||||
const state = surface.initialState as Record<string, unknown>;
|
||||
const pages = state.pages as PageMeta[];
|
||||
const totalPages = state.totalPages as number;
|
||||
const compositeId = state.questionId as string;
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
// "Other" per-page state
|
||||
const [otherSelectedPages, setOtherSelectedPages] = useState<Set<number>>(new Set());
|
||||
const [otherTexts, setOtherTexts] = useState<Map<number, string>>(new Map());
|
||||
|
||||
// Group components by page
|
||||
const pageComponentGroups = useMemo(() => {
|
||||
const groups: SurfaceComponent[][] = [];
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
groups.push(
|
||||
surface.components.filter((c) => (c as any).page === i)
|
||||
);
|
||||
}
|
||||
return groups;
|
||||
}, [surface.components, totalPages]);
|
||||
|
||||
// Extract current page title and body components
|
||||
const currentPageData = useMemo(() => {
|
||||
const comps = pageComponentGroups[currentPage] || [];
|
||||
const titleComp = comps.find((c) => c.id.endsWith('-title'));
|
||||
const messageComp = comps.find((c) => c.id.endsWith('-message'));
|
||||
const descComp = comps.find((c) => c.id.endsWith('-description'));
|
||||
const bodyComps = comps.filter(
|
||||
(c) => !c.id.endsWith('-title') && !c.id.endsWith('-message') && !c.id.endsWith('-description')
|
||||
);
|
||||
|
||||
return {
|
||||
title: getTextContent(titleComp),
|
||||
message: getTextContent(messageComp),
|
||||
description: getTextContent(descComp),
|
||||
bodyComponents: bodyComps,
|
||||
pageMeta: pages[currentPage],
|
||||
};
|
||||
}, [pageComponentGroups, currentPage, pages]);
|
||||
|
||||
// Handle "Other" text change for a specific page
|
||||
const handleOtherTextChange = useCallback(
|
||||
(pageIdx: number, value: string) => {
|
||||
setOtherTexts((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(pageIdx, value);
|
||||
return next;
|
||||
});
|
||||
// Send input-change to backend with __other__:{questionId}
|
||||
const qId = pages[pageIdx]?.questionId;
|
||||
if (qId) {
|
||||
sendA2UIAction('input-change', surface.surfaceId, {
|
||||
questionId: `__other__:${qId}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, pages]
|
||||
);
|
||||
|
||||
// Handle A2UI actions (pass through to backend without closing dialog)
|
||||
const handleAction = useCallback(
|
||||
(actionId: string, params?: Record<string, unknown>) => {
|
||||
// Track "Other" selection state per page
|
||||
if (actionId === 'select' && params?.value === '__other__') {
|
||||
setOtherSelectedPages((prev) => new Set(prev).add(currentPage));
|
||||
} else if (actionId === 'select' && params?.value !== '__other__') {
|
||||
setOtherSelectedPages((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(currentPage);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (actionId === 'toggle' && params?.value === '__other__') {
|
||||
setOtherSelectedPages((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(currentPage)) {
|
||||
next.delete(currentPage);
|
||||
} else {
|
||||
next.add(currentPage);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
sendA2UIAction(actionId, surface.surfaceId, params);
|
||||
},
|
||||
[sendA2UIAction, surface.surfaceId, currentPage]
|
||||
);
|
||||
|
||||
// Handle Cancel
|
||||
const handleCancel = useCallback(() => {
|
||||
sendA2UIAction('cancel', surface.surfaceId, { questionId: compositeId });
|
||||
onClose();
|
||||
}, [sendA2UIAction, surface.surfaceId, compositeId, onClose]);
|
||||
|
||||
// Handle Submit All
|
||||
const handleSubmitAll = useCallback(() => {
|
||||
sendA2UIAction('submit-all', surface.surfaceId, {
|
||||
compositeId,
|
||||
questionIds: pages.map((p) => p.questionId),
|
||||
});
|
||||
onClose();
|
||||
}, [sendA2UIAction, surface.surfaceId, compositeId, pages, onClose]);
|
||||
|
||||
// Handle dialog close
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleCancel]
|
||||
);
|
||||
|
||||
// Navigation
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentPage((p) => Math.min(p + 1, totalPages - 1));
|
||||
}, [totalPages]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentPage((p) => Math.max(p - 1, 0));
|
||||
}, []);
|
||||
|
||||
const isFirstPage = currentPage === 0;
|
||||
const isLastPage = currentPage === totalPages - 1;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'sm:max-w-[480px]',
|
||||
'max-h-[80vh]',
|
||||
'bg-card p-6 rounded-xl shadow-lg border border-border/50',
|
||||
// Animation classes
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
||||
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
|
||||
'data-[state=open]:duration-300 data-[state=closed]:duration-200'
|
||||
)}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Header with current page title */}
|
||||
<DialogHeader className="space-y-2 pb-4">
|
||||
<DialogTitle className="text-lg font-semibold leading-tight">
|
||||
{currentPageData.title ||
|
||||
formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' })}
|
||||
</DialogTitle>
|
||||
{currentPageData.message && (
|
||||
<div className="text-base text-foreground">
|
||||
<MarkdownContent content={currentPageData.message} />
|
||||
</div>
|
||||
)}
|
||||
{currentPageData.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<MarkdownContent content={currentPageData.description} className="prose-muted" />
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
{/* Page content with slide animation */}
|
||||
<div className="overflow-hidden">
|
||||
<div
|
||||
className="flex transition-transform duration-300 ease-in-out"
|
||||
style={{ transform: `translateX(-${currentPage * 100}%)` }}
|
||||
>
|
||||
{pageComponentGroups.map((pageComps, pageIdx) => {
|
||||
const bodyComps = pageComps.filter(
|
||||
(c) =>
|
||||
!c.id.endsWith('-title') &&
|
||||
!c.id.endsWith('-message') &&
|
||||
!c.id.endsWith('-description')
|
||||
);
|
||||
const pageType = pages[pageIdx]?.type || 'unknown';
|
||||
const hasOther = pageType === 'select' || pageType === 'multi-select';
|
||||
const isOtherSelected = otherSelectedPages.has(pageIdx);
|
||||
|
||||
return (
|
||||
<div key={pageIdx} className="w-full flex-shrink-0">
|
||||
{bodyComps.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'py-3',
|
||||
pageType === 'multi-select' && 'space-y-2 max-h-[300px] overflow-y-auto px-1'
|
||||
)}
|
||||
>
|
||||
{pageType === 'multi-select' ? (
|
||||
bodyComps.map((comp) => (
|
||||
<div key={comp.id} className="py-1">
|
||||
<A2UIRenderer
|
||||
surface={{ ...surface, components: [comp] }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<A2UIRenderer
|
||||
surface={{ ...surface, components: bodyComps }}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
)}
|
||||
{/* "Other" text input */}
|
||||
{hasOther && (
|
||||
<OtherInput
|
||||
visible={isOtherSelected}
|
||||
value={otherTexts.get(pageIdx) || ''}
|
||||
onChange={(v) => handleOtherTextChange(pageIdx, v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dot indicator */}
|
||||
<div className="flex justify-center gap-2 py-3">
|
||||
{pages.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setCurrentPage(i)}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-200',
|
||||
i === currentPage
|
||||
? 'bg-primary w-4 h-2'
|
||||
: 'bg-muted-foreground/30 w-2 h-2 hover:bg-muted-foreground/50'
|
||||
)}
|
||||
aria-label={`Page ${i + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer - Navigation buttons */}
|
||||
<DialogFooter className="pt-2">
|
||||
<div className="flex flex-row justify-between w-full">
|
||||
{/* Left: Cancel */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-sm rounded-md border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
{formatMessage({ id: 'askQuestion.cancel', defaultMessage: 'Cancel' })}
|
||||
</button>
|
||||
|
||||
{/* Right: Prev / Next / Submit */}
|
||||
<div className="flex flex-row gap-2">
|
||||
{!isFirstPage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
className="px-4 py-2 text-sm rounded-md border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
{formatMessage({ id: 'askQuestion.previous', defaultMessage: 'Previous' })}
|
||||
</button>
|
||||
)}
|
||||
{isLastPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmitAll}
|
||||
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{formatMessage({ id: 'askQuestion.submit', defaultMessage: 'Submit' })}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{formatMessage({ id: 'askQuestion.next', defaultMessage: 'Next' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
|
||||
const state = surface.initialState as Record<string, unknown> | undefined;
|
||||
const isMultiPage = state?.questionType === 'multi-question' && (state?.totalPages as number) > 1;
|
||||
|
||||
if (isMultiPage) {
|
||||
return <MultiPagePopup surface={surface} onClose={onClose} />;
|
||||
}
|
||||
|
||||
return <SinglePagePopup surface={surface} onClose={onClose} />;
|
||||
}
|
||||
|
||||
export default A2UIPopupCard;
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ModelCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', !model.installed && 'opacity-80')}>
|
||||
<Card className={cn('overflow-hidden hover-glow', !model.installed && 'opacity-80')}>
|
||||
{/* Header */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -105,12 +105,15 @@ export function ModelCard({
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span>Backend: {model.backend}</span>
|
||||
<span>Size: {formatSize(model.size)}</span>
|
||||
{model.dimensions && <span>{model.dimensions}d</span>}
|
||||
<span>{formatSize(model.size)}</span>
|
||||
{model.recommended && (
|
||||
<Badge variant="success" className="text-[10px] px-1 py-0">Rec</Badge>
|
||||
)}
|
||||
</div>
|
||||
{model.cache_path && (
|
||||
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">
|
||||
{model.cache_path}
|
||||
{model.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{model.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ function filterModels(models: CodexLensModel[], filter: FilterType, search: stri
|
||||
filtered = filtered.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.profile.toLowerCase().includes(query) ||
|
||||
m.backend.toLowerCase().includes(query)
|
||||
(m.description?.toLowerCase().includes(query) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// CodexLens Search Tab
|
||||
// ========================================
|
||||
// Semantic code search interface with multiple search types
|
||||
// Includes LSP availability check and hybrid search mode switching
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Search, FileCode, Code } from 'lucide-react';
|
||||
import { Search, FileCode, Code, Sparkles, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
@@ -20,11 +21,13 @@ import {
|
||||
useCodexLensSearch,
|
||||
useCodexLensFilesSearch,
|
||||
useCodexLensSymbolSearch,
|
||||
useCodexLensLspStatus,
|
||||
useCodexLensSemanticSearch,
|
||||
} from '@/hooks/useCodexLens';
|
||||
import type { CodexLensSearchParams } from '@/lib/api';
|
||||
import type { CodexLensSearchParams, CodexLensSemanticSearchMode, CodexLensFusionStrategy } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SearchType = 'search' | 'search_files' | 'symbol';
|
||||
type SearchType = 'search' | 'search_files' | 'symbol' | 'semantic';
|
||||
type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy';
|
||||
|
||||
interface SearchTabProps {
|
||||
@@ -35,14 +38,19 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchType, setSearchType] = useState<SearchType>('search');
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank');
|
||||
const [semanticMode, setSemanticMode] = useState<CodexLensSemanticSearchMode>('fusion');
|
||||
const [fusionStrategy, setFusionStrategy] = useState<CodexLensFusionStrategy>('rrf');
|
||||
const [query, setQuery] = useState('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
|
||||
// LSP status check
|
||||
const lspStatus = useCodexLensLspStatus({ enabled });
|
||||
|
||||
// Build search params based on search type
|
||||
const searchParams: CodexLensSearchParams = {
|
||||
query,
|
||||
limit: 20,
|
||||
mode: searchType !== 'symbol' ? searchMode : undefined,
|
||||
mode: searchType !== 'symbol' && searchType !== 'semantic' ? searchMode : undefined,
|
||||
max_content_length: 200,
|
||||
extra_files_count: 10,
|
||||
};
|
||||
@@ -63,12 +71,25 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
{ enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 }
|
||||
);
|
||||
|
||||
const semanticSearch = useCodexLensSemanticSearch(
|
||||
{
|
||||
query,
|
||||
mode: semanticMode,
|
||||
fusion_strategy: semanticMode === 'fusion' ? fusionStrategy : undefined,
|
||||
limit: 20,
|
||||
include_match_reason: true,
|
||||
},
|
||||
{ enabled: enabled && hasSearched && searchType === 'semantic' && query.trim().length > 0 }
|
||||
);
|
||||
|
||||
// Get loading state based on search type
|
||||
const isLoading = searchType === 'search'
|
||||
? contentSearch.isLoading
|
||||
: searchType === 'search_files'
|
||||
? fileSearch.isLoading
|
||||
: symbolSearch.isLoading;
|
||||
: searchType === 'symbol'
|
||||
? symbolSearch.isLoading
|
||||
: semanticSearch.isLoading;
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
@@ -84,17 +105,52 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
|
||||
const handleSearchTypeChange = (value: SearchType) => {
|
||||
setSearchType(value);
|
||||
setHasSearched(false); // Reset search state when changing type
|
||||
setHasSearched(false);
|
||||
};
|
||||
|
||||
const handleSearchModeChange = (value: SearchMode) => {
|
||||
setSearchMode(value);
|
||||
setHasSearched(false); // Reset search state when changing mode
|
||||
setHasSearched(false);
|
||||
};
|
||||
|
||||
const handleSemanticModeChange = (value: CodexLensSemanticSearchMode) => {
|
||||
setSemanticMode(value);
|
||||
setHasSearched(false);
|
||||
};
|
||||
|
||||
const handleFusionStrategyChange = (value: CodexLensFusionStrategy) => {
|
||||
setFusionStrategy(value);
|
||||
setHasSearched(false);
|
||||
};
|
||||
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setHasSearched(false); // Reset search state when query changes
|
||||
setHasSearched(false);
|
||||
};
|
||||
|
||||
// Get result count for display
|
||||
const getResultCount = (): string => {
|
||||
if (searchType === 'symbol') {
|
||||
return symbolSearch.data?.success
|
||||
? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: '';
|
||||
}
|
||||
if (searchType === 'search') {
|
||||
return contentSearch.data?.success
|
||||
? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: '';
|
||||
}
|
||||
if (searchType === 'search_files') {
|
||||
return fileSearch.data?.success
|
||||
? `${fileSearch.data.files?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: '';
|
||||
}
|
||||
if (searchType === 'semantic') {
|
||||
return semanticSearch.data?.success
|
||||
? `${semanticSearch.data.count ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
@@ -115,6 +171,29 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* LSP Status Indicator */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.search.lspStatus' })}:</span>
|
||||
{lspStatus.isLoading ? (
|
||||
<span className="text-muted-foreground">...</span>
|
||||
) : lspStatus.available ? (
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'codexlens.search.lspAvailable' })}
|
||||
</span>
|
||||
) : !lspStatus.semanticAvailable ? (
|
||||
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'codexlens.search.lspNoSemantic' })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'codexlens.search.lspNoVector' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Options */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Search Type */}
|
||||
@@ -143,12 +222,18 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
{formatMessage({ id: 'codexlens.search.symbol' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="semantic" disabled={!lspStatus.available}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{formatMessage({ id: 'codexlens.search.semantic' })}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Search Mode - only for content and file search */}
|
||||
{searchType !== 'symbol' && (
|
||||
{/* Search Mode - for CLI search types (content / file) */}
|
||||
{(searchType === 'search' || searchType === 'search_files') && (
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label>
|
||||
<Select value={searchMode} onValueChange={handleSearchModeChange}>
|
||||
@@ -169,8 +254,60 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Semantic Search Mode - for semantic search type */}
|
||||
{searchType === 'semantic' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'codexlens.search.semanticMode' })}</Label>
|
||||
<Select value={semanticMode} onValueChange={handleSemanticModeChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fusion">
|
||||
{formatMessage({ id: 'codexlens.search.semanticMode.fusion' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="vector">
|
||||
{formatMessage({ id: 'codexlens.search.semanticMode.vector' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="structural">
|
||||
{formatMessage({ id: 'codexlens.search.semanticMode.structural' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fusion Strategy - only when semantic + fusion mode */}
|
||||
{searchType === 'semantic' && semanticMode === 'fusion' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'codexlens.search.fusionStrategy' })}</Label>
|
||||
<Select value={fusionStrategy} onValueChange={handleFusionStrategyChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rrf">
|
||||
{formatMessage({ id: 'codexlens.search.fusionStrategy.rrf' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="dense_rerank">
|
||||
{formatMessage({ id: 'codexlens.search.fusionStrategy.dense_rerank' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="binary">
|
||||
{formatMessage({ id: 'codexlens.search.fusionStrategy.binary' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="hybrid">
|
||||
{formatMessage({ id: 'codexlens.search.fusionStrategy.hybrid' })}
|
||||
</SelectItem>
|
||||
<SelectItem value="staged">
|
||||
{formatMessage({ id: 'codexlens.search.fusionStrategy.staged' })}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Query Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>
|
||||
@@ -205,21 +342,7 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
{formatMessage({ id: 'codexlens.search.results' })}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{searchType === 'symbol'
|
||||
? (symbolSearch.data?.success
|
||||
? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: ''
|
||||
)
|
||||
: searchType === 'search'
|
||||
? (contentSearch.data?.success
|
||||
? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: ''
|
||||
)
|
||||
: (fileSearch.data?.success
|
||||
? `${fileSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}`
|
||||
: ''
|
||||
)
|
||||
}
|
||||
{getResultCount()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +378,7 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
fileSearch.data.success ? (
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(fileSearch.data.results, null, 2)}
|
||||
{JSON.stringify(fileSearch.data.files, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
@@ -264,6 +387,20 @@ export function SearchTab({ enabled }: SearchTabProps) {
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{searchType === 'semantic' && semanticSearch.data && (
|
||||
semanticSearch.data.success ? (
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-xs overflow-auto max-h-96">
|
||||
{JSON.stringify(semanticSearch.data.results, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-destructive">
|
||||
{semanticSearch.data.error || formatMessage({ id: 'common.error' })}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function DashboardHeader({
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
<h1 className="text-2xl font-semibold text-foreground gradient-text">
|
||||
{formatMessage({ id: titleKey })}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
|
||||
@@ -253,7 +253,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', className)}>
|
||||
{/* Project Info Banner - Separate Card */}
|
||||
<Card className="shrink-0">
|
||||
<Card className="shrink-0 border-gradient-brand">
|
||||
{projectLoading ? (
|
||||
<div className="px-4 py-3 flex items-center gap-4">
|
||||
<div className="h-5 w-32 bg-muted rounded animate-pulse" />
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MainContent } from './MainContent';
|
||||
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
|
||||
import { NotificationPanel } from '@/components/notification';
|
||||
import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui';
|
||||
import { BackgroundImage } from '@/components/shared/BackgroundImage';
|
||||
import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
import { useWebSocketNotifications, useWebSocket } from '@/hooks';
|
||||
@@ -160,6 +161,9 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-background">
|
||||
{/* Background image layer (z-index: -3 to -2) */}
|
||||
<BackgroundImage />
|
||||
|
||||
{/* Header - fixed at top */}
|
||||
<Header
|
||||
onRefresh={onRefresh}
|
||||
@@ -180,7 +184,7 @@ export function AppShell({
|
||||
{/* Main content area */}
|
||||
<MainContent
|
||||
className={cn(
|
||||
'transition-all duration-300',
|
||||
'app-shell-content transition-all duration-300',
|
||||
sidebarCollapsed ? 'md:ml-16' : 'md:ml-64'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function Header({
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
|
||||
className="relative flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
|
||||
role="banner"
|
||||
>
|
||||
{/* Left side - Logo */}
|
||||
@@ -200,6 +200,7 @@ export function Header({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-accent" aria-hidden="true" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FileText,
|
||||
HardDrive,
|
||||
MessageCircleQuestion,
|
||||
SearchCode,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
@@ -90,6 +91,7 @@ export const CCW_MCP_TOOLS: CcwTool[] = [
|
||||
{ name: 'read_file', desc: 'Read file contents', core: true },
|
||||
{ name: 'core_memory', desc: 'Core memory management', core: true },
|
||||
{ name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false },
|
||||
{ name: 'smart_search', desc: 'Intelligent code search', core: true },
|
||||
];
|
||||
|
||||
// ========== Component ==========
|
||||
@@ -470,6 +472,8 @@ function getToolIcon(toolName: string): React.ReactElement {
|
||||
return <Settings {...iconProps} />;
|
||||
case 'ask_question':
|
||||
return <MessageCircleQuestion {...iconProps} />;
|
||||
case 'smart_search':
|
||||
return <SearchCode {...iconProps} />;
|
||||
default:
|
||||
return <Settings {...iconProps} />;
|
||||
}
|
||||
|
||||
98
ccw/frontend/src/components/shared/BackgroundImage.tsx
Normal file
98
ccw/frontend/src/components/shared/BackgroundImage.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { DEFAULT_BACKGROUND_CONFIG } from '@/lib/theme';
|
||||
import type { BackgroundConfig } from '@/types/store';
|
||||
|
||||
/**
|
||||
* BackgroundImage Component
|
||||
* Renders background image layer with visual effects (blur, darken, grain, vignette).
|
||||
* Positioned behind all content via z-index layering.
|
||||
*/
|
||||
export function BackgroundImage() {
|
||||
const activeSlotId = useAppStore((s) => s.activeSlotId);
|
||||
const themeSlots = useAppStore((s) => s.themeSlots);
|
||||
const activeSlot = themeSlots.find((s) => s.id === activeSlotId);
|
||||
const config: BackgroundConfig = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
|
||||
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoaded(true);
|
||||
setError(false);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setError(true);
|
||||
setLoaded(false);
|
||||
}, []);
|
||||
|
||||
// Reset load state when image URL changes
|
||||
const imageUrl = config.imageUrl;
|
||||
const [prevUrl, setPrevUrl] = useState(imageUrl);
|
||||
if (imageUrl !== prevUrl) {
|
||||
setPrevUrl(imageUrl);
|
||||
setLoaded(false);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
// Don't render anything in gradient-only mode
|
||||
if (config.mode === 'gradient-only') return null;
|
||||
|
||||
// Don't render if no image URL or image failed to load
|
||||
if (!imageUrl || error) return null;
|
||||
|
||||
const { blur, darkenOpacity, saturation } = config.effects;
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
filter: [
|
||||
blur > 0 ? `blur(${blur}px)` : '',
|
||||
saturation !== 100 ? `saturate(${saturation / 100})` : '',
|
||||
].filter(Boolean).join(' ') || undefined,
|
||||
// Scale slightly when blurred to prevent white edges
|
||||
transform: blur > 0 ? 'scale(1.05)' : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Background image layer */}
|
||||
<div
|
||||
className="bg-image-layer"
|
||||
style={{ opacity: loaded ? 1 : 0 }}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
role="presentation"
|
||||
style={imageStyle}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Darken overlay */}
|
||||
{darkenOpacity > 0 && (
|
||||
<div
|
||||
className="bg-darken-overlay"
|
||||
style={{ backgroundColor: `rgba(0, 0, 0, ${darkenOpacity / 100})` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Grain texture overlay */}
|
||||
<div className="bg-grain-overlay" />
|
||||
|
||||
{/* Vignette overlay */}
|
||||
<div className="bg-vignette-overlay" />
|
||||
|
||||
{/* Loading spinner (hidden img for preloading) */}
|
||||
{!loaded && !error && (
|
||||
<div
|
||||
className="fixed inset-0 z-[-3] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div className="w-6 h-6 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
405
ccw/frontend/src/components/shared/BackgroundImagePicker.tsx
Normal file
405
ccw/frontend/src/components/shared/BackgroundImagePicker.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useUnsplashSearch } from '@/hooks/useUnsplashSearch';
|
||||
import { triggerUnsplashDownload, uploadBackgroundImage } from '@/lib/unsplash';
|
||||
import type { UnsplashPhoto } from '@/lib/unsplash';
|
||||
import type { BackgroundMode } from '@/types/store';
|
||||
|
||||
const MODES: { value: BackgroundMode; labelId: string }[] = [
|
||||
{ value: 'gradient-only', labelId: 'theme.background.mode.gradientOnly' },
|
||||
{ value: 'image-only', labelId: 'theme.background.mode.imageOnly' },
|
||||
{ value: 'image-gradient', labelId: 'theme.background.mode.imageGradient' },
|
||||
];
|
||||
|
||||
/**
|
||||
* BackgroundImagePicker Component
|
||||
* Allows users to search Unsplash, pick a background image,
|
||||
* adjust visual effects, and switch between background modes.
|
||||
*/
|
||||
export function BackgroundImagePicker() {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
backgroundConfig,
|
||||
setBackgroundMode,
|
||||
setBackgroundImage,
|
||||
updateBackgroundEffect,
|
||||
} = useTheme();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data, isLoading, isError, error } = useUnsplashSearch(searchQuery, page);
|
||||
|
||||
const showImageControls = backgroundConfig.mode !== 'gradient-only';
|
||||
|
||||
const handlePhotoSelect = useCallback(async (photo: UnsplashPhoto) => {
|
||||
setBackgroundImage(photo.regularUrl, {
|
||||
photographerName: photo.photographer,
|
||||
photographerUrl: photo.photographerUrl,
|
||||
photoUrl: photo.photoUrl,
|
||||
});
|
||||
// Trigger download event per Unsplash API guidelines
|
||||
triggerUnsplashDownload(photo.downloadLocation).catch(() => {});
|
||||
}, [setBackgroundImage]);
|
||||
|
||||
const handleCustomUrlApply = useCallback(() => {
|
||||
if (customUrl.trim()) {
|
||||
setBackgroundImage(customUrl.trim(), null);
|
||||
setCustomUrl('');
|
||||
}
|
||||
}, [customUrl, setBackgroundImage]);
|
||||
|
||||
const handleRemoveImage = useCallback(() => {
|
||||
setBackgroundImage(null, null);
|
||||
}, [setBackgroundImage]);
|
||||
|
||||
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Reset file input so the same file can be re-selected
|
||||
e.target.value = '';
|
||||
|
||||
setUploadError(null);
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setUploadError(formatMessage({ id: 'theme.background.fileTooLarge' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/') || !['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type)) {
|
||||
setUploadError(formatMessage({ id: 'theme.background.invalidType' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const result = await uploadBackgroundImage(file);
|
||||
setBackgroundImage(result.url, null);
|
||||
} catch (err) {
|
||||
setUploadError((err as Error).message || formatMessage({ id: 'theme.background.uploadError' }));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [formatMessage, setBackgroundImage]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section title */}
|
||||
<h3 className="text-sm font-medium text-text">
|
||||
{formatMessage({ id: 'theme.background.title' })}
|
||||
</h3>
|
||||
|
||||
{/* Background mode selector */}
|
||||
<div
|
||||
className="flex gap-2"
|
||||
role="radiogroup"
|
||||
aria-label={formatMessage({ id: 'theme.background.title' })}
|
||||
>
|
||||
{MODES.map(({ value, labelId }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setBackgroundMode(value)}
|
||||
role="radio"
|
||||
aria-checked={backgroundConfig.mode === value}
|
||||
className={`
|
||||
flex-1 px-3 py-2 rounded-lg text-xs font-medium
|
||||
transition-all duration-200 border-2
|
||||
${backgroundConfig.mode === value
|
||||
? 'border-accent bg-surface shadow-md'
|
||||
: 'border-border bg-bg hover:bg-surface'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
|
||||
`}
|
||||
>
|
||||
{formatMessage({ id: labelId })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Image selection area */}
|
||||
{showImageControls && (
|
||||
<div className="space-y-3">
|
||||
{/* Current image preview */}
|
||||
{backgroundConfig.imageUrl && (
|
||||
<div className="relative rounded-lg overflow-hidden border border-border">
|
||||
<img
|
||||
src={backgroundConfig.imageUrl}
|
||||
alt="Current background"
|
||||
className="w-full h-32 object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemoveImage}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs bg-black/60 text-white rounded hover:bg-black/80 transition-colors"
|
||||
>
|
||||
{formatMessage({ id: 'theme.background.removeImage' })}
|
||||
</button>
|
||||
{/* Unsplash attribution */}
|
||||
{backgroundConfig.attribution && (
|
||||
<div className="absolute bottom-0 left-0 right-0 px-2 py-1 bg-black/50 text-white text-xs">
|
||||
Photo by{' '}
|
||||
<a
|
||||
href={`${backgroundConfig.attribution.photographerUrl}?utm_source=ccw&utm_medium=referral`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{backgroundConfig.attribution.photographerName}
|
||||
</a>{' '}
|
||||
on{' '}
|
||||
<a
|
||||
href="https://unsplash.com/?utm_source=ccw&utm_medium=referral"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Unsplash
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search box */}
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||||
placeholder={formatMessage({ id: 'theme.background.searchPlaceholder' })}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg text-text
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1"
|
||||
/>
|
||||
|
||||
{/* Photo grid */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p className="text-xs text-destructive py-2">
|
||||
{(error as Error)?.message || formatMessage({ id: 'theme.background.searchError' })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && data.photos.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 max-h-60 overflow-y-auto">
|
||||
{data.photos.map((photo) => (
|
||||
<button
|
||||
key={photo.id}
|
||||
onClick={() => handlePhotoSelect(photo)}
|
||||
className={`
|
||||
relative rounded overflow-hidden border-2 transition-all
|
||||
hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent
|
||||
${backgroundConfig.imageUrl === photo.regularUrl
|
||||
? 'border-accent ring-2 ring-accent'
|
||||
: 'border-transparent'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<img
|
||||
src={photo.thumbUrl}
|
||||
alt={`Photo by ${photo.photographer}`}
|
||||
className="w-full h-20 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-1 text-xs rounded border border-border disabled:opacity-50 hover:bg-surface"
|
||||
>
|
||||
{formatMessage({ id: 'theme.background.prev' })}
|
||||
</button>
|
||||
<span className="text-xs text-text-secondary">
|
||||
{page} / {data.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
|
||||
disabled={page >= data.totalPages}
|
||||
className="px-2 py-1 text-xs rounded border border-border disabled:opacity-50 hover:bg-surface"
|
||||
>
|
||||
{formatMessage({ id: 'theme.background.next' })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{data && data.photos.length === 0 && searchQuery.trim() && (
|
||||
<p className="text-xs text-text-secondary py-2 text-center">
|
||||
{formatMessage({ id: 'theme.background.noResults' })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Custom URL input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={customUrl}
|
||||
onChange={(e) => setCustomUrl(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'theme.background.customUrlPlaceholder' })}
|
||||
className="flex-1 px-3 py-2 text-xs rounded-lg border border-border bg-bg text-text
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCustomUrlApply}
|
||||
disabled={!customUrl.trim()}
|
||||
className="px-3 py-2 text-xs rounded-lg bg-accent text-white disabled:opacity-50 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{formatMessage({ id: 'theme.background.apply' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Upload local image */}
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="w-full px-3 py-2 text-xs rounded-lg border border-dashed border-border
|
||||
bg-bg text-text hover:bg-surface hover:border-accent
|
||||
disabled:opacity-50 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="w-3.5 h-3.5 border-2 border-border border-t-accent rounded-full animate-spin" />
|
||||
{formatMessage({ id: 'theme.background.uploading' })}
|
||||
</>
|
||||
) : (
|
||||
formatMessage({ id: 'theme.background.upload' })
|
||||
)}
|
||||
</button>
|
||||
{uploadError && (
|
||||
<p className="text-xs text-destructive">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effects panel */}
|
||||
{showImageControls && (
|
||||
<div className="space-y-3 pt-2 border-t border-border">
|
||||
<h4 className="text-xs font-medium text-text-secondary">
|
||||
{formatMessage({ id: 'theme.background.effects' })}
|
||||
</h4>
|
||||
|
||||
{/* Blur slider */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<label className="text-xs text-text">
|
||||
{formatMessage({ id: 'theme.background.blur' })}
|
||||
</label>
|
||||
<span className="text-xs text-text-secondary">{backgroundConfig.effects.blur}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="20"
|
||||
step="1"
|
||||
value={backgroundConfig.effects.blur}
|
||||
onChange={(e) => updateBackgroundEffect('blur', Number(e.target.value))}
|
||||
className="w-full accent-[hsl(var(--accent))]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Darken slider */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<label className="text-xs text-text">
|
||||
{formatMessage({ id: 'theme.background.darken' })}
|
||||
</label>
|
||||
<span className="text-xs text-text-secondary">{backgroundConfig.effects.darkenOpacity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="80"
|
||||
step="1"
|
||||
value={backgroundConfig.effects.darkenOpacity}
|
||||
onChange={(e) => updateBackgroundEffect('darkenOpacity', Number(e.target.value))}
|
||||
className="w-full accent-[hsl(var(--accent))]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Saturation slider */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<label className="text-xs text-text">
|
||||
{formatMessage({ id: 'theme.background.saturation' })}
|
||||
</label>
|
||||
<span className="text-xs text-text-secondary">{backgroundConfig.effects.saturation}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
step="5"
|
||||
value={backgroundConfig.effects.saturation}
|
||||
onChange={(e) => updateBackgroundEffect('saturation', Number(e.target.value))}
|
||||
className="w-full accent-[hsl(var(--accent))]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frosted glass checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={backgroundConfig.effects.enableFrostedGlass}
|
||||
onChange={(e) => updateBackgroundEffect('enableFrostedGlass', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-text">
|
||||
{formatMessage({ id: 'theme.background.frostedGlass' })}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Grain checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={backgroundConfig.effects.enableGrain}
|
||||
onChange={(e) => updateBackgroundEffect('enableGrain', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-text">
|
||||
{formatMessage({ id: 'theme.background.grain' })}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Vignette checkbox */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={backgroundConfig.effects.enableVignette}
|
||||
onChange={(e) => updateBackgroundEffect('enableVignette', e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-text">
|
||||
{formatMessage({ id: 'theme.background.vignette' })}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ export function IssueCard({
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'p-3 bg-card border border-border rounded-lg cursor-pointer',
|
||||
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||
'hover:shadow-md hover:border-primary/50 transition-all hover-glow',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -198,7 +198,7 @@ export function IssueCard({
|
||||
{...draggableProps}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all',
|
||||
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all hover-glow',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function RuleCard({
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group transition-all duration-200 hover:shadow-md hover:border-primary/30',
|
||||
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
|
||||
!rule.enabled && 'opacity-60',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -102,7 +102,7 @@ export function SkillCard({
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'p-3 bg-card border rounded-lg cursor-pointer',
|
||||
'hover:shadow-md transition-all',
|
||||
'hover:shadow-md transition-all hover-glow',
|
||||
skill.enabled ? 'border-border hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/50 grayscale-[0.5]',
|
||||
className
|
||||
)}
|
||||
@@ -140,7 +140,7 @@ export function SkillCard({
|
||||
<Card
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'p-4 cursor-pointer hover:shadow-md transition-all',
|
||||
'p-4 cursor-pointer hover:shadow-md transition-all hover-glow',
|
||||
skill.enabled ? 'hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/30 grayscale-[0.3]',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react';
|
||||
import { Sparkline } from '@/components/charts/Sparkline';
|
||||
|
||||
const statCardVariants = cva(
|
||||
'transition-all duration-200 hover:shadow-md',
|
||||
'transition-all duration-200 hover:shadow-md hover-glow',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { COLOR_SCHEMES, THEME_MODES, getThemeName, THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '@/lib/theme';
|
||||
import type { ColorScheme, ThemeMode } from '@/lib/theme';
|
||||
import { generateThemeFromHue } from '@/lib/colorGenerator';
|
||||
import type { ThemeSlotId, StyleTier } from '@/types/store';
|
||||
import { generateThemeFromHue, applyStyleTier } from '@/lib/colorGenerator';
|
||||
import { checkThemeContrast, generateContrastFix } from '@/lib/accessibility';
|
||||
import type { ContrastResult, FixSuggestion } from '@/lib/accessibility';
|
||||
import type { ThemeSharePayload } from '@/lib/themeShare';
|
||||
import { BackgroundImagePicker } from './BackgroundImagePicker';
|
||||
|
||||
/**
|
||||
* Theme Selector Component
|
||||
@@ -28,17 +34,52 @@ export function ThemeSelector() {
|
||||
gradientLevel,
|
||||
enableHoverGlow,
|
||||
enableBackgroundAnimation,
|
||||
motionPreference,
|
||||
setColorScheme,
|
||||
setTheme,
|
||||
setCustomHue,
|
||||
setGradientLevel,
|
||||
setEnableHoverGlow,
|
||||
setEnableBackgroundAnimation,
|
||||
setMotionPreference,
|
||||
styleTier,
|
||||
setStyleTier,
|
||||
themeSlots,
|
||||
activeSlotId,
|
||||
canAddSlot,
|
||||
setActiveSlot,
|
||||
copySlot,
|
||||
renameSlot,
|
||||
deleteSlot,
|
||||
undoDeleteSlot,
|
||||
exportThemeCode,
|
||||
importThemeCode,
|
||||
setBackgroundConfig,
|
||||
} = useTheme();
|
||||
const { addToast, removeToast } = useNotifications();
|
||||
|
||||
// Local state for preview hue (uncommitted changes)
|
||||
const [previewHue, setPreviewHue] = useState<number | null>(customHue);
|
||||
|
||||
// Contrast warning state (non-blocking)
|
||||
const [contrastWarnings, setContrastWarnings] = useState<ContrastResult[]>([]);
|
||||
const [contrastFixes, setContrastFixes] = useState<Record<string, FixSuggestion[]>>({});
|
||||
const [showContrastWarning, setShowContrastWarning] = useState(false);
|
||||
|
||||
// Slot management state
|
||||
const [renamingSlotId, setRenamingSlotId] = useState<ThemeSlotId | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const undoToastIdRef = useRef<string | null>(null);
|
||||
|
||||
// Share/import state (local to component, not in store)
|
||||
const [showImportPanel, setShowImportPanel] = useState(false);
|
||||
const [importCode, setImportCode] = useState('');
|
||||
const [importPreview, setImportPreview] = useState<ThemeSharePayload | null>(null);
|
||||
const [importWarning, setImportWarning] = useState<string | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [copyFeedback, setCopyFeedback] = useState(false);
|
||||
|
||||
// Sync preview with customHue from store
|
||||
useEffect(() => {
|
||||
setPreviewHue(customHue);
|
||||
@@ -55,6 +96,25 @@ export function ThemeSelector() {
|
||||
return hslValue ? `hsl(${hslValue})` : '#888';
|
||||
};
|
||||
|
||||
// Style tier definitions for the selector UI
|
||||
const STYLE_TIERS: Array<{ id: StyleTier; nameKey: string; descKey: string }> = [
|
||||
{ id: 'soft', nameKey: 'theme.styleTier.soft', descKey: 'theme.styleTier.softDesc' },
|
||||
{ id: 'standard', nameKey: 'theme.styleTier.standard', descKey: 'theme.styleTier.standardDesc' },
|
||||
{ id: 'high-contrast', nameKey: 'theme.styleTier.highContrast', descKey: 'theme.styleTier.highContrastDesc' },
|
||||
];
|
||||
|
||||
// Get tier preview swatch colors (bg, surface, accent for a sample tier)
|
||||
const getTierPreviewColors = (tier: StyleTier): { bg: string; surface: string; accent: string } => {
|
||||
const sampleHue = customHue ?? 220; // Use current hue or default blue
|
||||
const baseVars = generateThemeFromHue(sampleHue, mode);
|
||||
const tieredVars = tier === 'standard' ? baseVars : applyStyleTier(baseVars, tier, mode);
|
||||
return {
|
||||
bg: tieredVars['--bg'] ? `hsl(${tieredVars['--bg']})` : '#888',
|
||||
surface: tieredVars['--surface'] ? `hsl(${tieredVars['--surface']})` : '#888',
|
||||
accent: tieredVars['--accent'] ? `hsl(${tieredVars['--accent']})` : '#888',
|
||||
};
|
||||
};
|
||||
|
||||
const handleSchemeSelect = (scheme: ColorScheme) => {
|
||||
// When selecting a preset scheme, reset custom hue
|
||||
if (isCustomTheme) {
|
||||
@@ -73,6 +133,28 @@ export function ThemeSelector() {
|
||||
const handleHueSave = () => {
|
||||
if (previewHue !== null) {
|
||||
setCustomHue(previewHue);
|
||||
|
||||
// Run contrast check on the new custom theme
|
||||
const mode: ThemeMode = resolvedTheme;
|
||||
const vars = generateThemeFromHue(previewHue, mode);
|
||||
const results = checkThemeContrast(vars);
|
||||
const failures = results.filter(r => !r.passed);
|
||||
|
||||
if (failures.length > 0) {
|
||||
setContrastWarnings(failures);
|
||||
// Generate fixes for each failing pair
|
||||
const fixes: Record<string, FixSuggestion[]> = {};
|
||||
for (const failure of failures) {
|
||||
const key = `${failure.fgVar}|${failure.bgVar}`;
|
||||
fixes[key] = generateContrastFix(failure.fgVar, failure.bgVar, vars, failure.required);
|
||||
}
|
||||
setContrastFixes(fixes);
|
||||
setShowContrastWarning(true);
|
||||
} else {
|
||||
setContrastWarnings([]);
|
||||
setContrastFixes({});
|
||||
setShowContrastWarning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,8 +181,379 @@ export function ThemeSelector() {
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Slot Management Handlers ==========
|
||||
|
||||
const handleSlotSelect = useCallback((slotId: ThemeSlotId) => {
|
||||
if (slotId !== activeSlotId) {
|
||||
setActiveSlot(slotId);
|
||||
}
|
||||
}, [activeSlotId, setActiveSlot]);
|
||||
|
||||
const handleCopySlot = useCallback(() => {
|
||||
if (!canAddSlot) return;
|
||||
copySlot();
|
||||
}, [canAddSlot, copySlot]);
|
||||
|
||||
const handleStartRename = useCallback((slotId: ThemeSlotId, currentName: string) => {
|
||||
setRenamingSlotId(slotId);
|
||||
setRenameValue(currentName);
|
||||
// Focus input after render
|
||||
setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRename = useCallback(() => {
|
||||
if (renamingSlotId && renameValue.trim()) {
|
||||
renameSlot(renamingSlotId, renameValue.trim());
|
||||
}
|
||||
setRenamingSlotId(null);
|
||||
setRenameValue('');
|
||||
}, [renamingSlotId, renameValue, renameSlot]);
|
||||
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setRenamingSlotId(null);
|
||||
setRenameValue('');
|
||||
}, []);
|
||||
|
||||
const handleRenameKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirmRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelRename();
|
||||
}
|
||||
}, [handleConfirmRename, handleCancelRename]);
|
||||
|
||||
const handleDeleteSlot = useCallback((slotId: ThemeSlotId) => {
|
||||
const slot = themeSlots.find(s => s.id === slotId);
|
||||
if (!slot || slot.isDefault) return;
|
||||
|
||||
deleteSlot(slotId);
|
||||
|
||||
// Remove previous undo toast if exists
|
||||
if (undoToastIdRef.current) {
|
||||
removeToast(undoToastIdRef.current);
|
||||
}
|
||||
|
||||
// Show undo toast with 10-second duration
|
||||
const toastId = addToast('info',
|
||||
formatMessage({ id: 'theme.slot.undoDelete' }),
|
||||
undefined,
|
||||
{
|
||||
duration: 10000,
|
||||
dismissible: true,
|
||||
action: {
|
||||
label: formatMessage({ id: 'theme.slot.undo' }),
|
||||
onClick: () => {
|
||||
undoDeleteSlot();
|
||||
removeToast(toastId);
|
||||
undoToastIdRef.current = null;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
undoToastIdRef.current = toastId;
|
||||
}, [themeSlots, deleteSlot, addToast, removeToast, undoDeleteSlot, formatMessage]);
|
||||
|
||||
// ========== Share/Import Handlers ==========
|
||||
|
||||
const handleCopyThemeCode = useCallback(async () => {
|
||||
try {
|
||||
const code = exportThemeCode();
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopyFeedback(true);
|
||||
setTimeout(() => setCopyFeedback(false), 2000);
|
||||
} catch {
|
||||
// Clipboard API may not be available
|
||||
addToast('error', 'Failed to copy to clipboard');
|
||||
}
|
||||
}, [exportThemeCode, addToast]);
|
||||
|
||||
const handleOpenImport = useCallback(() => {
|
||||
setShowImportPanel(true);
|
||||
setImportCode('');
|
||||
setImportPreview(null);
|
||||
setImportWarning(null);
|
||||
setImportError(null);
|
||||
}, []);
|
||||
|
||||
const handleCloseImport = useCallback(() => {
|
||||
setShowImportPanel(false);
|
||||
setImportCode('');
|
||||
setImportPreview(null);
|
||||
setImportWarning(null);
|
||||
setImportError(null);
|
||||
}, []);
|
||||
|
||||
const handleImportCodeChange = useCallback((value: string) => {
|
||||
setImportCode(value);
|
||||
setImportError(null);
|
||||
setImportWarning(null);
|
||||
setImportPreview(null);
|
||||
|
||||
if (!value.trim()) return;
|
||||
|
||||
const result = importThemeCode(value);
|
||||
if (result.ok) {
|
||||
setImportPreview(result.payload);
|
||||
if (result.warning) {
|
||||
setImportWarning(result.warning);
|
||||
}
|
||||
} else {
|
||||
setImportError(result.error);
|
||||
}
|
||||
}, [importThemeCode]);
|
||||
|
||||
const handleApplyImport = useCallback(() => {
|
||||
if (!importPreview) return;
|
||||
|
||||
// Check if we can add a slot or overwrite current
|
||||
if (!canAddSlot && activeSlotId === 'default') {
|
||||
// Apply to the default slot directly via individual setters
|
||||
if (importPreview.customHue !== null) {
|
||||
setCustomHue(importPreview.customHue);
|
||||
} else {
|
||||
setCustomHue(null);
|
||||
setColorScheme(importPreview.colorScheme);
|
||||
}
|
||||
setGradientLevel(importPreview.gradientLevel);
|
||||
setEnableHoverGlow(importPreview.enableHoverGlow);
|
||||
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
|
||||
setStyleTier(importPreview.styleTier);
|
||||
} else if (canAddSlot) {
|
||||
// Create a new slot via copySlot then apply settings
|
||||
copySlot();
|
||||
// After copySlot, the new slot is active. Apply imported settings.
|
||||
if (importPreview.customHue !== null) {
|
||||
setCustomHue(importPreview.customHue);
|
||||
} else {
|
||||
setCustomHue(null);
|
||||
setColorScheme(importPreview.colorScheme);
|
||||
}
|
||||
setGradientLevel(importPreview.gradientLevel);
|
||||
setEnableHoverGlow(importPreview.enableHoverGlow);
|
||||
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
|
||||
setStyleTier(importPreview.styleTier);
|
||||
} else {
|
||||
// Apply to current active slot via individual setters
|
||||
if (importPreview.customHue !== null) {
|
||||
setCustomHue(importPreview.customHue);
|
||||
} else {
|
||||
setCustomHue(null);
|
||||
setColorScheme(importPreview.colorScheme);
|
||||
}
|
||||
setGradientLevel(importPreview.gradientLevel);
|
||||
setEnableHoverGlow(importPreview.enableHoverGlow);
|
||||
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
|
||||
setStyleTier(importPreview.styleTier);
|
||||
}
|
||||
|
||||
// Apply background config from import (v2+ feature)
|
||||
setBackgroundConfig(importPreview.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
|
||||
|
||||
addToast('success', formatMessage({ id: 'theme.share.importSuccess' }));
|
||||
handleCloseImport();
|
||||
}, [
|
||||
importPreview, canAddSlot, activeSlotId, copySlot,
|
||||
setCustomHue, setColorScheme, setGradientLevel,
|
||||
setEnableHoverGlow, setEnableBackgroundAnimation, setStyleTier,
|
||||
setBackgroundConfig,
|
||||
addToast, formatMessage, handleCloseImport,
|
||||
]);
|
||||
|
||||
/** Generate preview swatch colors from an import payload */
|
||||
const getImportPreviewColors = useCallback((payload: ThemeSharePayload) => {
|
||||
const hue = payload.customHue ?? 220;
|
||||
const baseVars = generateThemeFromHue(hue, mode);
|
||||
const tieredVars = payload.styleTier === 'standard'
|
||||
? baseVars
|
||||
: applyStyleTier(baseVars, payload.styleTier, mode);
|
||||
return {
|
||||
bg: tieredVars['--bg'] ? `hsl(${tieredVars['--bg']})` : '#888',
|
||||
surface: tieredVars['--surface'] ? `hsl(${tieredVars['--surface']})` : '#888',
|
||||
accent: tieredVars['--accent'] ? `hsl(${tieredVars['--accent']})` : '#888',
|
||||
text: tieredVars['--text'] ? `hsl(${tieredVars['--text']})` : '#888',
|
||||
};
|
||||
}, [mode]);
|
||||
|
||||
/** Map error keys to i18n message IDs */
|
||||
const getShareErrorMessageId = (errorKey: string): string => {
|
||||
switch (errorKey) {
|
||||
case 'incompatible_version':
|
||||
return 'theme.share.incompatibleVersion';
|
||||
default:
|
||||
return 'theme.share.invalidCode';
|
||||
}
|
||||
};
|
||||
|
||||
// Focus rename input when entering rename mode
|
||||
useEffect(() => {
|
||||
if (renamingSlotId && renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
renameInputRef.current.select();
|
||||
}
|
||||
}, [renamingSlotId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Theme Slot Switcher */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-text">
|
||||
{formatMessage({ id: 'theme.slot.title' })}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCopySlot}
|
||||
disabled={!canAddSlot}
|
||||
title={
|
||||
canAddSlot
|
||||
? formatMessage({ id: 'theme.slot.copy' })
|
||||
: formatMessage({ id: 'theme.slot.limitReached' }, { limit: THEME_SLOT_LIMIT })
|
||||
}
|
||||
className={`
|
||||
px-2 py-1 rounded text-xs font-medium
|
||||
transition-all duration-200
|
||||
${canAddSlot
|
||||
? 'bg-accent text-white hover:bg-accent-hover focus:ring-2 focus:ring-accent focus:ring-offset-1'
|
||||
: 'bg-muted text-muted-text cursor-not-allowed'
|
||||
}
|
||||
focus:outline-none
|
||||
`}
|
||||
>
|
||||
+ {formatMessage({ id: 'theme.slot.copy' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2" role="tablist" aria-label={formatMessage({ id: 'theme.slot.title' })}>
|
||||
{themeSlots.map((slot) => {
|
||||
const isActive = slot.id === activeSlotId;
|
||||
const isRenaming = slot.id === renamingSlotId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => handleSlotSelect(slot.id)}
|
||||
className={`
|
||||
relative flex-1 min-w-0 p-2.5 rounded-lg cursor-pointer
|
||||
transition-all duration-200 border-2 group
|
||||
${isActive
|
||||
? 'border-accent bg-surface shadow-md'
|
||||
: 'border-border bg-bg hover:bg-surface'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1
|
||||
`}
|
||||
>
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-accent border-2 border-bg" />
|
||||
)}
|
||||
|
||||
{/* Slot name - inline rename or display */}
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onBlur={handleConfirmRename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="
|
||||
w-full px-1 py-0.5 text-xs font-medium text-text
|
||||
bg-bg border border-accent rounded
|
||||
focus:outline-none focus:ring-1 focus:ring-accent
|
||||
"
|
||||
maxLength={20}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-xs font-medium text-text truncate"
|
||||
title={slot.name}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!slot.isDefault) {
|
||||
handleStartRename(slot.id, slot.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{slot.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active label */}
|
||||
{isActive && !isRenaming && (
|
||||
<span className="text-[10px] text-accent font-medium mt-0.5 block">
|
||||
{formatMessage({ id: 'theme.slot.active' })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Action buttons - show on hover for non-default slots */}
|
||||
{!slot.isDefault && !isRenaming && (
|
||||
<div className="
|
||||
absolute top-1 right-1 flex gap-0.5
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-150
|
||||
">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartRename(slot.id, slot.name);
|
||||
}}
|
||||
title={formatMessage({ id: 'theme.slot.rename' })}
|
||||
className="
|
||||
p-0.5 rounded text-text-tertiary hover:text-text hover:bg-surface-hover
|
||||
transition-colors duration-150 focus:outline-none
|
||||
"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.5 1.5L14.5 4.5L5 14H2V11L11.5 1.5Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSlot(slot.id);
|
||||
}}
|
||||
title={formatMessage({ id: 'theme.slot.delete' })}
|
||||
className="
|
||||
p-0.5 rounded text-text-tertiary hover:text-error hover:bg-error-light
|
||||
transition-colors duration-150 focus:outline-none
|
||||
"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Default slot: show disabled delete tooltip */}
|
||||
{slot.isDefault && (
|
||||
<div className="
|
||||
absolute top-1 right-1 flex gap-0.5
|
||||
opacity-0 group-hover:opacity-100 transition-opacity duration-150
|
||||
">
|
||||
<button
|
||||
disabled
|
||||
title={formatMessage({ id: 'theme.slot.cannotDeleteDefault' })}
|
||||
className="p-0.5 rounded text-muted-text cursor-not-allowed"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Scheme Selection */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text mb-3">
|
||||
@@ -265,6 +718,64 @@ export function ThemeSelector() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Style Tier Selection */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text mb-3">
|
||||
{formatMessage({ id: 'theme.styleTier.label' })}
|
||||
</h3>
|
||||
<div
|
||||
className="grid grid-cols-3 gap-3"
|
||||
role="radiogroup"
|
||||
aria-label={formatMessage({ id: 'theme.styleTier.label' })}
|
||||
>
|
||||
{STYLE_TIERS.map((tier) => {
|
||||
const preview = getTierPreviewColors(tier.id);
|
||||
const isSelected = styleTier === tier.id;
|
||||
return (
|
||||
<button
|
||||
key={tier.id}
|
||||
onClick={() => setStyleTier(tier.id)}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
className={`
|
||||
flex flex-col items-center gap-2 p-3 rounded-lg
|
||||
transition-all duration-200 border-2
|
||||
${isSelected
|
||||
? 'border-accent bg-surface shadow-md'
|
||||
: 'border-border bg-bg hover:bg-surface'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
|
||||
`}
|
||||
>
|
||||
{/* Preview swatches */}
|
||||
<div className="flex gap-1" aria-hidden="true">
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border"
|
||||
style={{ backgroundColor: preview.bg }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border"
|
||||
style={{ backgroundColor: preview.surface }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border"
|
||||
style={{ backgroundColor: preview.accent }}
|
||||
/>
|
||||
</div>
|
||||
{/* Tier name */}
|
||||
<span className="text-xs font-medium text-text text-center">
|
||||
{formatMessage({ id: tier.nameKey })}
|
||||
</span>
|
||||
{/* Description */}
|
||||
<span className="text-[10px] text-text-tertiary text-center leading-tight">
|
||||
{formatMessage({ id: tier.descKey })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gradient Effects Settings */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text mb-3">
|
||||
@@ -333,6 +844,86 @@ export function ThemeSelector() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Image */}
|
||||
<BackgroundImagePicker />
|
||||
|
||||
{/* Motion Preference */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text mb-3">
|
||||
{formatMessage({ id: 'theme.motion.label' })}
|
||||
</h3>
|
||||
<div
|
||||
className="flex gap-2"
|
||||
role="radiogroup"
|
||||
aria-label={formatMessage({ id: 'theme.motion.label' })}
|
||||
>
|
||||
{(['system', 'reduce', 'enable'] as const).map((pref) => (
|
||||
<button
|
||||
key={pref}
|
||||
onClick={() => setMotionPreference(pref)}
|
||||
role="radio"
|
||||
aria-checked={motionPreference === pref}
|
||||
className={`
|
||||
flex-1 px-3 py-2 rounded-lg text-sm font-medium
|
||||
transition-all duration-200 border-2
|
||||
${motionPreference === pref
|
||||
? 'border-accent bg-surface shadow-md'
|
||||
: 'border-border bg-bg hover:bg-surface'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
|
||||
`}
|
||||
>
|
||||
{formatMessage({ id: `theme.motion.${pref}` })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contrast Warning Banner (non-blocking) */}
|
||||
{showContrastWarning && contrastWarnings.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-warning-light border border-warning text-warning-text space-y-2">
|
||||
<p className="text-xs font-medium">
|
||||
{formatMessage({ id: 'theme.accessibility.contrastWarning' })}
|
||||
</p>
|
||||
<ul className="text-xs space-y-1">
|
||||
{contrastWarnings.map((w) => {
|
||||
const key = `${w.fgVar}|${w.bgVar}`;
|
||||
const fixes = contrastFixes[key] || [];
|
||||
return (
|
||||
<li key={key} className="space-y-1">
|
||||
<span>
|
||||
{w.fgVar} / {w.bgVar}: {w.ratio}:1 (min {w.required}:1)
|
||||
</span>
|
||||
{fixes.length > 0 && (
|
||||
<div className="ml-2 text-[10px]">
|
||||
{fixes.slice(0, 1).map((fix, i) => (
|
||||
<span key={i} className="block">
|
||||
{formatMessage(
|
||||
{ id: 'theme.accessibility.fixSuggestion' },
|
||||
{
|
||||
target: fix.target === 'fg' ? w.fgVar : w.bgVar,
|
||||
original: fix.original,
|
||||
suggested: fix.suggested,
|
||||
ratio: fix.resultRatio,
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => setShowContrastWarning(false)}
|
||||
className="text-xs font-medium underline"
|
||||
>
|
||||
{formatMessage({ id: 'theme.accessibility.dismiss' })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme Mode Selection */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text mb-3">
|
||||
@@ -379,6 +970,173 @@ export function ThemeSelector() {
|
||||
{formatMessage({ id: 'theme.current' }, { name: getThemeName(colorScheme, mode) })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Theme Sharing Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text mb-3">
|
||||
{formatMessage({ id: 'theme.share.label' })}
|
||||
</h3>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Copy Theme Code button */}
|
||||
<button
|
||||
onClick={handleCopyThemeCode}
|
||||
className="
|
||||
flex-1 px-3 py-2 rounded-lg text-sm font-medium
|
||||
border-2 border-border bg-bg text-text
|
||||
hover:bg-surface transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
|
||||
"
|
||||
>
|
||||
{copyFeedback
|
||||
? formatMessage({ id: 'theme.share.copied' })
|
||||
: formatMessage({ id: 'theme.share.copyCode' })
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Import Theme button */}
|
||||
<button
|
||||
onClick={showImportPanel ? handleCloseImport : handleOpenImport}
|
||||
className={`
|
||||
flex-1 px-3 py-2 rounded-lg text-sm font-medium
|
||||
transition-all duration-200 border-2
|
||||
${showImportPanel
|
||||
? 'border-accent bg-surface shadow-md'
|
||||
: 'border-border bg-bg text-text hover:bg-surface'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
|
||||
`}
|
||||
>
|
||||
{formatMessage({ id: 'theme.share.import' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import Panel */}
|
||||
{showImportPanel && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Paste textarea */}
|
||||
<textarea
|
||||
value={importCode}
|
||||
onChange={(e) => handleImportCodeChange(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'theme.share.paste' })}
|
||||
rows={3}
|
||||
className="
|
||||
w-full px-3 py-2 rounded-lg text-sm font-mono
|
||||
bg-bg border-2 border-border text-text
|
||||
placeholder-text-tertiary resize-none
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
|
||||
"
|
||||
/>
|
||||
|
||||
{/* Error message */}
|
||||
{importError && (
|
||||
<div className="p-2 rounded-lg bg-error-light border border-error text-error-text text-xs">
|
||||
{formatMessage({ id: getShareErrorMessageId(importError) })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version warning */}
|
||||
{importWarning && !importError && (
|
||||
<div className="p-2 rounded-lg bg-warning-light border border-warning text-warning-text text-xs">
|
||||
{formatMessage({ id: 'theme.share.versionWarning' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Preview Card */}
|
||||
{importPreview && !importError && (
|
||||
<div className="p-3 rounded-lg bg-surface border border-border space-y-3">
|
||||
<p className="text-xs font-medium text-text">
|
||||
{formatMessage({ id: 'theme.share.preview' })}
|
||||
</p>
|
||||
|
||||
{/* Preview swatches */}
|
||||
<div className="flex gap-3 items-end">
|
||||
{(() => {
|
||||
const previewColors = getImportPreviewColors(importPreview);
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-10 h-10 rounded border-2 border-border shadow-sm"
|
||||
style={{ backgroundColor: previewColors.bg }}
|
||||
/>
|
||||
<span className="text-[10px] text-text-tertiary">
|
||||
{formatMessage({ id: 'theme.preview.background' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-10 h-10 rounded border-2 border-border shadow-sm"
|
||||
style={{ backgroundColor: previewColors.surface }}
|
||||
/>
|
||||
<span className="text-[10px] text-text-tertiary">
|
||||
{formatMessage({ id: 'theme.preview.surface' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-10 h-10 rounded border-2 border-border shadow-sm"
|
||||
style={{ backgroundColor: previewColors.accent }}
|
||||
/>
|
||||
<span className="text-[10px] text-text-tertiary">
|
||||
{formatMessage({ id: 'theme.preview.accent' })}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Settings summary */}
|
||||
<div className="text-xs text-text-secondary space-y-1">
|
||||
<p>
|
||||
{formatMessage({ id: 'theme.styleTier.label' })}: {formatMessage({ id: `theme.styleTier.${importPreview.styleTier === 'high-contrast' ? 'highContrast' : importPreview.styleTier}` })}
|
||||
</p>
|
||||
<p>
|
||||
{formatMessage({ id: 'theme.gradient.title' })}: {formatMessage({ id: `theme.gradient.${importPreview.gradientLevel}` })}
|
||||
</p>
|
||||
{importPreview.customHue !== null && (
|
||||
<p>
|
||||
{formatMessage({ id: 'theme.hueValue' }, { value: importPreview.customHue })}
|
||||
</p>
|
||||
)}
|
||||
{importPreview.customHue === null && (
|
||||
<p>
|
||||
{formatMessage({ id: 'theme.title.colorScheme' })}: {formatMessage({ id: `theme.colorScheme.${importPreview.colorScheme}` })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Apply / Cancel buttons */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleApplyImport}
|
||||
className="
|
||||
flex-1 px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-accent text-white hover:bg-accent-hover
|
||||
transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
|
||||
"
|
||||
>
|
||||
{formatMessage({ id: 'theme.share.apply' })}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCloseImport}
|
||||
className="
|
||||
px-4 py-2 rounded-lg text-sm font-medium
|
||||
border-2 border-border bg-bg text-text
|
||||
hover:bg-surface transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
|
||||
"
|
||||
>
|
||||
{formatMessage({ id: 'theme.share.cancel' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ const badgeVariants = cva(
|
||||
"border-transparent bg-info text-white",
|
||||
review:
|
||||
"border-transparent bg-purple-600 text-white",
|
||||
gradient:
|
||||
"border-transparent bg-gradient-brand bg-primary text-primary-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
Reference in New Issue
Block a user