feat: PL24 integration + EMEX on-demand parts loading
## Major Changes ### PL24 Integration (PartsLink24 API) - Add PL24 auth service with JWT token management - Add PL24 service for VIN decode and parts catalog - Support for VAG, Mercedes, BMW, Ford, Renault and more - Schema image download with base64 decoding - Image deduplication via SchemaPic table ### EMEX On-Demand Loading - Remove automatic parts scraping during VIN decode - Add fetchCategoryParts() for on-demand loading - Store category URLs in rawResponse for later fetching - Fix Chrome executable path for Puppeteer ### VIN Decode Flow - PL24 first, fallback to EMEX if not available - Both sources now use on-demand parts loading - Faster VIN lookup (no parts scraped upfront) ### Database Schema - Add SchemaPic model for image deduplication - Add schemaPicId to VehicleCategory - Add EMEX models for parallel scraping ### Frontend Improvements - Dark theme with Tailwind CSS - Improved UI components (card, button, input) - Better category and parts display - Schema image viewer Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,21 +3,50 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'inline-flex items-center rounded-full text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Primary
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
'bg-primary text-primary-foreground ring ring-primary/20',
|
||||
// Secondary
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
'bg-secondary text-secondary-foreground ring ring-border',
|
||||
// Outline
|
||||
outline:
|
||||
'bg-transparent text-foreground ring ring-border',
|
||||
// Destructive/Danger
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
'bg-destructive text-destructive-foreground ring ring-destructive/20',
|
||||
// Success
|
||||
success:
|
||||
'bg-success text-success-foreground ring ring-success/20',
|
||||
// Warning
|
||||
warning:
|
||||
'bg-warning text-warning-foreground ring ring-warning/20',
|
||||
// Accent
|
||||
accent:
|
||||
'bg-accent text-accent-foreground ring ring-accent/20',
|
||||
// Soft variants
|
||||
'soft-primary':
|
||||
'bg-primary-50 text-primary ring ring-primary/10',
|
||||
'soft-destructive':
|
||||
'bg-destructive-50 text-destructive ring ring-destructive/10',
|
||||
'soft-success':
|
||||
'bg-success-50 text-success ring ring-success/10',
|
||||
'soft-warning':
|
||||
'bg-warning-50 text-warning ring ring-warning/10',
|
||||
},
|
||||
size: {
|
||||
sm: 'px-2 py-0.5 text-[10px]',
|
||||
default: 'px-2.5 py-0.5',
|
||||
lg: 'px-3 py-1 text-sm',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -26,9 +55,9 @@ export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
<div className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,76 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.98]',
|
||||
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium select-none cursor-pointer transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm hover:shadow-md',
|
||||
outline: 'border-2 border-input bg-background hover:bg-accent hover:text-accent-foreground hover:border-accent',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
// Primary - Selia style with inset shadow
|
||||
default:
|
||||
'bg-primary text-primary-foreground ring ring-primary/20 shadow-sm inset-shadow-white/15 hover:bg-primary/90 active:scale-[0.98]',
|
||||
// Secondary
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground ring ring-border hover:bg-secondary/80 active:scale-[0.98]',
|
||||
// Tertiary
|
||||
tertiary:
|
||||
'bg-tertiary text-tertiary-foreground ring ring-border hover:bg-tertiary/80 active:scale-[0.98]',
|
||||
// Danger/Destructive
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground ring ring-destructive/20 shadow-sm inset-shadow-white/15 hover:bg-destructive/90 active:scale-[0.98]',
|
||||
// Outline - Ring based border
|
||||
outline:
|
||||
'bg-transparent ring ring-border text-foreground hover:bg-muted hover:ring-border-secondary active:bg-muted/80',
|
||||
// Ghost
|
||||
ghost:
|
||||
'text-foreground hover:bg-muted active:bg-muted/80',
|
||||
// Plain/Link
|
||||
plain:
|
||||
'text-foreground hover:text-primary underline-offset-4 hover:underline',
|
||||
// Link
|
||||
link:
|
||||
'text-primary underline-offset-4 hover:underline',
|
||||
// Gradient - Selia premium style
|
||||
gradient:
|
||||
'gradient-primary text-white shadow-md ring ring-primary/20 inset-shadow-white/15 hover:opacity-95 active:scale-[0.98]',
|
||||
// Success
|
||||
success:
|
||||
'bg-success text-success-foreground ring ring-success/20 shadow-sm inset-shadow-white/15 hover:bg-success/90 active:scale-[0.98]',
|
||||
// Warning
|
||||
warning:
|
||||
'bg-warning text-warning-foreground ring ring-warning/20 shadow-sm hover:bg-warning/90 active:scale-[0.98]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-5 py-2',
|
||||
sm: 'h-9 rounded-lg px-4',
|
||||
lg: 'h-12 rounded-xl px-8',
|
||||
icon: 'h-10 w-10',
|
||||
xs: 'h-7 px-2.5 text-xs rounded-md [&_svg]:size-3',
|
||||
sm: 'h-8 px-3 text-sm rounded-lg [&_svg]:size-3.5',
|
||||
default: 'h-9 px-4 text-sm rounded-lg [&_svg]:size-4',
|
||||
md: 'h-10 px-5 text-sm rounded-lg [&_svg]:size-4',
|
||||
lg: 'h-11 px-6 text-base rounded-xl [&_svg]:size-5',
|
||||
xl: 'h-12 px-8 text-base font-semibold rounded-xl [&_svg]:size-5',
|
||||
// Icon sizes
|
||||
'icon-xs': 'h-7 w-7 rounded-md [&_svg]:size-3.5',
|
||||
'icon-sm': 'h-8 w-8 rounded-lg [&_svg]:size-4',
|
||||
icon: 'h-9 w-9 rounded-lg [&_svg]:size-4',
|
||||
'icon-md': 'h-10 w-10 rounded-lg [&_svg]:size-5',
|
||||
'icon-lg': 'h-11 w-11 rounded-xl [&_svg]:size-5',
|
||||
},
|
||||
pill: {
|
||||
true: 'rounded-full',
|
||||
false: '',
|
||||
},
|
||||
block: {
|
||||
true: 'w-full',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
pill: false,
|
||||
block: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -33,12 +79,35 @@ export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
isLoading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
({ className, variant, size, pill, block, asChild = false, isLoading, leftIcon, rightIcon, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, pill, block, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
<span>{children}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{leftIcon && <span className="inline-flex">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="inline-flex">{rightIcon}</span>}
|
||||
</>
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
@@ -1,51 +1,134 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
const cardVariants = cva(
|
||||
'rounded-xl bg-card text-card-foreground ring ring-card-border shadow-card transition-all',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
elevated: 'shadow-md hover:shadow-lg',
|
||||
outline: 'bg-transparent shadow-none',
|
||||
ghost: 'ring-0 bg-transparent shadow-none',
|
||||
glass: 'glass ring-0',
|
||||
gradient: 'gradient-border ring-0 bg-card',
|
||||
interactive: 'cursor-pointer hover:ring-border-secondary hover:shadow-md',
|
||||
success: 'ring-success/30 bg-success-50',
|
||||
warning: 'ring-warning/30 bg-warning-50',
|
||||
destructive: 'ring-destructive/30 bg-destructive-50',
|
||||
},
|
||||
padding: {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
default: 'p-6',
|
||||
lg: 'p-8',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
padding: 'none',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface CardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof cardVariants> {}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant, padding, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow-sm transition-all duration-300',
|
||||
className
|
||||
)}
|
||||
className={cn(cardVariants({ variant, padding }), className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
);
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
compact?: boolean;
|
||||
align?: 'default' | 'center' | 'right';
|
||||
}
|
||||
>(({ className, compact, align = 'default', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col border-b border-card-separator',
|
||||
compact ? 'gap-y-1 p-4' : 'gap-y-1.5 p-6',
|
||||
align === 'center' && 'items-center text-center',
|
||||
align === 'right' && 'items-end text-right',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
);
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { as?: 'h1' | 'h2' | 'h3' | 'h4' }
|
||||
>(({ className, as: Component = 'h3', ...props }, ref) => (
|
||||
<Component
|
||||
ref={ref as React.Ref<HTMLHeadingElement>}
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-none tracking-tight text-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
);
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||
);
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { compact?: boolean }
|
||||
>(({ className, compact, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(compact ? 'p-4' : 'p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
),
|
||||
);
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { compact?: boolean }
|
||||
>(({ className, compact, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 border-t border-card-separator bg-card-footer rounded-b-xl',
|
||||
compact ? 'p-4' : 'p-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
cardVariants,
|
||||
};
|
||||
|
||||
@@ -1,23 +1,108 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
const inputVariants = cva(
|
||||
'flex w-full rounded-lg text-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-dimmed focus:outline-0 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Default - Selia ring-based border
|
||||
default:
|
||||
'bg-input ring ring-input-border hover:not-disabled:not-focus:ring-input-accent-border focus:ring-2 focus:ring-primary',
|
||||
// Subtle - Transparent background
|
||||
subtle:
|
||||
'bg-input/60 ring ring-input-border hover:not-disabled:not-focus:ring-input-accent-border focus:ring-2 focus:ring-primary',
|
||||
// Filled
|
||||
filled:
|
||||
'bg-secondary ring-0 focus:ring-2 focus:ring-primary',
|
||||
// Ghost
|
||||
ghost:
|
||||
'bg-transparent ring-0 hover:bg-muted focus:ring-2 focus:ring-primary',
|
||||
// Error
|
||||
error:
|
||||
'bg-destructive-50 ring ring-destructive/50 text-destructive focus:ring-2 focus:ring-destructive',
|
||||
// Success
|
||||
success:
|
||||
'bg-success-50 ring ring-success/50 text-success focus:ring-2 focus:ring-success',
|
||||
},
|
||||
size: {
|
||||
xs: 'h-7 px-2.5 text-xs',
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
default: 'h-9 px-3.5',
|
||||
md: 'h-10 px-4',
|
||||
lg: 'h-11 px-4 text-base',
|
||||
xl: 'h-12 px-5 text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>,
|
||||
VariantProps<typeof inputVariants> {
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
error?: string;
|
||||
success?: string;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
variant,
|
||||
size,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
error,
|
||||
success,
|
||||
wrapperClassName,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputVariant = error ? 'error' : success ? 'success' : variant;
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
<div className={cn('relative w-full', wrapperClassName)}>
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dimmed pointer-events-none">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
inputVariants({ variant: inputVariant, size }),
|
||||
leftIcon && 'pl-10',
|
||||
rightIcon && 'pr-10',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-dimmed">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mt-1.5 text-xs text-destructive animate-fade-in">{error}</p>
|
||||
)}
|
||||
{success && !error && (
|
||||
<p className="mt-1.5 text-xs text-success animate-fade-in">{success}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
export { Input, inputVariants };
|
||||
|
||||
@@ -6,15 +6,37 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
'text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: '',
|
||||
muted: 'text-muted-foreground font-normal',
|
||||
dimmed: 'text-dimmed font-normal',
|
||||
},
|
||||
size: {
|
||||
sm: 'text-xs',
|
||||
default: 'text-sm',
|
||||
lg: 'text-base',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
export { Label, labelVariants };
|
||||
|
||||
178
apps/web/components/ui/theme-toggle.tsx
Normal file
178
apps/web/components/ui/theme-toggle.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
variant?: 'default' | 'compact' | 'dropdown';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ variant = 'default', className }: ThemeToggleProps) {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className={cn('h-9 w-9 rounded-lg bg-muted animate-pulse', className)} />
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
||||
className={cn(
|
||||
'relative h-9 w-9 rounded-lg transition-colors',
|
||||
'hover:bg-primary/10 hover:text-primary',
|
||||
className
|
||||
)}
|
||||
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
<Sun className={cn(
|
||||
'h-[1.125rem] w-[1.125rem] transition-all duration-300',
|
||||
resolvedTheme === 'dark' ? 'rotate-0 scale-100' : 'rotate-90 scale-0'
|
||||
)} />
|
||||
<Moon className={cn(
|
||||
'absolute h-[1.125rem] w-[1.125rem] transition-all duration-300',
|
||||
resolvedTheme === 'dark' ? 'rotate-90 scale-0' : 'rotate-0 scale-100'
|
||||
)} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: segmented toggle with all three options
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-lg p-1 gap-0.5',
|
||||
'bg-muted/50 border border-border',
|
||||
className
|
||||
)}
|
||||
role="radiogroup"
|
||||
aria-label="Theme selection"
|
||||
>
|
||||
{[
|
||||
{ value: 'light', icon: Sun, label: 'Aydinlik' },
|
||||
{ value: 'dark', icon: Moon, label: 'Karanlik' },
|
||||
{ value: 'system', icon: Monitor, label: 'Sistem' },
|
||||
].map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-md px-3 py-1.5 text-sm font-medium transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
theme === value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
)}
|
||||
role="radio"
|
||||
aria-checked={theme === value}
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-1.5" />
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown version for mobile
|
||||
export function ThemeToggleDropdown({ className }: { className?: string }) {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = () => setIsOpen(false);
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className={cn('h-9 w-9 rounded-lg bg-muted animate-pulse', className)} />;
|
||||
}
|
||||
|
||||
const themes = [
|
||||
{ value: 'light', icon: Sun, label: 'Aydinlik Mod' },
|
||||
{ value: 'dark', icon: Moon, label: 'Karanlik Mod' },
|
||||
{ value: 'system', icon: Monitor, label: 'Sistem Ayari' },
|
||||
];
|
||||
|
||||
const currentTheme = themes.find(t => t.value === theme) || themes[2];
|
||||
const CurrentIcon = resolvedTheme === 'dark' ? Moon : Sun;
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-lg transition-colors',
|
||||
'hover:bg-primary/10 hover:text-primary',
|
||||
isOpen && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
aria-label="Tema sec"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<CurrentIcon className="h-[1.125rem] w-[1.125rem]" />
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-full mt-2 z-50',
|
||||
'min-w-[160px] rounded-lg border border-border bg-popover p-1 shadow-lg',
|
||||
'animate-scale-in origin-top-right'
|
||||
)}
|
||||
role="menu"
|
||||
>
|
||||
{themes.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => {
|
||||
setTheme(value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
theme === value
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-foreground hover:bg-muted'
|
||||
)}
|
||||
role="menuitem"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
{theme === value && (
|
||||
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,12 +24,14 @@ const ToastViewport = React.forwardRef<
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-xl p-4 pr-8 ring ring-card-border shadow-card transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
default: 'bg-card text-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground ring-destructive/20',
|
||||
success: 'bg-success text-success-foreground ring-success/20',
|
||||
warning: 'bg-warning text-warning-foreground ring-warning/20',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -53,7 +55,7 @@ const ToastAction = React.forwardRef<
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-lg ring ring-border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:ring-destructive/30 group-[.destructive]:hover:bg-destructive/90 group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -68,7 +70,7 @@ const ToastClose = React.forwardRef<
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
'absolute right-2 top-2 rounded-lg p-1.5 text-foreground/50 opacity-0 transition-all hover:text-foreground hover:bg-muted focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover:opacity-100 group-[.destructive]:text-destructive-foreground/70 group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:hover:bg-destructive/80',
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
@@ -91,7 +93,7 @@ const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} />
|
||||
<ToastPrimitives.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user