feat: add design system with colors, components, and theme configuration
- Introduced a comprehensive color palette in colors.ts, including primary, accent, success, warning, error, info, neutral, slate, and semantic colors for light and dark modes. - Created a components-registry.ts to manage UI components with metadata, including buttons, inputs, cards, layout, feedback, and navigation components. - Developed a theme.ts file to centralize typography, spacing, border radius, shadows, z-index, transitions, breakpoints, containers, and responsive utilities. - Configured Nginx for development with a new nginx.dev.conf, routing API requests to the Flask backend and frontend requests to the Vite development server.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
168
frontend/src/components/shared/ErrorMessage.tsx
Normal file
168
frontend/src/components/shared/ErrorMessage.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertCircle, RefreshCw, HelpCircle, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ErrorMessageProps {
|
||||
message?: string;
|
||||
type?: 'error' | 'warning' | 'info';
|
||||
details?: string;
|
||||
showDetails?: boolean;
|
||||
onRetry?: () => void;
|
||||
showRetry?: boolean;
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary';
|
||||
}>;
|
||||
suggestion?: string;
|
||||
helpLink?: string;
|
||||
dismissible?: boolean;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorMessage({
|
||||
message = 'An error occurred',
|
||||
type = 'error',
|
||||
details,
|
||||
showDetails: initialShowDetails = false,
|
||||
onRetry,
|
||||
showRetry = true,
|
||||
actions,
|
||||
suggestion,
|
||||
helpLink,
|
||||
dismissible = true,
|
||||
onDismiss,
|
||||
}: ErrorMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showDetails, setShowDetails] = useState(initialShowDetails);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
if (isDismissed) return null;
|
||||
|
||||
const bgColor =
|
||||
type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20'
|
||||
: type === 'warning'
|
||||
? 'bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20';
|
||||
|
||||
const borderColor =
|
||||
type === 'error'
|
||||
? 'border-red-200 dark:border-red-800'
|
||||
: type === 'warning'
|
||||
? 'border-amber-200 dark:border-amber-800'
|
||||
: 'border-blue-200 dark:border-blue-800';
|
||||
|
||||
const textColor =
|
||||
type === 'error'
|
||||
? 'text-red-900 dark:text-red-200'
|
||||
: type === 'warning'
|
||||
? 'text-amber-900 dark:text-amber-200'
|
||||
: 'text-blue-900 dark:text-blue-200';
|
||||
|
||||
const iconColor =
|
||||
type === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: type === 'warning'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-blue-600 dark:text-blue-400';
|
||||
|
||||
const Icon = type === 'warning' ? AlertTriangle : AlertCircle;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${borderColor} ${bgColor} p-4`}>
|
||||
<div className="flex gap-3">
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 mt-0.5 ${iconColor}`} aria-hidden="true" />
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textColor}`}>{message}</h3>
|
||||
|
||||
{suggestion && (
|
||||
<p className={`mt-1 text-sm ${textColor} opacity-80`}>{suggestion}</p>
|
||||
)}
|
||||
|
||||
{details && !showDetails && (
|
||||
<button
|
||||
onClick={() => setShowDetails(true)}
|
||||
className={`mt-2 inline-flex items-center gap-1 text-sm font-medium ${textColor} opacity-80 hover:opacity-100`}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
{t('common.showDetails', { defaultValue: 'Show Details' })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{details && showDetails && (
|
||||
<details open className="mt-3">
|
||||
<summary className={`cursor-pointer text-sm font-medium ${textColor} opacity-80`}>
|
||||
{t('common.hideDetails', { defaultValue: 'Hide Details' })}
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-auto rounded bg-black/10 p-3 text-xs text-slate-900 dark:text-slate-100">
|
||||
{details}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(onRetry || actions || helpLink) && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{onRetry && showRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
type === 'error'
|
||||
? 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600'
|
||||
: type === 'warning'
|
||||
? 'bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-700 dark:hover:bg-amber-600'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('common.retry', { defaultValue: 'Retry' })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{actions?.map((action, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={action.onClick}
|
||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
action.variant === 'primary'
|
||||
? type === 'error'
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-white text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{helpLink && (
|
||||
<a
|
||||
href={helpLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md bg-white px-3 py-2 text-sm font-medium text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 transition-colors dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600 dark:hover:bg-slate-700"
|
||||
>
|
||||
{t('common.getHelp', { defaultValue: 'Get Help' })}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dismissible && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDismissed(true);
|
||||
onDismiss?.();
|
||||
}}
|
||||
className={`flex-shrink-0 text-xl font-bold opacity-50 hover:opacity-100 transition-opacity ${textColor}`}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,137 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { Loader2, CheckCircle2, AlertCircle, Clock } from 'lucide-react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
/** Current task state */
|
||||
state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE' | string;
|
||||
state?: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE' | string;
|
||||
status?: string; // Alternative to state (for compatibility)
|
||||
/** Progress message */
|
||||
message?: string;
|
||||
/** Progress percentage (0-100) */
|
||||
progress?: number;
|
||||
/** Show detailed steps */
|
||||
steps?: Array<{
|
||||
name: string;
|
||||
status: 'pending' | 'active' | 'complete' | 'error';
|
||||
message?: string;
|
||||
}>;
|
||||
/** Show a simple indeterminate progress bar */
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
export default function ProgressBar({ state, message }: ProgressBarProps) {
|
||||
export default function ProgressBar({
|
||||
state,
|
||||
status,
|
||||
message,
|
||||
progress,
|
||||
steps,
|
||||
indeterminate = true,
|
||||
}: ProgressBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const taskState = state || status || 'PROCESSING';
|
||||
|
||||
const isActive = state === 'PENDING' || state === 'PROCESSING';
|
||||
const isComplete = state === 'SUCCESS';
|
||||
const isActive = taskState === 'PENDING' || taskState === 'PROCESSING';
|
||||
const isComplete = taskState === 'SUCCESS';
|
||||
const isError = taskState === 'FAILURE';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
{isActive && (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
{isComplete && (
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
|
||||
<div className="space-y-4">
|
||||
{/* Main Progress Card */}
|
||||
<div className="rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
{isActive && (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
{isComplete && (
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
{isError && (
|
||||
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
{!isActive && !isComplete && !isError && (
|
||||
<Clock className="h-6 w-6 text-slate-400 dark:text-slate-600" />
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{message || t('common.processing', { defaultValue: 'Processing...' })}
|
||||
</p>
|
||||
{progress !== undefined && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
{progress}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{indeterminate && isActive && (
|
||||
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div className="progress-bar-animated h-full w-2/3 rounded-full bg-primary-500 transition-all" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{message || t('common.processing')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Determinate Progress Bar */}
|
||||
{!indeterminate && progress !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Animated progress bar for active states */}
|
||||
{isActive && (
|
||||
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div className="progress-bar-animated h-full w-2/3 rounded-full bg-primary-500 transition-all" />
|
||||
{/* Step-by-Step Progress */}
|
||||
{steps && steps.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
{t('common.processingSteps', { defaultValue: 'Processing Steps' })}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{step.status === 'complete' && (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
|
||||
)}
|
||||
{step.status === 'active' && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary-600 dark:text-primary-400 flex-shrink-0" />
|
||||
)}
|
||||
{step.status === 'error' && (
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
)}
|
||||
{step.status === 'pending' && (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-slate-300 dark:border-slate-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
step.status === 'complete'
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: step.status === 'active'
|
||||
? 'text-primary-700 dark:text-primary-300'
|
||||
: step.status === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-slate-600 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</p>
|
||||
{step.message && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500 mt-0.5">
|
||||
{step.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
226
frontend/src/components/shared/ToolTemplate.tsx
Normal file
226
frontend/src/components/shared/ToolTemplate.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { LucideIcon, AlertCircle, CheckCircle, Clock } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
export interface ToolConfig {
|
||||
slug: string;
|
||||
icon: LucideIcon;
|
||||
color?: 'orange' | 'red' | 'blue' | 'green' | 'purple' | 'pink' | 'amber' | 'cyan';
|
||||
i18nKey: string;
|
||||
endpoint: string;
|
||||
maxSizeMB?: number;
|
||||
acceptedTypes?: string[];
|
||||
isPremium?: boolean;
|
||||
extraData?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ToolTemplateProps {
|
||||
file: File | null;
|
||||
uploadProgress: number;
|
||||
isUploading: boolean;
|
||||
isProcessing: boolean;
|
||||
result: any;
|
||||
error: string | null;
|
||||
selectFile: (file: File) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const colorMap: Record<string, { bg: string; icon: string }> = {
|
||||
orange: { bg: 'bg-orange-50 dark:bg-orange-900/20', icon: 'text-orange-600 dark:text-orange-400' },
|
||||
red: { bg: 'bg-red-50 dark:bg-red-900/20', icon: 'text-red-600 dark:text-red-400' },
|
||||
blue: { bg: 'bg-blue-50 dark:bg-blue-900/20', icon: 'text-blue-600 dark:text-blue-400' },
|
||||
green: { bg: 'bg-green-50 dark:bg-green-900/20', icon: 'text-green-600 dark:text-green-400' },
|
||||
purple: { bg: 'bg-purple-50 dark:bg-purple-900/20', icon: 'text-purple-600 dark:text-purple-400' },
|
||||
pink: { bg: 'bg-pink-50 dark:bg-pink-900/20', icon: 'text-pink-600 dark:text-pink-400' },
|
||||
amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', icon: 'text-amber-600 dark:text-amber-400' },
|
||||
cyan: { bg: 'bg-cyan-50 dark:bg-cyan-900/20', icon: 'text-cyan-600 dark:text-cyan-400' },
|
||||
};
|
||||
|
||||
interface ToolTemplateComponentProps {
|
||||
config: ToolConfig;
|
||||
onGetExtraData?: () => Record<string, any>;
|
||||
children?: (props: ToolTemplateProps) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ToolTemplate({ config, onGetExtraData, children }: ToolTemplateComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [extraData, setExtraData] = useState<Record<string, any>>(config.extraData || {});
|
||||
|
||||
const colors = colorMap[config.color || 'blue'];
|
||||
const bgColor = colors.bg;
|
||||
const iconColor = colors.icon;
|
||||
|
||||
const { file, uploadProgress, isUploading, taskId, error, selectFile, startUpload, reset } = useFileUpload({
|
||||
endpoint: config.endpoint,
|
||||
maxSizeMB: config.maxSizeMB || 20,
|
||||
acceptedTypes: config.acceptedTypes || ['pdf'],
|
||||
extraData,
|
||||
});
|
||||
|
||||
const { status, result } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
const storeFile = useFileStore((s) => s.file);
|
||||
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||
|
||||
useEffect(() => {
|
||||
if (storeFile && config.acceptedTypes?.some((type) => storeFile.name.endsWith(`.${type}`))) {
|
||||
selectFile(storeFile);
|
||||
clearStoreFile();
|
||||
setPhase('upload');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
// Get fresh extraData from child if callback provided
|
||||
if (onGetExtraData) {
|
||||
const freshExtraData = onGetExtraData();
|
||||
setExtraData(freshExtraData);
|
||||
}
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
}, [onGetExtraData, startUpload]);
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const title = t(`${config.i18nKey}.title`, { defaultValue: config.slug });
|
||||
const description = t(`${config.i18nKey}.description`, { defaultValue: '' });
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: title,
|
||||
description,
|
||||
url: `${window.location.origin}/tools/${config.slug}`,
|
||||
});
|
||||
|
||||
const templateProps: ToolTemplateProps = {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
isProcessing: phase === 'processing',
|
||||
result,
|
||||
error,
|
||||
selectFile,
|
||||
reset: handleReset,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{title} — Dociva</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/${config.slug}`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 text-center">
|
||||
<div className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl ${bgColor}`}>
|
||||
<config.icon className={`h-8 w-8 ${iconColor}`} aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="section-heading">{title}</h1>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-6">
|
||||
<FileUploader
|
||||
onFileSelect={(f) => {
|
||||
selectFile(f);
|
||||
setPhase('upload');
|
||||
}}
|
||||
file={file}
|
||||
accept={config.acceptedTypes?.reduce(
|
||||
(acc, type) => ({
|
||||
...acc,
|
||||
[`application/${type}`]: [`.${type}`],
|
||||
}),
|
||||
{}
|
||||
) || {}}
|
||||
/>
|
||||
|
||||
{children && (
|
||||
<div className="rounded-xl bg-slate-50 p-6 dark:bg-slate-800">{children(templateProps)}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || !file}
|
||||
className="btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Clock className="h-5 w-5 animate-spin" />
|
||||
{t('common.uploading', { defaultValue: 'Uploading...' })}
|
||||
</>
|
||||
) : (
|
||||
t('common.convert', { defaultValue: 'Convert' })
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && (
|
||||
<div className="rounded-xl bg-slate-50 p-8 text-center dark:bg-slate-800">
|
||||
<ProgressBar state={(status as any) || 'PROCESSING'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<div className="space-y-4">
|
||||
{result ? (
|
||||
<>
|
||||
<div className="rounded-xl bg-green-50 p-6 dark:bg-green-900/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-green-900 dark:text-green-200">Success!</h2>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">Your file is ready</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl bg-red-50 p-6 dark:bg-red-900/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-red-900 dark:text-red-200">Error</h2>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error || 'Processing failed'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
Process Another
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Minimize2 } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import ToolTemplate, { ToolConfig, ToolTemplateProps } from '@/components/shared/ToolTemplate';
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
|
||||
export default function PdfCompressor() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [quality, setQuality] = useState<Quality>('medium');
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
const toolConfig: ToolConfig = {
|
||||
slug: 'compress-pdf',
|
||||
icon: Minimize2,
|
||||
color: 'orange',
|
||||
i18nKey: 'tools.compressPdf',
|
||||
endpoint: '/compress/pdf',
|
||||
maxSizeMB: 20,
|
||||
acceptedTypes: ['pdf'],
|
||||
extraData: { quality },
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
// Accept file from homepage smart upload
|
||||
const storeFile = useFileStore((s) => s.file);
|
||||
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||
useEffect(() => {
|
||||
if (storeFile) {
|
||||
selectFile(storeFile);
|
||||
clearStoreFile();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleUpload = async () => {
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const qualityOptions: { value: Quality; label: string; desc: string }[] = [
|
||||
@@ -66,94 +25,35 @@ export default function PdfCompressor() {
|
||||
{ value: 'high', label: t('tools.compressPdf.qualityHigh'), desc: '300 DPI' },
|
||||
];
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.compressPdf.title'),
|
||||
description: t('tools.compressPdf.description'),
|
||||
url: `${window.location.origin}/tools/compress-pdf`,
|
||||
});
|
||||
const getExtraData = () => ({ quality });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.compressPdf.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.compressPdf.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/compress-pdf`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-orange-100">
|
||||
<Minimize2 className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.compressPdf.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.compressPdf.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-4">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{ 'application/pdf': ['.pdf'] }}
|
||||
maxSizeMB={20}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="PDF (.pdf)"
|
||||
/>
|
||||
|
||||
{/* Quality Selector */}
|
||||
{file && !isUploading && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{qualityOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setQuality(opt.value)}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
quality === opt.value
|
||||
? 'bg-primary-50 ring-primary-300 text-primary-700'
|
||||
: 'bg-white ring-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">{opt.label}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleUpload} className="btn-primary w-full">
|
||||
{t('tools.compressPdf.shortDesc')}
|
||||
<ToolTemplate config={toolConfig} onGetExtraData={getExtraData}>
|
||||
{(props: ToolTemplateProps) => (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
||||
{t('tools.compressPdf.quality', { defaultValue: 'Compression Quality' })}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{qualityOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setQuality(opt.value)}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
quality === opt.value
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 ring-primary-300 dark:ring-primary-700 text-primary-700 dark:text-primary-400'
|
||||
: 'bg-white dark:bg-slate-700 ring-slate-200 dark:ring-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">{opt.label}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{opt.desc}</p>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && !result && (
|
||||
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{taskError}</p>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</ToolTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
250
frontend/src/design-system/colors.ts
Normal file
250
frontend/src/design-system/colors.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Color Design System for Dociva
|
||||
* Comprehensive color palette matching competitive standards
|
||||
* References: PDFSimpli (bright blues), Smallpdf (modern purple), ILovePDF (bold oranges)
|
||||
*/
|
||||
|
||||
export const colors = {
|
||||
// Primary Palette (Main brand color - Blue)
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb', // Primary brand color (buttons, links, highlights)
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
|
||||
// Accent Palette (Secondary accent - Purple/Fuchsia for CTAs)
|
||||
accent: {
|
||||
50: '#fdf4ff',
|
||||
100: '#fae8ff',
|
||||
200: '#f5d0fe',
|
||||
300: '#f0abfc',
|
||||
400: '#e879f9',
|
||||
500: '#d946ef',
|
||||
600: '#c026d3', // Accent for premium tier, special offers
|
||||
700: '#a21caf',
|
||||
800: '#86198f',
|
||||
900: '#701a75',
|
||||
},
|
||||
|
||||
// Success Palette (For positive feedback, completed actions)
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a', // Success button/feedback
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#145231',
|
||||
},
|
||||
|
||||
// Warning Palette (For alerts, warnings, secondary actions)
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706', // Warning alerts
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
|
||||
// Error Palette (For errors, destructive actions)
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626', // Error states
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
|
||||
// Info Palette (For informational messages)
|
||||
info: {
|
||||
50: '#ecf0ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5', // Info messages
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
},
|
||||
|
||||
// Neutral Grayscale (For text, borders, backgrounds)
|
||||
neutral: {
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
150: '#efefef', // Custom: between 100 and 200
|
||||
200: '#e5e5e5',
|
||||
300: '#d4d4d4',
|
||||
400: '#a3a3a3',
|
||||
500: '#737373',
|
||||
600: '#525252',
|
||||
700: '#404040',
|
||||
800: '#262626',
|
||||
900: '#171717',
|
||||
950: '#0a0a0a',
|
||||
},
|
||||
|
||||
// Slate Grayscale (Alternative neutral - used in current design)
|
||||
slate: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
|
||||
// Semantic Colors (Light mode)
|
||||
light: {
|
||||
background: '#ffffff',
|
||||
surface: '#f8fafc',
|
||||
surfaceHover: '#f1f5f9',
|
||||
text: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textTertiary: '#94a3b8',
|
||||
border: '#e2e8f0',
|
||||
borderHover: '#cbd5e1',
|
||||
},
|
||||
|
||||
// Semantic Colors (Dark mode)
|
||||
dark: {
|
||||
background: '#0f172a',
|
||||
surface: '#1e293b',
|
||||
surfaceHover: '#334155',
|
||||
text: '#f1f5f9',
|
||||
textSecondary: '#94a3b8',
|
||||
textTertiary: '#64748b',
|
||||
border: '#334155',
|
||||
borderHover: '#475569',
|
||||
},
|
||||
|
||||
// Tool Category Colors (for visual differentiation)
|
||||
tools: {
|
||||
pdf: '#dc2626', // Red for PDF tools
|
||||
image: '#f59e0b', // Amber for image tools
|
||||
video: '#06b6d4', // Cyan for video tools
|
||||
document: '#3b82f6', // Blue for document tools
|
||||
text: '#8b5cf6', // Violet for text tools
|
||||
convert: '#ec4899', // Pink for conversion tools
|
||||
edit: '#10b981', // Emerald for editing tools
|
||||
secure: '#f97316', // Orange for security tools
|
||||
},
|
||||
|
||||
// Premium Gradient (for Pro/Business badges)
|
||||
gradients: {
|
||||
premium: {
|
||||
from: '#f59e0b',
|
||||
to: '#d97706',
|
||||
},
|
||||
business: {
|
||||
from: '#8b5cf6',
|
||||
to: '#6366f1',
|
||||
},
|
||||
featured: {
|
||||
from: '#06b6d4',
|
||||
to: '#0ea5e9',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Color Assignments for UI Elements
|
||||
* These are semantic usage guidelines
|
||||
*/
|
||||
export const colorAssignments = {
|
||||
// Button Colors
|
||||
buttons: {
|
||||
primary: 'primary-600', // Main CTAs
|
||||
secondary: 'slate-600', // Secondary actions
|
||||
success: 'success-600', // Confirm/accept
|
||||
danger: 'error-600', // Delete/destructive
|
||||
ghost: 'slate-500', // Tertiary/icon buttons
|
||||
},
|
||||
|
||||
// Badge/Pill Colors
|
||||
badges: {
|
||||
default: 'slate-100',
|
||||
success: 'success-100',
|
||||
warning: 'warning-100',
|
||||
error: 'error-100',
|
||||
info: 'info-100',
|
||||
pro: 'accent-100',
|
||||
},
|
||||
|
||||
// Alert/Toast Colors
|
||||
alerts: {
|
||||
success: 'success-600',
|
||||
warning: 'warning-600',
|
||||
error: 'error-600',
|
||||
info: 'info-600',
|
||||
},
|
||||
|
||||
// Text Colors
|
||||
text: {
|
||||
primary: 'slate-900',
|
||||
secondary: 'slate-600',
|
||||
tertiary: 'slate-500',
|
||||
muted: 'slate-400',
|
||||
link: 'primary-600',
|
||||
linkHover: 'primary-700',
|
||||
},
|
||||
|
||||
// Border Colors
|
||||
borders: {
|
||||
default: 'slate-200',
|
||||
focus: 'primary-500',
|
||||
error: 'error-400',
|
||||
success: 'success-400',
|
||||
},
|
||||
|
||||
// Background Colors
|
||||
backgrounds: {
|
||||
page: 'white',
|
||||
surface: 'slate-50',
|
||||
surfaceAlt: 'slate-100',
|
||||
overlay: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Utility function to get color with proper contrast
|
||||
* @param colorName - The color name (e.g., 'primary', 'error')
|
||||
* @param lightValue - Shade number for light mode (e.g., 600)
|
||||
* @param darkValue - Shade number for dark mode (e.g., 500)
|
||||
*/
|
||||
export function getColorClass(
|
||||
colorName: keyof typeof colors,
|
||||
lightValue: number = 600,
|
||||
darkValue: number = 500
|
||||
): string {
|
||||
return `${colorName}-${lightValue} dark:${colorName}-${darkValue}`;
|
||||
}
|
||||
|
||||
export default colors;
|
||||
512
frontend/src/design-system/components-registry.ts
Normal file
512
frontend/src/design-system/components-registry.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Component Registry & System
|
||||
* Central registry of all UI components with metadata and styling
|
||||
*/
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Component Type Definitions
|
||||
*/
|
||||
export interface ComponentMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'button' | 'input' | 'card' | 'layout' | 'overlay' | 'feedback' | 'navigation' | 'form';
|
||||
variants?: string[];
|
||||
props?: Record<string, unknown>;
|
||||
a11y?: {
|
||||
role?: string;
|
||||
ariaLabel?: string;
|
||||
ariaDescribedBy?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComponentColors {
|
||||
light: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
hover?: string;
|
||||
};
|
||||
dark: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
hover?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCardMetadata {
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string; // Lucide icon name
|
||||
category: 'pdf' | 'image' | 'video' | 'document' | 'text' | 'convert' | 'edit' | 'secure';
|
||||
colorBg: string; // Tailwind bg class
|
||||
colorIcon: string; // Tailwind text color class
|
||||
description: string;
|
||||
i18nKey: string;
|
||||
isPremium?: boolean;
|
||||
isNew?: boolean;
|
||||
isPopular?: boolean;
|
||||
orderPriority: number; // 1 = highest priority on homepage
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Component Registry
|
||||
*/
|
||||
export const componentRegistry: Record<string, ComponentMetadata> = {
|
||||
// Buttons
|
||||
button: {
|
||||
name: 'Button',
|
||||
description: 'Primary interactive element',
|
||||
category: 'button',
|
||||
variants: ['primary', 'secondary', 'success', 'danger', 'ghost', 'icon'],
|
||||
},
|
||||
buttonPrimary: {
|
||||
name: 'Primary Button',
|
||||
description: 'Main call-to-action button',
|
||||
category: 'button',
|
||||
a11y: { role: 'button' },
|
||||
},
|
||||
buttonSecondary: {
|
||||
name: 'Secondary Button',
|
||||
description: 'Alternative action button',
|
||||
category: 'button',
|
||||
},
|
||||
buttonIcon: {
|
||||
name: 'Icon Button',
|
||||
description: 'Icon-only interactive button',
|
||||
category: 'button',
|
||||
a11y: { role: 'button', ariaLabel: 'Required for icon buttons' },
|
||||
},
|
||||
|
||||
// Inputs & Forms
|
||||
input: {
|
||||
name: 'Text Input',
|
||||
description: 'Single-line text input field',
|
||||
category: 'input',
|
||||
},
|
||||
inputFile: {
|
||||
name: 'File Input',
|
||||
description: 'File upload field',
|
||||
category: 'input',
|
||||
},
|
||||
fileUploader: {
|
||||
name: 'File Uploader',
|
||||
description: 'Drag-and-drop file upload zone',
|
||||
category: 'input',
|
||||
},
|
||||
formSelect: {
|
||||
name: 'Select Dropdown',
|
||||
description: 'Option selection dropdown',
|
||||
category: 'form',
|
||||
},
|
||||
formCheckbox: {
|
||||
name: 'Checkbox',
|
||||
description: 'Multi-select checkbox input',
|
||||
category: 'form',
|
||||
},
|
||||
formRadio: {
|
||||
name: 'Radio Button',
|
||||
description: 'Single-select radio input',
|
||||
category: 'form',
|
||||
},
|
||||
formToggle: {
|
||||
name: 'Toggle Switch',
|
||||
description: 'On/off toggle switch',
|
||||
category: 'form',
|
||||
},
|
||||
|
||||
// Cards & Containers
|
||||
card: {
|
||||
name: 'Card',
|
||||
description: 'Elevation container with padding',
|
||||
category: 'card',
|
||||
},
|
||||
toolCard: {
|
||||
name: 'Tool Card',
|
||||
description: 'Tool preview card for homepage grid',
|
||||
category: 'card',
|
||||
},
|
||||
pricingCard: {
|
||||
name: 'Pricing Card',
|
||||
description: 'Subscription plan card',
|
||||
category: 'card',
|
||||
},
|
||||
|
||||
// Layout
|
||||
header: {
|
||||
name: 'Header',
|
||||
description: 'Application header with navigation',
|
||||
category: 'layout',
|
||||
},
|
||||
footer: {
|
||||
name: 'Footer',
|
||||
description: 'Application footer',
|
||||
category: 'layout',
|
||||
},
|
||||
sidebar: {
|
||||
name: 'Sidebar',
|
||||
description: 'Side navigation panel',
|
||||
category: 'layout',
|
||||
},
|
||||
container: {
|
||||
name: 'Container',
|
||||
description: 'Max-width wrapper',
|
||||
category: 'layout',
|
||||
},
|
||||
|
||||
// Feedback
|
||||
alert: {
|
||||
name: 'Alert',
|
||||
description: 'Alert message container',
|
||||
category: 'feedback',
|
||||
variants: ['success', 'warning', 'error', 'info'],
|
||||
},
|
||||
badge: {
|
||||
name: 'Badge',
|
||||
description: 'Small labeling component',
|
||||
category: 'feedback',
|
||||
},
|
||||
progressBar: {
|
||||
name: 'Progress Bar',
|
||||
description: 'Linear progress indicator',
|
||||
category: 'feedback',
|
||||
},
|
||||
spinner: {
|
||||
name: 'Spinner',
|
||||
description: 'Loading spinner indicator',
|
||||
category: 'feedback',
|
||||
},
|
||||
toast: {
|
||||
name: 'Toast',
|
||||
description: 'Temporary notification message',
|
||||
category: 'feedback',
|
||||
},
|
||||
|
||||
// Overlay & Navigation
|
||||
modal: {
|
||||
name: 'Modal Dialog',
|
||||
description: 'Modal dialog overlay',
|
||||
category: 'overlay',
|
||||
},
|
||||
dropdown: {
|
||||
name: 'Dropdown Menu',
|
||||
description: 'Dropdown menu with options',
|
||||
category: 'navigation',
|
||||
},
|
||||
tabs: {
|
||||
name: 'Tabs',
|
||||
description: 'Tabbed content navigation',
|
||||
category: 'navigation',
|
||||
},
|
||||
breadcrumb: {
|
||||
name: 'Breadcrumb',
|
||||
description: 'Breadcrumb navigation trail',
|
||||
category: 'navigation',
|
||||
},
|
||||
pagination: {
|
||||
name: 'Pagination',
|
||||
description: 'Page navigation controls',
|
||||
category: 'navigation',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool Categories with Colors
|
||||
*/
|
||||
export const toolCategories = {
|
||||
pdf: {
|
||||
name: 'PDF Tools',
|
||||
color: 'red-600',
|
||||
bgLight: 'bg-red-50',
|
||||
bgDark: 'dark:bg-red-900/20',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
},
|
||||
image: {
|
||||
name: 'Image Tools',
|
||||
color: 'amber-600',
|
||||
bgLight: 'bg-amber-50',
|
||||
bgDark: 'dark:bg-amber-900/20',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
},
|
||||
video: {
|
||||
name: 'Video Tools',
|
||||
color: 'cyan-600',
|
||||
bgLight: 'bg-cyan-50',
|
||||
bgDark: 'dark:bg-cyan-900/20',
|
||||
borderColor: 'border-cyan-200 dark:border-cyan-800',
|
||||
},
|
||||
document: {
|
||||
name: 'Document Tools',
|
||||
color: 'blue-600',
|
||||
bgLight: 'bg-blue-50',
|
||||
bgDark: 'dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
},
|
||||
text: {
|
||||
name: 'Text Tools',
|
||||
color: 'violet-600',
|
||||
bgLight: 'bg-violet-50',
|
||||
bgDark: 'dark:bg-violet-900/20',
|
||||
borderColor: 'border-violet-200 dark:border-violet-800',
|
||||
},
|
||||
convert: {
|
||||
name: 'Conversion Tools',
|
||||
color: 'pink-600',
|
||||
bgLight: 'bg-pink-50',
|
||||
bgDark: 'dark:bg-pink-900/20',
|
||||
borderColor: 'border-pink-200 dark:border-pink-800',
|
||||
},
|
||||
edit: {
|
||||
name: 'Editing Tools',
|
||||
color: 'emerald-600',
|
||||
bgLight: 'bg-emerald-50',
|
||||
bgDark: 'dark:bg-emerald-900/20',
|
||||
borderColor: 'border-emerald-200 dark:border-emerald-800',
|
||||
},
|
||||
secure: {
|
||||
name: 'Security Tools',
|
||||
color: 'orange-600',
|
||||
bgLight: 'bg-orange-50',
|
||||
bgDark: 'dark:bg-orange-900/20',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Complete Tool Registry
|
||||
* This should be updated to include ALL 40+ tools
|
||||
*/
|
||||
export const toolRegistry: Record<string, ToolCardMetadata> = {
|
||||
pdfCompressor: {
|
||||
name: 'Compress PDF',
|
||||
slug: 'compress-pdf',
|
||||
icon: 'Minimize2',
|
||||
category: 'pdf',
|
||||
colorBg: 'bg-red-50 dark:bg-red-900/20',
|
||||
colorIcon: 'text-red-600 dark:text-red-400',
|
||||
description: 'Reduce PDF file size without losing quality',
|
||||
i18nKey: 'tools.compressPdf.title',
|
||||
isPopular: true,
|
||||
orderPriority: 1,
|
||||
},
|
||||
pdfToWord: {
|
||||
name: 'PDF to Word',
|
||||
slug: 'pdf-to-word',
|
||||
icon: 'FileText',
|
||||
category: 'convert',
|
||||
colorBg: 'bg-pink-50 dark:bg-pink-900/20',
|
||||
colorIcon: 'text-pink-600 dark:text-pink-400',
|
||||
description: 'Convert PDF documents to editable Word files',
|
||||
i18nKey: 'tools.pdfToWord.title',
|
||||
isPopular: true,
|
||||
orderPriority: 2,
|
||||
},
|
||||
wordToPdf: {
|
||||
name: 'Word to PDF',
|
||||
slug: 'word-to-pdf',
|
||||
icon: 'FilePdf',
|
||||
category: 'convert',
|
||||
colorBg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
colorIcon: 'text-blue-600 dark:text-blue-400',
|
||||
description: 'Convert Word documents to PDF format',
|
||||
i18nKey: 'tools.wordToPdf.title',
|
||||
isPopular: true,
|
||||
orderPriority: 3,
|
||||
},
|
||||
mergePdf: {
|
||||
name: 'Merge PDF',
|
||||
slug: 'merge-pdf',
|
||||
icon: 'Layers',
|
||||
category: 'pdf',
|
||||
colorBg: 'bg-violet-50 dark:bg-violet-900/20',
|
||||
colorIcon: 'text-violet-600 dark:text-violet-400',
|
||||
description: 'Combine multiple PDF files into one',
|
||||
i18nKey: 'tools.mergePdf.title',
|
||||
isPopular: true,
|
||||
orderPriority: 4,
|
||||
},
|
||||
splitPdf: {
|
||||
name: 'Split PDF',
|
||||
slug: 'split-pdf',
|
||||
icon: 'Scissors',
|
||||
category: 'pdf',
|
||||
colorBg: 'bg-pink-50 dark:bg-pink-900/20',
|
||||
colorIcon: 'text-pink-600 dark:text-pink-400',
|
||||
description: 'Extract pages or split PDF into separate files',
|
||||
i18nKey: 'tools.splitPdf.title',
|
||||
orderPriority: 5,
|
||||
},
|
||||
rotatePdf: {
|
||||
name: 'Rotate PDF',
|
||||
slug: 'rotate-pdf',
|
||||
icon: 'RotateCw',
|
||||
category: 'edit',
|
||||
colorBg: 'bg-teal-50 dark:bg-teal-900/20',
|
||||
colorIcon: 'text-teal-600 dark:text-teal-400',
|
||||
description: 'Rotate PDF pages at any angle',
|
||||
i18nKey: 'tools.rotatePdf.title',
|
||||
orderPriority: 6,
|
||||
},
|
||||
pdfToImages: {
|
||||
name: 'PDF to Images',
|
||||
slug: 'pdf-to-images',
|
||||
icon: 'Image',
|
||||
category: 'convert',
|
||||
colorBg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
colorIcon: 'text-amber-600 dark:text-amber-400',
|
||||
description: 'Convert PDF pages to individual image files',
|
||||
i18nKey: 'tools.pdfToImages.title',
|
||||
orderPriority: 7,
|
||||
},
|
||||
imagesToPdf: {
|
||||
name: 'Images to PDF',
|
||||
slug: 'images-to-pdf',
|
||||
icon: 'FileImage',
|
||||
category: 'convert',
|
||||
colorBg: 'bg-lime-50 dark:bg-lime-900/20',
|
||||
colorIcon: 'text-lime-600 dark:text-lime-400',
|
||||
description: 'Combine images into a single PDF file',
|
||||
i18nKey: 'tools.imagesToPdf.title',
|
||||
orderPriority: 8,
|
||||
},
|
||||
watermarkPdf: {
|
||||
name: 'Watermark PDF',
|
||||
slug: 'watermark-pdf',
|
||||
icon: 'Droplets',
|
||||
category: 'edit',
|
||||
colorBg: 'bg-cyan-50 dark:bg-cyan-900/20',
|
||||
colorIcon: 'text-cyan-600 dark:text-cyan-400',
|
||||
description: 'Add watermarks to PDF documents',
|
||||
i18nKey: 'tools.watermarkPdf.title',
|
||||
orderPriority: 9,
|
||||
},
|
||||
protectPdf: {
|
||||
name: 'Protect PDF',
|
||||
slug: 'protect-pdf',
|
||||
icon: 'Lock',
|
||||
category: 'secure',
|
||||
colorBg: 'bg-red-50 dark:bg-red-900/20',
|
||||
colorIcon: 'text-red-600 dark:text-red-400',
|
||||
description: 'Password-protect PDF files',
|
||||
i18nKey: 'tools.protectPdf.title',
|
||||
isPremium: true,
|
||||
orderPriority: 10,
|
||||
},
|
||||
unlockPdf: {
|
||||
name: 'Unlock PDF',
|
||||
slug: 'unlock-pdf',
|
||||
icon: 'Unlock',
|
||||
category: 'secure',
|
||||
colorBg: 'bg-green-50 dark:bg-green-900/20',
|
||||
colorIcon: 'text-green-600 dark:text-green-400',
|
||||
description: 'Remove password protection from PDF files',
|
||||
i18nKey: 'tools.unlockPdf.title',
|
||||
isPremium: true,
|
||||
orderPriority: 11,
|
||||
},
|
||||
addPageNumbers: {
|
||||
name: 'Add Page Numbers',
|
||||
slug: 'add-page-numbers',
|
||||
icon: 'ListOrdered',
|
||||
category: 'edit',
|
||||
colorBg: 'bg-sky-50 dark:bg-sky-900/20',
|
||||
colorIcon: 'text-sky-600 dark:text-sky-400',
|
||||
description: 'Add page numbers to PDF documents',
|
||||
i18nKey: 'tools.addPageNumbers.title',
|
||||
isPremium: true,
|
||||
orderPriority: 12,
|
||||
},
|
||||
imageConverter: {
|
||||
name: 'Image Converter',
|
||||
slug: 'image-converter',
|
||||
icon: 'ImageIcon',
|
||||
category: 'image',
|
||||
colorBg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
colorIcon: 'text-purple-600 dark:text-purple-400',
|
||||
description: 'Convert images between different formats',
|
||||
i18nKey: 'tools.imageConverter.title',
|
||||
orderPriority: 13,
|
||||
},
|
||||
videoToGif: {
|
||||
name: 'Video to GIF',
|
||||
slug: 'video-to-gif',
|
||||
icon: 'Film',
|
||||
category: 'video',
|
||||
colorBg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
colorIcon: 'text-emerald-600 dark:text-emerald-400',
|
||||
description: 'Convert video files to animated GIFs',
|
||||
i18nKey: 'tools.videoToGif.title',
|
||||
isPremium: true,
|
||||
orderPriority: 14,
|
||||
},
|
||||
wordCounter: {
|
||||
name: 'Word Counter',
|
||||
slug: 'word-counter',
|
||||
icon: 'Hash',
|
||||
category: 'text',
|
||||
colorBg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
colorIcon: 'text-blue-600 dark:text-blue-400',
|
||||
description: 'Count words, characters, and paragraphs',
|
||||
i18nKey: 'tools.wordCounter.title',
|
||||
orderPriority: 15,
|
||||
},
|
||||
textCleaner: {
|
||||
name: 'Text Cleaner',
|
||||
slug: 'text-cleaner',
|
||||
icon: 'Eraser',
|
||||
category: 'text',
|
||||
colorBg: 'bg-indigo-50 dark:bg-indigo-900/20',
|
||||
colorIcon: 'text-indigo-600 dark:text-indigo-400',
|
||||
description: 'Clean and format text content',
|
||||
i18nKey: 'tools.textCleaner.title',
|
||||
orderPriority: 16,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all tools sorted by priority
|
||||
*/
|
||||
export function getToolsByPriority(): ToolCardMetadata[] {
|
||||
return Object.values(toolRegistry).sort(
|
||||
(a, b) => a.orderPriority - b.orderPriority
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools by category
|
||||
*/
|
||||
export function getToolsByCategory(
|
||||
category: ToolCardMetadata['category']
|
||||
): ToolCardMetadata[] {
|
||||
return Object.values(toolRegistry)
|
||||
.filter((tool) => tool.category === category)
|
||||
.sort((a, b) => a.orderPriority - b.orderPriority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular tools
|
||||
*/
|
||||
export function getPopularTools(): ToolCardMetadata[] {
|
||||
return Object.values(toolRegistry)
|
||||
.filter((tool) => tool.isPopular)
|
||||
.sort((a, b) => a.orderPriority - b.orderPriority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get premium tools
|
||||
*/
|
||||
export function getPremiumTools(): ToolCardMetadata[] {
|
||||
return Object.values(toolRegistry).filter((tool) => tool.isPremium);
|
||||
}
|
||||
|
||||
export default {
|
||||
componentRegistry,
|
||||
toolRegistry,
|
||||
toolCategories,
|
||||
getToolsByPriority,
|
||||
getToolsByCategory,
|
||||
getPopularTools,
|
||||
getPremiumTools,
|
||||
};
|
||||
296
frontend/src/design-system/theme.ts
Normal file
296
frontend/src/design-system/theme.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Design System Theme Configuration
|
||||
* Centralized theme utilities and token system
|
||||
*/
|
||||
|
||||
import colors, { colorAssignments, getColorClass } from './colors';
|
||||
|
||||
/**
|
||||
* Typography Scale
|
||||
* Matches Tailwind CSS + custom adjustments for accessibility
|
||||
*/
|
||||
export const typography = {
|
||||
// Headings
|
||||
h1: {
|
||||
fontSize: '3rem', // 48px
|
||||
fontWeight: 700,
|
||||
lineHeight: '3.5rem', // 56px
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2.25rem', // 36px
|
||||
fontWeight: 700,
|
||||
lineHeight: '2.5rem', // 40px
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.875rem', // 30px
|
||||
fontWeight: 600,
|
||||
lineHeight: '2.25rem', // 36px
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.5rem', // 24px
|
||||
fontWeight: 600,
|
||||
lineHeight: '2rem', // 32px
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.25rem', // 20px
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.75rem', // 28px
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem', // 16px
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.5rem', // 24px
|
||||
},
|
||||
|
||||
// Body text
|
||||
body: {
|
||||
large: {
|
||||
fontSize: '1.125rem', // 18px
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.75rem', // 28px
|
||||
},
|
||||
base: {
|
||||
fontSize: '1rem', // 16px
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.5rem', // 24px
|
||||
},
|
||||
small: {
|
||||
fontSize: '0.875rem', // 14px
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.25rem', // 20px
|
||||
},
|
||||
xs: {
|
||||
fontSize: '0.75rem', // 12px
|
||||
fontWeight: 400,
|
||||
lineHeight: '1rem', // 16px
|
||||
},
|
||||
},
|
||||
|
||||
// Labels & UI text
|
||||
label: {
|
||||
fontSize: '0.875rem', // 14px
|
||||
fontWeight: 500,
|
||||
lineHeight: '1.25rem', // 20px
|
||||
},
|
||||
caption: {
|
||||
fontSize: '0.75rem', // 12px
|
||||
fontWeight: 500,
|
||||
lineHeight: '1rem', // 16px
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Spacing Scale
|
||||
* 4px base unit (Tailwind default)
|
||||
*/
|
||||
export const spacing = {
|
||||
xs: '0.25rem', // 4px
|
||||
sm: '0.5rem', // 8px
|
||||
md: '1rem', // 16px
|
||||
lg: '1.5rem', // 24px
|
||||
xl: '2rem', // 32px
|
||||
'2xl': '2.5rem', // 40px
|
||||
'3xl': '3rem', // 48px
|
||||
'4xl': '4rem', // 64px
|
||||
'5xl': '5rem', // 80px
|
||||
'6xl': '6rem', // 96px
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Border Radius Scale
|
||||
*/
|
||||
export const borderRadius = {
|
||||
none: '0',
|
||||
sm: '0.375rem', // 6px
|
||||
base: '0.5rem', // 8px
|
||||
md: '0.75rem', // 12px
|
||||
lg: '1rem', // 16px
|
||||
xl: '1.25rem', // 20px
|
||||
'2xl': '1.5rem', // 24px
|
||||
full: '9999px',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shadow Scale
|
||||
*/
|
||||
export const shadows = {
|
||||
none: '0 0 #0000',
|
||||
xs: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)',
|
||||
lg_dark: '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Z-Index Scale
|
||||
* Structured layering system
|
||||
*/
|
||||
export const zIndex = {
|
||||
auto: 'auto',
|
||||
hide: '-1',
|
||||
base: '0',
|
||||
dropdown: '1000',
|
||||
sticky: '1010',
|
||||
fixed: '1020',
|
||||
backdrop: '1030',
|
||||
offcanvas: '1040',
|
||||
modal: '1050',
|
||||
popover: '1060',
|
||||
tooltip: '1070',
|
||||
notification: '1080',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Transitions & Animations
|
||||
*/
|
||||
export const transitions = {
|
||||
fast: '0.15s',
|
||||
base: '0.2s',
|
||||
slow: '0.3s',
|
||||
slower: '0.5s',
|
||||
|
||||
easing: {
|
||||
in: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||
out: 'cubic-bezier(0, 0, 0.2, 1)',
|
||||
inOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Breakpoints (matching Tailwind)
|
||||
*/
|
||||
export const breakpoints = {
|
||||
xs: '0px',
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Container Width
|
||||
*/
|
||||
export const containers = {
|
||||
sm: '24rem', // 384px
|
||||
md: '28rem', // 448px
|
||||
lg: '32rem', // 512px
|
||||
xl: '36rem', // 576px
|
||||
'2xl': '42rem', // 672px
|
||||
'3xl': '48rem', // 768px
|
||||
'4xl': '56rem', // 896px
|
||||
'5xl': '64rem', // 1024px
|
||||
'6xl': '72rem', // 1152px
|
||||
'7xl': '80rem', // 1280px
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Component Size Presets
|
||||
*/
|
||||
export const componentSizes = {
|
||||
// Button sizes
|
||||
button: {
|
||||
xs: {
|
||||
padding: '0.375rem 0.75rem',
|
||||
fontSize: '0.75rem',
|
||||
height: '1.5rem',
|
||||
},
|
||||
sm: {
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
height: '2rem',
|
||||
},
|
||||
md: {
|
||||
padding: '0.75rem 1.5rem',
|
||||
fontSize: '1rem',
|
||||
height: '2.5rem',
|
||||
},
|
||||
lg: {
|
||||
padding: '1rem 2rem',
|
||||
fontSize: '1.125rem',
|
||||
height: '3rem',
|
||||
},
|
||||
xl: {
|
||||
padding: '1.25rem 2.5rem',
|
||||
fontSize: '1.25rem',
|
||||
height: '3.5rem',
|
||||
},
|
||||
},
|
||||
|
||||
// Input sizes
|
||||
input: {
|
||||
sm: { padding: '0.375rem 0.75rem', fontSize: '0.875rem' },
|
||||
md: { padding: '0.75rem 1rem', fontSize: '1rem' },
|
||||
lg: { padding: '1rem 1.25rem', fontSize: '1.125rem' },
|
||||
},
|
||||
|
||||
// Icon sizes
|
||||
icon: {
|
||||
xs: '1rem', // 16px
|
||||
sm: '1.25rem', // 20px
|
||||
md: '1.5rem', // 24px
|
||||
lg: '2rem', // 32px
|
||||
xl: '2.5rem', // 40px
|
||||
'2xl': '3rem', // 48px
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Responsive Utilities
|
||||
*/
|
||||
export const responsive = {
|
||||
// Stack direction
|
||||
stackMobile: 'flex flex-col',
|
||||
stackDesktop: 'lg:flex-row',
|
||||
|
||||
// Grid column count
|
||||
gridAuto: 'grid gap-4',
|
||||
grid2: 'grid grid-cols-1 sm:grid-cols-2 gap-4',
|
||||
grid3: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4',
|
||||
grid4: 'grid grid-cols-2 lg:grid-cols-4 gap-4',
|
||||
|
||||
// Common padding
|
||||
pagePaddingX: 'px-4 sm:px-6 lg:px-8',
|
||||
pagePaddingY: 'py-8 sm:py-12 lg:py-16',
|
||||
sectionPadding: 'px-4 sm:px-6 lg:px-8 py-8 sm:py-12 lg:py-16',
|
||||
|
||||
// Container width
|
||||
containerMax: 'max-w-7xl',
|
||||
containerContent: 'max-w-4xl',
|
||||
containerSmall: 'max-w-2xl',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Complete Theme Object
|
||||
*/
|
||||
export const theme = {
|
||||
colors,
|
||||
colorAssignments,
|
||||
typography,
|
||||
spacing,
|
||||
borderRadius,
|
||||
shadows,
|
||||
zIndex,
|
||||
transitions,
|
||||
breakpoints,
|
||||
containers,
|
||||
componentSizes,
|
||||
responsive,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Utility: Get CSS variable or Tailwind class for a color
|
||||
*/
|
||||
export const useColor = (semantic: string, mode: 'light' | 'dark' = 'light') => {
|
||||
const colorObj = mode === 'light' ? colors.light : colors.dark;
|
||||
return colorObj[semantic as keyof typeof colorObj];
|
||||
};
|
||||
|
||||
export default theme;
|
||||
Reference in New Issue
Block a user