feat: modernize frontend UI with Tailwind v4 and professional design system
BREAKING CHANGE: Migrated to Tailwind CSS v4 configuration system Key Changes: - Migrated from Tailwind v3 to v4 configuration system - Removed tailwind.config.js (incompatible with v4) - Updated index.css with @theme directive and oklch color space - Defined all custom animations directly in CSS using @keyframes - Redesigned LoginPage with modern, enterprise-grade UI: - Full-screen gradient background (blue → purple → pink) - Floating animated orbs with blur effects - Glass morphism white card with backdrop-blur - Gradient buttons with shadow effects - 7 custom animations: fade-in, slide-in-right, slide-in-left, scale-in, shimmer, pulse, float - Added shadcn/ui components: - alert.tsx, dialog.tsx, input.tsx, label.tsx, select.tsx, tabs.tsx - Updated dependencies: - Added class-variance-authority ^0.7.0 - Added react-markdown ^9.0.1 - Updated frontend documentation: - Comprehensive README.md with feature list, tech stack, project structure - Quick start guide and deployment instructions Technical Details: - Tailwind v4 uses @import "tailwindcss" instead of @tailwind directives - All theme customization now in @theme block with CSS variables - Color system migrated to oklch for better perceptual uniformity - Animation definitions moved from config to CSS @layer utilities 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
76
frontend/src/components/ui/alert.tsx
Normal file
76
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertCircle, CheckCircle2, Info, AlertTriangle } from 'lucide-react'
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:pl-7',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground border-border',
|
||||
info: 'bg-primary/10 text-primary border-primary/30',
|
||||
success: 'bg-success/10 text-success border-success/30',
|
||||
warning: 'bg-warning/10 text-warning border-warning/30',
|
||||
destructive: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const icons = {
|
||||
default: Info,
|
||||
info: Info,
|
||||
success: CheckCircle2,
|
||||
warning: AlertTriangle,
|
||||
destructive: AlertCircle,
|
||||
}
|
||||
|
||||
export interface AlertProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof alertVariants> {
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant = 'default', showIcon = true, children, ...props }, ref) => {
|
||||
const Icon = icons[variant || 'default']
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && <Icon className="h-5 w-5" />}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Alert.displayName = 'Alert'
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn('mb-1 font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
AlertTitle.displayName = 'AlertTitle'
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||
))
|
||||
AlertDescription.displayName = 'AlertDescription'
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
118
frontend/src/components/ui/dialog.tsx
Normal file
118
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Dialog: React.FC<DialogProps> = ({ open, onClose, children }) => {
|
||||
React.useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Dialog */}
|
||||
<div className="relative z-50 w-full max-w-lg mx-4 animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-card rounded-xl border border-border shadow-xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex items-center justify-between p-6 border-b border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold text-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
DialogTitle.displayName = 'DialogTitle'
|
||||
|
||||
const DialogBody = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('p-6', className)} {...props} />
|
||||
)
|
||||
DialogBody.displayName = 'DialogBody'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex items-center justify-end gap-3 p-6 border-t border-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogClose = ({
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
onClose: () => void
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'p-2 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)
|
||||
|
||||
export { Dialog, DialogContent, DialogHeader, DialogTitle, DialogBody, DialogFooter, DialogClose }
|
||||
28
frontend/src/components/ui/input.tsx
Normal file
28
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
23
frontend/src/components/ui/label.tsx
Normal file
23
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none text-foreground',
|
||||
'peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
export { Label }
|
||||
38
frontend/src/components/ui/select.tsx
Normal file
38
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
options: Array<{ value: string; label: string }>
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, options, ...props }, ref) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm',
|
||||
'ring-offset-background',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-colors appearance-none pr-10',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Select.displayName = 'Select'
|
||||
|
||||
export { Select }
|
||||
108
frontend/src/components/ui/tabs.tsx
Normal file
108
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface TabsProps {
|
||||
defaultValue?: string
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface TabsContextValue {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
}
|
||||
|
||||
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined)
|
||||
|
||||
const Tabs: React.FC<TabsProps> = ({
|
||||
defaultValue,
|
||||
value: controlledValue,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const [internalValue, setInternalValue] = React.useState(defaultValue || '')
|
||||
const value = controlledValue !== undefined ? controlledValue : internalValue
|
||||
const handleValueChange = onValueChange || setInternalValue
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<div className={className}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
TabsList.displayName = 'TabsList'
|
||||
|
||||
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
value: string
|
||||
}
|
||||
|
||||
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const context = React.useContext(TabsContext)
|
||||
if (!context) throw new Error('TabsTrigger must be used within Tabs')
|
||||
|
||||
const isSelected = context.value === value
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
onClick={() => context.onValueChange(value)}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium',
|
||||
'ring-offset-background transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
isSelected
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsTrigger.displayName = 'TabsTrigger'
|
||||
|
||||
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: string
|
||||
}
|
||||
|
||||
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const context = React.useContext(TabsContext)
|
||||
if (!context) throw new Error('TabsContent must be used within Tabs')
|
||||
|
||||
if (context.value !== value) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
TabsContent.displayName = 'TabsContent'
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -1,115 +1,172 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Clean, modern color palette */
|
||||
--background: 220 15% 97%;
|
||||
--foreground: 220 15% 15%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 15% 15%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 15% 15%;
|
||||
@theme {
|
||||
/* Clean, modern color palette */
|
||||
--color-background: oklch(95% 0.02 220);
|
||||
--color-foreground: oklch(20% 0.02 220);
|
||||
--color-card: oklch(100% 0 0);
|
||||
--color-card-foreground: oklch(20% 0.02 220);
|
||||
--color-popover: oklch(100% 0 0);
|
||||
--color-popover-foreground: oklch(20% 0.02 220);
|
||||
|
||||
/* Primary: Professional blue */
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary-hover: 217 91% 50%;
|
||||
/* Primary: Professional blue */
|
||||
--color-primary: oklch(65% 0.25 250);
|
||||
--color-primary-foreground: oklch(100% 0 0);
|
||||
--color-primary-hover: oklch(60% 0.25 250);
|
||||
|
||||
/* Secondary: Subtle gray-blue */
|
||||
--secondary: 220 15% 95%;
|
||||
--secondary-foreground: 220 15% 25%;
|
||||
/* Secondary: Subtle gray-blue */
|
||||
--color-secondary: oklch(95% 0.02 220);
|
||||
--color-secondary-foreground: oklch(30% 0.02 220);
|
||||
|
||||
/* Accent: Vibrant teal */
|
||||
--accent: 173 80% 50%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
/* Accent: Vibrant teal */
|
||||
--color-accent: oklch(65% 0.20 180);
|
||||
--color-accent-foreground: oklch(100% 0 0);
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: 220 25% 12%;
|
||||
--sidebar-foreground: 220 10% 90%;
|
||||
--sidebar-active: 217 91% 60%;
|
||||
/* Muted */
|
||||
--color-muted: oklch(93% 0.02 220);
|
||||
--color-muted-foreground: oklch(50% 0.02 220);
|
||||
|
||||
/* Muted */
|
||||
--muted: 220 15% 93%;
|
||||
--muted-foreground: 220 10% 45%;
|
||||
/* Destructive */
|
||||
--color-destructive: oklch(60% 0.22 25);
|
||||
--color-destructive-foreground: oklch(100% 0 0);
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 85% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
/* Success */
|
||||
--color-success: oklch(55% 0.20 150);
|
||||
--color-success-foreground: oklch(100% 0 0);
|
||||
|
||||
/* Success */
|
||||
--success: 142 72% 45%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
/* Warning */
|
||||
--color-warning: oklch(65% 0.22 60);
|
||||
--color-warning-foreground: oklch(100% 0 0);
|
||||
|
||||
/* Warning */
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
/* Borders and inputs */
|
||||
--color-border: oklch(88% 0.02 220);
|
||||
--color-input: oklch(88% 0.02 220);
|
||||
--color-ring: oklch(65% 0.25 250);
|
||||
|
||||
/* Borders and inputs */
|
||||
--border: 220 13% 88%;
|
||||
--input: 220 13% 88%;
|
||||
--ring: 217 91% 60%;
|
||||
--radius: 0.5rem;
|
||||
/* Border radius */
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-md: calc(0.5rem - 2px);
|
||||
--radius-sm: calc(0.5rem - 4px);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark mode with rich colors */
|
||||
--background: 240 20% 8%;
|
||||
--foreground: 240 5% 95%;
|
||||
--card: 240 15% 12%;
|
||||
--card-foreground: 240 5% 95%;
|
||||
--popover: 240 15% 12%;
|
||||
--popover-foreground: 240 5% 95%;
|
||||
|
||||
/* Primary: Brighter in dark mode */
|
||||
--primary: 250 85% 65%;
|
||||
--primary-foreground: 240 20% 8%;
|
||||
--primary-glow: 250 85% 65%;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: 240 15% 18%;
|
||||
--secondary-foreground: 240 5% 95%;
|
||||
|
||||
/* Accent */
|
||||
--accent: 190 85% 55%;
|
||||
--accent-foreground: 240 20% 8%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 240 15% 15%;
|
||||
--muted-foreground: 240 5% 65%;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 80% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Success */
|
||||
--success: 142 70% 50%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders */
|
||||
--border: 240 15% 20%;
|
||||
--input: 240 15% 20%;
|
||||
--ring: 250 85% 65%;
|
||||
}
|
||||
/* Animations */
|
||||
--animate-fade-in: fade-in 0.5s ease-out;
|
||||
--animate-slide-in-right: slide-in-right 0.5s ease-out;
|
||||
--animate-slide-in-left: slide-in-left 0.5s ease-out;
|
||||
--animate-scale-in: scale-in 0.3s ease-out;
|
||||
--animate-shimmer: shimmer 2s linear infinite;
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-float: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Animations */
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slide-in-left 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-shimmer {
|
||||
animation: shimmer 2s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -117,17 +174,17 @@
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
background: var(--color-muted);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
background: color-mix(in oklch, var(--color-muted-foreground) 30%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
background: color-mix(in oklch, var(--color-muted-foreground) 50%, transparent);
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
@@ -149,31 +206,31 @@
|
||||
.page-title {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
color: var(--color-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: hsl(var(--muted-foreground));
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
color: var(--color-foreground);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Status badge */
|
||||
@@ -187,22 +244,22 @@
|
||||
}
|
||||
|
||||
.status-badge-success {
|
||||
background: hsl(var(--success) / 0.1);
|
||||
color: hsl(var(--success));
|
||||
background: color-mix(in oklch, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.status-badge-warning {
|
||||
background: hsl(var(--warning) / 0.1);
|
||||
color: hsl(var(--warning));
|
||||
background: color-mix(in oklch, var(--color-warning) 10%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.status-badge-error {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
background: color-mix(in oklch, var(--color-destructive) 10%, transparent);
|
||||
color: var(--color-destructive);
|
||||
}
|
||||
|
||||
.status-badge-info {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
background: color-mix(in oklch, var(--color-primary) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { Lock, User, LayoutDashboard, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { Lock, User, LayoutDashboard, AlertCircle, Loader2, Sparkles, Zap, Shield } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -21,13 +21,11 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
await apiClient.login({ username, password })
|
||||
// For now, just set a basic user object (backend doesn't return user info)
|
||||
setUser({ id: 1, username })
|
||||
navigate('/upload')
|
||||
} catch (err: any) {
|
||||
const errorDetail = err.response?.data?.detail
|
||||
if (Array.isArray(errorDetail)) {
|
||||
// Handle validation error array from backend
|
||||
setError(errorDetail.map((e: any) => e.msg || e.message || String(e)).join(', '))
|
||||
} else if (typeof errorDetail === 'string') {
|
||||
setError(errorDetail)
|
||||
@@ -40,197 +38,194 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Branding (hidden on mobile) */}
|
||||
<div className="hidden lg:flex lg:flex-1 bg-gradient-to-br from-primary via-primary/90 to-accent relative overflow-hidden">
|
||||
{/* Subtle background pattern */}
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
{/* Full-screen Animated Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-600 via-purple-600 to-pink-500">
|
||||
{/* Animated overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/50 via-transparent to-purple-500/50 animate-pulse"></div>
|
||||
|
||||
{/* Floating orbs */}
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-white/10 rounded-full blur-3xl animate-float"></div>
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl animate-float" style={{ animationDelay: '1s' }}></div>
|
||||
<div className="absolute top-1/2 left-1/2 w-64 h-64 bg-purple-400/10 rounded-full blur-3xl animate-float" style={{ animationDelay: '0.5s' }}></div>
|
||||
|
||||
{/* Grid pattern */}
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 left-0 w-full h-full"
|
||||
style={{ backgroundImage: 'radial-gradient(circle at 2px 2px, white 1px, transparent 0)', backgroundSize: '32px 32px' }}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col justify-center px-16 py-12 text-white">
|
||||
{/* Logo & Title */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-14 h-14 bg-white/20 backdrop-blur-sm rounded-xl flex items-center justify-center">
|
||||
<LayoutDashboard className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{t('app.title')}</h1>
|
||||
<p className="text-white/80 text-sm mt-1">{t('app.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature highlights */}
|
||||
<div className="space-y-6 mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-6">智能文件識別系統</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">高精度識別</h3>
|
||||
<p className="text-white/70 text-sm">支援 10+ 種文件格式,識別準確率達 99%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">快速處理</h3>
|
||||
<p className="text-white/70 text-sm">批量處理大量文件,節省時間成本</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">安全可靠</h3>
|
||||
<p className="text-white/70 text-sm">企業級安全保障,數據加密傳輸</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
||||
<div className="text-3xl font-bold mb-1">99%</div>
|
||||
<div className="text-white/70 text-sm">識別準確率</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
||||
<div className="text-3xl font-bold mb-1">10+</div>
|
||||
<div className="text-white/70 text-sm">支援格式</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-4">
|
||||
<div className="text-3xl font-bold mb-1">1M+</div>
|
||||
<div className="text-white/70 text-sm">處理文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0" style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px)', backgroundSize: '50px 50px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Login form */}
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-background">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile logo (shown only on small screens) */}
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<div className="inline-flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 bg-primary/20 rounded-lg flex items-center justify-center">
|
||||
<LayoutDashboard className="w-6 h-6 text-primary" />
|
||||
{/* Content Container */}
|
||||
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-6xl flex flex-col lg:flex-row gap-8 items-center">
|
||||
|
||||
{/* Left Side - Branding */}
|
||||
<div className="flex-1 text-white text-center lg:text-left animate-fade-in">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center lg:justify-start gap-4 mb-8">
|
||||
<div className="w-16 h-16 bg-white/20 backdrop-blur-md rounded-2xl flex items-center justify-center shadow-2xl animate-float">
|
||||
<LayoutDashboard className="w-10 h-10" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">Tool_OCR</h1>
|
||||
<p className="text-white/80 text-sm mt-1">智能 OCR 處理平台</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features - Only show on larger screens */}
|
||||
<div className="hidden lg:block space-y-6 mb-12 animate-slide-in-left" style={{ animationDelay: '0.1s' }}>
|
||||
<h2 className="text-3xl font-bold mb-8">為什麼選擇我們?</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-xl mb-1">高精度識別</h3>
|
||||
<p className="text-white/70">AI 驅動的 OCR 引擎,識別準確率高達 99%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Zap className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-xl mb-1">閃電般快速</h3>
|
||||
<p className="text-white/70">批量處理數百份文件,大幅提升工作效率</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 group hover:translate-x-2 transition-transform">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-white/20 to-white/10 backdrop-blur-sm rounded-xl flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform">
|
||||
<Shield className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-xl mb-1">安全可靠</h3>
|
||||
<p className="text-white/70">企業級加密,確保您的資料絕對安全</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden lg:grid grid-cols-3 gap-4 animate-slide-in-left" style={{ animationDelay: '0.2s' }}>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||
<div className="text-4xl font-bold mb-2">99%</div>
|
||||
<div className="text-white/70 text-sm">準確率</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||
<div className="text-4xl font-bold mb-2">10+</div>
|
||||
<div className="text-white/70 text-sm">支援格式</div>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 hover:bg-white/20 hover:scale-105 transition-all cursor-pointer border border-white/20">
|
||||
<div className="text-4xl font-bold mb-2">24/7</div>
|
||||
<div className="text-white/70 text-sm">全天候</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">{t('app.title')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('app.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div className="bg-card rounded-xl border border-border p-8 shadow-lg">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">{t('auth.loginButton')}</h2>
|
||||
<p className="text-sm text-muted-foreground">請輸入您的帳號資訊以繼續</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="block text-sm font-medium text-foreground">
|
||||
{t('auth.username')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg
|
||||
text-foreground placeholder-muted-foreground
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary
|
||||
transition-colors"
|
||||
placeholder="輸入用戶名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/* Right Side - Login Form */}
|
||||
<div className="w-full lg:w-auto lg:min-w-[480px] animate-scale-in">
|
||||
<div className="bg-white/95 backdrop-blur-xl rounded-3xl p-8 md:p-10 shadow-2xl border border-white/20">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">歡迎回來</h2>
|
||||
<p className="text-gray-600">登入以開始使用 OCR 服務</p>
|
||||
</div>
|
||||
|
||||
{/* Password field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-foreground">
|
||||
{t('auth.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-muted-foreground" />
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Username */}
|
||||
<div className="space-y-2 animate-slide-in-right" style={{ animationDelay: '0.1s' }}>
|
||||
<label htmlFor="username" className="block text-sm font-semibold text-gray-700">
|
||||
使用者名稱
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-gray-50 border-2 border-gray-200 rounded-xl
|
||||
text-gray-900 placeholder-gray-400
|
||||
focus:outline-none focus:ring-4 focus:ring-blue-500/20 focus:border-blue-500
|
||||
hover:border-gray-300
|
||||
transition-all duration-200"
|
||||
placeholder="輸入您的用戶名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg
|
||||
text-foreground placeholder-muted-foreground
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary
|
||||
transition-colors"
|
||||
placeholder="輸入密碼"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-destructive/10 border border-destructive/30 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{/* Password */}
|
||||
<div className="space-y-2 animate-slide-in-right" style={{ animationDelay: '0.2s' }}>
|
||||
<label htmlFor="password" className="block text-sm font-semibold text-gray-700">
|
||||
密碼
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400 group-focus-within:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-11 pr-4 py-3 bg-gray-50 border-2 border-gray-200 rounded-xl
|
||||
text-gray-900 placeholder-gray-400
|
||||
focus:outline-none focus:ring-4 focus:ring-blue-500/20 focus:border-blue-500
|
||||
hover:border-gray-300
|
||||
transition-all duration-200"
|
||||
placeholder="輸入您的密碼"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-6 bg-primary text-white rounded-lg font-semibold
|
||||
shadow-md hover:bg-primary-hover hover:shadow-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-all duration-200
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>{t('common.loading')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t('auth.loginButton')}</span>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 bg-red-50 border-2 border-red-200 rounded-xl animate-scale-in">
|
||||
<AlertCircle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Powered by <span className="font-semibold text-foreground">AI Technology</span>
|
||||
</p>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 px-6 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl font-bold text-lg
|
||||
shadow-lg shadow-blue-500/50 hover:shadow-xl hover:shadow-blue-500/60 hover:scale-[1.02]
|
||||
focus:outline-none focus:ring-4 focus:ring-blue-500/50
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
||||
transition-all duration-200
|
||||
relative overflow-hidden group
|
||||
animate-slide-in-right"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-700 to-purple-700 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
<span className="relative flex items-center justify-center gap-2">
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<span>登入中...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>立即登入</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center animate-fade-in" style={{ animationDelay: '0.4s' }}>
|
||||
<p className="text-xs text-gray-500">
|
||||
Powered by <span className="font-bold text-gray-700">AI Technology</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user