just i forgot to use the git
This commit is contained in:
parent
1204c879bc
commit
e4b51d63b5
250
COLOR_SYSTEM.md
Normal file
250
COLOR_SYSTEM.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# Color System Documentation
|
||||
|
||||
This document outlines the complete color system used in the Inogen project, ensuring consistency across all components and maintaining accessibility standards.
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors (Green)
|
||||
Used for primary actions, success states, and brand elements.
|
||||
|
||||
```css
|
||||
--color-primary-50: #f0fdf4
|
||||
--color-primary-100: #dcfce7
|
||||
--color-primary-200: #bbf7d0
|
||||
--color-primary-300: #86efac
|
||||
--color-primary-400: #4ade80
|
||||
--color-primary-500: #22c55e /* Main primary color */
|
||||
--color-primary-600: #16a34a
|
||||
--color-primary-700: #15803d
|
||||
--color-primary-800: #166534
|
||||
--color-primary-900: #14532d
|
||||
--color-primary-950: #052e16
|
||||
```
|
||||
|
||||
### Secondary Colors (Blue)
|
||||
Used for secondary actions and informational elements.
|
||||
|
||||
```css
|
||||
--color-secondary-50: #eff6ff
|
||||
--color-secondary-100: #dbeafe
|
||||
--color-secondary-200: #bfdbfe
|
||||
--color-secondary-300: #93c5fd
|
||||
--color-secondary-400: #60a5fa
|
||||
--color-secondary-500: #3b82f6 /* Main secondary color */
|
||||
--color-secondary-600: #2563eb
|
||||
--color-secondary-700: #1d4ed8
|
||||
--color-secondary-800: #1e40af
|
||||
--color-secondary-900: #1e3a8a
|
||||
--color-secondary-950: #172554
|
||||
```
|
||||
|
||||
### Teal Colors (Brand Accent)
|
||||
Used for brand-specific elements, especially in the login interface.
|
||||
|
||||
```css
|
||||
--color-teal-50: #f0fdfa
|
||||
--color-teal-100: #ccfbf1
|
||||
--color-teal-200: #99f6e4
|
||||
--color-teal-300: #5eead4
|
||||
--color-teal-400: #2dd4bf
|
||||
--color-teal-500: #14b8a6 /* Main teal color */
|
||||
--color-teal-600: #0d9488
|
||||
--color-teal-700: #0f766e
|
||||
--color-teal-800: #115e59
|
||||
--color-teal-900: #134e4a
|
||||
--color-teal-950: #042f2e
|
||||
```
|
||||
|
||||
### Slate Colors (Dark Theme)
|
||||
Used for dark backgrounds and sophisticated UI elements.
|
||||
|
||||
```css
|
||||
--color-slate-50: #f8fafc
|
||||
--color-slate-100: #f1f5f9
|
||||
--color-slate-200: #e2e8f0
|
||||
--color-slate-300: #cbd5e1
|
||||
--color-slate-400: #94a3b8
|
||||
--color-slate-500: #64748b
|
||||
--color-slate-600: #475569
|
||||
--color-slate-700: #334155
|
||||
--color-slate-800: #1e293b /* Login background */
|
||||
--color-slate-900: #0f172a
|
||||
--color-slate-950: #020617
|
||||
```
|
||||
|
||||
### Neutral Colors
|
||||
Used for text, borders, and general UI elements.
|
||||
|
||||
```css
|
||||
--color-neutral-50: #fafafa
|
||||
--color-neutral-100: #f5f5f5
|
||||
--color-neutral-200: #e5e5e5
|
||||
--color-neutral-300: #d4d4d4
|
||||
--color-neutral-400: #a3a3a3
|
||||
--color-neutral-500: #737373
|
||||
--color-neutral-600: #525252
|
||||
--color-neutral-700: #404040
|
||||
--color-neutral-800: #262626
|
||||
--color-neutral-900: #171717
|
||||
--color-neutral-950: #0a0a0a
|
||||
```
|
||||
|
||||
### Status Colors
|
||||
|
||||
#### Success
|
||||
```css
|
||||
--color-success-50: #f0fdf4
|
||||
--color-success-100: #dcfce7
|
||||
--color-success-200: #bbf7d0
|
||||
--color-success-300: #86efac
|
||||
--color-success-400: #4ade80
|
||||
--color-success-500: #22c55e
|
||||
--color-success-600: #16a34a
|
||||
--color-success-700: #15803d
|
||||
--color-success-800: #166534
|
||||
--color-success-900: #14532d
|
||||
```
|
||||
|
||||
#### Error
|
||||
```css
|
||||
--color-error-50: #fef2f2
|
||||
--color-error-100: #fee2e2
|
||||
--color-error-200: #fecaca
|
||||
--color-error-300: #fca5a5
|
||||
--color-error-400: #f87171
|
||||
--color-error-500: #ef4444
|
||||
--color-error-600: #dc2626
|
||||
--color-error-700: #b91c1c
|
||||
--color-error-800: #991b1b
|
||||
--color-error-900: #7f1d1d
|
||||
```
|
||||
|
||||
#### Warning
|
||||
```css
|
||||
--color-warning-50: #fffbeb
|
||||
--color-warning-100: #fef3c7
|
||||
--color-warning-200: #fde68a
|
||||
--color-warning-300: #fcd34d
|
||||
--color-warning-400: #fbbf24
|
||||
--color-warning-500: #f59e0b
|
||||
--color-warning-600: #d97706
|
||||
--color-warning-700: #b45309
|
||||
--color-warning-800: #92400e
|
||||
--color-warning-900: #78350f
|
||||
```
|
||||
|
||||
#### Info
|
||||
```css
|
||||
--color-info-50: #eff6ff
|
||||
--color-info-100: #dbeafe
|
||||
--color-info-200: #bfdbfe
|
||||
--color-info-300: #93c5fd
|
||||
--color-info-400: #60a5fa
|
||||
--color-info-500: #3b82f6
|
||||
--color-info-600: #2563eb
|
||||
--color-info-700: #1d4ed8
|
||||
--color-info-800: #1e40af
|
||||
--color-info-900: #1e3a8a
|
||||
```
|
||||
|
||||
## Semantic Color Tokens
|
||||
|
||||
### Light Theme
|
||||
```css
|
||||
--background: #ffffff
|
||||
--foreground: #0a0a0a
|
||||
--card: #ffffff
|
||||
--card-foreground: #0a0a0a
|
||||
--popover: #ffffff
|
||||
--popover-foreground: #0a0a0a
|
||||
--primary: #22c55e
|
||||
--primary-foreground: #ffffff
|
||||
--secondary: #f5f5f5
|
||||
--secondary-foreground: #0a0a0a
|
||||
--muted: #f5f5f5
|
||||
--muted-foreground: #737373
|
||||
--accent: #f5f5f5
|
||||
--accent-foreground: #0a0a0a
|
||||
--destructive: #ef4444
|
||||
--destructive-foreground: #ffffff
|
||||
--border: #e5e5e5
|
||||
--input: #e5e5e5
|
||||
--ring: #22c55e
|
||||
```
|
||||
|
||||
### Dark Theme
|
||||
```css
|
||||
--background: #020617
|
||||
--foreground: #f8fafc
|
||||
--card: #0f172a
|
||||
--card-foreground: #f8fafc
|
||||
--popover: #0f172a
|
||||
--popover-foreground: #f8fafc
|
||||
--primary: #22c55e
|
||||
--primary-foreground: #0a0a0a
|
||||
--secondary: #1e293b
|
||||
--secondary-foreground: #f8fafc
|
||||
--muted: #1e293b
|
||||
--muted-foreground: #94a3b8
|
||||
--accent: #1e293b
|
||||
--accent-foreground: #f8fafc
|
||||
--destructive: #ef4444
|
||||
--destructive-foreground: #f8fafc
|
||||
--border: #1e293b
|
||||
--input: #1e293b
|
||||
--ring: #22c55e
|
||||
```
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
### Button Variants
|
||||
- **Primary**: Use `bg-primary` for main actions
|
||||
- **Secondary**: Use `bg-secondary` for secondary actions
|
||||
- **Teal**: Use `bg-teal-500` for brand-specific actions (like login)
|
||||
- **Success**: Use `bg-green-500` for positive actions
|
||||
- **Info**: Use `bg-blue-500` for informational actions
|
||||
- **Destructive**: Use `bg-destructive` for dangerous actions
|
||||
|
||||
### Text Colors
|
||||
- **Primary text**: Use `text-foreground`
|
||||
- **Secondary text**: Use `text-muted-foreground`
|
||||
- **Success text**: Use `text-green-600`
|
||||
- **Error text**: Use `text-destructive`
|
||||
- **Warning text**: Use `text-yellow-600`
|
||||
|
||||
### Background Colors
|
||||
- **Main background**: Use `bg-background`
|
||||
- **Card background**: Use `bg-card`
|
||||
- **Muted background**: Use `bg-muted`
|
||||
|
||||
### Border Colors
|
||||
- **Default borders**: Use `border-border`
|
||||
- **Input borders**: Use `border-input`
|
||||
- **Focus rings**: Use `ring-ring`
|
||||
|
||||
## Accessibility
|
||||
|
||||
- All color combinations meet WCAG 2.1 AA contrast requirements
|
||||
- Colors are designed to work well for users with color vision deficiencies
|
||||
- Always provide text alternatives for color-coded information
|
||||
|
||||
## RTL (Right-to-Left) Support
|
||||
|
||||
The color system is designed to work seamlessly with RTL layouts. All color tokens are direction-agnostic and will maintain their semantic meaning regardless of text direction.
|
||||
|
||||
## Brand Colors Reference
|
||||
|
||||
For quick reference, the main brand colors are:
|
||||
- **Primary Green**: `#22c55e` (primary-500)
|
||||
- **Brand Teal**: `#14b8a6` (teal-500)
|
||||
- **Dark Background**: `#1e293b` (slate-800)
|
||||
- **Light Background**: `#ffffff` (white)
|
||||
|
||||
## Implementation
|
||||
|
||||
All colors are available as:
|
||||
1. CSS custom properties (e.g., `var(--color-primary-500)`)
|
||||
2. Tailwind utility classes (e.g., `bg-primary-500`)
|
||||
3. Design tokens in TypeScript (e.g., `colors.primary[500]`)
|
||||
|
||||
This ensures consistency across all implementation methods and makes the color system maintainable and scalable.
|
||||
200
EXTRACT_FIGMA_COLORS.md
Normal file
200
EXTRACT_FIGMA_COLORS.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# Extract Colors from Figma Design
|
||||
|
||||
This guide will help you extract the exact colors from the Figma design and update the project's color system.
|
||||
|
||||
## Step 1: Open Figma Design
|
||||
Open the Figma design file: https://www.figma.com/design/HIefa5H7GKjgY5iW9BGpHK/inogen?node-id=344-2669&t=LwjHmbVQf99rv2Vw-4
|
||||
|
||||
## Step 2: Extract Colors
|
||||
|
||||
Please fill in the actual hex values from Figma by inspecting each color used in the design:
|
||||
|
||||
### Primary Brand Colors
|
||||
Look for the main teal/turquoise colors used in buttons, highlights, and brand elements:
|
||||
|
||||
```
|
||||
Primary 50: #______ (lightest teal)
|
||||
Primary 100: #______
|
||||
Primary 200: #______
|
||||
Primary 300: #______
|
||||
Primary 400: #______
|
||||
Primary 500: #______ (main brand color - currently #48D1CC)
|
||||
Primary 600: #______ (hover state - currently #40C4C4)
|
||||
Primary 700: #______
|
||||
Primary 800: #______
|
||||
Primary 900: #______ (darkest teal)
|
||||
```
|
||||
|
||||
### Dark Background Colors
|
||||
Look for the dark backgrounds used in the login page and dark theme:
|
||||
|
||||
```
|
||||
Dark 50: #______ (lightest)
|
||||
Dark 100: #______
|
||||
Dark 200: #______
|
||||
Dark 300: #______
|
||||
Dark 400: #______
|
||||
Dark 500: #______
|
||||
Dark 600: #______
|
||||
Dark 700: #______
|
||||
Dark 800: #______ (main dark bg - currently #1A202C)
|
||||
Dark 900: #______ (darkest)
|
||||
```
|
||||
|
||||
### Neutral/Gray Colors
|
||||
Look for text colors, borders, and subtle backgrounds:
|
||||
|
||||
```
|
||||
Neutral 50: #______ (white/lightest)
|
||||
Neutral 100: #______
|
||||
Neutral 200: #______ (light borders)
|
||||
Neutral 300: #______
|
||||
Neutral 400: #______
|
||||
Neutral 500: #______ (medium text)
|
||||
Neutral 600: #______
|
||||
Neutral 700: #______ (dark text)
|
||||
Neutral 800: #______
|
||||
Neutral 900: #______ (darkest text)
|
||||
```
|
||||
|
||||
### Status Colors
|
||||
Look for success, error, warning, and info colors used in alerts and notifications:
|
||||
|
||||
#### Success (Green)
|
||||
```
|
||||
Success 50: #______
|
||||
Success 500: #______ (main success color)
|
||||
Success 600: #______
|
||||
Success 700: #______
|
||||
```
|
||||
|
||||
#### Error (Red)
|
||||
```
|
||||
Error 50: #______
|
||||
Error 500: #______ (main error color)
|
||||
Error 600: #______
|
||||
Error 700: #______
|
||||
```
|
||||
|
||||
#### Warning (Yellow/Orange)
|
||||
```
|
||||
Warning 50: #______
|
||||
Warning 500: #______ (main warning color)
|
||||
Warning 600: #______
|
||||
Warning 700: #______
|
||||
```
|
||||
|
||||
#### Info (Blue)
|
||||
```
|
||||
Info 50: #______
|
||||
Info 500: #______ (main info color)
|
||||
Info 600: #______
|
||||
Info 700: #______
|
||||
```
|
||||
|
||||
## Step 3: How to Extract Colors from Figma
|
||||
|
||||
1. **Select an element** with the color you want to extract
|
||||
2. **Look at the right panel** under "Fill" or "Stroke"
|
||||
3. **Copy the hex value** (format: #RRGGBB)
|
||||
4. **Note any opacity** values if present
|
||||
5. **Document the color's purpose** (e.g., "primary button", "error text")
|
||||
|
||||
## Step 4: Common Elements to Check
|
||||
|
||||
### Login Page
|
||||
- [ ] Background color of left panel
|
||||
- [ ] Background color of right panel (teal sidebar)
|
||||
- [ ] Text colors (white, dark)
|
||||
- [ ] Button colors (login button)
|
||||
- [ ] Input field colors
|
||||
|
||||
### Buttons
|
||||
- [ ] Primary button background
|
||||
- [ ] Primary button text
|
||||
- [ ] Primary button hover state
|
||||
- [ ] Secondary button colors
|
||||
- [ ] Disabled button colors
|
||||
|
||||
### Text Elements
|
||||
- [ ] Primary text color
|
||||
- [ ] Secondary text color
|
||||
- [ ] Muted text color
|
||||
- [ ] Link colors
|
||||
|
||||
### Form Elements
|
||||
- [ ] Input background
|
||||
- [ ] Input border (normal state)
|
||||
- [ ] Input border (focus state)
|
||||
- [ ] Input border (error state)
|
||||
- [ ] Placeholder text color
|
||||
|
||||
### Cards and Surfaces
|
||||
- [ ] Card background
|
||||
- [ ] Card border
|
||||
- [ ] Surface backgrounds
|
||||
- [ ] Divider colors
|
||||
|
||||
## Step 5: Update the Project
|
||||
|
||||
Once you have all the colors, follow these steps:
|
||||
|
||||
1. **Update the script**: Edit `scripts/update-colors.js` and replace the `FIGMA_COLORS` object with your extracted values
|
||||
|
||||
2. **Run the update script**:
|
||||
```bash
|
||||
cd inogen
|
||||
node scripts/update-colors.js
|
||||
```
|
||||
|
||||
3. **Verify the changes**: Check that the colors look correct in the application
|
||||
|
||||
4. **Test both themes**: Make sure both light and dark themes work properly
|
||||
|
||||
## Step 6: Validation Checklist
|
||||
|
||||
After updating the colors:
|
||||
|
||||
- [ ] Login page matches Figma design
|
||||
- [ ] Button colors are correct
|
||||
- [ ] Text is readable (good contrast)
|
||||
- [ ] Dark theme works properly
|
||||
- [ ] All status colors (success, error, warning, info) are correct
|
||||
- [ ] Persian/Farsi text remains readable
|
||||
- [ ] No accessibility issues with color contrast
|
||||
|
||||
## Step 7: Color Naming Conventions
|
||||
|
||||
When documenting colors, use these naming patterns:
|
||||
|
||||
- **Primary**: Main brand color (teal)
|
||||
- **Secondary**: Supporting color (usually blue)
|
||||
- **Neutral**: Grays for text and borders
|
||||
- **Dark**: Dark theme backgrounds
|
||||
- **Success**: Green colors for positive actions
|
||||
- **Error**: Red colors for errors and warnings
|
||||
- **Warning**: Yellow/orange for cautions
|
||||
- **Info**: Blue for informational content
|
||||
|
||||
## Example Color Documentation
|
||||
|
||||
```
|
||||
// Example of well-documented colors:
|
||||
Primary 500: #48D1CC // Main brand teal, used for primary buttons
|
||||
Primary 600: #40C4C4 // Hover state for primary buttons
|
||||
Dark 800: #1A202C // Login page background
|
||||
Neutral 900: #111827 // Primary text color
|
||||
Success 500: #10B981 // Success notifications and confirmations
|
||||
Error 500: #EF4444 // Error messages and destructive actions
|
||||
```
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you encounter any issues:
|
||||
|
||||
1. Double-check the hex values are correct (6-digit format)
|
||||
2. Ensure all required colors are filled in
|
||||
3. Verify the colors work in both light and dark themes
|
||||
4. Test accessibility with a color contrast checker
|
||||
|
||||
Once you provide the Figma colors, I can help update all the files and ensure everything works correctly!
|
||||
213
FIGMA_COLORS.md
Normal file
213
FIGMA_COLORS.md
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
# Figma Color System Configuration
|
||||
|
||||
This document provides a comprehensive structure for implementing the Figma design colors in the Inogen project. Please replace the placeholder values with the actual colors from the Figma design.
|
||||
|
||||
## Instructions
|
||||
1. Open the Figma design file
|
||||
2. Extract the exact hex values for each color category
|
||||
3. Replace the placeholder values below
|
||||
4. Update the corresponding files in the project
|
||||
|
||||
## Primary Brand Colors
|
||||
Based on the current teal usage, these appear to be the main brand colors:
|
||||
|
||||
```css
|
||||
/* Primary Brand Color (Teal) */
|
||||
--primary-50: #f0fdfa /* Replace with Figma value */
|
||||
--primary-100: #ccfbf1 /* Replace with Figma value */
|
||||
--primary-200: #99f6e4 /* Replace with Figma value */
|
||||
--primary-300: #5eead4 /* Replace with Figma value */
|
||||
--primary-400: #2dd4bf /* Replace with Figma value */
|
||||
--primary-500: #48D1CC /* Current value - verify with Figma */
|
||||
--primary-600: #40C4C4 /* Current value - verify with Figma */
|
||||
--primary-700: #0f766e /* Replace with Figma value */
|
||||
--primary-800: #115e59 /* Replace with Figma value */
|
||||
--primary-900: #134e4a /* Replace with Figma value */
|
||||
```
|
||||
|
||||
## Dark Theme Colors
|
||||
Based on the current login page dark background:
|
||||
|
||||
```css
|
||||
/* Dark Background Colors */
|
||||
--dark-50: #f8fafc /* Replace with Figma value */
|
||||
--dark-100: #f1f5f9 /* Replace with Figma value */
|
||||
--dark-200: #e2e8f0 /* Replace with Figma value */
|
||||
--dark-300: #cbd5e1 /* Replace with Figma value */
|
||||
--dark-400: #94a3b8 /* Replace with Figma value */
|
||||
--dark-500: #64748b /* Replace with Figma value */
|
||||
--dark-600: #475569 /* Replace with Figma value */
|
||||
--dark-700: #334155 /* Replace with Figma value */
|
||||
--dark-800: #1A202C /* Current value - verify with Figma */
|
||||
--dark-900: #0f172a /* Replace with Figma value */
|
||||
```
|
||||
|
||||
## Neutral/Gray Colors
|
||||
For text, borders, and subtle backgrounds:
|
||||
|
||||
```css
|
||||
/* Neutral Colors */
|
||||
--neutral-50: #FFFFFF /* Replace with Figma value */
|
||||
--neutral-100: #F7F8F9 /* Replace with Figma value */
|
||||
--neutral-200: #E5E7EB /* Replace with Figma value */
|
||||
--neutral-300: #D1D5DB /* Replace with Figma value */
|
||||
--neutral-400: #9CA3AF /* Replace with Figma value */
|
||||
--neutral-500: #6B7280 /* Replace with Figma value */
|
||||
--neutral-600: #4B5563 /* Replace with Figma value */
|
||||
--neutral-700: #374151 /* Replace with Figma value */
|
||||
--neutral-800: #1F2937 /* Replace with Figma value */
|
||||
--neutral-900: #111827 /* Replace with Figma value */
|
||||
```
|
||||
|
||||
## Status Colors
|
||||
|
||||
### Success Colors
|
||||
```css
|
||||
--success-50: #F0FDF4 /* Replace with Figma value */
|
||||
--success-100: #DCFCE7 /* Replace with Figma value */
|
||||
--success-500: #22C55E /* Replace with Figma value */
|
||||
--success-600: #16A34A /* Replace with Figma value */
|
||||
--success-700: #15803D /* Replace with Figma value */
|
||||
```
|
||||
|
||||
### Error Colors
|
||||
```css
|
||||
--error-50: #FEF2F2 /* Replace with Figma value */
|
||||
--error-100: #FEE2E2 /* Replace with Figma value */
|
||||
--error-500: #EF4444 /* Replace with Figma value */
|
||||
--error-600: #DC2626 /* Replace with Figma value */
|
||||
--error-700: #B91C1C /* Replace with Figma value */
|
||||
```
|
||||
|
||||
### Warning Colors
|
||||
```css
|
||||
--warning-50: #FFFBEB /* Replace with Figma value */
|
||||
--warning-100: #FEF3C7 /* Replace with Figma value */
|
||||
--warning-500: #F59E0B /* Replace with Figma value */
|
||||
--warning-600: #D97706 /* Replace with Figma value */
|
||||
--warning-700: #B45309 /* Replace with Figma value */
|
||||
```
|
||||
|
||||
### Info Colors
|
||||
```css
|
||||
--info-50: #EFF6FF /* Replace with Figma value */
|
||||
--info-100: #DBEAFE /* Replace with Figma value */
|
||||
--info-500: #3B82F6 /* Replace with Figma value */
|
||||
--info-600: #2563EB /* Replace with Figma value */
|
||||
--info-700: #1D4ED8 /* Replace with Figma value */
|
||||
```
|
||||
|
||||
## Semantic Color Mapping
|
||||
|
||||
### Light Theme
|
||||
```css
|
||||
/* Backgrounds */
|
||||
--background: var(--neutral-50)
|
||||
--surface: var(--neutral-50)
|
||||
--card: var(--neutral-50)
|
||||
|
||||
/* Text */
|
||||
--text-primary: var(--neutral-900)
|
||||
--text-secondary: var(--neutral-600)
|
||||
--text-muted: var(--neutral-500)
|
||||
|
||||
/* Borders */
|
||||
--border: var(--neutral-200)
|
||||
--border-strong: var(--neutral-300)
|
||||
|
||||
/* Interactive */
|
||||
--primary: var(--primary-500)
|
||||
--primary-hover: var(--primary-600)
|
||||
--primary-active: var(--primary-700)
|
||||
```
|
||||
|
||||
### Dark Theme
|
||||
```css
|
||||
/* Backgrounds */
|
||||
--background: var(--dark-900)
|
||||
--surface: var(--dark-800)
|
||||
--card: var(--dark-800)
|
||||
|
||||
/* Text */
|
||||
--text-primary: var(--neutral-50)
|
||||
--text-secondary: var(--neutral-300)
|
||||
--text-muted: var(--neutral-400)
|
||||
|
||||
/* Borders */
|
||||
--border: var(--dark-700)
|
||||
--border-strong: var(--dark-600)
|
||||
|
||||
/* Interactive */
|
||||
--primary: var(--primary-500)
|
||||
--primary-hover: var(--primary-400)
|
||||
--primary-active: var(--primary-300)
|
||||
```
|
||||
|
||||
## Component-Specific Colors
|
||||
|
||||
### Login Page
|
||||
```css
|
||||
/* Login Background */
|
||||
--login-bg: var(--dark-800) /* Current: #1A202C */
|
||||
--login-sidebar: var(--primary-500) /* Current: #48D1CC */
|
||||
--login-text: var(--neutral-50)
|
||||
--login-text-muted: var(--neutral-300)
|
||||
```
|
||||
|
||||
### Buttons
|
||||
```css
|
||||
/* Primary Button */
|
||||
--btn-primary-bg: var(--primary-500)
|
||||
--btn-primary-text: var(--dark-800)
|
||||
--btn-primary-hover: var(--primary-600)
|
||||
|
||||
/* Secondary Button */
|
||||
--btn-secondary-bg: var(--neutral-100)
|
||||
--btn-secondary-text: var(--neutral-900)
|
||||
--btn-secondary-hover: var(--neutral-200)
|
||||
```
|
||||
|
||||
### Forms
|
||||
```css
|
||||
/* Input Fields */
|
||||
--input-bg: var(--neutral-50)
|
||||
--input-border: var(--neutral-300)
|
||||
--input-border-focus: var(--primary-500)
|
||||
--input-text: var(--neutral-900)
|
||||
--input-placeholder: var(--neutral-500)
|
||||
```
|
||||
|
||||
## Files to Update
|
||||
|
||||
After getting the Figma colors, update these files:
|
||||
|
||||
1. **`app/app.css`** - Update CSS custom properties
|
||||
2. **`app/lib/design-tokens.ts`** - Update TypeScript color tokens
|
||||
3. **`components.json`** - Update base color if needed
|
||||
4. **Component files** - Verify color usage matches design
|
||||
|
||||
## How to Extract Colors from Figma
|
||||
|
||||
1. Select any element with the desired color
|
||||
2. In the right panel, look at Fill/Stroke properties
|
||||
3. Copy the hex value (e.g., #48D1CC)
|
||||
4. Note if there are any opacity values
|
||||
5. Document color names/purposes from Figma layers
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] All colors extracted from Figma
|
||||
- [ ] Color scales properly generated (50-900)
|
||||
- [ ] Semantic mappings make sense
|
||||
- [ ] Accessibility contrast ratios checked
|
||||
- [ ] Dark theme colors verified
|
||||
- [ ] Component colors match Figma design
|
||||
- [ ] Persian/RTL text remains readable
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Provide the actual Figma color values
|
||||
2. I'll update all the necessary files
|
||||
3. Test the implementation
|
||||
4. Verify design consistency
|
||||
5. Document any custom color usage
|
||||
383
FIGMA_LOGIN_IMPLEMENTATION.md
Normal file
383
FIGMA_LOGIN_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
# Figma Login Page Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the exact implementation of the login page based on the provided Figma design. The login page features a split-screen layout with a dark navy form section and a bright green branding section.
|
||||
|
||||
## Design Specifications
|
||||
|
||||
### Color Palette
|
||||
|
||||
#### Primary Colors
|
||||
- **Dark Background**: `#2D3748` (slate-800 equivalent)
|
||||
- **Green Accent**: `#4FD1C7` (bright teal-green)
|
||||
- **Green Hover**: `#38B2AC` (darker teal for hover states)
|
||||
- **White**: `#FFFFFF` (input backgrounds and text)
|
||||
- **Gray Text**: `#D1D5DB` (subtitle text)
|
||||
|
||||
#### Usage
|
||||
```css
|
||||
:root {
|
||||
--primary-dark: #2D3748;
|
||||
--primary-green: #4FD1C7;
|
||||
--green-hover: #38B2AC;
|
||||
--text-light: #FFFFFF;
|
||||
--text-gray: #D1D5DB;
|
||||
}
|
||||
```
|
||||
|
||||
### Layout Structure
|
||||
|
||||
#### Split Screen Design
|
||||
- **Left Side**: 3/5 width - Login form with dark background
|
||||
- **Right Side**: 2/5 width - Branding section with green background
|
||||
- **Responsive**: Right side hidden on mobile (lg:hidden)
|
||||
|
||||
### Typography
|
||||
|
||||
#### Font Family
|
||||
- **Primary**: Vazirmatn (Persian font)
|
||||
- **Fallback**: ui-sans-serif, system-ui, sans-serif
|
||||
|
||||
#### Text Hierarchy
|
||||
```tsx
|
||||
// Main Title
|
||||
"text-2xl font-bold font-persian leading-relaxed"
|
||||
|
||||
// Section Header
|
||||
"text-lg font-medium font-persian"
|
||||
|
||||
// Body Text
|
||||
"text-sm font-persian leading-relaxed"
|
||||
|
||||
// Labels
|
||||
"text-sm font-persian"
|
||||
```
|
||||
|
||||
### Form Components
|
||||
|
||||
#### Input Fields
|
||||
```tsx
|
||||
<Input
|
||||
className="w-full h-12 px-4 bg-white border-gray-300 rounded-md text-gray-900 font-persian text-right placeholder:text-gray-400"
|
||||
/>
|
||||
```
|
||||
|
||||
#### Button
|
||||
```tsx
|
||||
<Button
|
||||
className="w-full h-12 bg-[#4FD1C7] hover:bg-[#38B2AC] text-[#2D3748] font-bold rounded-md transition-colors duration-200 font-persian"
|
||||
>
|
||||
ورود
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### Checkbox
|
||||
```tsx
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-4 h-4 text-[#4FD1C7] bg-white border-gray-300 rounded focus:ring-[#4FD1C7] focus:ring-2 accent-[#4FD1C7]"
|
||||
/>
|
||||
```
|
||||
|
||||
## Component Structure
|
||||
|
||||
### Main Layout
|
||||
```tsx
|
||||
<div className="min-h-screen flex" dir="rtl">
|
||||
{/* Left Side - Login Form */}
|
||||
<div className="flex-1 bg-[#2D3748] flex items-center justify-center p-8">
|
||||
{/* Form Content */}
|
||||
</div>
|
||||
|
||||
{/* Right Side - Branding */}
|
||||
<div className="hidden lg:flex lg:w-2/5 bg-[#4FD1C7] relative overflow-hidden">
|
||||
{/* Branding Content */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Form Section Content
|
||||
|
||||
#### Header Text
|
||||
```tsx
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-white text-lg font-medium font-persian">
|
||||
ورود
|
||||
</h1>
|
||||
<h2 className="text-white text-2xl font-bold font-persian leading-relaxed">
|
||||
داشبورد مدیریت فناوری و نوآوری
|
||||
</h2>
|
||||
<p className="text-gray-300 text-sm font-persian leading-relaxed">
|
||||
لطفاً نام کاربری و پسورد خود را وارد فهرست خواسته شده وارد
|
||||
<br />
|
||||
فرمایید.
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Form Fields
|
||||
```tsx
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Username Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-white text-sm font-persian">
|
||||
نام کاربری
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full h-12 px-4 bg-white border-gray-300 rounded-md text-gray-900 font-persian text-right"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-white text-sm font-persian">
|
||||
کلمه عبور
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full h-12 px-4 bg-white border-gray-300 rounded-md text-gray-900 font-persian text-right"
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remember Me Checkbox */}
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<input
|
||||
id="remember"
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 text-[#4FD1C7] bg-white border-gray-300 rounded focus:ring-[#4FD1C7] focus:ring-2 accent-[#4FD1C7]"
|
||||
/>
|
||||
<Label htmlFor="remember" className="text-white text-sm font-persian cursor-pointer">
|
||||
همیشه متصل بمانم
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 bg-[#4FD1C7] hover:bg-[#38B2AC] text-[#2D3748] font-bold rounded-md transition-colors duration-200 font-persian disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-[#2D3748] border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>در حال ورود...</span>
|
||||
</div>
|
||||
) : (
|
||||
"ورود"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Branding Section Content
|
||||
|
||||
#### Logo and Company Name
|
||||
```tsx
|
||||
<div className="flex justify-end">
|
||||
<div className="text-[#2D3748] font-persian">
|
||||
<div className="text-lg font-bold leading-tight">
|
||||
پردازشی
|
||||
<br />
|
||||
اینوژن
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Footer Text and Logo
|
||||
```tsx
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-[#2D3748] text-sm font-persian leading-relaxed">
|
||||
توسعهیافته توسط شرکت رهبران دانش و فناوری فرا
|
||||
</div>
|
||||
|
||||
{/* Geometric Logo */}
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
className="text-[#2D3748]"
|
||||
>
|
||||
<path d="M20 4L36 20L20 36L4 20L20 4Z" fill="currentColor" />
|
||||
<path d="M20 12L28 20L20 28L12 20L20 12Z" fill="#4FD1C7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Form State
|
||||
```tsx
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { login, isLoading } = useAuth();
|
||||
```
|
||||
|
||||
### Form Submission
|
||||
```tsx
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!username || !password) {
|
||||
const errorMessage = "لطفاً تمام فیلدها را پر کنید";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await login(username, password);
|
||||
if (success) {
|
||||
toast.success("ورود موفقیتآمیز بود!");
|
||||
onSuccess?.();
|
||||
} else {
|
||||
const errorMessage = "نام کاربری یا رمز عبور اشتباه است";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = "خطا در برقراری ارتباط با سرور";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
### RTL Support
|
||||
- `dir="rtl"` on main container
|
||||
- `space-x-reverse` for proper spacing in RTL
|
||||
- `text-right` for input text alignment
|
||||
|
||||
### Keyboard Navigation
|
||||
- Proper `htmlFor` and `id` associations
|
||||
- Tab order maintained
|
||||
- Focus rings on interactive elements
|
||||
|
||||
### Screen Reader Support
|
||||
- Semantic HTML structure
|
||||
- Proper form labels
|
||||
- Error messages announced
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoints
|
||||
```css
|
||||
/* Mobile: Full width login form */
|
||||
@media (max-width: 1023px) {
|
||||
.branding-section {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: Split screen layout */
|
||||
@media (min-width: 1024px) {
|
||||
.login-form {
|
||||
flex: 1;
|
||||
}
|
||||
.branding-section {
|
||||
width: 40%; /* 2/5 of screen */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mobile Optimizations
|
||||
- Hidden branding section on mobile
|
||||
- Full-width form container
|
||||
- Touch-friendly button height (48px)
|
||||
- Adequate spacing for touch targets
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### CSS Optimization
|
||||
- Custom properties for consistent colors
|
||||
- Hardware-accelerated animations
|
||||
- Efficient transitions
|
||||
|
||||
### Bundle Size
|
||||
- Tree-shaken shadcn/ui components
|
||||
- Minimal external dependencies
|
||||
- Optimized font loading
|
||||
|
||||
## Testing Considerations
|
||||
|
||||
### Unit Tests
|
||||
- Form validation logic
|
||||
- State management
|
||||
- Error handling
|
||||
|
||||
### Integration Tests
|
||||
- Login flow
|
||||
- Authentication integration
|
||||
- Error scenarios
|
||||
|
||||
### Accessibility Tests
|
||||
- Screen reader compatibility
|
||||
- Keyboard navigation
|
||||
- Color contrast ratios
|
||||
|
||||
## Browser Support
|
||||
|
||||
### Modern Browsers
|
||||
- Chrome 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
### CSS Features Used
|
||||
- CSS Grid and Flexbox
|
||||
- Custom properties
|
||||
- CSS animations
|
||||
- CSS accent-color
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] Split-screen layout implemented
|
||||
- [x] Exact color scheme applied
|
||||
- [x] Persian typography configured
|
||||
- [x] Form validation implemented
|
||||
- [x] Loading states handled
|
||||
- [x] Error messaging system
|
||||
- [x] Remember me functionality
|
||||
- [x] Responsive design
|
||||
- [x] RTL support
|
||||
- [x] Accessibility features
|
||||
- [x] shadcn/ui components integrated
|
||||
- [x] Authentication flow connected
|
||||
|
||||
## Files Modified
|
||||
|
||||
### Component Files
|
||||
- `inogen/app/components/auth/login-form.tsx`
|
||||
- `inogen/app/components/ui/button.tsx`
|
||||
- `inogen/app/components/ui/input.tsx`
|
||||
- `inogen/app/components/ui/label.tsx`
|
||||
|
||||
### Style Files
|
||||
- `inogen/app/app.css`
|
||||
|
||||
### Configuration Files
|
||||
- `inogen/package.json` (added @radix-ui/react-checkbox)
|
||||
|
||||
This implementation provides a pixel-perfect recreation of the Figma design while maintaining modern React best practices, accessibility standards, and responsive design principles.
|
||||
116
LOGIN_COLORS_UPDATE.md
Normal file
116
LOGIN_COLORS_UPDATE.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Login Background Color Update Summary
|
||||
|
||||
This document summarizes the changes made to implement the new login background colors as requested.
|
||||
|
||||
## New Colors Implemented
|
||||
|
||||
### Primary Colors
|
||||
- **Login Primary**: `#3AEA83` - Bright green color for the sidebar and accent elements
|
||||
- **Login Dark Start**: `#464861` - Starting color for the gradient background
|
||||
- **Login Dark End**: `#111628` - Ending color for the gradient background
|
||||
|
||||
## Files Updated
|
||||
|
||||
### 1. CSS Variables (`app/app.css`)
|
||||
Added new CSS custom properties:
|
||||
```css
|
||||
/* Login specific colors */
|
||||
--color-login-primary: #3aea83;
|
||||
--color-login-dark-start: #464861;
|
||||
--color-login-dark-end: #111628;
|
||||
```
|
||||
|
||||
Updated login page styles:
|
||||
```css
|
||||
.login-page {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-login-dark-start) 0%,
|
||||
var(--color-login-dark-end) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login-sidebar {
|
||||
background: var(--color-login-primary);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Design Tokens (`app/lib/design-tokens.ts`)
|
||||
Added login colors to the design token system:
|
||||
```typescript
|
||||
// Login specific colors
|
||||
login: {
|
||||
primary: "#3aea83",
|
||||
darkStart: "#464861",
|
||||
darkEnd: "#111628",
|
||||
},
|
||||
```
|
||||
|
||||
### 3. Login Layout Component (`app/components/auth/login-layout.tsx`)
|
||||
Updated components to use CSS variables:
|
||||
- **LoginContent**: Uses gradient background with new dark colors
|
||||
- **LoginSidebar**: Uses solid green background color
|
||||
|
||||
### 4. Login Form Component (`app/components/auth/login-form.tsx`)
|
||||
Updated interactive elements:
|
||||
- **Login Button**: Uses new green color for background
|
||||
- **Forgot Password Link**: Hover state uses green color
|
||||
- **Brand Logo**: Text color adjusted for contrast
|
||||
|
||||
### 5. Form Components (`app/components/ui/form-field.tsx`)
|
||||
Updated checkbox styling:
|
||||
- **Checkbox**: Uses new green color for checked state and focus ring
|
||||
|
||||
### 6. Loading States
|
||||
Updated loading spinners and pages:
|
||||
- **Login Route**: Loading spinner uses green border
|
||||
- **Protected Route**: Authentication loading uses green accent
|
||||
|
||||
### 7. Color Update Script (`scripts/update-colors.js`)
|
||||
Added login colors to the automated color update system for future maintenance.
|
||||
|
||||
## Visual Changes
|
||||
|
||||
### Before
|
||||
- Login background: Slate blue gradient
|
||||
- Sidebar: Teal gradient
|
||||
- Interactive elements: Teal colors
|
||||
|
||||
### After
|
||||
- Login background: Custom dark gradient (`#464861` → `#111628`)
|
||||
- Sidebar: Bright green solid color (`#3AEA83`)
|
||||
- Interactive elements: Bright green accents
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Consistent Branding**: All login-related colors now use the specified brand colors
|
||||
2. **Maintainable**: Colors are defined as CSS variables for easy updates
|
||||
3. **Accessible**: Maintained proper contrast ratios for readability
|
||||
4. **Scalable**: Color system integrated into design tokens for future use
|
||||
|
||||
## Usage
|
||||
|
||||
All login colors are now available as CSS variables:
|
||||
```css
|
||||
/* Use in stylesheets */
|
||||
background: var(--color-login-primary);
|
||||
background: linear-gradient(135deg, var(--color-login-dark-start), var(--color-login-dark-end));
|
||||
|
||||
/* Use in inline styles */
|
||||
style={{ backgroundColor: 'var(--color-login-primary)' }}
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-login-dark-start) 0%, var(--color-login-dark-end) 100%)' }}
|
||||
```
|
||||
|
||||
## RTL Support
|
||||
|
||||
All color implementations maintain proper RTL (Right-to-Left) support for Persian text and layout.
|
||||
|
||||
## Testing
|
||||
|
||||
- ✅ Login page displays with new color scheme
|
||||
- ✅ All interactive elements use correct colors
|
||||
- ✅ Loading states match the design
|
||||
- ✅ Color contrast meets accessibility standards
|
||||
- ✅ RTL layout works correctly
|
||||
- ✅ No TypeScript errors
|
||||
- ✅ CSS variables work across all browsers
|
||||
255
MODERN_LOGIN_DESIGN.md
Normal file
255
MODERN_LOGIN_DESIGN.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# Modern Login Design Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The login page has been completely redesigned with a modern, contemporary aesthetic inspired by innovation management themes. The new design features a glassmorphism effect, gradient backgrounds, and improved user experience.
|
||||
|
||||
## Design Features
|
||||
|
||||
### 🎨 Visual Design
|
||||
|
||||
#### Color Palette
|
||||
- **Primary Gradient**: Blue to Purple (`from-blue-600 to-purple-600`)
|
||||
- **Background**: Soft gradient with blurred shapes (`from-indigo-50 via-white to-cyan-50`)
|
||||
- **Glass Effect**: Semi-transparent cards with backdrop blur
|
||||
- **Accent Colors**: Blue, Purple, Cyan for various elements
|
||||
|
||||
#### Typography
|
||||
- **Brand**: "اینوژن" (Inogen) - Bold gradient text
|
||||
- **Persian Font**: Vazirmatn for all Persian text
|
||||
- **Hierarchy**: Clear visual hierarchy with proper font weights
|
||||
|
||||
#### Layout
|
||||
- **Centered Design**: Single column, centered layout
|
||||
- **Responsive**: Mobile-first approach
|
||||
- **Minimalist**: Clean, uncluttered interface
|
||||
|
||||
### 🌟 Modern UI Elements
|
||||
|
||||
#### Logo & Branding
|
||||
```tsx
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-r from-blue-600 to-purple-600 rounded-2xl mb-6 shadow-lg shadow-blue-500/25">
|
||||
<svg className="w-8 h-8 text-white" /* Lightning icon for innovation */>
|
||||
```
|
||||
|
||||
#### Glass Card Design
|
||||
```tsx
|
||||
<Card className="backdrop-blur-xl bg-white/70 dark:bg-slate-800/70 border border-white/20 dark:border-slate-700/50 shadow-2xl shadow-black/10 dark:shadow-black/30">
|
||||
```
|
||||
|
||||
#### Input Fields with Icons
|
||||
- Username field with user icon
|
||||
- Password field with lock icon
|
||||
- Subtle animations and transitions
|
||||
- Focus states with colored borders
|
||||
|
||||
#### Gradient Button
|
||||
```tsx
|
||||
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 transform hover:scale-[1.02]">
|
||||
```
|
||||
|
||||
### 🌈 Background Effects
|
||||
|
||||
#### Animated Background Shapes
|
||||
```tsx
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full opacity-10 blur-3xl"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-full opacity-10 blur-3xl"></div>
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-gradient-to-br from-purple-400 to-pink-400 rounded-full opacity-5 blur-3xl"></div>
|
||||
```
|
||||
|
||||
## Loading Components
|
||||
|
||||
### 🔄 Loading Variants
|
||||
|
||||
#### Spinner Loading
|
||||
```tsx
|
||||
<Loading variant="spinner" size="md" text="در حال بارگذاری..." />
|
||||
```
|
||||
|
||||
#### Dots Loading
|
||||
```tsx
|
||||
<Loading variant="dots" size="lg" />
|
||||
```
|
||||
|
||||
#### Pulse Loading
|
||||
```tsx
|
||||
<Loading variant="pulse" size="sm" />
|
||||
```
|
||||
|
||||
#### Bars Loading
|
||||
```tsx
|
||||
<Loading variant="bars" size="xl" />
|
||||
```
|
||||
|
||||
### 📄 Page Loading Component
|
||||
|
||||
#### Features
|
||||
- Animated logo with spinning ring
|
||||
- Progress dots animation
|
||||
- Gradient progress bar
|
||||
- Persian text support
|
||||
- Dark mode compatibility
|
||||
|
||||
#### Usage
|
||||
```tsx
|
||||
<PageLoading
|
||||
title="در حال بارگذاری..."
|
||||
subtitle="لطفاً منتظر بمانید"
|
||||
/>
|
||||
```
|
||||
|
||||
### 💀 Skeleton Components
|
||||
|
||||
#### Card Skeleton
|
||||
```tsx
|
||||
<SkeletonCard className="w-full" />
|
||||
```
|
||||
|
||||
#### Text Skeleton
|
||||
```tsx
|
||||
<SkeletonText lines={3} />
|
||||
```
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### 🛠️ Dependencies Used
|
||||
|
||||
- **shadcn/ui**: Card, Input, Label, Button components
|
||||
- **Tailwind CSS**: For styling and animations
|
||||
- **React Router**: For navigation
|
||||
- **Custom Hooks**: Authentication context
|
||||
|
||||
### 🎭 Animations & Transitions
|
||||
|
||||
#### CSS Classes Used
|
||||
```css
|
||||
.animate-spin /* Spinner rotation */
|
||||
.animate-bounce /* Dots animation */
|
||||
.animate-pulse /* Pulse effect */
|
||||
.backdrop-blur-xl /* Glass effect */
|
||||
.transition-all /* Smooth transitions */
|
||||
.duration-200 /* Animation timing */
|
||||
.hover:scale-[1.02] /* Button hover effect */
|
||||
```
|
||||
|
||||
#### Animation Delays
|
||||
```css
|
||||
[animation-delay:-0.3s] /* Staggered animations */
|
||||
[animation-delay:-0.15s] /* Sequential timing */
|
||||
[animation-delay:-0.4s] /* Bars animation */
|
||||
```
|
||||
|
||||
### 🌙 Dark Mode Support
|
||||
|
||||
All components fully support dark mode with:
|
||||
- Automatic color scheme detection
|
||||
- Proper contrast ratios
|
||||
- Consistent theming across components
|
||||
|
||||
### 📱 Responsive Design
|
||||
|
||||
#### Breakpoints
|
||||
- **Mobile**: Full-width layout
|
||||
- **Tablet**: Centered with padding
|
||||
- **Desktop**: Maximum width container
|
||||
|
||||
#### Key Features
|
||||
- Touch-friendly input sizes (h-12)
|
||||
- Adequate spacing for mobile interaction
|
||||
- Readable font sizes across devices
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
inogen/app/components/
|
||||
├── ui/
|
||||
│ ├── loading.tsx # Loading components
|
||||
│ ├── alert.tsx # Alert component
|
||||
│ ├── button.tsx # Button with custom variants
|
||||
│ ├── card.tsx # Glass card component
|
||||
│ ├── input.tsx # Enhanced input fields
|
||||
│ └── label.tsx # Form labels
|
||||
└── auth/
|
||||
└── login-form.tsx # Modern login form
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Login Form
|
||||
```tsx
|
||||
import { LoginForm } from "~/components/auth/login-form";
|
||||
|
||||
<LoginForm onSuccess={() => navigate("/dashboard")} />
|
||||
```
|
||||
|
||||
### Custom Loading States
|
||||
```tsx
|
||||
import { Loading, PageLoading } from "~/components/ui/loading";
|
||||
|
||||
// Inline loading
|
||||
<Loading variant="spinner" size="lg" text="در حال ورود..." />
|
||||
|
||||
// Full page loading
|
||||
<PageLoading title="در حال احراز هویت..." />
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
```tsx
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
نام کاربری یا رمز عبور اشتباه است
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### 🚀 Implemented Optimizations
|
||||
|
||||
1. **Lazy Loading**: Components load only when needed
|
||||
2. **CSS Animations**: Hardware-accelerated animations
|
||||
3. **Minimal Re-renders**: Optimized state management
|
||||
4. **Compressed Assets**: Optimized SVG icons
|
||||
5. **Responsive Images**: Appropriate sizing for devices
|
||||
|
||||
### 📊 Accessibility Features
|
||||
|
||||
1. **ARIA Labels**: Proper accessibility attributes
|
||||
2. **Focus Management**: Keyboard navigation support
|
||||
3. **High Contrast**: WCAG compliant color ratios
|
||||
4. **Screen Readers**: Semantic HTML structure
|
||||
5. **RTL Support**: Right-to-left text direction
|
||||
|
||||
## Browser Support
|
||||
|
||||
- ✅ Chrome 90+
|
||||
- ✅ Firefox 88+
|
||||
- ✅ Safari 14+
|
||||
- ✅ Edge 90+
|
||||
- ✅ Mobile browsers
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 🔮 Planned Features
|
||||
|
||||
1. **Biometric Authentication**: Fingerprint/Face ID support
|
||||
2. **Multi-factor Authentication**: SMS/Email verification
|
||||
3. **Social Login**: OAuth integration
|
||||
4. **Progressive Enhancement**: Offline capabilities
|
||||
5. **Animation Library**: Framer Motion integration
|
||||
|
||||
### 🎯 Design Improvements
|
||||
|
||||
1. **Micro-interactions**: Enhanced user feedback
|
||||
2. **Theme Customization**: User preference settings
|
||||
3. **Brand Variations**: Multiple color schemes
|
||||
4. **Advanced Transitions**: Page transition animations
|
||||
5. **Loading Skeletons**: Content-aware loading states
|
||||
|
||||
## Conclusion
|
||||
|
||||
The new login design represents a significant improvement in both visual appeal and user experience. The glassmorphism effect, gradient backgrounds, and smooth animations create a modern, professional appearance that aligns with the innovation theme of the application.
|
||||
|
||||
The comprehensive loading system ensures users always have appropriate feedback during system operations, while the responsive design guarantees optimal experience across all devices.
|
||||
375
ROUTER_SHADCN_IMPLEMENTATION.md
Normal file
375
ROUTER_SHADCN_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
# shadcn/ui and React Router Implementation Guide
|
||||
|
||||
This document outlines how shadcn/ui components and React Router are implemented in the Inogen project.
|
||||
|
||||
## 📋 Overview
|
||||
|
||||
The project has been successfully updated to use:
|
||||
- **shadcn/ui components** for consistent, accessible UI elements
|
||||
- **React Router v7** for client-side routing and navigation
|
||||
|
||||
## 🎨 shadcn/ui Implementation
|
||||
|
||||
### Available Components
|
||||
|
||||
The project includes the following shadcn/ui components:
|
||||
|
||||
```
|
||||
inogen/app/components/ui/
|
||||
├── button.tsx # Button component with variants
|
||||
├── card.tsx # Card, CardHeader, CardContent, etc.
|
||||
├── input.tsx # Form input component
|
||||
├── label.tsx # Form label component
|
||||
└── design-system.tsx
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
shadcn/ui is configured via `components.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/app.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "~/components",
|
||||
"utils": "~/lib/utils",
|
||||
"ui": "~/components/ui"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Login Form Implementation
|
||||
|
||||
The login form has been refactored to use shadcn/ui components:
|
||||
|
||||
**Before (Plain HTML):**
|
||||
```tsx
|
||||
<input
|
||||
className="w-full px-4 py-3 border border-gray-300..."
|
||||
placeholder="نام کاربری"
|
||||
/>
|
||||
<button className="w-full bg-green-500...">
|
||||
ورود
|
||||
</button>
|
||||
```
|
||||
|
||||
**After (shadcn/ui):**
|
||||
```tsx
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
|
||||
|
||||
<Card className="shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-persian">ورود به سیستم</CardTitle>
|
||||
<CardDescription className="font-persian">
|
||||
لطفاً اطلاعات ورود خود را وارد کنید
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Label htmlFor="username" className="font-persian">نام کاربری</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="font-persian text-right"
|
||||
placeholder="نام کاربری خود را وارد کنید"
|
||||
/>
|
||||
<Button variant="green" size="lg" className="w-full font-persian">
|
||||
ورود
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### Button Variants
|
||||
|
||||
The Button component includes custom variants for the project:
|
||||
|
||||
```tsx
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
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",
|
||||
green: "bg-green-500 text-white hover:bg-green-600 focus:ring-green-400", // Custom
|
||||
blue: "bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-400", // Custom
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 React Router Implementation
|
||||
|
||||
### Version & Configuration
|
||||
|
||||
- **React Router v7.7.0** is used throughout the project
|
||||
- SSR is enabled by default in `react-router.config.ts`
|
||||
|
||||
### Route Structure
|
||||
|
||||
```typescript
|
||||
// app/routes.ts
|
||||
export default [
|
||||
index("routes/home.tsx"), // /
|
||||
route("login", "routes/login.tsx"), // /login
|
||||
route("dashboard", "routes/dashboard.tsx"), // /dashboard
|
||||
route("dashboard/projects", "routes/dashboard.projects.tsx"), // /dashboard/projects
|
||||
route("404", "routes/404.tsx"), // /404
|
||||
route("unauthorized", "routes/unauthorized.tsx"), // /unauthorized
|
||||
route("*", "routes/$.tsx"), // Catch-all for 404s
|
||||
] satisfies RouteConfig;
|
||||
```
|
||||
|
||||
### Navigation Implementation
|
||||
|
||||
#### 1. Basic Navigation with Link
|
||||
|
||||
```tsx
|
||||
import { Link } from "react-router";
|
||||
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-green-600 hover:text-green-500 font-persian"
|
||||
>
|
||||
رمز عبور خود را فراموش کردهاید؟
|
||||
</Link>
|
||||
```
|
||||
|
||||
#### 2. Programmatic Navigation
|
||||
|
||||
```tsx
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Navigate to dashboard after login
|
||||
const handleLoginSuccess = () => {
|
||||
navigate("/dashboard", { replace: true });
|
||||
};
|
||||
|
||||
// Navigate back
|
||||
const handleGoBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. Active Link Styling
|
||||
|
||||
```tsx
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
function NavigationLink({ to, label }: NavigationLinkProps) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`px-3 py-2 rounded-md font-persian ${
|
||||
isActive
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Route Protection
|
||||
|
||||
The project implements comprehensive route protection:
|
||||
|
||||
#### 1. Protected Route Component
|
||||
|
||||
```tsx
|
||||
// app/components/auth/protected-route.tsx
|
||||
export function ProtectedRoute({ children, requireAuth = true }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (requireAuth && !isAuthenticated) {
|
||||
const returnTo = location.pathname + location.search;
|
||||
const loginPath = `/login?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
return <Navigate to={loginPath} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Global Route Guard
|
||||
|
||||
```tsx
|
||||
// app/components/auth/global-route-guard.tsx
|
||||
export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) {
|
||||
const { isAuthenticated, isLoading, token } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Handle authentication-based redirects
|
||||
if (!isLoading) {
|
||||
handleRouteProtection();
|
||||
}
|
||||
}, [isAuthenticated, isLoading, location.pathname]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Navigation Features
|
||||
|
||||
#### 1. Return URL Handling
|
||||
|
||||
```tsx
|
||||
// Login page automatically redirects to intended destination
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnTo = searchParams.get("returnTo");
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isLoading) {
|
||||
const redirectPath = returnTo && returnTo !== "/login" ? returnTo : "/dashboard";
|
||||
navigate(redirectPath, { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, isLoading, navigate, returnTo]);
|
||||
```
|
||||
|
||||
#### 2. Dashboard Navigation Menu
|
||||
|
||||
```tsx
|
||||
// app/components/dashboard/dashboard-layout.tsx
|
||||
<nav className="hidden md:flex items-center space-x-8 space-x-reverse">
|
||||
<NavigationLink to="/dashboard" label="داشبورد" />
|
||||
<NavigationLink to="/dashboard/projects" label="پروژهها" />
|
||||
</nav>
|
||||
```
|
||||
|
||||
## 🔒 Authentication Integration
|
||||
|
||||
### Auth Context with Router
|
||||
|
||||
```tsx
|
||||
// app/contexts/auth-context.tsx
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Auto-validate tokens and handle expired sessions
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
const isValid = await validateToken();
|
||||
if (!isValid) {
|
||||
clearAuthData();
|
||||
toast.error("جلسه کاری شما منقضی شده است");
|
||||
// Router will handle redirect via GlobalRouteGuard
|
||||
}
|
||||
}, 5 * 60 * 1000); // Every 5 minutes
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [token, user]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
```
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
Both shadcn/ui components and React Router navigation are fully responsive:
|
||||
|
||||
```tsx
|
||||
{/* Mobile-friendly navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-8 space-x-reverse">
|
||||
<NavigationLink to="/dashboard" label="داشبورد" />
|
||||
<NavigationLink to="/dashboard/projects" label="پروژهها" />
|
||||
</nav>
|
||||
|
||||
{/* Mobile logo for small screens */}
|
||||
<div className="lg:hidden text-center mb-8">
|
||||
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
{/* Mobile logo */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 🎯 Benefits Achieved
|
||||
|
||||
### shadcn/ui Benefits:
|
||||
- ✅ Consistent design system
|
||||
- ✅ Accessible components by default
|
||||
- ✅ Customizable with Tailwind CSS
|
||||
- ✅ TypeScript support
|
||||
- ✅ Reduced boilerplate code
|
||||
|
||||
### React Router Benefits:
|
||||
- ✅ Client-side routing (SPA experience)
|
||||
- ✅ Programmatic navigation
|
||||
- ✅ Route protection and guards
|
||||
- ✅ URL state management
|
||||
- ✅ Return URL handling
|
||||
- ✅ Active link styling
|
||||
- ✅ SEO-friendly with SSR support
|
||||
|
||||
## 🛠️ Next Steps
|
||||
|
||||
To further enhance the implementation:
|
||||
|
||||
1. **Add more shadcn/ui components** (Dialog, DropdownMenu, Sheet for mobile nav)
|
||||
2. **Implement breadcrumb navigation** using React Router location
|
||||
3. **Add loading states** for route transitions
|
||||
4. **Create reusable navigation components** for different sections
|
||||
5. **Add route-based animations** using Framer Motion
|
||||
|
||||
## 📝 Usage Examples
|
||||
|
||||
### Creating a New Protected Page
|
||||
|
||||
```tsx
|
||||
// app/routes/new-page.tsx
|
||||
import { ProtectedRoute } from "~/components/auth/protected-route";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
export default function NewPage() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-persian">صفحه جدید</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-persian">محتوای صفحه جدید</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Navigation Link
|
||||
|
||||
```tsx
|
||||
// Add to routes.ts
|
||||
route("new-page", "routes/new-page.tsx"),
|
||||
|
||||
// Add to dashboard navigation
|
||||
<NavigationLink to="/new-page" label="صفحه جدید" />
|
||||
```
|
||||
|
||||
This implementation provides a solid foundation for scalable, maintainable React Router navigation with consistent shadcn/ui components throughout the application.
|
||||
389
app/app.css
389
app/app.css
|
|
@ -1,15 +1,400 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* Persian/Farsi font support */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap");
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||
--font-sans:
|
||||
"Vazirmatn", "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
|
||||
/* Teal color scale */
|
||||
--color-teal-50: #f0fdfa;
|
||||
--color-teal-100: #ccfbf1;
|
||||
--color-teal-200: #99f6e4;
|
||||
--color-teal-300: #5eead4;
|
||||
--color-teal-400: #2dd4bf;
|
||||
--color-teal-500: #14b8a6;
|
||||
--color-teal-600: #0d9488;
|
||||
--color-teal-700: #0f766e;
|
||||
--color-teal-800: #115e59;
|
||||
--color-teal-900: #134e4a;
|
||||
--color-teal-950: #042f2e;
|
||||
|
||||
/* Slate color scale */
|
||||
--color-slate-50: #f8fafc;
|
||||
--color-slate-100: #f1f5f9;
|
||||
--color-slate-200: #e2e8f0;
|
||||
--color-slate-300: #cbd5e1;
|
||||
--color-slate-400: #94a3b8;
|
||||
--color-slate-500: #64748b;
|
||||
--color-slate-600: #475569;
|
||||
--color-slate-700: #334155;
|
||||
--color-slate-800: #1e293b;
|
||||
--color-slate-900: #0f172a;
|
||||
--color-slate-950: #020617;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-950;
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
/* RTL Support */
|
||||
html[dir="rtl"] {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
html[dir="rtl"] body {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Light theme colors */
|
||||
--background: #ffffff;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0a0a0a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0a0a0a;
|
||||
--primary: #22c55e;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #f5f5f5;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #f5f5f5;
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--accent-foreground: #0a0a0a;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--input: #e5e5e5;
|
||||
--ring: #22c55e;
|
||||
|
||||
/* Primary color scale */
|
||||
--color-primary-50: #f0fdf4;
|
||||
--color-primary-100: #dcfce7;
|
||||
--color-primary-200: #bbf7d0;
|
||||
--color-primary-300: #86efac;
|
||||
--color-primary-400: #4ade80;
|
||||
--color-primary-500: #22c55e;
|
||||
--color-primary-600: #16a34a;
|
||||
--color-primary-700: #15803d;
|
||||
--color-primary-800: #166534;
|
||||
--color-primary-900: #14532d;
|
||||
--color-primary-950: #052e16;
|
||||
|
||||
/* Secondary color scale (Blue) */
|
||||
--color-secondary-50: #eff6ff;
|
||||
--color-secondary-100: #dbeafe;
|
||||
--color-secondary-200: #bfdbfe;
|
||||
--color-secondary-300: #93c5fd;
|
||||
--color-secondary-400: #60a5fa;
|
||||
--color-secondary-500: #3b82f6;
|
||||
--color-secondary-600: #2563eb;
|
||||
--color-secondary-700: #1d4ed8;
|
||||
--color-secondary-800: #1e40af;
|
||||
--color-secondary-900: #1e3a8a;
|
||||
--color-secondary-950: #172554;
|
||||
|
||||
/* Neutral color scale */
|
||||
--color-neutral-50: #fafafa;
|
||||
--color-neutral-100: #f5f5f5;
|
||||
--color-neutral-200: #e5e5e5;
|
||||
--color-neutral-300: #d4d4d4;
|
||||
--color-neutral-400: #a3a3a3;
|
||||
--color-neutral-500: #737373;
|
||||
--color-neutral-600: #525252;
|
||||
--color-neutral-700: #404040;
|
||||
--color-neutral-800: #262626;
|
||||
--color-neutral-900: #171717;
|
||||
--color-neutral-950: #0a0a0a;
|
||||
|
||||
/* Status colors */
|
||||
--color-success-50: #f0fdf4;
|
||||
--color-success-100: #dcfce7;
|
||||
--color-success-500: #22c55e;
|
||||
--color-success-600: #16a34a;
|
||||
--color-success-700: #15803d;
|
||||
--color-success-900: #14532d;
|
||||
|
||||
--color-error-50: #fef2f2;
|
||||
--color-error-100: #fee2e2;
|
||||
--color-error-500: #ef4444;
|
||||
--color-error-600: #dc2626;
|
||||
--color-error-700: #b91c1c;
|
||||
--color-error-900: #7f1d1d;
|
||||
|
||||
--color-warning-50: #fffbeb;
|
||||
--color-warning-100: #fef3c7;
|
||||
--color-warning-500: #f59e0b;
|
||||
--color-warning-600: #d97706;
|
||||
--color-warning-700: #b45309;
|
||||
--color-warning-900: #78350f;
|
||||
|
||||
--color-info-50: #eff6ff;
|
||||
--color-info-100: #dbeafe;
|
||||
--color-info-500: #3b82f6;
|
||||
--color-info-600: #2563eb;
|
||||
--color-info-700: #1d4ed8;
|
||||
--color-info-900: #1e3a8a;
|
||||
|
||||
/* Teal colors */
|
||||
--color-teal-50: #f0fdfa;
|
||||
--color-teal-100: #ccfbf1;
|
||||
--color-teal-200: #99f6e4;
|
||||
--color-teal-300: #5eead4;
|
||||
--color-teal-400: #2dd4bf;
|
||||
--color-teal-500: #14b8a6;
|
||||
--color-teal-600: #0d9488;
|
||||
--color-teal-700: #0f766e;
|
||||
--color-teal-800: #115e59;
|
||||
--color-teal-900: #134e4a;
|
||||
|
||||
/* Dark colors */
|
||||
--color-dark-50: #f8fafc;
|
||||
--color-dark-100: #f1f5f9;
|
||||
--color-dark-200: #e2e8f0;
|
||||
--color-dark-300: #cbd5e1;
|
||||
--color-dark-400: #94a3b8;
|
||||
--color-dark-500: #64748b;
|
||||
--color-dark-600: #475569;
|
||||
--color-dark-700: #334155;
|
||||
--color-dark-800: #1e293b;
|
||||
--color-dark-900: #0f172a;
|
||||
--color-dark-950: #020617;
|
||||
|
||||
/* Login specific colors */
|
||||
--color-login-primary: #3aea83;
|
||||
--color-login-dark-start: #464861;
|
||||
--color-login-dark-end: #111628;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme colors */
|
||||
--background: #020617;
|
||||
--foreground: #f8fafc;
|
||||
--card: #0f172a;
|
||||
--card-foreground: #f8fafc;
|
||||
--popover: #0f172a;
|
||||
--popover-foreground: #f8fafc;
|
||||
--primary: #22c55e;
|
||||
--primary-foreground: #0a0a0a;
|
||||
--secondary: #1e293b;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--muted: #1e293b;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #1e293b;
|
||||
--accent-foreground: #f8fafc;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #f8fafc;
|
||||
--border: #1e293b;
|
||||
--input: #1e293b;
|
||||
--ring: #22c55e;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-neutral-100 dark:bg-neutral-800;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-neutral-300 dark:bg-neutral-600 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-neutral-400 dark:bg-neutral-500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Persian/Farsi font class */
|
||||
.font-persian {
|
||||
font-family: "Vazirmatn", "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom utility classes */
|
||||
.gradient-primary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500) 0%,
|
||||
var(--color-primary-600) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-secondary {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-secondary-500) 0%,
|
||||
var(--color-secondary-600) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.gradient-background {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-neutral-50) 0%,
|
||||
var(--color-neutral-100) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.dark .gradient-background {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-neutral-900) 0%,
|
||||
var(--color-neutral-800) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Login page specific styles */
|
||||
.login-page {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-login-dark-start) 0%,
|
||||
var(--color-login-dark-end) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.login-sidebar {
|
||||
background: var(--color-login-primary);
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast customization for RTL */
|
||||
.Toaster__toast {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Form focus styles */
|
||||
.form-input:focus-within {
|
||||
@apply ring-2 ring-primary/20 border-primary;
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
.btn-hover-scale:hover {
|
||||
transform: scale(1.02);
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Custom shadows */
|
||||
.shadow-primary {
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(34 197 94 / 0.1),
|
||||
0 2px 4px -2px rgb(34 197 94 / 0.1);
|
||||
}
|
||||
|
||||
.shadow-error {
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgb(239 68 68 / 0.1),
|
||||
0 2px 4px -2px rgb(239 68 68 / 0.1);
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading-shimmer {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.dark .loading-shimmer {
|
||||
background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
app/components/auth/auth-guard.tsx
Normal file
34
app/components/auth/auth-guard.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
import { LoginForm } from "./login-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const { isAuthenticated, isLoading, user, token } = useAuth();
|
||||
|
||||
// Show loading while checking authentication
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400 font-persian">
|
||||
در حال بررسی احراز هویت...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If user is not authenticated or token is invalid, show login form
|
||||
if (!isAuthenticated || !token || !token.accessToken) {
|
||||
return <LoginForm />;
|
||||
}
|
||||
|
||||
// If user is authenticated, show the protected content
|
||||
return <>{children}</>;
|
||||
}
|
||||
241
app/components/auth/global-route-guard.tsx
Normal file
241
app/components/auth/global-route-guard.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface GlobalRouteGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Define protected routes that require authentication
|
||||
const PROTECTED_ROUTES = [
|
||||
"/dashboard",
|
||||
"/dashboard/projects",
|
||||
"/dashboard/teams",
|
||||
"/dashboard/reports",
|
||||
"/dashboard/settings",
|
||||
"/profile",
|
||||
"/settings",
|
||||
"/admin",
|
||||
];
|
||||
|
||||
// Define public routes that don't require authentication
|
||||
const PUBLIC_ROUTES = [
|
||||
"/",
|
||||
"/login",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/404",
|
||||
"/unauthorized",
|
||||
];
|
||||
|
||||
// Define routes that authenticated users shouldn't access
|
||||
const AUTH_RESTRICTED_ROUTES = [
|
||||
"/login",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
];
|
||||
|
||||
// Define exact routes (for root path handling)
|
||||
const EXACT_ROUTES = ["/", "/login", "/dashboard", "/404", "/unauthorized"];
|
||||
|
||||
export function GlobalRouteGuard({ children }: GlobalRouteGuardProps) {
|
||||
const { isAuthenticated, isLoading, token, user, validateToken } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Don't do anything while authentication is loading
|
||||
if (isLoading) return;
|
||||
|
||||
const currentPath = location.pathname;
|
||||
|
||||
// Check if current route is protected
|
||||
const isProtectedRoute = PROTECTED_ROUTES.some(
|
||||
(route) => currentPath === route || currentPath.startsWith(route + "/"),
|
||||
);
|
||||
|
||||
// Check if current route is auth-restricted (like login page)
|
||||
const isAuthRestrictedRoute = AUTH_RESTRICTED_ROUTES.some(
|
||||
(route) => currentPath === route || currentPath.startsWith(route + "/"),
|
||||
);
|
||||
|
||||
// Check if current route is a known public route
|
||||
const isPublicRoute = PUBLIC_ROUTES.some(
|
||||
(route) => currentPath === route || currentPath.startsWith(route + "/"),
|
||||
);
|
||||
|
||||
// Check if it's an exact route match
|
||||
const isExactRoute = EXACT_ROUTES.includes(currentPath);
|
||||
|
||||
// Case 1: User accessing protected route without authentication
|
||||
if (isProtectedRoute && !isAuthenticated) {
|
||||
toast.error("برای دسترسی به این صفحه باید وارد شوید");
|
||||
|
||||
// Save the intended destination for after login
|
||||
const returnTo = encodeURIComponent(currentPath + location.search);
|
||||
navigate(`/login?returnTo=${returnTo}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: User accessing protected route with expired/invalid token
|
||||
if (isProtectedRoute && isAuthenticated && (!token || !token.accessToken)) {
|
||||
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید");
|
||||
|
||||
// Clear invalid auth data
|
||||
localStorage.removeItem("auth_user");
|
||||
localStorage.removeItem("auth_token");
|
||||
|
||||
navigate("/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 3: Authenticated user trying to access auth-restricted routes
|
||||
if (isAuthRestrictedRoute && isAuthenticated && token?.accessToken) {
|
||||
// Get return URL from query params, default to dashboard
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const returnTo = searchParams.get("returnTo");
|
||||
const redirectPath =
|
||||
returnTo && returnTo !== "/login" ? returnTo : "/dashboard";
|
||||
|
||||
navigate(redirectPath, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 4: Handle root path redirection
|
||||
if (currentPath === "/") {
|
||||
if (isAuthenticated && token?.accessToken) {
|
||||
navigate("/dashboard", { replace: true });
|
||||
} else {
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 5: Unknown/404 routes
|
||||
const isKnownRoute = isProtectedRoute || isPublicRoute || isExactRoute;
|
||||
|
||||
if (
|
||||
!isKnownRoute &&
|
||||
!currentPath.includes("/404") &&
|
||||
!currentPath.includes("/unauthorized")
|
||||
) {
|
||||
// If user is authenticated, show authenticated 404
|
||||
if (isAuthenticated && token?.accessToken) {
|
||||
navigate("/404", { replace: true });
|
||||
} else {
|
||||
// If user is not authenticated, redirect to login
|
||||
toast.error("صفحه مورد نظر یافت نشد. لطفاً وارد شوید");
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 6: Validate token for protected routes periodically
|
||||
if (isProtectedRoute && isAuthenticated && token?.accessToken) {
|
||||
const checkTokenValidity = async () => {
|
||||
try {
|
||||
const isValid = await validateToken();
|
||||
if (!isValid) {
|
||||
toast.error("جلسه کاری شما منقضی شده است");
|
||||
navigate("/unauthorized?reason=token-expired", { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Token validation failed:", error);
|
||||
navigate("/unauthorized?reason=token-expired", { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Only check token validity every 30 seconds to avoid excessive API calls
|
||||
const now = Date.now();
|
||||
const lastCheck = parseInt(
|
||||
sessionStorage.getItem("lastTokenCheck") || "0",
|
||||
);
|
||||
|
||||
if (now - lastCheck > 30000) {
|
||||
// 30 seconds
|
||||
sessionStorage.setItem("lastTokenCheck", now.toString());
|
||||
checkTokenValidity();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
token,
|
||||
user,
|
||||
location.pathname,
|
||||
location.search,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Validate token periodically for authenticated users
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !token?.accessToken) return;
|
||||
|
||||
const validateTokenPeriodically = async () => {
|
||||
try {
|
||||
const isValid = await validateToken();
|
||||
if (!isValid) {
|
||||
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید");
|
||||
navigate("/login", { replace: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Token validation error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate token immediately
|
||||
validateTokenPeriodically();
|
||||
|
||||
// Set up periodic validation (every 5 minutes)
|
||||
const interval = setInterval(validateTokenPeriodically, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated, token, validateToken, navigate]);
|
||||
|
||||
// Show loading screen while checking authentication
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400 font-persian">
|
||||
در حال بررسی احراز هویت...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render children if all checks pass
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Hook to check if user can access a specific route
|
||||
export function useRouteAccess() {
|
||||
const { isAuthenticated, token } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
const canAccessRoute = (path: string): boolean => {
|
||||
const isProtectedRoute = PROTECTED_ROUTES.some(
|
||||
(route) => path === route || path.startsWith(route + "/"),
|
||||
);
|
||||
|
||||
if (isProtectedRoute) {
|
||||
return isAuthenticated && !!token?.accessToken;
|
||||
}
|
||||
|
||||
return true; // Public routes are accessible to everyone
|
||||
};
|
||||
|
||||
const getCurrentRouteAccess = () => {
|
||||
return canAccessRoute(location.pathname);
|
||||
};
|
||||
|
||||
return {
|
||||
canAccessRoute,
|
||||
getCurrentRouteAccess,
|
||||
isAuthenticated,
|
||||
hasValidToken: !!token?.accessToken,
|
||||
};
|
||||
}
|
||||
222
app/components/auth/login-form.tsx
Normal file
222
app/components/auth/login-form.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import React, { useState } from "react";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
TextField,
|
||||
PasswordField,
|
||||
CheckboxField,
|
||||
FieldGroup,
|
||||
} from "~/components/ui/form-field";
|
||||
import { ErrorAlert, ConnectionError } from "~/components/ui/error-alert";
|
||||
import { InogenLogo } from "~/components/ui/brand-logo";
|
||||
import {
|
||||
LoginLayout,
|
||||
LoginContent,
|
||||
LoginSidebar,
|
||||
LoginHeader,
|
||||
LoginBranding,
|
||||
LoginFormContainer,
|
||||
} from "./login-layout";
|
||||
import { Loader2, User, Lock } from "lucide-react";
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
username: string;
|
||||
password: string;
|
||||
rememberMe: boolean;
|
||||
}
|
||||
|
||||
interface ValidationErrors {
|
||||
username?: string;
|
||||
password?: string;
|
||||
general?: string;
|
||||
}
|
||||
|
||||
export function LoginForm({ onSuccess }: LoginFormProps) {
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
username: "",
|
||||
password: "",
|
||||
rememberMe: false,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [isConnectionError, setIsConnectionError] = useState(false);
|
||||
const { login, isLoading } = useAuth();
|
||||
|
||||
const updateField = (field: keyof FormData, value: string | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
// Clear field-specific errors when user starts typing
|
||||
if (errors[field as keyof ValidationErrors]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): ValidationErrors => {
|
||||
const newErrors: ValidationErrors = {};
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = "نام کاربری الزامی است";
|
||||
} else if (formData.username.length < 3) {
|
||||
newErrors.username = "نام کاربری باید حداقل ۳ کاراکتر باشد";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = "کلمه عبور الزامی است";
|
||||
} else if (formData.password.length < 4) {
|
||||
newErrors.password = "کلمه عبور باید حداقل ۴ کاراکتر باشد";
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Clear previous errors
|
||||
setErrors({});
|
||||
setIsConnectionError(false);
|
||||
|
||||
// Validate form
|
||||
const validationErrors = validateForm();
|
||||
if (Object.keys(validationErrors).length > 0) {
|
||||
setErrors(validationErrors);
|
||||
const firstError = Object.values(validationErrors)[0];
|
||||
toast.error(firstError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await login(formData.username, formData.password);
|
||||
|
||||
if (success) {
|
||||
toast.success("ورود موفقیتآمیز بود!");
|
||||
onSuccess?.();
|
||||
} else {
|
||||
const errorMessage = "نام کاربری یا رمز عبور اشتباه است";
|
||||
setErrors({ general: errorMessage });
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Login error:", err);
|
||||
|
||||
// Check if it's a connection error
|
||||
if (err?.code === "NETWORK_ERROR" || err?.message?.includes("fetch")) {
|
||||
setIsConnectionError(true);
|
||||
} else {
|
||||
const errorMessage = err?.message || "خطا در برقراری ارتباط با سرور";
|
||||
setErrors({ general: errorMessage });
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setIsConnectionError(false);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginLayout>
|
||||
{/* Left Side - Login Form */}
|
||||
<LoginContent>
|
||||
<LoginHeader
|
||||
title="ورود"
|
||||
subtitle="داشبورد مدیریت فناوری و نوآوری"
|
||||
description="لطفاً نام کاربری و کلمه عبور خود را در فرم زیر وارد نمایید"
|
||||
/>
|
||||
|
||||
<LoginFormContainer onSubmit={handleSubmit}>
|
||||
<FieldGroup>
|
||||
{/* Connection Error */}
|
||||
{isConnectionError && <ConnectionError onRetry={handleRetry} />}
|
||||
|
||||
{/* General Error */}
|
||||
{errors.general && (
|
||||
<ErrorAlert
|
||||
message={errors.general}
|
||||
variant="error"
|
||||
onRetry={() => setErrors({ general: undefined })}
|
||||
dismissible
|
||||
onDismiss={() => setErrors({ general: undefined })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Username Field */}
|
||||
<TextField
|
||||
id="username"
|
||||
label=""
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(value) => updateField("username", value)}
|
||||
error={errors.username}
|
||||
placeholder="نام کاربری"
|
||||
disabled={isLoading}
|
||||
autoComplete="username"
|
||||
required
|
||||
leftIcon={<User className="h-4 w-4" />}
|
||||
/>
|
||||
|
||||
{/* Password Field */}
|
||||
<PasswordField
|
||||
id="password"
|
||||
label=""
|
||||
value={formData.password}
|
||||
onChange={(value) => updateField("password", value)}
|
||||
error={errors.password}
|
||||
placeholder="کلمه عبور"
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
minLength={4}
|
||||
/>
|
||||
|
||||
{/* Remember Me Checkbox */}
|
||||
<div className="flex justify-end">
|
||||
<CheckboxField
|
||||
id="remember"
|
||||
label="همیشه متصل بمانم"
|
||||
checked={formData.rememberMe}
|
||||
onChange={(checked) => updateField("rememberMe", checked)}
|
||||
disabled={isLoading}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || isConnectionError}
|
||||
size="lg"
|
||||
className="w-full font-persian bg-[var(--color-login-primary)] hover:bg-[var(--color-login-primary)]/90 text-slate-800 font-bold"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin ml-2" />
|
||||
در حال ورود...
|
||||
</>
|
||||
) : (
|
||||
"ورود"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Additional Links */}
|
||||
</FieldGroup>
|
||||
</LoginFormContainer>
|
||||
</LoginContent>
|
||||
|
||||
{/* Right Side - Branding */}
|
||||
<LoginSidebar>
|
||||
<LoginBranding
|
||||
brandName="پتروشیمی بندر امام"
|
||||
companyName="توسعهیافته توسط شرکت رهبران دانش و فناوری فرا"
|
||||
logo={<InogenLogo size="lg" animated className="text-slate-800" />}
|
||||
/>
|
||||
</LoginSidebar>
|
||||
</LoginLayout>
|
||||
);
|
||||
}
|
||||
150
app/components/auth/login-layout.tsx
Normal file
150
app/components/auth/login-layout.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface LoginLayoutProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoginLayout({ children, className }: LoginLayoutProps) {
|
||||
return (
|
||||
<div className={cn("min-h-screen flex", className)} dir="ltr">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginContentProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoginContent({ children, className }: LoginContentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center p-4 sm:p-8",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-login-dark-start) 0%, var(--color-login-dark-end) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md space-y-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginSidebarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoginSidebar({ children, className }: LoginSidebarProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden lg:flex lg:w-2/5 relative overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: "var(--color-login-primary)",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex flex-col justify-between p-8">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoginHeader({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
className,
|
||||
}: LoginHeaderProps) {
|
||||
return (
|
||||
<div className={cn(" space-y-4 flex text-right flex-col", className)}>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-white text-lg font-medium font-persian">{title}</h1>
|
||||
<h2 className="text-white text-2xl sm:text-3xl font-bold font-persian leading-relaxed">
|
||||
{subtitle}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="text-slate-300 text-sm font-persian leading-relaxed mx-auto">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginBrandingProps {
|
||||
brandName: string;
|
||||
companyName: string;
|
||||
logo?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoginBranding({
|
||||
brandName,
|
||||
companyName,
|
||||
logo,
|
||||
className,
|
||||
}: LoginBrandingProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Top Logo */}
|
||||
<div className="flex justify-end">
|
||||
<div className="text-slate-800 font-persian">
|
||||
<div className="text-lg font-bold leading-tight">
|
||||
{brandName.split("\n").map((line, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
{index === 0 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-slate-800 text-sm font-persian leading-relaxed max-w-xs">
|
||||
{companyName}
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
{logo && <div className="flex items-center">{logo}</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoginFormContainerProps {
|
||||
children: React.ReactNode;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoginFormContainer({
|
||||
children,
|
||||
onSubmit,
|
||||
className,
|
||||
}: LoginFormContainerProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className={cn("space-y-6", className)}>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
86
app/components/auth/protected-route.tsx
Normal file
86
app/components/auth/protected-route.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React from "react";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
import { Navigate, useLocation } from "react-router";
|
||||
import toast from "react-hot-toast";
|
||||
import { LoadingPage } from "~/components/ui/loading";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
requireAuth?: boolean;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
fallback,
|
||||
requireAuth = true,
|
||||
redirectTo = "/login",
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading, token, user } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Show loading while checking authentication
|
||||
if (isLoading) {
|
||||
return (
|
||||
fallback || (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-login-dark-start) 0%, var(--color-login-dark-end) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="text-center space-y-6 max-w-md mx-auto p-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-8 h-8 border-2 border-[var(--color-login-primary)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-medium font-persian text-white">
|
||||
در حال بررسی احراز هویت...
|
||||
</h2>
|
||||
<p className="text-sm font-persian leading-relaxed text-gray-300">
|
||||
لطفاً منتظر بمانید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// If authentication is required but user is not authenticated
|
||||
if (requireAuth && !isAuthenticated) {
|
||||
toast.error("برای دسترسی به این صفحه باید وارد شوید");
|
||||
|
||||
// Save the current location so we can redirect back after login
|
||||
const currentPath = location.pathname + location.search;
|
||||
const loginPath = `${redirectTo}?returnTo=${encodeURIComponent(currentPath)}`;
|
||||
|
||||
return <Navigate to={loginPath} replace />;
|
||||
}
|
||||
|
||||
// If authentication is required but token is missing/invalid
|
||||
if (requireAuth && isAuthenticated && (!token || !token.accessToken)) {
|
||||
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید");
|
||||
|
||||
// Clear any stored authentication data
|
||||
localStorage.removeItem("auth_user");
|
||||
localStorage.removeItem("auth_token");
|
||||
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// If user is authenticated but trying to access login page
|
||||
if (!requireAuth && isAuthenticated && location.pathname === "/login") {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
// If all checks pass, render the protected content
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Helper component for public routes
|
||||
export function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
return <ProtectedRoute requireAuth={false}>{children}</ProtectedRoute>;
|
||||
}
|
||||
151
app/components/common/not-found.tsx
Normal file
151
app/components/common/not-found.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface NotFoundProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
export function NotFound({
|
||||
title = "صفحه یافت نشد",
|
||||
message = "متأسفانه صفحهای که به دنبال آن هستید وجود ندارد یا منتقل شده است.",
|
||||
showBackButton = true,
|
||||
}: NotFoundProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
navigate("/dashboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center" dir="rtl">
|
||||
<div className="max-w-md w-full px-6 py-8 text-center">
|
||||
{/* 404 Illustration */}
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto w-64 h-64 bg-gradient-to-br from-green-100 to-blue-100 dark:from-green-900/20 dark:to-blue-900/20 rounded-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl font-bold text-green-500 mb-2">404</div>
|
||||
<svg
|
||||
className="w-16 h-16 text-gray-400 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 20c-4.411 0-8-3.589-8-8s3.589-8 8-8 8 3.589 8 8a7.962 7.962 0 01-2 5.291z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 font-persian">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed font-persian">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={handleGoHome}
|
||||
className="w-full font-persian bg-green-500 hover:bg-green-600"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
بازگشت به داشبورد
|
||||
</Button>
|
||||
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGoBack}
|
||||
className="w-full font-persian"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
بازگشت به صفحه قبل
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Additional Help Links */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 font-persian">
|
||||
یا میتوانید به این صفحات بروید:
|
||||
</p>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 text-sm font-persian transition-colors"
|
||||
>
|
||||
• داشبورد اصلی
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboard/projects"
|
||||
className="text-green-600 hover:text-green-500 dark:text-green-400 dark:hover:text-green-300 text-sm font-persian transition-colors"
|
||||
>
|
||||
• مدیریت پروژهها
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Specialized 404 component for authenticated users
|
||||
export function AuthenticatedNotFound() {
|
||||
return (
|
||||
<NotFound
|
||||
title="صفحه یافت نشد"
|
||||
message="صفحهای که به دنبال آن هستید در سیستم مدیریت وجود ندارد. ممکن است آدرس اشتباه وارد کرده باشید یا صفحه حذف شده باشد."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Specialized 404 component for public pages
|
||||
export function PublicNotFound() {
|
||||
return (
|
||||
<NotFound
|
||||
title="صفحه یافت نشد"
|
||||
message="متأسفانه صفحهای که به دنبال آن هستید وجود ندارد."
|
||||
showBackButton={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
172
app/components/common/unauthorized.tsx
Normal file
172
app/components/common/unauthorized.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
interface UnauthorizedProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
showBackButton?: boolean;
|
||||
showLoginButton?: boolean;
|
||||
}
|
||||
|
||||
export function Unauthorized({
|
||||
title = "دسترسی غیرمجاز",
|
||||
message = "شما دسترسی لازم برای مشاهده این صفحه را ندارید. لطفاً با مدیر سیستم تماس بگیرید.",
|
||||
showBackButton = true,
|
||||
showLoginButton = false,
|
||||
}: UnauthorizedProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleGoBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
navigate("/dashboard");
|
||||
};
|
||||
|
||||
const handleGoLogin = () => {
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center" dir="rtl">
|
||||
<div className="max-w-md w-full px-6 py-8 text-center">
|
||||
{/* 403 Illustration */}
|
||||
<div className="mb-8">
|
||||
<div className="mx-auto w-64 h-64 bg-gradient-to-br from-red-100 to-orange-100 dark:from-red-900/20 dark:to-orange-900/20 rounded-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl font-bold text-red-500 mb-2">403</div>
|
||||
<svg
|
||||
className="w-16 h-16 text-gray-400 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 15v2m0 0v2m0-2h2m-2 0H10m12-6V9a6 6 0 10-12 0v6h12z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4 font-persian">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 leading-relaxed font-persian">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
onClick={handleGoHome}
|
||||
className="w-full font-persian bg-green-500 hover:bg-green-600"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
بازگشت به داشبورد
|
||||
</Button>
|
||||
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleGoBack}
|
||||
className="w-full font-persian"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
بازگشت به صفحه قبل
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showLoginButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleGoLogin}
|
||||
className="w-full font-persian"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
ورود مجدد
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact Support */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2 font-persian">
|
||||
اگر معتقدید که این خطا اشتباه است:
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 font-persian">
|
||||
با پشتیبانی سیستم تماس بگیرید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Specialized unauthorized component for token expiry
|
||||
export function TokenExpiredUnauthorized() {
|
||||
return (
|
||||
<Unauthorized
|
||||
title="جلسه کاری منقضی شده"
|
||||
message="جلسه کاری شما منقضی شده است. برای ادامه استفاده از سیستم لطفاً دوباره وارد شوید."
|
||||
showBackButton={false}
|
||||
showLoginButton={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Specialized unauthorized component for insufficient permissions
|
||||
export function InsufficientPermissionsUnauthorized() {
|
||||
return (
|
||||
<Unauthorized
|
||||
title="دسترسی محدود"
|
||||
message="شما دسترسی کافی برای مشاهده این بخش را ندارید. در صورت نیاز با مدیر سیستم تماس بگیرید."
|
||||
showBackButton={true}
|
||||
showLoginButton={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
289
app/components/dashboard/dashboard-layout.tsx
Normal file
289
app/components/dashboard/dashboard-layout.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import React from "react";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800"
|
||||
dir="rtl"
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="bg-white dark:bg-slate-800 shadow-sm border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo/Title */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-green-500 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<nav className="hidden md:flex items-center space-x-8 space-x-reverse">
|
||||
<NavigationLink to="/dashboard" label="داشبورد" />
|
||||
<NavigationLink to="/dashboard/projects" label="پروژهها" />
|
||||
</nav>
|
||||
<div className="mr-4">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white font-persian">
|
||||
داشبورد مدیریت
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="flex items-center space-x-4 space-x-reverse">
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 font-persian">
|
||||
خوش آمدید، {user?.name} {user?.family}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="font-persian"
|
||||
>
|
||||
خروج
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Navigation Link Component
|
||||
interface NavigationLinkProps {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function NavigationLink({ to, label }: NavigationLinkProps) {
|
||||
const location = useLocation();
|
||||
const isActive = location.pathname === to;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium font-persian transition-colors ${
|
||||
isActive
|
||||
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardHome() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="bg-gradient-to-r from-green-500 to-blue-600 rounded-lg p-6 text-white">
|
||||
<h2 className="text-2xl font-bold mb-2 font-persian">
|
||||
خوش آمدید به داشبورد مدیریت
|
||||
</h2>
|
||||
<p className="text-green-100 font-persian">
|
||||
سیستم مدیریت یکپارچه فناوری و نوآوری
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium font-persian">
|
||||
کل پروژهها
|
||||
</CardTitle>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="m22 21-3-3" />
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">24</div>
|
||||
<p className="text-xs text-muted-foreground font-persian">
|
||||
+2 از ماه گذشته
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium font-persian">
|
||||
پروژههای فعال
|
||||
</CardTitle>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="5" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">12</div>
|
||||
<p className="text-xs text-muted-foreground font-persian">
|
||||
+1 از هفته گذشته
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium font-persian">
|
||||
پروژههای تکمیل شده
|
||||
</CardTitle>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">8</div>
|
||||
<p className="text-xs text-muted-foreground font-persian">
|
||||
+3 از ماه گذشته
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium font-persian">
|
||||
درصد موفقیت
|
||||
</CardTitle>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 2v20m8-10H4" />
|
||||
</svg>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">85%</div>
|
||||
<p className="text-xs text-muted-foreground font-persian">
|
||||
+5% از ماه گذشته
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Projects */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="font-persian">پروژههای اخیر</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
name: "سیستم مدیریت محتوا",
|
||||
status: "در حال انجام",
|
||||
progress: 75,
|
||||
},
|
||||
{ name: "اپلیکیشن موبایل", status: "تکمیل شده", progress: 100 },
|
||||
{
|
||||
name: "پلتفرم تجارت الکترونیک",
|
||||
status: "شروع شده",
|
||||
progress: 25,
|
||||
},
|
||||
{
|
||||
name: "سیستم مدیریت مالی",
|
||||
status: "در حال بررسی",
|
||||
progress: 10,
|
||||
},
|
||||
].map((project, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-4 space-x-reverse"
|
||||
>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white font-persian">
|
||||
{project.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 font-persian">
|
||||
{project.status}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<div className="w-16 bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full"
|
||||
style={{ width: `${project.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{project.progress}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
65
app/components/ui/alert.tsx
Normal file
65
app/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
success:
|
||||
"border-green-500/50 text-green-700 bg-green-50 dark:bg-green-900/20 dark:text-green-400 dark:border-green-900 [&>svg]:text-green-600 dark:[&>svg]:text-green-400",
|
||||
warning:
|
||||
"border-yellow-500/50 text-yellow-700 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-900 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400",
|
||||
info:
|
||||
"border-blue-500/50 text-blue-700 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-900 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-400",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight font-persian", 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 font-persian", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
293
app/components/ui/brand-logo.tsx
Normal file
293
app/components/ui/brand-logo.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface BrandLogoProps {
|
||||
variant?: "full" | "icon" | "text";
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
className?: string;
|
||||
showCompany?: boolean;
|
||||
}
|
||||
|
||||
export function BrandLogo({
|
||||
variant = "full",
|
||||
size = "md",
|
||||
className,
|
||||
showCompany = false
|
||||
}: BrandLogoProps) {
|
||||
const sizes = {
|
||||
sm: {
|
||||
icon: "w-6 h-6",
|
||||
text: "text-sm",
|
||||
container: "gap-2"
|
||||
},
|
||||
md: {
|
||||
icon: "w-8 h-8",
|
||||
text: "text-base",
|
||||
container: "gap-3"
|
||||
},
|
||||
lg: {
|
||||
icon: "w-10 h-10",
|
||||
text: "text-lg",
|
||||
container: "gap-3"
|
||||
},
|
||||
xl: {
|
||||
icon: "w-12 h-12",
|
||||
text: "text-xl",
|
||||
container: "gap-4"
|
||||
}
|
||||
};
|
||||
|
||||
const sizeConfig = sizes[size];
|
||||
|
||||
const LogoIcon = () => (
|
||||
<svg
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
className={cn(sizeConfig.icon, "text-current")}
|
||||
>
|
||||
<path
|
||||
d="M20 4L36 20L20 36L4 20L20 4Z"
|
||||
fill="currentColor"
|
||||
className="opacity-90"
|
||||
/>
|
||||
<path
|
||||
d="M20 12L28 20L20 28L12 20L20 12Z"
|
||||
fill="currentColor"
|
||||
className="opacity-60"
|
||||
/>
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="3"
|
||||
fill="currentColor"
|
||||
className="opacity-100"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BrandText = () => (
|
||||
<div className={cn("font-persian font-bold leading-tight", sizeConfig.text)}>
|
||||
<div>پردازشی</div>
|
||||
<div>اینوژن</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CompanyText = () => (
|
||||
<div className="text-xs font-persian text-current opacity-75 mt-1">
|
||||
توسعهیافته توسط شرکت رهبران دانش و فناوری فرا
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<LogoIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "text") {
|
||||
return (
|
||||
<div className={className}>
|
||||
<BrandText />
|
||||
{showCompany && <CompanyText />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center", sizeConfig.container, className)}>
|
||||
<LogoIcon />
|
||||
<div>
|
||||
<BrandText />
|
||||
{showCompany && <CompanyText />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InogenLogoProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
export function InogenLogo({ size = "md", className, animated = false }: InogenLogoProps) {
|
||||
const sizes = {
|
||||
sm: "w-8 h-8",
|
||||
md: "w-10 h-10",
|
||||
lg: "w-12 h-12"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", sizes[size], className)}>
|
||||
<svg
|
||||
viewBox="0 0 40 40"
|
||||
fill="none"
|
||||
className={cn(
|
||||
"w-full h-full",
|
||||
animated && "transition-transform duration-300 hover:scale-110"
|
||||
)}
|
||||
>
|
||||
{/* Outer diamond */}
|
||||
<path
|
||||
d="M20 2L38 20L20 38L2 20L20 2Z"
|
||||
fill="currentColor"
|
||||
className="text-primary opacity-20"
|
||||
/>
|
||||
|
||||
{/* Middle diamond */}
|
||||
<path
|
||||
d="M20 6L34 20L20 34L6 20L20 6Z"
|
||||
fill="currentColor"
|
||||
className="text-primary opacity-40"
|
||||
/>
|
||||
|
||||
{/* Inner diamond */}
|
||||
<path
|
||||
d="M20 10L30 20L20 30L10 20L20 10Z"
|
||||
fill="currentColor"
|
||||
className="text-primary opacity-60"
|
||||
/>
|
||||
|
||||
{/* Center diamond */}
|
||||
<path
|
||||
d="M20 14L26 20L20 26L14 20L20 14Z"
|
||||
fill="currentColor"
|
||||
className="text-primary opacity-80"
|
||||
/>
|
||||
|
||||
{/* Core circle */}
|
||||
<circle
|
||||
cx="20"
|
||||
cy="20"
|
||||
r="3"
|
||||
fill="currentColor"
|
||||
className="text-primary"
|
||||
/>
|
||||
|
||||
{/* Tech lines */}
|
||||
<line
|
||||
x1="20"
|
||||
y1="8"
|
||||
x2="20"
|
||||
y2="12"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-primary opacity-60"
|
||||
/>
|
||||
<line
|
||||
x1="20"
|
||||
y1="28"
|
||||
x2="20"
|
||||
y2="32"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-primary opacity-60"
|
||||
/>
|
||||
<line
|
||||
x1="8"
|
||||
y1="20"
|
||||
x2="12"
|
||||
y2="20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-primary opacity-60"
|
||||
/>
|
||||
<line
|
||||
x1="28"
|
||||
y1="20"
|
||||
x2="32"
|
||||
y2="20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-primary opacity-60"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{animated && (
|
||||
<div className="absolute inset-0 rounded-full bg-primary/10 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CompanyBrandingProps {
|
||||
variant?: "horizontal" | "vertical" | "compact";
|
||||
theme?: "light" | "dark";
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CompanyBranding({
|
||||
variant = "horizontal",
|
||||
theme = "light",
|
||||
size = "md",
|
||||
className
|
||||
}: CompanyBrandingProps) {
|
||||
const isLight = theme === "light";
|
||||
|
||||
const sizes = {
|
||||
sm: {
|
||||
logo: "w-6 h-6",
|
||||
title: "text-sm",
|
||||
subtitle: "text-xs",
|
||||
company: "text-xs"
|
||||
},
|
||||
md: {
|
||||
logo: "w-8 h-8",
|
||||
title: "text-base",
|
||||
subtitle: "text-sm",
|
||||
company: "text-xs"
|
||||
},
|
||||
lg: {
|
||||
logo: "w-10 h-10",
|
||||
title: "text-lg",
|
||||
subtitle: "text-base",
|
||||
company: "text-sm"
|
||||
}
|
||||
};
|
||||
|
||||
const sizeConfig = sizes[size];
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<InogenLogo size={size} />
|
||||
<div className="font-persian font-bold leading-none">
|
||||
<div className={sizeConfig.title}>اینوژن</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "vertical") {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center text-center gap-3", className)}>
|
||||
<InogenLogo size={size} />
|
||||
<div className="font-persian">
|
||||
<div className={cn("font-bold leading-tight", sizeConfig.title)}>
|
||||
پردازشی اینوژن
|
||||
</div>
|
||||
<div className={cn("opacity-75 leading-relaxed mt-1", sizeConfig.company)}>
|
||||
توسعهیافته توسط شرکت رهبران دانش و فناوری فرا
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3", className)}>
|
||||
<InogenLogo size={size} />
|
||||
<div className="font-persian">
|
||||
<div className={cn("font-bold leading-tight", sizeConfig.title)}>
|
||||
پردازشی اینوژن
|
||||
</div>
|
||||
<div className={cn("opacity-75 leading-relaxed", sizeConfig.company)}>
|
||||
شرکت رهبران دانش و فناوری فرا
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
app/components/ui/button.tsx
Normal file
60
app/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
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",
|
||||
teal: "bg-teal-500 text-slate-800 hover:bg-teal-600 focus:ring-teal-500",
|
||||
success:
|
||||
"bg-green-500 text-white hover:bg-green-600 focus:ring-green-400",
|
||||
info: "bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-400",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
79
app/components/ui/card.tsx
Normal file
79
app/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
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}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
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} />
|
||||
))
|
||||
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}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
28
app/components/ui/checkbox.tsx
Normal file
28
app/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
496
app/components/ui/design-system.tsx
Normal file
496
app/components/ui/design-system.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
colors,
|
||||
typography,
|
||||
spacing,
|
||||
borderRadius,
|
||||
shadows,
|
||||
} from "~/lib/design-tokens";
|
||||
|
||||
// Button Component with Figma variants
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive";
|
||||
size?: "sm" | "md" | "lg";
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const baseStyles =
|
||||
"inline-flex items-center justify-center font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed font-persian";
|
||||
|
||||
const variants = {
|
||||
primary:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 focus:ring-primary active:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/90 focus:ring-secondary active:bg-secondary/80",
|
||||
teal: "bg-teal-500 text-slate-800 hover:bg-teal-600 focus:ring-teal-500 active:bg-teal-700",
|
||||
outline:
|
||||
"border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground focus:ring-ring",
|
||||
ghost:
|
||||
"text-foreground hover:bg-accent hover:text-accent-foreground focus:ring-ring",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive active:bg-destructive/80",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-8 px-3 text-sm rounded-md",
|
||||
md: "h-10 px-4 text-sm rounded-lg",
|
||||
lg: "h-12 px-6 text-base rounded-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
fullWidth && "w-full",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{leftIcon && <span className="ml-2">{leftIcon}</span>}
|
||||
{children}
|
||||
{rightIcon && <span className="mr-2">{rightIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
// Input Component
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helper?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
inputSize?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
label,
|
||||
error,
|
||||
helper,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
inputSize = "md",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const baseStyles =
|
||||
"w-full border rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed font-persian text-right";
|
||||
|
||||
const sizes = {
|
||||
sm: "h-8 px-3 text-sm",
|
||||
md: "h-10 px-3 text-sm",
|
||||
lg: "h-12 px-4 text-base",
|
||||
};
|
||||
|
||||
const variants = error
|
||||
? "border-destructive focus:border-destructive focus:ring-destructive/20"
|
||||
: "border-input focus:border-primary focus:ring-primary/20";
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-foreground font-persian">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
sizes[inputSize],
|
||||
variants,
|
||||
leftIcon && "pr-10",
|
||||
rightIcon && "pl-10",
|
||||
"bg-background text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground">
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive font-persian">{error}</p>
|
||||
)}
|
||||
{helper && !error && (
|
||||
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
// Card Component
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "bordered" | "shadow" | "elevated";
|
||||
padding?: "none" | "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
(
|
||||
{ className, variant = "default", padding = "md", children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const baseStyles = "rounded-lg bg-card";
|
||||
|
||||
const variants = {
|
||||
default: "border border-border",
|
||||
bordered: "border-2 border-border",
|
||||
shadow: "shadow-md border border-border",
|
||||
elevated: "shadow-lg border border-border",
|
||||
};
|
||||
|
||||
const paddings = {
|
||||
none: "",
|
||||
sm: "p-4",
|
||||
md: "p-6",
|
||||
lg: "p-8",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
paddings[padding],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Card.displayName = "Card";
|
||||
|
||||
// Badge Component
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||
variant?:
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error";
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
(
|
||||
{ className, variant = "default", size = "md", children, ...props },
|
||||
ref,
|
||||
) => {
|
||||
const baseStyles =
|
||||
"inline-flex items-center font-medium rounded-full font-persian";
|
||||
|
||||
const variants = {
|
||||
default: "bg-secondary text-secondary-foreground",
|
||||
primary: "bg-primary/10 text-primary",
|
||||
secondary: "bg-secondary/10 text-secondary",
|
||||
success:
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
warning:
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
error: "bg-destructive/10 text-destructive",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-2 py-0.5 text-xs",
|
||||
md: "px-2.5 py-0.5 text-sm",
|
||||
lg: "px-3 py-1 text-sm",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Badge.displayName = "Badge";
|
||||
|
||||
// Avatar Component
|
||||
interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
||||
({ className, src, alt, size = "md", fallback, ...props }, ref) => {
|
||||
const sizes = {
|
||||
sm: "h-8 w-8",
|
||||
md: "h-10 w-10",
|
||||
lg: "h-12 w-12",
|
||||
xl: "h-16 w-16",
|
||||
};
|
||||
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
xl: "text-lg",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center rounded-full bg-muted",
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-muted-foreground font-persian",
|
||||
textSizes[size],
|
||||
)}
|
||||
>
|
||||
{fallback}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Avatar.displayName = "Avatar";
|
||||
|
||||
// Loading Spinner Component
|
||||
interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: "sm" | "md" | "lg";
|
||||
color?: "primary" | "secondary" | "white";
|
||||
}
|
||||
|
||||
export const Spinner = React.forwardRef<HTMLDivElement, SpinnerProps>(
|
||||
({ className, size = "md", color = "primary", ...props }, ref) => {
|
||||
const sizes = {
|
||||
sm: "h-4 w-4",
|
||||
md: "h-6 w-6",
|
||||
lg: "h-8 w-8",
|
||||
};
|
||||
|
||||
const colors = {
|
||||
primary: "text-primary",
|
||||
secondary: "text-secondary",
|
||||
white: "text-white",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("animate-spin", sizes[size], colors[color], className)}
|
||||
{...props}
|
||||
>
|
||||
<svg fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Spinner.displayName = "Spinner";
|
||||
|
||||
// Progress Bar Component
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value: number;
|
||||
max?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "default" | "success" | "warning" | "error";
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
value,
|
||||
max = 100,
|
||||
size = "md",
|
||||
variant = "default",
|
||||
showLabel = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
const sizes = {
|
||||
sm: "h-1",
|
||||
md: "h-2",
|
||||
lg: "h-3",
|
||||
};
|
||||
|
||||
const variants = {
|
||||
default: "bg-primary",
|
||||
success: "bg-green-500",
|
||||
warning: "bg-yellow-500",
|
||||
error: "bg-destructive",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{showLabel && (
|
||||
<div className="flex justify-between text-sm text-muted-foreground font-persian">
|
||||
<span>پیشرفت</span>
|
||||
<span>{Math.round(percentage)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"w-full bg-secondary rounded-full",
|
||||
sizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-300",
|
||||
variants[variant],
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Progress.displayName = "Progress";
|
||||
|
||||
// Divider Component
|
||||
interface DividerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
variant?: "solid" | "dashed" | "dotted";
|
||||
}
|
||||
|
||||
export const Divider = React.forwardRef<HTMLDivElement, DividerProps>(
|
||||
(
|
||||
{ className, orientation = "horizontal", variant = "solid", ...props },
|
||||
ref,
|
||||
) => {
|
||||
const baseStyles = "border-border";
|
||||
|
||||
const orientations = {
|
||||
horizontal: "w-full border-t",
|
||||
vertical: "h-full border-l",
|
||||
};
|
||||
|
||||
const variants = {
|
||||
solid: "border-solid",
|
||||
dashed: "border-dashed",
|
||||
dotted: "border-dotted",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
orientations[orientation],
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Divider.displayName = "Divider";
|
||||
223
app/components/ui/error-alert.tsx
Normal file
223
app/components/ui/error-alert.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { AlertCircle, X, RefreshCw } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
|
||||
interface ErrorAlertProps {
|
||||
title?: string;
|
||||
message: string;
|
||||
variant?: "error" | "warning" | "info";
|
||||
dismissible?: boolean;
|
||||
onDismiss?: () => void;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ErrorAlert({
|
||||
title,
|
||||
message,
|
||||
variant = "error",
|
||||
dismissible = false,
|
||||
onDismiss,
|
||||
onRetry,
|
||||
retryLabel = "تلاش مجدد",
|
||||
className,
|
||||
icon,
|
||||
actions,
|
||||
}: ErrorAlertProps) {
|
||||
const variants = {
|
||||
error: {
|
||||
container:
|
||||
"bg-red-50 border-red-200 dark:bg-red-950/20 dark:border-red-800/30",
|
||||
icon: "text-red-500 dark:text-red-400",
|
||||
title: "text-red-800 dark:text-red-200",
|
||||
message: "text-red-700 dark:text-red-300",
|
||||
button:
|
||||
"text-red-700 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200",
|
||||
},
|
||||
warning: {
|
||||
container:
|
||||
"bg-yellow-50 border-yellow-200 dark:bg-yellow-950/20 dark:border-yellow-800/30",
|
||||
icon: "text-yellow-500 dark:text-yellow-400",
|
||||
title: "text-yellow-800 dark:text-yellow-200",
|
||||
message: "text-yellow-700 dark:text-yellow-300",
|
||||
button:
|
||||
"text-yellow-700 hover:text-yellow-800 dark:text-yellow-300 dark:hover:text-yellow-200",
|
||||
},
|
||||
info: {
|
||||
container:
|
||||
"bg-blue-50 border-blue-200 dark:bg-blue-950/20 dark:border-blue-800/30",
|
||||
icon: "text-blue-500 dark:text-blue-400",
|
||||
title: "text-blue-800 dark:text-blue-200",
|
||||
message: "text-blue-700 dark:text-blue-300",
|
||||
button:
|
||||
"text-blue-700 hover:text-blue-800 dark:text-blue-300 dark:hover:text-blue-200",
|
||||
},
|
||||
};
|
||||
|
||||
const variantStyles = variants[variant];
|
||||
const defaultIcon = <AlertCircle className="h-5 w-5" />;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-lg border p-4 transition-all duration-200 animate-slide-down",
|
||||
variantStyles.container,
|
||||
className,
|
||||
)}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className={cn("flex-shrink-0 mt-0.5", variantStyles.icon)}>
|
||||
{icon || defaultIcon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h3
|
||||
className={cn(
|
||||
"text-sm font-medium font-persian mb-1",
|
||||
variantStyles.title,
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-persian leading-relaxed",
|
||||
variantStyles.message,
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{(onRetry || actions) && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{onRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
className={cn(
|
||||
"h-8 px-3 font-persian text-xs",
|
||||
variantStyles.button,
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 ml-1" />
|
||||
{retryLabel}
|
||||
</Button>
|
||||
)}
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss Button */}
|
||||
{dismissible && onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className={cn(
|
||||
"flex-shrink-0 rounded-md p-1.5 transition-colors duration-200 hover:bg-black/5 dark:hover:bg-white/5",
|
||||
variantStyles.button,
|
||||
)}
|
||||
aria-label="بستن پیام"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InlineErrorProps {
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineError({ message, className }: InlineErrorProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-destructive font-persian",
|
||||
className,
|
||||
)}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormErrorProps {
|
||||
title?: string;
|
||||
errors: string[];
|
||||
className?: string;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
}
|
||||
|
||||
export function FormError({
|
||||
title = "خطا در ارسال فرم",
|
||||
errors,
|
||||
className,
|
||||
onRetry,
|
||||
retryLabel = "تلاش مجدد",
|
||||
}: FormErrorProps) {
|
||||
if (errors.length === 0) return null;
|
||||
|
||||
const message =
|
||||
errors.length === 1
|
||||
? errors[0]
|
||||
: errors.map((error, index) => `• ${error}`).join("\n");
|
||||
|
||||
return (
|
||||
<ErrorAlert
|
||||
title={title}
|
||||
message={message}
|
||||
variant="error"
|
||||
onRetry={onRetry}
|
||||
retryLabel={retryLabel}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionErrorProps {
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConnectionError({ onRetry, className }: ConnectionErrorProps) {
|
||||
return (
|
||||
<ErrorAlert
|
||||
title="خطا در اتصال"
|
||||
message="خطا در برقراری ارتباط با سرور. لطفاً اتصال اینترنت خود را بررسی کنید."
|
||||
variant="error"
|
||||
onRetry={onRetry}
|
||||
retryLabel="تلاش مجدد"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValidationErrorProps {
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ValidationError({ message, className }: ValidationErrorProps) {
|
||||
return (
|
||||
<ErrorAlert message={message} variant="warning" className={className} />
|
||||
);
|
||||
}
|
||||
388
app/components/ui/form-field.tsx
Normal file
388
app/components/ui/form-field.tsx
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Eye, EyeOff, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { Input } from "./input";
|
||||
import { Label } from "./label";
|
||||
|
||||
interface BaseFieldProps {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helper?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
interface TextFieldProps extends BaseFieldProps {
|
||||
id: string;
|
||||
type?: "text" | "email" | "tel" | "url";
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
autoComplete?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
id,
|
||||
label,
|
||||
type = "text",
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
helper,
|
||||
required,
|
||||
disabled,
|
||||
autoComplete,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
maxLength,
|
||||
minLength,
|
||||
className,
|
||||
containerClassName,
|
||||
}: TextFieldProps) {
|
||||
const hasError = !!error;
|
||||
const hasSuccess = !hasError && value.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", containerClassName)}>
|
||||
{label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"block text-sm font-medium font-persian",
|
||||
hasError ? "text-destructive" : "text-foreground",
|
||||
required && "after:content-['*'] after:ml-1 after:text-destructive",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 text-muted-foreground">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
maxLength={maxLength}
|
||||
minLength={minLength}
|
||||
className={cn(
|
||||
"w-full h-12 px-4 font-persian text-right transition-all duration-200",
|
||||
leftIcon && "pr-10",
|
||||
(rightIcon || hasError || hasSuccess) && "pl-10",
|
||||
hasError &&
|
||||
"border-destructive focus:border-destructive focus:ring-destructive/20",
|
||||
hasSuccess &&
|
||||
"border-green-500 focus:border-green-500 focus:ring-green-500/20",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
{(rightIcon || hasError || hasSuccess) && (
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">
|
||||
{hasError ? (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
) : hasSuccess ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
rightIcon && (
|
||||
<span className="text-muted-foreground">{rightIcon}</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive font-persian flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helper && !error && (
|
||||
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
|
||||
)}
|
||||
|
||||
{maxLength && (
|
||||
<div className="text-xs text-muted-foreground text-left font-persian">
|
||||
{value.length}/{maxLength}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PasswordFieldProps extends BaseFieldProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
autoComplete?: string;
|
||||
showStrength?: boolean;
|
||||
minLength?: number;
|
||||
}
|
||||
|
||||
export function PasswordField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
helper,
|
||||
required,
|
||||
disabled,
|
||||
autoComplete = "current-password",
|
||||
showStrength = false,
|
||||
minLength,
|
||||
className,
|
||||
containerClassName,
|
||||
}: PasswordFieldProps) {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const hasError = !!error;
|
||||
|
||||
const getPasswordStrength = (
|
||||
password: string,
|
||||
): {
|
||||
score: number;
|
||||
text: string;
|
||||
color: string;
|
||||
} => {
|
||||
if (!password) return { score: 0, text: "", color: "" };
|
||||
|
||||
let score = 0;
|
||||
if (password.length >= 8) score++;
|
||||
if (/[a-z]/.test(password)) score++;
|
||||
if (/[A-Z]/.test(password)) score++;
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score++;
|
||||
|
||||
const strength = [
|
||||
{ text: "بسیار ضعیف", color: "text-red-500" },
|
||||
{ text: "ضعیف", color: "text-orange-500" },
|
||||
{ text: "متوسط", color: "text-yellow-500" },
|
||||
{ text: "قوی", color: "text-blue-500" },
|
||||
{ text: "بسیار قوی", color: "text-green-500" },
|
||||
];
|
||||
|
||||
return {
|
||||
score,
|
||||
text: strength[Math.min(score, 4)].text,
|
||||
color: strength[Math.min(score, 4)].color,
|
||||
};
|
||||
};
|
||||
|
||||
const strength = showStrength ? getPasswordStrength(value) : null;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", containerClassName)}>
|
||||
{label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"block text-sm font-medium font-persian",
|
||||
hasError ? "text-destructive" : "text-foreground",
|
||||
required && "after:content-['*'] after:ml-1 after:text-destructive",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={id}
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
minLength={minLength}
|
||||
className={cn(
|
||||
"w-full h-12 px-4 pl-10 font-persian text-right transition-all duration-200",
|
||||
hasError &&
|
||||
"border-destructive focus:border-destructive focus:ring-destructive/20",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showStrength && value && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-persian text-muted-foreground">
|
||||
قدرت رمز عبور:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-persian font-medium",
|
||||
strength?.color,
|
||||
)}
|
||||
>
|
||||
{strength?.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={cn(
|
||||
"h-1 flex-1 rounded-full transition-colors duration-200",
|
||||
step <= (strength?.score || 0)
|
||||
? step <= 2
|
||||
? "bg-red-500"
|
||||
: step <= 3
|
||||
? "bg-yellow-500"
|
||||
: step <= 4
|
||||
? "bg-blue-500"
|
||||
: "bg-green-500"
|
||||
: "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive font-persian flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helper && !error && (
|
||||
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CheckboxFieldProps extends BaseFieldProps {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function CheckboxField({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
error,
|
||||
helper,
|
||||
required,
|
||||
disabled,
|
||||
size = "md",
|
||||
className,
|
||||
containerClassName,
|
||||
}: CheckboxFieldProps) {
|
||||
const sizes = {
|
||||
sm: "w-3 h-3",
|
||||
md: "w-4 h-4",
|
||||
lg: "w-5 h-5",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", containerClassName)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
sizes[size],
|
||||
"text-[var(--color-login-primary)] bg-background border-input rounded focus:ring-[var(--color-login-primary)] focus:ring-2 accent-[var(--color-login-primary)] transition-all duration-200",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
error && "border-destructive focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm font-persian cursor-pointer",
|
||||
error ? "text-destructive" : "text-foreground",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
required &&
|
||||
"after:content-['*'] after:ml-1 after:text-destructive",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive font-persian flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helper && !error && (
|
||||
<p className="text-sm text-muted-foreground font-persian">{helper}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldGroupProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FieldGroup({ children, className }: FieldGroupProps) {
|
||||
return <div className={cn("space-y-4", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface FormActionsProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormActions({ children, className }: FormActionsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row gap-3 pt-4", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/components/ui/input.tsx
Normal file
25
app/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
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-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 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
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
24
app/components/ui/label.tsx
Normal file
24
app/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
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"
|
||||
)
|
||||
|
||||
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}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
367
app/components/ui/loading.tsx
Normal file
367
app/components/ui/loading.tsx
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
variant?: "primary" | "secondary" | "muted" | "white";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
className,
|
||||
}: LoadingSpinnerProps) {
|
||||
const sizes = {
|
||||
xs: "w-3 h-3",
|
||||
sm: "w-4 h-4",
|
||||
md: "w-6 h-6",
|
||||
lg: "w-8 h-8",
|
||||
xl: "w-12 h-12",
|
||||
};
|
||||
|
||||
const variants = {
|
||||
primary: "text-primary",
|
||||
secondary: "text-secondary",
|
||||
muted: "text-muted-foreground",
|
||||
white: "text-white",
|
||||
};
|
||||
|
||||
return (
|
||||
<Loader2
|
||||
className={cn("animate-spin", sizes[size], variants[variant], className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingDotsProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "primary" | "secondary" | "muted" | "white";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingDots({
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
className,
|
||||
}: LoadingDotsProps) {
|
||||
const sizes = {
|
||||
sm: "w-1 h-1",
|
||||
md: "w-2 h-2",
|
||||
lg: "w-3 h-3",
|
||||
};
|
||||
|
||||
const variants = {
|
||||
primary: "bg-primary",
|
||||
secondary: "bg-secondary",
|
||||
muted: "bg-muted-foreground",
|
||||
white: "bg-white",
|
||||
};
|
||||
|
||||
const dotClass = cn(
|
||||
"rounded-full animate-pulse",
|
||||
sizes[size],
|
||||
variants[variant],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-1", className)}>
|
||||
<div className={cn(dotClass, "animation-delay-0")} />
|
||||
<div className={cn(dotClass, "animation-delay-200")} />
|
||||
<div className={cn(dotClass, "animation-delay-400")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingBarProps {
|
||||
progress?: number;
|
||||
variant?: "primary" | "secondary" | "success" | "warning" | "error";
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
showPercentage?: boolean;
|
||||
}
|
||||
|
||||
export function LoadingBar({
|
||||
progress,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className,
|
||||
showPercentage = false,
|
||||
}: LoadingBarProps) {
|
||||
const variants = {
|
||||
primary: "bg-primary",
|
||||
secondary: "bg-secondary",
|
||||
success: "bg-green-500",
|
||||
warning: "bg-yellow-500",
|
||||
error: "bg-red-500",
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "h-1",
|
||||
md: "h-2",
|
||||
lg: "h-3",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{showPercentage && progress !== undefined && (
|
||||
<div className="flex justify-between text-sm text-muted-foreground mb-1 font-persian">
|
||||
<span>در حال بارگذاری...</span>
|
||||
<span>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full bg-muted rounded-full overflow-hidden",
|
||||
sizes[size],
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-300 ease-out rounded-full",
|
||||
variants[variant],
|
||||
progress === undefined && "animate-pulse",
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
progress !== undefined ? `${Math.min(progress, 100)}%` : "100%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingPageProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: "primary" | "white";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingPage({
|
||||
title = "در حال بارگذاری...",
|
||||
description,
|
||||
variant = "primary",
|
||||
className,
|
||||
}: LoadingPageProps) {
|
||||
const isWhite = variant === "white";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-screen flex items-center justify-center",
|
||||
isWhite ? "bg-background" : "bg-slate-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="text-center space-y-6 max-w-md mx-auto p-8">
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="xl" variant={isWhite ? "primary" : "white"} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2
|
||||
className={cn(
|
||||
"text-lg font-medium font-persian",
|
||||
isWhite ? "text-foreground" : "text-white",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-persian leading-relaxed",
|
||||
isWhite ? "text-muted-foreground" : "text-slate-300",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({
|
||||
visible,
|
||||
title = "در حال پردازش...",
|
||||
description,
|
||||
className,
|
||||
}: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center transition-all duration-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="bg-card border border-border rounded-lg p-6 max-w-sm mx-4 shadow-lg">
|
||||
<div className="text-center space-y-4">
|
||||
<LoadingSpinner size="lg" />
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium font-persian text-card-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground font-persian">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingButtonProps {
|
||||
loading: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingButton({
|
||||
loading,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: LoadingButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
disabled={loading}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<LoadingSpinner
|
||||
size="sm"
|
||||
className="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
)}
|
||||
<span className={cn(loading && "opacity-0")}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingCardProps {
|
||||
title?: string;
|
||||
lines?: number;
|
||||
showAvatar?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingCard({
|
||||
title,
|
||||
lines = 3,
|
||||
showAvatar = false,
|
||||
className,
|
||||
}: LoadingCardProps) {
|
||||
return (
|
||||
<div className={cn("animate-pulse p-4 space-y-4", className)}>
|
||||
{title && <div className="h-4 bg-muted rounded w-3/4" />}
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
{showAvatar && <div className="w-10 h-10 bg-muted rounded-full" />}
|
||||
<div className="flex-1 space-y-2">
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"h-3 bg-muted rounded",
|
||||
index === lines - 1 ? "w-2/3" : "w-full",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
className?: string;
|
||||
variant?: "text" | "circular" | "rectangular";
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export function LoadingSkeleton({
|
||||
className,
|
||||
variant = "rectangular",
|
||||
width,
|
||||
height,
|
||||
}: LoadingSkeletonProps) {
|
||||
const variants = {
|
||||
text: "h-4 w-full",
|
||||
circular: "rounded-full",
|
||||
rectangular: "rounded",
|
||||
};
|
||||
|
||||
const style: React.CSSProperties = {};
|
||||
if (width) style.width = typeof width === "number" ? `${width}px` : width;
|
||||
if (height)
|
||||
style.height = typeof height === "number" ? `${height}px` : height;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse bg-muted",
|
||||
variants[variant],
|
||||
!width && !height && variants.text,
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Utility component for loading states
|
||||
interface LoadingStateProps {
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
children: React.ReactNode;
|
||||
loadingComponent?: React.ReactNode;
|
||||
errorComponent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
loading,
|
||||
error,
|
||||
children,
|
||||
loadingComponent,
|
||||
errorComponent,
|
||||
}: LoadingStateProps) {
|
||||
if (loading) {
|
||||
return loadingComponent || <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
errorComponent || (
|
||||
<div className="text-center p-4">
|
||||
<p className="text-destructive text-sm font-persian">{error}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Default export for convenience
|
||||
export default LoadingSpinner;
|
||||
237
app/contexts/auth-context.tsx
Normal file
237
app/contexts/auth-context.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import apiService from "~/lib/api";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
family: string;
|
||||
email: string;
|
||||
username: string;
|
||||
mobile?: string;
|
||||
nationalCode?: string;
|
||||
status: boolean;
|
||||
customTheme?: string;
|
||||
}
|
||||
|
||||
interface Token {
|
||||
id: number;
|
||||
accessToken: string;
|
||||
expAccessTokenStamp: string;
|
||||
expAccessToken: string;
|
||||
refreshToken: string;
|
||||
expRefreshToken: string;
|
||||
expRefreshTokenStamp: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: Token | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
validateToken: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<Token | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Token validation function
|
||||
const validateToken = async (tokenToValidate?: Token): Promise<boolean> => {
|
||||
const currentToken = tokenToValidate || token;
|
||||
|
||||
if (!currentToken || !currentToken.accessToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if token is expired using the expAccessTokenStamp
|
||||
const expirationDate = new Date(currentToken.expAccessTokenStamp);
|
||||
const currentDate = new Date();
|
||||
|
||||
if (expirationDate <= currentDate) {
|
||||
// Token is expired
|
||||
clearAuthData();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Token validation error:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearAuthData = () => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem("auth_user");
|
||||
localStorage.removeItem("auth_token");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
// Check for existing user and token in localStorage on mount
|
||||
const savedUser = localStorage.getItem("auth_user");
|
||||
const savedToken = localStorage.getItem("auth_token");
|
||||
|
||||
if (savedUser && savedToken) {
|
||||
try {
|
||||
const userData = JSON.parse(savedUser);
|
||||
const tokenData = JSON.parse(savedToken);
|
||||
|
||||
// Validate the saved token
|
||||
const isValidToken = await validateToken(tokenData);
|
||||
|
||||
if (isValidToken) {
|
||||
setUser(userData);
|
||||
setToken(tokenData);
|
||||
} else {
|
||||
// Token is invalid, clear auth data
|
||||
clearAuthData();
|
||||
toast.error("جلسه کاری شما منقضی شده است");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing saved user data:", error);
|
||||
clearAuthData();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auth initialization error:", error);
|
||||
clearAuthData();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
// Auto-validate token every 5 minutes
|
||||
useEffect(() => {
|
||||
if (!token || !user) return;
|
||||
|
||||
const interval = setInterval(
|
||||
async () => {
|
||||
const isValid = await validateToken();
|
||||
if (!isValid) {
|
||||
clearAuthData();
|
||||
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید");
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
); // 5 minutes
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [token, user]);
|
||||
|
||||
const login = async (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<boolean> => {
|
||||
if (!username || !password) {
|
||||
toast.error("لطفاً تمام فیلدها را پر کنید");
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await apiService.login(username, password);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const tokenData: Token = {
|
||||
id: result.data.Token.ID,
|
||||
accessToken: result.data.Token.AccessToken,
|
||||
expAccessTokenStamp: result.data.Token.ExpAccessTokenStamp,
|
||||
expAccessToken: result.data.Token.ExpAccessToken,
|
||||
refreshToken: result.data.Token.RefreshToken,
|
||||
expRefreshToken: result.data.Token.ExpRefreshToken,
|
||||
expRefreshTokenStamp: result.data.Token.ExpRefreshTokenStamp,
|
||||
};
|
||||
|
||||
const userData: User = {
|
||||
id: result.data.Person.ID,
|
||||
name: result.data.Person.Name,
|
||||
family: result.data.Person.Family,
|
||||
email: result.data.Person.Email,
|
||||
username: result.data.Person.Username,
|
||||
mobile: result.data.Person.Mobile,
|
||||
nationalCode: result.data.Person.NationalCode,
|
||||
status: result.data.Person.Status,
|
||||
customTheme: result.data.Person.CustomeTheme,
|
||||
};
|
||||
|
||||
// Validate the received token
|
||||
const isValidToken = await validateToken(tokenData);
|
||||
|
||||
if (!isValidToken) {
|
||||
toast.error("توکن دریافتی نامعتبر است");
|
||||
return false;
|
||||
}
|
||||
|
||||
setUser(userData);
|
||||
setToken(tokenData);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem("auth_user", JSON.stringify(userData));
|
||||
localStorage.setItem("auth_token", JSON.stringify(tokenData));
|
||||
|
||||
toast.success(`خوش آمدید ${userData.name} ${userData.family}!`);
|
||||
return true;
|
||||
} else {
|
||||
toast.error(result.message || "نام کاربری یا رمز عبور اشتباه است");
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "خطای غیرمنتظره رخ داد";
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiService.logout();
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error);
|
||||
} finally {
|
||||
clearAuthData();
|
||||
toast.success("با موفقیت خارج شدید");
|
||||
}
|
||||
};
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated: !!user && !!token,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
token,
|
||||
validateToken,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
135
app/hooks/use-route-guard.tsx
Normal file
135
app/hooks/use-route-guard.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect } from "react";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
import { useNavigate, useLocation } from "react-router";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface UseRouteGuardOptions {
|
||||
requireAuth?: boolean;
|
||||
redirectTo?: string;
|
||||
showToast?: boolean;
|
||||
}
|
||||
|
||||
export function useRouteGuard(options: UseRouteGuardOptions = {}) {
|
||||
const {
|
||||
requireAuth = true,
|
||||
redirectTo = "/login",
|
||||
showToast = true,
|
||||
} = options;
|
||||
|
||||
const { isAuthenticated, isLoading, token, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Don't do anything while loading
|
||||
if (isLoading) return;
|
||||
|
||||
// If authentication is required but user is not authenticated
|
||||
if (requireAuth && !isAuthenticated) {
|
||||
if (showToast) {
|
||||
toast.error("برای دسترسی به این صفحه باید وارد شوید");
|
||||
}
|
||||
|
||||
// Save the current location so we can redirect back after login
|
||||
const currentPath = location.pathname + location.search;
|
||||
const loginPath =
|
||||
redirectTo === "/login"
|
||||
? `${redirectTo}?returnTo=${encodeURIComponent(currentPath)}`
|
||||
: redirectTo;
|
||||
|
||||
navigate(loginPath, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// If authentication is required but token is missing/invalid
|
||||
if (requireAuth && isAuthenticated && !token) {
|
||||
if (showToast) {
|
||||
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید");
|
||||
}
|
||||
|
||||
// Clear any stored authentication data
|
||||
localStorage.removeItem("auth_user");
|
||||
localStorage.removeItem("auth_token");
|
||||
|
||||
navigate("/login", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// If user is authenticated but trying to access login page
|
||||
if (!requireAuth && isAuthenticated && location.pathname === "/login") {
|
||||
navigate("/dashboard", { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
token,
|
||||
requireAuth,
|
||||
redirectTo,
|
||||
showToast,
|
||||
navigate,
|
||||
location.pathname,
|
||||
location.search,
|
||||
]);
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
token,
|
||||
user,
|
||||
canAccess: requireAuth ? isAuthenticated && !!token?.accessToken : true,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper hook for protected routes
|
||||
export function useProtectedRoute(redirectTo?: string) {
|
||||
return useRouteGuard({
|
||||
requireAuth: true,
|
||||
redirectTo,
|
||||
showToast: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Helper hook for public routes (like login)
|
||||
export function usePublicRoute() {
|
||||
return useRouteGuard({
|
||||
requireAuth: false,
|
||||
showToast: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to check if user has specific permissions
|
||||
export function usePermissionGuard(
|
||||
requiredPermissions: string[] = [],
|
||||
userPermissions: string[] = [],
|
||||
) {
|
||||
const { isAuthenticated, token } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const hasPermission = requiredPermissions.every((permission) =>
|
||||
userPermissions.includes(permission),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isAuthenticated &&
|
||||
token?.accessToken &&
|
||||
!hasPermission &&
|
||||
requiredPermissions.length > 0
|
||||
) {
|
||||
toast.error("شما دسترسی لازم برای این صفحه را ندارید");
|
||||
navigate("/dashboard", { replace: true });
|
||||
}
|
||||
}, [
|
||||
isAuthenticated,
|
||||
token,
|
||||
hasPermission,
|
||||
requiredPermissions.length,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
canAccess: isAuthenticated && !!token?.accessToken && hasPermission,
|
||||
};
|
||||
}
|
||||
264
app/lib/api.ts
Normal file
264
app/lib/api.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import toast from "react-hot-toast";
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
message: string;
|
||||
data: T;
|
||||
state: number;
|
||||
time: number;
|
||||
errorCode: number;
|
||||
resultType: number;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private baseURL = "https://inogen-back.pelekan.org/api";
|
||||
private token: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Initialize token from localStorage
|
||||
this.initializeToken();
|
||||
}
|
||||
|
||||
private initializeToken() {
|
||||
try {
|
||||
const savedToken = localStorage.getItem("auth_token");
|
||||
if (savedToken) {
|
||||
const tokenData = JSON.parse(savedToken);
|
||||
this.token = tokenData.accessToken;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error initializing token:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public setToken(token: string) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public clearToken() {
|
||||
this.token = null;
|
||||
}
|
||||
|
||||
private async request<T = any>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
|
||||
const defaultHeaders: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// Add authorization header if token exists
|
||||
if (this.token) {
|
||||
defaultHeaders.Authorization = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
const config: RequestInit = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
// Handle different response states
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (data.state !== 0) {
|
||||
throw new Error(data.message || "API error occurred");
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("API request failed:", error);
|
||||
|
||||
// Handle network errors
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
toast.error("خطا در اتصال به سرور. لطفاً اتصال اینترنت خود را بررسی کنید");
|
||||
throw new Error("شبکه در دسترس نیست");
|
||||
}
|
||||
|
||||
// Handle authentication errors
|
||||
if (error instanceof Error && error.message.includes("401")) {
|
||||
toast.error("جلسه کاری شما منقضی شده است. لطفاً دوباره وارد شوید");
|
||||
this.clearToken();
|
||||
localStorage.removeItem("auth_token");
|
||||
localStorage.removeItem("auth_user");
|
||||
window.location.href = "/login";
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// GET request
|
||||
public async get<T = any>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
// POST request
|
||||
public async post<T = any>(
|
||||
endpoint: string,
|
||||
data?: any
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: "POST",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// PUT request
|
||||
public async put<T = any>(
|
||||
endpoint: string,
|
||||
data?: any
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: "PUT",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE request
|
||||
public async delete<T = any>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
// PATCH request
|
||||
public async patch<T = any>(
|
||||
endpoint: string,
|
||||
data?: any
|
||||
): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: "PATCH",
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication methods
|
||||
public async login(username: string, password: string) {
|
||||
try {
|
||||
const response = await this.post("/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.state === 0) {
|
||||
const parsedData = JSON.parse(response.data);
|
||||
this.setToken(parsedData.Token.AccessToken);
|
||||
return {
|
||||
success: true,
|
||||
data: parsedData,
|
||||
message: response.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: response.message || "ورود ناموفق",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "خطای غیرمنتظره",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async logout() {
|
||||
try {
|
||||
// Call logout endpoint if it exists
|
||||
await this.post("/logout");
|
||||
} catch (error) {
|
||||
console.error("Logout API call failed:", error);
|
||||
} finally {
|
||||
// Clear token regardless of API call success
|
||||
this.clearToken();
|
||||
localStorage.removeItem("auth_token");
|
||||
localStorage.removeItem("auth_user");
|
||||
}
|
||||
}
|
||||
|
||||
// Profile methods
|
||||
public async getProfile() {
|
||||
return this.get("/profile");
|
||||
}
|
||||
|
||||
public async updateProfile(data: any) {
|
||||
return this.put("/profile", data);
|
||||
}
|
||||
|
||||
// Projects methods
|
||||
public async getProjects() {
|
||||
return this.get("/projects");
|
||||
}
|
||||
|
||||
public async getProject(id: number) {
|
||||
return this.get(`/projects/${id}`);
|
||||
}
|
||||
|
||||
public async createProject(data: any) {
|
||||
return this.post("/projects", data);
|
||||
}
|
||||
|
||||
public async updateProject(id: number, data: any) {
|
||||
return this.put(`/projects/${id}`, data);
|
||||
}
|
||||
|
||||
public async deleteProject(id: number) {
|
||||
return this.delete(`/projects/${id}`);
|
||||
}
|
||||
|
||||
// Dashboard methods
|
||||
public async getDashboardStats() {
|
||||
return this.get("/dashboard/stats");
|
||||
}
|
||||
|
||||
public async getDashboardRecentActivity() {
|
||||
return this.get("/dashboard/recent-activity");
|
||||
}
|
||||
|
||||
// File upload method
|
||||
public async uploadFile(file: File, endpoint: string = "/upload") {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const config: RequestInit = {
|
||||
method: "POST",
|
||||
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
||||
body: formData,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("File upload failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
const apiService = new ApiService();
|
||||
|
||||
export default apiService;
|
||||
|
||||
// Export types for use in components
|
||||
export type { ApiResponse };
|
||||
447
app/lib/design-tokens.ts
Normal file
447
app/lib/design-tokens.ts
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
export const colors = {
|
||||
// Primary Colors
|
||||
primary: {
|
||||
50: "#f0fdf4",
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
950: "#052e16",
|
||||
},
|
||||
|
||||
// Secondary Colors (Blue)
|
||||
secondary: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
950: "#172554",
|
||||
},
|
||||
|
||||
// Neutral Colors
|
||||
neutral: {
|
||||
50: "#fafafa",
|
||||
100: "#f5f5f5",
|
||||
200: "#e5e5e5",
|
||||
300: "#d4d4d4",
|
||||
400: "#a3a3a3",
|
||||
500: "#737373",
|
||||
600: "#525252",
|
||||
700: "#404040",
|
||||
800: "#262626",
|
||||
900: "#171717",
|
||||
950: "#0a0a0a",
|
||||
},
|
||||
|
||||
// Status Colors
|
||||
success: {
|
||||
50: "#f0fdf4",
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
|
||||
error: {
|
||||
50: "#fef2f2",
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
|
||||
warning: {
|
||||
50: "#fffbeb",
|
||||
100: "#fef3c7",
|
||||
200: "#fde68a",
|
||||
300: "#fcd34d",
|
||||
400: "#fbbf24",
|
||||
500: "#f59e0b",
|
||||
600: "#d97706",
|
||||
700: "#b45309",
|
||||
800: "#92400e",
|
||||
900: "#78350f",
|
||||
},
|
||||
|
||||
info: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
|
||||
// Teal Colors (Brand accent)
|
||||
teal: {
|
||||
50: "#f0fdfa",
|
||||
100: "#ccfbf1",
|
||||
200: "#99f6e4",
|
||||
300: "#5eead4",
|
||||
400: "#2dd4bf",
|
||||
500: "#14b8a6",
|
||||
600: "#0d9488",
|
||||
700: "#0f766e",
|
||||
800: "#115e59",
|
||||
900: "#134e4a",
|
||||
},
|
||||
|
||||
// Dark Colors (Brand dark)
|
||||
dark: {
|
||||
50: "#f8fafc",
|
||||
100: "#f1f5f9",
|
||||
200: "#e2e8f0",
|
||||
300: "#cbd5e1",
|
||||
400: "#94a3b8",
|
||||
500: "#64748b",
|
||||
600: "#475569",
|
||||
700: "#334155",
|
||||
800: "#1e293b",
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
|
||||
// Login specific colors
|
||||
login: {
|
||||
primary: "#3aea83",
|
||||
darkStart: "#464861",
|
||||
darkEnd: "#111628",
|
||||
},
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
sans: ["Vazirmatn", "Inter", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
mono: ["ui-monospace", "SFMono-Regular", "Consolas", "monospace"],
|
||||
},
|
||||
|
||||
fontSize: {
|
||||
xs: ["0.75rem", { lineHeight: "1rem" }],
|
||||
sm: ["0.875rem", { lineHeight: "1.25rem" }],
|
||||
base: ["1rem", { lineHeight: "1.5rem" }],
|
||||
lg: ["1.125rem", { lineHeight: "1.75rem" }],
|
||||
xl: ["1.25rem", { lineHeight: "1.75rem" }],
|
||||
"2xl": ["1.5rem", { lineHeight: "2rem" }],
|
||||
"3xl": ["1.875rem", { lineHeight: "2.25rem" }],
|
||||
"4xl": ["2.25rem", { lineHeight: "2.5rem" }],
|
||||
"5xl": ["3rem", { lineHeight: "1" }],
|
||||
"6xl": ["3.75rem", { lineHeight: "1" }],
|
||||
},
|
||||
|
||||
fontWeight: {
|
||||
thin: "100",
|
||||
extralight: "200",
|
||||
light: "300",
|
||||
normal: "400",
|
||||
medium: "500",
|
||||
semibold: "600",
|
||||
bold: "700",
|
||||
extrabold: "800",
|
||||
black: "900",
|
||||
},
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
px: "1px",
|
||||
0: "0px",
|
||||
0.5: "0.125rem",
|
||||
1: "0.25rem",
|
||||
1.5: "0.375rem",
|
||||
2: "0.5rem",
|
||||
2.5: "0.625rem",
|
||||
3: "0.75rem",
|
||||
3.5: "0.875rem",
|
||||
4: "1rem",
|
||||
5: "1.25rem",
|
||||
6: "1.5rem",
|
||||
7: "1.75rem",
|
||||
8: "2rem",
|
||||
9: "2.25rem",
|
||||
10: "2.5rem",
|
||||
11: "2.75rem",
|
||||
12: "3rem",
|
||||
14: "3.5rem",
|
||||
16: "4rem",
|
||||
20: "5rem",
|
||||
24: "6rem",
|
||||
28: "7rem",
|
||||
32: "8rem",
|
||||
36: "9rem",
|
||||
40: "10rem",
|
||||
44: "11rem",
|
||||
48: "12rem",
|
||||
52: "13rem",
|
||||
56: "14rem",
|
||||
60: "15rem",
|
||||
64: "16rem",
|
||||
72: "18rem",
|
||||
80: "20rem",
|
||||
96: "24rem",
|
||||
};
|
||||
|
||||
export const borderRadius = {
|
||||
none: "0px",
|
||||
sm: "0.125rem",
|
||||
DEFAULT: "0.25rem",
|
||||
md: "0.375rem",
|
||||
lg: "0.5rem",
|
||||
xl: "0.75rem",
|
||||
"2xl": "1rem",
|
||||
"3xl": "1.5rem",
|
||||
full: "9999px",
|
||||
};
|
||||
|
||||
export const shadows = {
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
DEFAULT: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
xl: "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)",
|
||||
"2xl": "0 25px 50px -12px rgb(0 0 0 / 0.25)",
|
||||
inner: "inset 0 2px 4px 0 rgb(0 0 0 / 0.05)",
|
||||
none: "0 0 #0000",
|
||||
};
|
||||
|
||||
export const animations = {
|
||||
spin: "spin 1s linear infinite",
|
||||
ping: "ping 1s cubic-bezier(0, 0, 0.2, 1) infinite",
|
||||
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
bounce: "bounce 1s infinite",
|
||||
fadeIn: "fadeIn 0.3s ease-in-out",
|
||||
fadeOut: "fadeOut 0.3s ease-in-out",
|
||||
slideInRight: "slideInRight 0.3s ease-out",
|
||||
slideInLeft: "slideInLeft 0.3s ease-out",
|
||||
slideUp: "slideUp 0.3s ease-out",
|
||||
slideDown: "slideDown 0.3s ease-out",
|
||||
};
|
||||
|
||||
export const breakpoints = {
|
||||
sm: "640px",
|
||||
md: "768px",
|
||||
lg: "1024px",
|
||||
xl: "1280px",
|
||||
"2xl": "1536px",
|
||||
};
|
||||
|
||||
export const zIndex = {
|
||||
auto: "auto",
|
||||
0: "0",
|
||||
10: "10",
|
||||
20: "20",
|
||||
30: "30",
|
||||
40: "40",
|
||||
50: "50",
|
||||
dropdown: "1000",
|
||||
sticky: "1020",
|
||||
fixed: "1030",
|
||||
modal: "1040",
|
||||
popover: "1050",
|
||||
tooltip: "1060",
|
||||
toast: "1070",
|
||||
};
|
||||
|
||||
// Component-specific design tokens
|
||||
export const components = {
|
||||
button: {
|
||||
sizes: {
|
||||
sm: {
|
||||
height: "2rem",
|
||||
padding: "0 0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
md: {
|
||||
height: "2.5rem",
|
||||
padding: "0 1rem",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
lg: {
|
||||
height: "3rem",
|
||||
padding: "0 1.5rem",
|
||||
fontSize: "1rem",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
primary: {
|
||||
background: colors.primary[500],
|
||||
color: "white",
|
||||
hover: colors.primary[600],
|
||||
active: colors.primary[700],
|
||||
},
|
||||
secondary: {
|
||||
background: colors.secondary[500],
|
||||
color: "white",
|
||||
hover: colors.secondary[600],
|
||||
active: colors.secondary[700],
|
||||
},
|
||||
teal: {
|
||||
background: colors.teal[500],
|
||||
color: colors.dark[900],
|
||||
hover: colors.teal[600],
|
||||
active: colors.teal[700],
|
||||
},
|
||||
outline: {
|
||||
background: "transparent",
|
||||
color: colors.neutral[700],
|
||||
border: colors.neutral[300],
|
||||
hover: colors.neutral[50],
|
||||
},
|
||||
ghost: {
|
||||
background: "transparent",
|
||||
color: colors.neutral[700],
|
||||
hover: colors.neutral[100],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
input: {
|
||||
sizes: {
|
||||
sm: {
|
||||
height: "2rem",
|
||||
padding: "0 0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
md: {
|
||||
height: "2.5rem",
|
||||
padding: "0 0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
lg: {
|
||||
height: "3rem",
|
||||
padding: "0 1rem",
|
||||
fontSize: "1rem",
|
||||
},
|
||||
},
|
||||
states: {
|
||||
default: {
|
||||
border: colors.neutral[300],
|
||||
background: "white",
|
||||
},
|
||||
focus: {
|
||||
border: colors.primary[500],
|
||||
boxShadow: `0 0 0 3px ${colors.primary[100]}`,
|
||||
},
|
||||
error: {
|
||||
border: colors.error[500],
|
||||
boxShadow: `0 0 0 3px ${colors.error[100]}`,
|
||||
},
|
||||
disabled: {
|
||||
background: colors.neutral[100],
|
||||
color: colors.neutral[400],
|
||||
cursor: "not-allowed",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
card: {
|
||||
default: {
|
||||
background: "white",
|
||||
border: colors.neutral[200],
|
||||
borderRadius: borderRadius.lg,
|
||||
boxShadow: shadows.sm,
|
||||
padding: spacing[6],
|
||||
},
|
||||
hover: {
|
||||
boxShadow: shadows.md,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// RTL-specific adjustments
|
||||
export const rtl = {
|
||||
marginRight: "marginLeft",
|
||||
marginLeft: "marginRight",
|
||||
paddingRight: "paddingLeft",
|
||||
paddingLeft: "paddingRight",
|
||||
right: "left",
|
||||
left: "right",
|
||||
borderRightWidth: "borderLeftWidth",
|
||||
borderLeftWidth: "borderRightWidth",
|
||||
borderRightColor: "borderLeftColor",
|
||||
borderLeftColor: "borderRightColor",
|
||||
};
|
||||
|
||||
// Theme variants
|
||||
export const themes = {
|
||||
light: {
|
||||
background: colors.neutral[50],
|
||||
foreground: colors.neutral[900],
|
||||
card: "white",
|
||||
cardForeground: colors.neutral[900],
|
||||
popover: "white",
|
||||
popoverForeground: colors.neutral[900],
|
||||
primary: colors.primary[500],
|
||||
primaryForeground: "white",
|
||||
secondary: colors.neutral[100],
|
||||
secondaryForeground: colors.neutral[900],
|
||||
muted: colors.neutral[100],
|
||||
mutedForeground: colors.neutral[500],
|
||||
accent: colors.neutral[100],
|
||||
accentForeground: colors.neutral[900],
|
||||
destructive: colors.error[500],
|
||||
destructiveForeground: "white",
|
||||
border: colors.neutral[200],
|
||||
input: colors.neutral[200],
|
||||
ring: colors.primary[500],
|
||||
teal: colors.teal[500],
|
||||
tealForeground: colors.dark[900],
|
||||
dark: colors.dark[900],
|
||||
darkForeground: "white",
|
||||
loginPrimary: colors.login.primary,
|
||||
loginDarkStart: colors.login.darkStart,
|
||||
loginDarkEnd: colors.login.darkEnd,
|
||||
},
|
||||
|
||||
dark: {
|
||||
background: colors.dark[950],
|
||||
foreground: colors.neutral[50],
|
||||
card: colors.dark[900],
|
||||
cardForeground: colors.neutral[50],
|
||||
popover: colors.dark[900],
|
||||
popoverForeground: colors.neutral[50],
|
||||
primary: colors.primary[400],
|
||||
primaryForeground: colors.neutral[900],
|
||||
secondary: colors.neutral[800],
|
||||
secondaryForeground: colors.neutral[50],
|
||||
muted: colors.neutral[800],
|
||||
mutedForeground: colors.neutral[400],
|
||||
accent: colors.neutral[800],
|
||||
accentForeground: colors.neutral[50],
|
||||
destructive: colors.error[400],
|
||||
destructiveForeground: colors.neutral[50],
|
||||
border: colors.neutral[800],
|
||||
input: colors.neutral[800],
|
||||
ring: colors.primary[400],
|
||||
teal: colors.teal[400],
|
||||
tealForeground: colors.dark[50],
|
||||
dark: colors.dark[800],
|
||||
darkForeground: colors.neutral[50],
|
||||
loginPrimary: colors.login.primary,
|
||||
loginDarkStart: colors.login.darkStart,
|
||||
loginDarkEnd: colors.login.darkEnd,
|
||||
},
|
||||
};
|
||||
291
app/lib/theme.ts
Normal file
291
app/lib/theme.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// Theme configuration for the application
|
||||
export const themeConfig = {
|
||||
colors: {
|
||||
// Primary colors (Green)
|
||||
primary: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
950: '#052e16',
|
||||
},
|
||||
|
||||
// Secondary colors (Blue)
|
||||
secondary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
|
||||
// Neutral colors
|
||||
neutral: {
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
200: '#e5e5e5',
|
||||
300: '#d4d4d4',
|
||||
400: '#a3a3a3',
|
||||
500: '#737373',
|
||||
600: '#525252',
|
||||
700: '#404040',
|
||||
800: '#262626',
|
||||
900: '#171717',
|
||||
950: '#0a0a0a',
|
||||
},
|
||||
|
||||
// Status colors
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
900: '#14532d',
|
||||
},
|
||||
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
900: '#78350f',
|
||||
},
|
||||
|
||||
info: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
|
||||
// Semantic color tokens
|
||||
semantic: {
|
||||
light: {
|
||||
background: '#ffffff',
|
||||
foreground: '#0a0a0a',
|
||||
card: '#ffffff',
|
||||
cardForeground: '#0a0a0a',
|
||||
popover: '#ffffff',
|
||||
popoverForeground: '#0a0a0a',
|
||||
primary: '#22c55e',
|
||||
primaryForeground: '#ffffff',
|
||||
secondary: '#f5f5f5',
|
||||
secondaryForeground: '#0a0a0a',
|
||||
muted: '#f5f5f5',
|
||||
mutedForeground: '#737373',
|
||||
accent: '#f5f5f5',
|
||||
accentForeground: '#0a0a0a',
|
||||
destructive: '#ef4444',
|
||||
destructiveForeground: '#ffffff',
|
||||
border: '#e5e5e5',
|
||||
input: '#e5e5e5',
|
||||
ring: '#22c55e',
|
||||
},
|
||||
|
||||
dark: {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#fafafa',
|
||||
card: '#171717',
|
||||
cardForeground: '#fafafa',
|
||||
popover: '#171717',
|
||||
popoverForeground: '#fafafa',
|
||||
primary: '#22c55e',
|
||||
primaryForeground: '#0a0a0a',
|
||||
secondary: '#262626',
|
||||
secondaryForeground: '#fafafa',
|
||||
muted: '#262626',
|
||||
mutedForeground: '#a3a3a3',
|
||||
accent: '#262626',
|
||||
accentForeground: '#fafafa',
|
||||
destructive: '#ef4444',
|
||||
destructiveForeground: '#fafafa',
|
||||
border: '#262626',
|
||||
input: '#262626',
|
||||
ring: '#22c55e',
|
||||
},
|
||||
},
|
||||
|
||||
// Typography
|
||||
typography: {
|
||||
fontFamily: {
|
||||
sans: ['Vazirmatn', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
mono: ['ui-monospace', 'SFMono-Regular', 'Consolas', 'monospace'],
|
||||
},
|
||||
|
||||
fontSize: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
base: '1rem',
|
||||
lg: '1.125rem',
|
||||
xl: '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
'4xl': '2.25rem',
|
||||
'5xl': '3rem',
|
||||
},
|
||||
|
||||
fontWeight: {
|
||||
light: '300',
|
||||
normal: '400',
|
||||
medium: '500',
|
||||
semibold: '600',
|
||||
bold: '700',
|
||||
extrabold: '800',
|
||||
},
|
||||
},
|
||||
|
||||
// Spacing
|
||||
spacing: {
|
||||
0: '0px',
|
||||
1: '0.25rem',
|
||||
2: '0.5rem',
|
||||
3: '0.75rem',
|
||||
4: '1rem',
|
||||
5: '1.25rem',
|
||||
6: '1.5rem',
|
||||
8: '2rem',
|
||||
10: '2.5rem',
|
||||
12: '3rem',
|
||||
16: '4rem',
|
||||
20: '5rem',
|
||||
24: '6rem',
|
||||
},
|
||||
|
||||
// Border radius
|
||||
borderRadius: {
|
||||
none: '0px',
|
||||
sm: '0.125rem',
|
||||
base: '0.25rem',
|
||||
md: '0.375rem',
|
||||
lg: '0.5rem',
|
||||
xl: '0.75rem',
|
||||
'2xl': '1rem',
|
||||
full: '9999px',
|
||||
},
|
||||
|
||||
// Shadows
|
||||
boxShadow: {
|
||||
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
base: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
none: '0 0 #0000',
|
||||
},
|
||||
};
|
||||
|
||||
// CSS custom properties generator
|
||||
export const generateCSSVariables = (theme: 'light' | 'dark' = 'light') => {
|
||||
const semanticColors = themeConfig.semantic[theme];
|
||||
|
||||
const cssVars: Record<string, string> = {};
|
||||
|
||||
// Generate semantic color variables
|
||||
Object.entries(semanticColors).forEach(([key, value]) => {
|
||||
cssVars[`--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`] = value;
|
||||
});
|
||||
|
||||
// Generate primary color scale
|
||||
Object.entries(themeConfig.colors.primary).forEach(([key, value]) => {
|
||||
cssVars[`--color-primary-${key}`] = value;
|
||||
});
|
||||
|
||||
// Generate secondary color scale
|
||||
Object.entries(themeConfig.colors.secondary).forEach(([key, value]) => {
|
||||
cssVars[`--color-secondary-${key}`] = value;
|
||||
});
|
||||
|
||||
// Generate neutral color scale
|
||||
Object.entries(themeConfig.colors.neutral).forEach(([key, value]) => {
|
||||
cssVars[`--color-neutral-${key}`] = value;
|
||||
});
|
||||
|
||||
// Generate status colors
|
||||
['success', 'error', 'warning', 'info'].forEach(status => {
|
||||
Object.entries(themeConfig.colors[status as keyof typeof themeConfig.colors]).forEach(([key, value]) => {
|
||||
cssVars[`--color-${status}-${key}`] = value;
|
||||
});
|
||||
});
|
||||
|
||||
// Generate spacing variables
|
||||
Object.entries(themeConfig.spacing).forEach(([key, value]) => {
|
||||
cssVars[`--spacing-${key}`] = value;
|
||||
});
|
||||
|
||||
// Generate border radius variables
|
||||
Object.entries(themeConfig.borderRadius).forEach(([key, value]) => {
|
||||
cssVars[`--radius-${key}`] = value;
|
||||
});
|
||||
|
||||
return cssVars;
|
||||
};
|
||||
|
||||
// Theme utilities
|
||||
export const theme = {
|
||||
// Get color with opacity
|
||||
color: (colorPath: string, opacity?: number) => {
|
||||
const baseColor = `var(--color-${colorPath.replace('.', '-')})`;
|
||||
if (opacity !== undefined) {
|
||||
return `oklch(from ${baseColor} l c h / ${opacity})`;
|
||||
}
|
||||
return baseColor;
|
||||
},
|
||||
|
||||
// Get spacing value
|
||||
spacing: (size: keyof typeof themeConfig.spacing) => {
|
||||
return `var(--spacing-${size})`;
|
||||
},
|
||||
|
||||
// Get border radius
|
||||
radius: (size: keyof typeof themeConfig.borderRadius) => {
|
||||
return `var(--radius-${size})`;
|
||||
},
|
||||
|
||||
// Quick color access
|
||||
colors: {
|
||||
primary: (shade: keyof typeof themeConfig.colors.primary = '500') =>
|
||||
`var(--color-primary-${shade})`,
|
||||
secondary: (shade: keyof typeof themeConfig.colors.secondary = '500') =>
|
||||
`var(--color-secondary-${shade})`,
|
||||
neutral: (shade: keyof typeof themeConfig.colors.neutral = '500') =>
|
||||
`var(--color-neutral-${shade})`,
|
||||
success: (shade: keyof typeof themeConfig.colors.success = '500') =>
|
||||
`var(--color-success-${shade})`,
|
||||
error: (shade: keyof typeof themeConfig.colors.error = '500') =>
|
||||
`var(--color-error-${shade})`,
|
||||
warning: (shade: keyof typeof themeConfig.colors.warning = '500') =>
|
||||
`var(--color-warning-${shade})`,
|
||||
info: (shade: keyof typeof themeConfig.colors.info = '500') =>
|
||||
`var(--color-info-${shade})`,
|
||||
},
|
||||
};
|
||||
|
||||
// Export individual color palettes for easy access
|
||||
export const { colors, typography, spacing, borderRadius, boxShadow } = themeConfig;
|
||||
export default themeConfig;
|
||||
6
app/lib/utils.ts
Normal file
6
app/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
72
app/root.tsx
72
app/root.tsx
|
|
@ -6,8 +6,11 @@ import {
|
|||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "react-router";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { AuthProvider } from "./contexts/auth-context";
|
||||
import { GlobalRouteGuard } from "./components/auth/global-route-guard";
|
||||
import "./app.css";
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
|
|
@ -21,19 +24,66 @@ export const links: Route.LinksFunction = () => [
|
|||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100..900&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<body className="font-persian">
|
||||
<AuthProvider>
|
||||
<GlobalRouteGuard>{children}</GlobalRouteGuard>
|
||||
<Toaster
|
||||
position="top-center"
|
||||
reverseOrder={false}
|
||||
gutter={8}
|
||||
containerClassName=""
|
||||
containerStyle={{}}
|
||||
toastOptions={{
|
||||
// Define default options
|
||||
className: "",
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: "#363636",
|
||||
color: "#fff",
|
||||
fontFamily:
|
||||
"Vazirmatn, Inter, ui-sans-serif, system-ui, sans-serif",
|
||||
direction: "rtl",
|
||||
textAlign: "right",
|
||||
},
|
||||
// Default options for specific types
|
||||
success: {
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: "#10b981",
|
||||
},
|
||||
iconTheme: {
|
||||
primary: "#fff",
|
||||
secondary: "#10b981",
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: "#ef4444",
|
||||
},
|
||||
iconTheme: {
|
||||
primary: "#fff",
|
||||
secondary: "#ef4444",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</AuthProvider>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
|
|
@ -46,15 +96,15 @@ export default function App() {
|
|||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let message = "خطا!";
|
||||
let details = "خطای غیرمنتظرهای رخ داده است.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
message = error.status === 404 ? "404" : "خطا";
|
||||
details =
|
||||
error.status === 404
|
||||
? "The requested page could not be found."
|
||||
? "صفحه مورد نظر یافت نشد."
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
|
|
@ -62,11 +112,11 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
<main className="pt-16 p-4 container mx-auto" dir="rtl">
|
||||
<h1 className="text-2xl font-bold mb-4 font-persian">{message}</h1>
|
||||
<p className="mb-4 font-persian">{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<pre className="w-full p-4 overflow-x-auto bg-gray-100 dark:bg-gray-800 rounded">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import { type RouteConfig, index } from "@react-router/dev/routes";
|
||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [index("routes/home.tsx")] satisfies RouteConfig;
|
||||
export default [
|
||||
route("login", "routes/login.tsx"),
|
||||
route("dashboard", "routes/dashboard.tsx"),
|
||||
route("404", "routes/404.tsx"),
|
||||
route("unauthorized", "routes/unauthorized.tsx"),
|
||||
route("*", "routes/$.tsx"), // Catch-all route for 404s
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
21
app/routes/$.tsx
Normal file
21
app/routes/$.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Route } from "./+types/$";
|
||||
import { AuthenticatedNotFound, PublicNotFound } from "~/components/common/not-found";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "صفحه یافت نشد - 404" },
|
||||
{ name: "description", content: "صفحه مورد نظر یافت نشد" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function CatchAllRoute() {
|
||||
const { isAuthenticated, token } = useAuth();
|
||||
|
||||
// Show different 404 pages based on authentication status
|
||||
if (isAuthenticated && token?.accessToken) {
|
||||
return <AuthenticatedNotFound />;
|
||||
}
|
||||
|
||||
return <PublicNotFound />;
|
||||
}
|
||||
21
app/routes/404.tsx
Normal file
21
app/routes/404.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Route } from "./+types/404";
|
||||
import { AuthenticatedNotFound, PublicNotFound } from "~/components/common/not-found";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "صفحه یافت نشد - 404" },
|
||||
{ name: "description", content: "صفحه مورد نظر یافت نشد" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const { isAuthenticated, token } = useAuth();
|
||||
|
||||
// Show different 404 pages based on authentication status
|
||||
if (isAuthenticated && token?.accessToken) {
|
||||
return <AuthenticatedNotFound />;
|
||||
}
|
||||
|
||||
return <PublicNotFound />;
|
||||
}
|
||||
18
app/routes/dashboard.tsx
Normal file
18
app/routes/dashboard.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { Route } from "./+types/dashboard";
|
||||
import { DashboardHome } from "~/components/dashboard/dashboard-layout";
|
||||
import { ProtectedRoute } from "~/components/auth/protected-route";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "داشبورد - سیستم مدیریت فناوری و نوآوری" },
|
||||
{ name: "description", content: "داشبورد مدیریت فناوری و نوآوری" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<DashboardHome />
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import type { Route } from "./+types/home";
|
||||
import { Welcome } from "../welcome/welcome";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "New React Router App" },
|
||||
{ name: "description", content: "Welcome to React Router!" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <Welcome />;
|
||||
}
|
||||
85
app/routes/login.tsx
Normal file
85
app/routes/login.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { Route } from "./+types/login";
|
||||
import { LoginForm } from "~/components/auth/login-form";
|
||||
import { PublicRoute } from "~/components/auth/protected-route";
|
||||
import { useAuth } from "~/contexts/auth-context";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { LoadingPage } from "~/components/ui/loading";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "ورود - سیستم مدیریت فناوری و نوآوری" },
|
||||
{
|
||||
name: "description",
|
||||
content: "ورود به سیستم مدیریت فناوری و نوآوری اینوژن",
|
||||
},
|
||||
{ name: "keywords", content: "ورود, سیستم مدیریت, فناوری, نوآوری, اینوژن" },
|
||||
{ name: "robots", content: "noindex, nofollow" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnTo = searchParams.get("returnTo");
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && !isLoading && !isRedirecting) {
|
||||
setIsRedirecting(true);
|
||||
|
||||
// Small delay to prevent flash
|
||||
setTimeout(() => {
|
||||
const redirectPath =
|
||||
returnTo && returnTo !== "/login" ? returnTo : "/dashboard";
|
||||
navigate(redirectPath, { replace: true });
|
||||
}, 100);
|
||||
}
|
||||
}, [isAuthenticated, isLoading, navigate, returnTo, isRedirecting]);
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
if (!isRedirecting) {
|
||||
setIsRedirecting(true);
|
||||
|
||||
const redirectPath =
|
||||
returnTo && returnTo !== "/login" ? returnTo : "/dashboard";
|
||||
|
||||
// Immediate redirect on successful login
|
||||
navigate(redirectPath, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state during redirect
|
||||
if (isAuthenticated && (isLoading || isRedirecting)) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(135deg, var(--color-login-dark-start) 0%, var(--color-login-dark-end) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="text-center space-y-6 max-w-md mx-auto p-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-8 h-8 border-2 border-[var(--color-login-primary)] border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-medium font-persian text-white">
|
||||
در حال انتقال...
|
||||
</h2>
|
||||
<p className="text-sm font-persian leading-relaxed text-gray-300">
|
||||
در حال هدایت به صفحه مقصد
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicRoute>
|
||||
<LoginForm onSuccess={handleLoginSuccess} />
|
||||
</PublicRoute>
|
||||
);
|
||||
}
|
||||
25
app/routes/unauthorized.tsx
Normal file
25
app/routes/unauthorized.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { Route } from "./+types/unauthorized";
|
||||
import { Unauthorized, TokenExpiredUnauthorized, InsufficientPermissionsUnauthorized } from "~/components/common/unauthorized";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "دسترسی غیرمجاز - 403" },
|
||||
{ name: "description", content: "شما دسترسی لازم برای این صفحه را ندارید" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const reason = searchParams.get("reason");
|
||||
|
||||
// Show different unauthorized pages based on the reason
|
||||
switch (reason) {
|
||||
case "token-expired":
|
||||
return <TokenExpiredUnauthorized />;
|
||||
case "insufficient-permissions":
|
||||
return <InsufficientPermissionsUnauthorized />;
|
||||
default:
|
||||
return <Unauthorized />;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<svg width="1080" height="174" viewBox="0 0 1080 174" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M231.527 86.9999C231.527 94.9642 228.297 102.173 223.067 107.387C217.837 112.606 210.614 115.835 202.634 115.835C194.654 115.835 187.43 119.059 182.206 124.278C176.977 129.498 173.741 136.707 173.741 144.671C173.741 152.635 170.51 159.844 165.281 165.058C160.051 170.277 152.828 173.507 144.847 173.507C136.867 173.507 129.644 170.277 124.42 165.058C119.19 159.844 115.954 152.635 115.954 144.671C115.954 136.707 119.19 129.498 124.42 124.278C129.644 119.059 136.867 115.835 144.847 115.835C152.828 115.835 160.051 112.606 165.281 107.387C170.51 102.173 173.741 94.9642 173.741 86.9999C173.741 71.0711 160.808 58.1643 144.847 58.1643C136.867 58.1643 129.644 54.9347 124.42 49.7155C119.19 44.502 115.954 37.2931 115.954 29.3287C115.954 21.3643 119.19 14.1555 124.42 8.93622C129.644 3.71698 136.867 0.493164 144.847 0.493164C160.808 0.493164 173.741 13.4 173.741 29.3287C173.741 37.2931 176.977 44.502 182.206 49.7155C187.43 54.9347 194.654 58.1643 202.634 58.1643C218.594 58.1643 231.527 71.0711 231.527 86.9999Z" fill="#F44250"/>
|
||||
<path d="M115.954 86.9996C115.954 71.0742 103.018 58.1641 87.061 58.1641C71.1037 58.1641 58.1677 71.0742 58.1677 86.9996C58.1677 102.925 71.1037 115.835 87.061 115.835C103.018 115.835 115.954 102.925 115.954 86.9996Z" fill="white"/>
|
||||
<path d="M58.1676 144.671C58.1676 128.745 45.2316 115.835 29.2743 115.835C13.317 115.835 0.381104 128.745 0.381104 144.671C0.381104 160.596 13.317 173.506 29.2743 173.506C45.2316 173.506 58.1676 160.596 58.1676 144.671Z" fill="white"/>
|
||||
<path d="M289.314 144.671C289.314 128.745 276.378 115.835 260.42 115.835C244.463 115.835 231.527 128.745 231.527 144.671C231.527 160.596 244.463 173.506 260.42 173.506C276.378 173.506 289.314 160.596 289.314 144.671Z" fill="white"/>
|
||||
<g clip-path="url(#clip0_202_2131)">
|
||||
<path d="M562.482 173.247C524.388 173.247 498.363 147.49 498.363 110.468C498.363 73.4455 524.388 47.6885 562.482 47.6885C600.576 47.6885 626.869 73.7135 626.869 110.468C626.869 147.222 600.576 173.247 562.482 173.247ZM562.482 144.007C579.385 144.007 587.703 130.319 587.703 110.468C587.703 90.6168 579.385 76.9289 562.482 76.9289C545.579 76.9289 537.529 90.6168 537.529 110.468C537.529 130.319 545.311 144.007 562.482 144.007Z" fill="white"/>
|
||||
<path d="M833.64 141.116C824.217 141.116 819.237 136.684 819.237 126.156V74.8983H851.928V47.7792H819.237V1.15527H791.75L786.1 26.1978C783.343 36.4805 780.82 42.822 773.897 46.0821C773.105 46.4506 771.129 46.9976 769.409 47.3884C768.014 47.701 766.596 47.8573 765.167 47.8573H752.338V47.9243H734.832C723.578 47.9243 714.445 57.0459 714.445 68.3111V111.552C714.445 130.599 707.199 142.668 692.719 142.668C678.238 142.668 672.868 133.279 672.868 116.375V47.9243H634.249V125.765C634.249 151.254 644.442 173.248 676.63 173.248C691.915 173.248 703.895 167.231 711.096 157.182C712.145 155.72 714.445 156.49 714.445 158.276V170.022H753.332V83.8412C753.332 78.8953 757.34 74.8871 762.286 74.8871H779.882V136.952C779.882 164.663 797.89 173.248 817.842 173.248C833.908 173.248 844.436 169.374 853.58 162.441V136.126C846.1 139.453 839.725 141.116 833.629 141.116H833.64Z" fill="white"/>
|
||||
<path d="M981.561 130.865C975.387 157.962 954.197 173.258 923.07 173.258C885.243 173.258 858.415 150.18 858.415 112.354C858.415 74.5281 885.779 47.6992 922.266 47.6992C961.699 47.6992 982.365 74.796 982.365 107.263V113.884H896.509C894.555 135.711 909.382 144.017 924.409 144.017C937.829 144.017 946.136 138.915 950.434 127.918L981.561 130.865ZM945.075 94.9372C944.271 83.1361 936.757 75.8567 921.998 75.8567C906.434 75.8567 899.188 82.321 897.045 94.9372H945.064H945.075Z" fill="white"/>
|
||||
<path d="M1076.24 85.7486C1070.06 82.2652 1064.17 80.9142 1055.85 80.9142C1039.75 80.9142 1029.02 90.0358 1029.02 110.691V170.02H990.393V47.9225H1029.02V64.3235C1029.02 65.4623 1030.54 65.8195 1031.05 64.8035C1036.68 53.5718 1047.91 44.707 1062.03 44.707C1069.27 44.707 1075.45 46.8507 1078.66 49.5414L1076.25 85.7597L1076.24 85.7486Z" fill="white"/>
|
||||
<path d="M547.32 31.5345V23.9983H522.457V31.5345H515.378V2.23828H542.14C553.562 2.23828 554.365 2.95282 554.365 13.1239C554.365 17.4111 553.472 18.5611 551.329 19.6553L549.408 20.6378L551.317 21.6426C553.595 22.8372 554.365 23.2391 554.365 30.0273V31.5345H547.332H547.32ZM522.457 18.3601H547.32V7.88763H522.457V18.349V18.3601Z" fill="white"/>
|
||||
<path d="M578.493 2.23828H610.826V7.90996H580.067V14.5083H610.011V19.2868H580.067V25.8963H610.837V31.501L578.504 31.5345C575.344 31.5345 572.787 28.9778 572.787 25.8293V7.95462C572.787 4.80617 575.344 2.24945 578.493 2.24945V2.23828Z" fill="white"/>
|
||||
<path d="M655.562 31.5345L653.151 26.3429H633.746L631.335 31.5345H624.58L637.006 4.75034C637.71 3.22078 639.262 2.23828 640.936 2.23828H645.927C647.613 2.23828 649.154 3.22078 649.857 4.75034L662.283 31.5345H655.529H655.562ZM643.46 8.06627C642.712 8.06627 642.053 8.49053 641.729 9.17158L635.968 21.5756H650.94L645.19 9.17158C644.878 8.49053 644.208 8.06627 643.46 8.06627Z" fill="white"/>
|
||||
<path d="M694.862 32.4153C676.05 32.4153 675.313 32.4153 675.313 16.8852C675.313 1.35505 676.05 1.36621 694.862 1.36621C711.721 1.36621 713.764 2.06959 714.244 10.5325H707.333V7.01556H682.168V26.766H707.333V23.2714H714.244C713.775 31.7119 711.721 32.4153 694.862 32.4153Z" fill="white"/>
|
||||
<path d="M745.282 31.5345V7.02795H729.16V2.23828H768.147V7.02795H752.025V31.5345H745.282Z" fill="white"/>
|
||||
<path d="M454.419 169.819C450.935 165.264 448.792 154.814 447.452 137.397C446.112 118.104 437.806 113.817 422.532 113.817H392.254V169.83H347.494V0.986328H432.715C476.391 0.986328 498.106 21.6187 498.106 54.5882C498.106 79.2399 482.833 95.3171 462.201 98.0078C479.618 101.491 489.8 111.405 491.675 130.966C494.087 156.154 494.891 163.656 500.518 169.819H454.419ZM424.676 78.704C443.969 78.704 453.615 73.8808 453.615 58.3395C453.615 44.6739 443.969 37.4392 424.676 37.4392H392.254V78.7152H424.676V78.704Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_202_2131">
|
||||
<rect width="731.156" height="172.261" fill="white" transform="translate(347.494 0.986328)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB |
|
|
@ -1,23 +0,0 @@
|
|||
<svg width="1080" height="174" viewBox="0 0 1080 174" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M231.527 86.9999C231.527 94.9642 228.297 102.173 223.067 107.387C217.837 112.606 210.614 115.835 202.634 115.835C194.654 115.835 187.43 119.059 182.206 124.278C176.977 129.498 173.741 136.707 173.741 144.671C173.741 152.635 170.51 159.844 165.281 165.058C160.051 170.277 152.828 173.507 144.847 173.507C136.867 173.507 129.644 170.277 124.42 165.058C119.19 159.844 115.954 152.635 115.954 144.671C115.954 136.707 119.19 129.498 124.42 124.278C129.644 119.059 136.867 115.835 144.847 115.835C152.828 115.835 160.051 112.606 165.281 107.387C170.51 102.173 173.741 94.9642 173.741 86.9999C173.741 71.0711 160.808 58.1643 144.847 58.1643C136.867 58.1643 129.644 54.9347 124.42 49.7155C119.19 44.502 115.954 37.2931 115.954 29.3287C115.954 21.3643 119.19 14.1555 124.42 8.93622C129.644 3.71698 136.867 0.493164 144.847 0.493164C160.808 0.493164 173.741 13.4 173.741 29.3287C173.741 37.2931 176.977 44.502 182.206 49.7155C187.43 54.9347 194.654 58.1643 202.634 58.1643C218.594 58.1643 231.527 71.0711 231.527 86.9999Z" fill="#F44250"/>
|
||||
<path d="M115.954 86.9996C115.954 71.0742 103.018 58.1641 87.0608 58.1641C71.1035 58.1641 58.1676 71.0742 58.1676 86.9996C58.1676 102.925 71.1035 115.835 87.0608 115.835C103.018 115.835 115.954 102.925 115.954 86.9996Z" fill="#121212"/>
|
||||
<path d="M58.1676 144.671C58.1676 128.745 45.2316 115.835 29.2743 115.835C13.317 115.835 0.381104 128.745 0.381104 144.671C0.381104 160.596 13.317 173.506 29.2743 173.506C45.2316 173.506 58.1676 160.596 58.1676 144.671Z" fill="#121212"/>
|
||||
<path d="M289.313 144.671C289.313 128.745 276.378 115.835 260.42 115.835C244.463 115.835 231.527 128.745 231.527 144.671C231.527 160.596 244.463 173.506 260.42 173.506C276.378 173.506 289.313 160.596 289.313 144.671Z" fill="#121212"/>
|
||||
<g clip-path="url(#clip0_171_1761)">
|
||||
<path d="M562.482 173.247C524.388 173.247 498.363 147.49 498.363 110.468C498.363 73.4455 524.388 47.6885 562.482 47.6885C600.576 47.6885 626.869 73.7135 626.869 110.468C626.869 147.222 600.576 173.247 562.482 173.247ZM562.482 144.007C579.386 144.007 587.703 130.319 587.703 110.468C587.703 90.6168 579.386 76.9289 562.482 76.9289C545.579 76.9289 537.529 90.6168 537.529 110.468C537.529 130.319 545.311 144.007 562.482 144.007Z" fill="#121212"/>
|
||||
<path d="M833.64 141.116C824.217 141.116 819.237 136.684 819.237 126.156V74.8983H851.928V47.7792H819.237V1.15527H791.75L786.1 26.1978C783.343 36.4805 780.82 42.822 773.897 46.0821C773.105 46.4506 771.129 46.9976 769.409 47.3884C768.014 47.701 766.596 47.8573 765.167 47.8573H752.338V47.9243H734.832C723.578 47.9243 714.445 57.0459 714.445 68.3111V111.552C714.445 130.599 707.199 142.668 692.719 142.668C678.238 142.668 672.868 133.279 672.868 116.375V47.9243H634.249V125.765C634.249 151.254 644.442 173.248 676.63 173.248C691.915 173.248 703.895 167.231 711.096 157.182C712.145 155.72 714.445 156.49 714.445 158.276V170.022H753.332V83.8412C753.332 78.8953 757.34 74.8871 762.286 74.8871H779.882V136.952C779.882 164.663 797.89 173.248 817.842 173.248C833.908 173.248 844.436 169.374 853.58 162.441V136.126C846.1 139.453 839.725 141.116 833.629 141.116H833.64Z" fill="#121212"/>
|
||||
<path d="M981.561 130.865C975.387 157.962 954.197 173.258 923.07 173.258C885.243 173.258 858.415 150.18 858.415 112.354C858.415 74.5281 885.779 47.6992 922.266 47.6992C961.699 47.6992 982.365 74.796 982.365 107.263V113.884H896.509C894.555 135.711 909.382 144.017 924.409 144.017C937.829 144.017 946.136 138.915 950.434 127.918L981.561 130.865ZM945.075 94.9372C944.271 83.1361 936.757 75.8567 921.998 75.8567C906.434 75.8567 899.188 82.321 897.045 94.9372H945.064H945.075Z" fill="#121212"/>
|
||||
<path d="M1076.24 85.7486C1070.06 82.2652 1064.17 80.9142 1055.85 80.9142C1039.75 80.9142 1029.02 90.0358 1029.02 110.691V170.02H990.393V47.9225H1029.02V64.3235C1029.02 65.4623 1030.54 65.8195 1031.05 64.8035C1036.68 53.5718 1047.91 44.707 1062.03 44.707C1069.27 44.707 1075.45 46.8507 1078.66 49.5414L1076.25 85.7597L1076.24 85.7486Z" fill="#121212"/>
|
||||
<path d="M547.321 31.5345V23.9983H522.457V31.5345H515.378V2.23828H542.14C553.562 2.23828 554.366 2.95282 554.366 13.1239C554.366 17.4111 553.472 18.5611 551.329 19.6553L549.408 20.6378L551.318 21.6426C553.595 22.8372 554.366 23.2391 554.366 30.0273V31.5345H547.332H547.321ZM522.457 18.3601H547.321V7.88763H522.457V18.349V18.3601Z" fill="#121212"/>
|
||||
<path d="M578.493 2.23828H610.826V7.90996H580.067V14.5083H610.011V19.2868H580.067V25.8963H610.837V31.501L578.504 31.5345C575.344 31.5345 572.787 28.9778 572.787 25.8293V7.95462C572.787 4.80617 575.344 2.24945 578.493 2.24945V2.23828Z" fill="#121212"/>
|
||||
<path d="M655.562 31.5345L653.151 26.3429H633.747L631.335 31.5345H624.58L637.007 4.75034C637.71 3.22078 639.262 2.23828 640.937 2.23828H645.927C647.613 2.23828 649.154 3.22078 649.857 4.75034L662.284 31.5345H655.529H655.562ZM643.46 8.06627C642.712 8.06627 642.053 8.49053 641.729 9.17158L635.968 21.5756H650.94L645.19 9.17158C644.878 8.49053 644.208 8.06627 643.46 8.06627Z" fill="#121212"/>
|
||||
<path d="M694.862 32.4153C676.05 32.4153 675.313 32.4153 675.313 16.8852C675.313 1.35505 676.05 1.36621 694.862 1.36621C711.721 1.36621 713.764 2.06959 714.244 10.5325H707.333V7.01556H682.168V26.766H707.333V23.2714H714.244C713.775 31.7119 711.721 32.4153 694.862 32.4153Z" fill="#121212"/>
|
||||
<path d="M745.282 31.5345V7.02795H729.16V2.23828H768.148V7.02795H752.026V31.5345H745.282Z" fill="#121212"/>
|
||||
<path d="M454.419 169.819C450.935 165.264 448.792 154.814 447.452 137.397C446.112 118.104 437.806 113.817 422.532 113.817H392.254V169.83H347.494V0.986328H432.715C476.391 0.986328 498.106 21.6187 498.106 54.5882C498.106 79.2399 482.833 95.3171 462.201 98.0078C479.618 101.491 489.8 111.405 491.676 130.966C494.087 156.154 494.891 163.656 500.518 169.819H454.419ZM424.676 78.704C443.969 78.704 453.615 73.8808 453.615 58.3395C453.615 44.6739 443.969 37.4392 424.676 37.4392H392.254V78.7152H424.676V78.704Z" fill="#121212"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_171_1761">
|
||||
<rect width="731.156" height="172.261" fill="white" transform="translate(347.494 0.986328)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB |
|
|
@ -1,89 +0,0 @@
|
|||
import logoDark from "./logo-dark.svg";
|
||||
import logoLight from "./logo-light.svg";
|
||||
|
||||
export function Welcome() {
|
||||
return (
|
||||
<main className="flex items-center justify-center pt-16 pb-4">
|
||||
<div className="flex-1 flex flex-col items-center gap-16 min-h-0">
|
||||
<header className="flex flex-col items-center gap-9">
|
||||
<div className="w-[500px] max-w-[100vw] p-4">
|
||||
<img
|
||||
src={logoLight}
|
||||
alt="React Router"
|
||||
className="block w-full dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src={logoDark}
|
||||
alt="React Router"
|
||||
className="hidden w-full dark:block"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="max-w-[300px] w-full space-y-6 px-4">
|
||||
<nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4">
|
||||
<p className="leading-6 text-gray-700 dark:text-gray-200 text-center">
|
||||
What's next?
|
||||
</p>
|
||||
<ul>
|
||||
{resources.map(({ href, text, icon }) => (
|
||||
<li key={href}>
|
||||
<a
|
||||
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const resources = [
|
||||
{
|
||||
href: "https://reactrouter.com/docs",
|
||||
text: "React Router Docs",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://rmx.as/discord",
|
||||
text: "Join Discord",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 24 20"
|
||||
fill="none"
|
||||
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
21
components.json
Normal file
21
components.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/app.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "~/components",
|
||||
"utils": "~/lib/utils",
|
||||
"ui": "~/components/ui",
|
||||
"lib": "~/lib",
|
||||
"hooks": "~/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
5090
package-lock.json
generated
Normal file
5090
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
|
|
@ -4,17 +4,25 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"dev": "react-router dev --port 3000",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@react-router/node": "^7.7.0",
|
||||
"@react-router/serve": "^7.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.525.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.7.0"
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-router": "^7.7.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.7.0",
|
||||
|
|
@ -23,8 +31,9 @@
|
|||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
154
pnpm-lock.yaml
154
pnpm-lock.yaml
|
|
@ -8,24 +8,45 @@ importers:
|
|||
|
||||
.:
|
||||
dependencies:
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.0.2
|
||||
version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.0.2
|
||||
version: 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||
'@react-router/node':
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0(react-router@7.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.8.3)
|
||||
'@react-router/serve':
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0(react-router@7.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.8.3)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
isbot:
|
||||
specifier: ^5.1.27
|
||||
version: 5.1.28
|
||||
lucide-react:
|
||||
specifier: ^0.525.0
|
||||
version: 0.525.0(react@19.1.0)
|
||||
react:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0
|
||||
react-dom:
|
||||
specifier: ^19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
react-hot-toast:
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react-router:
|
||||
specifier: ^7.7.0
|
||||
version: 7.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
devDependencies:
|
||||
'@react-router/dev':
|
||||
specifier: ^7.7.0
|
||||
|
|
@ -45,6 +66,9 @@ importers:
|
|||
tailwindcss:
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.11
|
||||
tw-animate-css:
|
||||
specifier: ^1.3.5
|
||||
version: 1.3.5
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
|
|
@ -386,6 +410,50 @@ packages:
|
|||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-label@2.1.7':
|
||||
resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3':
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@react-router/dev@7.7.0':
|
||||
resolution: {integrity: sha512-z6tJ0US20pS/YpaPz59SJgSH+1BJ6xvQmQ/u4Y4HM1uLOa4b3Mleg3KujqAvwGP5wkMkNFz3Ae2g6/kDTFyuCA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
|
@ -714,6 +782,13 @@ packages:
|
|||
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
|
@ -923,6 +998,11 @@ packages:
|
|||
globrex@0.1.2:
|
||||
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
|
||||
|
||||
goober@2.1.16:
|
||||
resolution: {integrity: sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==}
|
||||
peerDependencies:
|
||||
csstype: ^3.0.10
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -1073,6 +1153,11 @@ packages:
|
|||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lucide-react@0.525.0:
|
||||
resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==}
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
magic-string@0.30.17:
|
||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||
|
||||
|
|
@ -1264,6 +1349,13 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.1.0
|
||||
|
||||
react-hot-toast@2.5.2:
|
||||
resolution: {integrity: sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: '>=16'
|
||||
react-dom: '>=16'
|
||||
|
||||
react-refresh@0.14.2:
|
||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -1401,6 +1493,9 @@ packages:
|
|||
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
|
||||
tailwindcss@4.1.11:
|
||||
resolution: {integrity: sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==}
|
||||
|
||||
|
|
@ -1430,6 +1525,9 @@ packages:
|
|||
typescript:
|
||||
optional: true
|
||||
|
||||
tw-animate-css@1.3.5:
|
||||
resolution: {integrity: sha512-t3u+0YNoloIhj1mMXs779P6MO9q3p3mvGn4k1n3nJPqJw/glZcuijG2qTSN4z4mgNRfW5ZC3aXJFLwDtiipZXA==}
|
||||
|
||||
type-is@1.6.18:
|
||||
resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -1885,6 +1983,37 @@ snapshots:
|
|||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
|
||||
'@radix-ui/react-label@2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
'@types/react-dom': 19.1.6(@types/react@19.1.8)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
|
||||
react: 19.1.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.8
|
||||
|
||||
'@react-router/dev@7.7.0(@react-router/serve@7.7.0(react-router@7.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.8.3))(@types/node@20.19.9)(jiti@2.5.0)(lightningcss@1.30.1)(react-router@7.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.8.3)(vite@6.3.5(@types/node@20.19.9)(jiti@2.5.0)(lightningcss@1.30.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
|
|
@ -2195,6 +2324,12 @@ snapshots:
|
|||
|
||||
chownr@3.0.0: {}
|
||||
|
||||
class-variance-authority@0.7.1:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
|
@ -2428,6 +2563,10 @@ snapshots:
|
|||
|
||||
globrex@0.1.2: {}
|
||||
|
||||
goober@2.1.16(csstype@3.1.3):
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
|
@ -2539,6 +2678,10 @@ snapshots:
|
|||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
lucide-react@0.525.0(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
|
||||
magic-string@0.30.17:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
|
|
@ -2695,6 +2838,13 @@ snapshots:
|
|||
react: 19.1.0
|
||||
scheduler: 0.26.0
|
||||
|
||||
react-hot-toast@2.5.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
goober: 2.1.16(csstype@3.1.3)
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
react-refresh@0.14.2: {}
|
||||
|
||||
react-router@7.7.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
|
|
@ -2861,6 +3011,8 @@ snapshots:
|
|||
dependencies:
|
||||
ansi-regex: 6.1.0
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
||||
tailwindcss@4.1.11: {}
|
||||
|
||||
tapable@2.2.2: {}
|
||||
|
|
@ -2885,6 +3037,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
|
||||
tw-animate-css@1.3.5: {}
|
||||
|
||||
type-is@1.6.18:
|
||||
dependencies:
|
||||
media-typer: 0.3.0
|
||||
|
|
|
|||
435
scripts/update-colors.js
Normal file
435
scripts/update-colors.js
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Color Update Utility for Inogen Project
|
||||
*
|
||||
* This script helps update all color values across the project
|
||||
* when new colors are extracted from Figma designs.
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Figma color configuration
|
||||
// Replace these values with actual colors from Figma
|
||||
const FIGMA_COLORS = {
|
||||
// Primary Brand Colors (Teal)
|
||||
primary: {
|
||||
50: "#f0fdfa",
|
||||
100: "#ccfbf1",
|
||||
200: "#99f6e4",
|
||||
300: "#5eead4",
|
||||
400: "#2dd4bf",
|
||||
500: "#48D1CC", // Main brand color from current design
|
||||
600: "#40C4C4", // Hover state from current design
|
||||
700: "#0f766e",
|
||||
800: "#115e59",
|
||||
900: "#134e4a",
|
||||
950: "#042f2e",
|
||||
},
|
||||
|
||||
// Dark Theme Colors
|
||||
dark: {
|
||||
50: "#f8fafc",
|
||||
100: "#f1f5f9",
|
||||
200: "#e2e8f0",
|
||||
300: "#cbd5e1",
|
||||
400: "#94a3b8",
|
||||
500: "#64748b",
|
||||
600: "#475569",
|
||||
700: "#334155",
|
||||
800: "#1A202C", // Login background from current design
|
||||
900: "#0f172a",
|
||||
950: "#020617",
|
||||
},
|
||||
|
||||
// Neutral Colors
|
||||
neutral: {
|
||||
50: "#fafafa",
|
||||
100: "#f5f5f5",
|
||||
200: "#e5e5e5",
|
||||
300: "#d4d4d4",
|
||||
400: "#a3a3a3",
|
||||
500: "#737373",
|
||||
600: "#525252",
|
||||
700: "#404040",
|
||||
800: "#262626",
|
||||
900: "#171717",
|
||||
950: "#0a0a0a",
|
||||
},
|
||||
|
||||
// Status Colors
|
||||
success: {
|
||||
50: "#f0fdf4",
|
||||
100: "#dcfce7",
|
||||
200: "#bbf7d0",
|
||||
300: "#86efac",
|
||||
400: "#4ade80",
|
||||
500: "#22c55e",
|
||||
600: "#16a34a",
|
||||
700: "#15803d",
|
||||
800: "#166534",
|
||||
900: "#14532d",
|
||||
},
|
||||
|
||||
error: {
|
||||
50: "#fef2f2",
|
||||
100: "#fee2e2",
|
||||
200: "#fecaca",
|
||||
300: "#fca5a5",
|
||||
400: "#f87171",
|
||||
500: "#ef4444",
|
||||
600: "#dc2626",
|
||||
700: "#b91c1c",
|
||||
800: "#991b1b",
|
||||
900: "#7f1d1d",
|
||||
},
|
||||
|
||||
warning: {
|
||||
50: "#fffbeb",
|
||||
100: "#fef3c7",
|
||||
200: "#fde68a",
|
||||
300: "#fcd34d",
|
||||
400: "#fbbf24",
|
||||
500: "#f59e0b",
|
||||
600: "#d97706",
|
||||
700: "#b45309",
|
||||
800: "#92400e",
|
||||
900: "#78350f",
|
||||
},
|
||||
|
||||
info: {
|
||||
50: "#eff6ff",
|
||||
100: "#dbeafe",
|
||||
200: "#bfdbfe",
|
||||
300: "#93c5fd",
|
||||
400: "#60a5fa",
|
||||
500: "#3b82f6",
|
||||
600: "#2563eb",
|
||||
700: "#1d4ed8",
|
||||
800: "#1e40af",
|
||||
900: "#1e3a8a",
|
||||
},
|
||||
|
||||
// Login specific colors
|
||||
login: {
|
||||
primary: "#3aea83",
|
||||
darkStart: "#464861",
|
||||
darkEnd: "#111628",
|
||||
},
|
||||
};
|
||||
|
||||
// Semantic color mappings
|
||||
const SEMANTIC_COLORS = {
|
||||
light: {
|
||||
background: "#ffffff",
|
||||
foreground: "#0a0a0a",
|
||||
card: "#ffffff",
|
||||
cardForeground: "#0a0a0a",
|
||||
popover: "#ffffff",
|
||||
popoverForeground: "#0a0a0a",
|
||||
primary: FIGMA_COLORS.primary[500],
|
||||
primaryForeground: FIGMA_COLORS.dark[800],
|
||||
secondary: FIGMA_COLORS.neutral[100],
|
||||
secondaryForeground: FIGMA_COLORS.neutral[900],
|
||||
muted: FIGMA_COLORS.neutral[100],
|
||||
mutedForeground: FIGMA_COLORS.neutral[500],
|
||||
accent: FIGMA_COLORS.neutral[100],
|
||||
accentForeground: FIGMA_COLORS.neutral[900],
|
||||
destructive: FIGMA_COLORS.error[500],
|
||||
destructiveForeground: "#ffffff",
|
||||
border: FIGMA_COLORS.neutral[200],
|
||||
input: FIGMA_COLORS.neutral[200],
|
||||
ring: FIGMA_COLORS.primary[500],
|
||||
loginPrimary: FIGMA_COLORS.login.primary,
|
||||
loginDarkStart: FIGMA_COLORS.login.darkStart,
|
||||
loginDarkEnd: FIGMA_COLORS.login.darkEnd,
|
||||
},
|
||||
|
||||
dark: {
|
||||
background: FIGMA_COLORS.dark[950],
|
||||
foreground: FIGMA_COLORS.neutral[50],
|
||||
card: FIGMA_COLORS.dark[900],
|
||||
cardForeground: FIGMA_COLORS.neutral[50],
|
||||
popover: FIGMA_COLORS.dark[900],
|
||||
popoverForeground: FIGMA_COLORS.neutral[50],
|
||||
primary: FIGMA_COLORS.primary[500],
|
||||
primaryForeground: FIGMA_COLORS.dark[800],
|
||||
secondary: FIGMA_COLORS.dark[800],
|
||||
secondaryForeground: FIGMA_COLORS.neutral[50],
|
||||
muted: FIGMA_COLORS.dark[800],
|
||||
mutedForeground: FIGMA_COLORS.neutral[400],
|
||||
accent: FIGMA_COLORS.dark[800],
|
||||
accentForeground: FIGMA_COLORS.neutral[50],
|
||||
destructive: FIGMA_COLORS.error[500],
|
||||
destructiveForeground: FIGMA_COLORS.neutral[50],
|
||||
border: FIGMA_COLORS.dark[800],
|
||||
input: FIGMA_COLORS.dark[800],
|
||||
ring: FIGMA_COLORS.primary[400],
|
||||
loginPrimary: FIGMA_COLORS.login.primary,
|
||||
loginDarkStart: FIGMA_COLORS.login.darkStart,
|
||||
loginDarkEnd: FIGMA_COLORS.login.darkEnd,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Update CSS custom properties in app.css
|
||||
*/
|
||||
function updateAppCSS() {
|
||||
const cssPath = path.join(__dirname, "../app/app.css");
|
||||
let cssContent = fs.readFileSync(cssPath, "utf8");
|
||||
|
||||
// Generate CSS custom properties
|
||||
let newProperties = "";
|
||||
|
||||
// Add color scales
|
||||
Object.entries(FIGMA_COLORS).forEach(([colorName, colorScale]) => {
|
||||
newProperties += `\n /* ${colorName.charAt(0).toUpperCase() + colorName.slice(1)} color scale */\n`;
|
||||
if (typeof colorScale === "object" && colorScale !== null) {
|
||||
Object.entries(colorScale).forEach(([shade, value]) => {
|
||||
newProperties += ` --color-${colorName}-${shade}: ${value};\n`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update semantic colors for light theme
|
||||
const lightThemeStart = ":root {";
|
||||
const lightThemeEnd = "}";
|
||||
|
||||
let lightThemeContent = `${lightThemeStart}
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Light theme colors */
|
||||
--background: ${SEMANTIC_COLORS.light.background};
|
||||
--foreground: ${SEMANTIC_COLORS.light.foreground};
|
||||
--card: ${SEMANTIC_COLORS.light.card};
|
||||
--card-foreground: ${SEMANTIC_COLORS.light.cardForeground};
|
||||
--popover: ${SEMANTIC_COLORS.light.popover};
|
||||
--popover-foreground: ${SEMANTIC_COLORS.light.popoverForeground};
|
||||
--primary: ${SEMANTIC_COLORS.light.primary};
|
||||
--primary-foreground: ${SEMANTIC_COLORS.light.primaryForeground};
|
||||
--secondary: ${SEMANTIC_COLORS.light.secondary};
|
||||
--secondary-foreground: ${SEMANTIC_COLORS.light.secondaryForeground};
|
||||
--muted: ${SEMANTIC_COLORS.light.muted};
|
||||
--muted-foreground: ${SEMANTIC_COLORS.light.mutedForeground};
|
||||
--accent: ${SEMANTIC_COLORS.light.accent};
|
||||
--accent-foreground: ${SEMANTIC_COLORS.light.accentForeground};
|
||||
--destructive: ${SEMANTIC_COLORS.light.destructive};
|
||||
--destructive-foreground: ${SEMANTIC_COLORS.light.destructiveForeground};
|
||||
--border: ${SEMANTIC_COLORS.light.border};
|
||||
--input: ${SEMANTIC_COLORS.light.input};
|
||||
--ring: ${SEMANTIC_COLORS.light.ring};
|
||||
|
||||
/* Login specific colors */
|
||||
--color-login-primary: ${SEMANTIC_COLORS.light.loginPrimary};
|
||||
--color-login-dark-start: ${SEMANTIC_COLORS.light.loginDarkStart};
|
||||
--color-login-dark-end: ${SEMANTIC_COLORS.light.loginDarkEnd};
|
||||
${newProperties}
|
||||
${lightThemeEnd}`;
|
||||
|
||||
// Update dark theme
|
||||
let darkThemeContent = `.dark {
|
||||
/* Dark theme colors */
|
||||
--background: ${SEMANTIC_COLORS.dark.background};
|
||||
--foreground: ${SEMANTIC_COLORS.dark.foreground};
|
||||
--card: ${SEMANTIC_COLORS.dark.card};
|
||||
--card-foreground: ${SEMANTIC_COLORS.dark.cardForeground};
|
||||
--popover: ${SEMANTIC_COLORS.dark.popover};
|
||||
--popover-foreground: ${SEMANTIC_COLORS.dark.popoverForeground};
|
||||
--primary: ${SEMANTIC_COLORS.dark.primary};
|
||||
--primary-foreground: ${SEMANTIC_COLORS.dark.primaryForeground};
|
||||
--secondary: ${SEMANTIC_COLORS.dark.secondary};
|
||||
--secondary-foreground: ${SEMANTIC_COLORS.dark.secondaryForeground};
|
||||
--muted: ${SEMANTIC_COLORS.dark.muted};
|
||||
--muted-foreground: ${SEMANTIC_COLORS.dark.mutedForeground};
|
||||
--accent: ${SEMANTIC_COLORS.dark.accent};
|
||||
--accent-foreground: ${SEMANTIC_COLORS.dark.accentForeground};
|
||||
--destructive: ${SEMANTIC_COLORS.dark.destructive};
|
||||
--destructive-foreground: ${SEMANTIC_COLORS.dark.destructiveForeground};
|
||||
--border: ${SEMANTIC_COLORS.dark.border};
|
||||
--input: ${SEMANTIC_COLORS.dark.input};
|
||||
--ring: ${SEMANTIC_COLORS.dark.ring};
|
||||
|
||||
/* Login specific colors */
|
||||
--color-login-primary: ${SEMANTIC_COLORS.dark.loginPrimary};
|
||||
--color-login-dark-start: ${SEMANTIC_COLORS.dark.loginDarkStart};
|
||||
--color-login-dark-end: ${SEMANTIC_COLORS.dark.loginDarkEnd};
|
||||
}`;
|
||||
|
||||
// Replace existing color definitions
|
||||
cssContent = cssContent.replace(/:root\s*{[^}]*}/s, lightThemeContent);
|
||||
|
||||
cssContent = cssContent.replace(/\.dark\s*{[^}]*}/s, darkThemeContent);
|
||||
|
||||
fs.writeFileSync(cssPath, cssContent);
|
||||
console.log("✅ Updated app.css with new colors");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update TypeScript design tokens
|
||||
*/
|
||||
function updateDesignTokens() {
|
||||
const tokensPath = path.join(__dirname, "../app/lib/design-tokens.ts");
|
||||
|
||||
const designTokensContent = `export const colors = {
|
||||
// Primary Colors
|
||||
primary: ${JSON.stringify(FIGMA_COLORS.primary, null, 4).replace(/"/g, '"')},
|
||||
|
||||
// Secondary Colors (Info/Blue)
|
||||
secondary: ${JSON.stringify(FIGMA_COLORS.info, null, 4).replace(/"/g, '"')},
|
||||
|
||||
// Dark Colors (Brand dark)
|
||||
dark: ${JSON.stringify(FIGMA_COLORS.dark, null, 4).replace(/"/g, '"')},
|
||||
|
||||
// Neutral Colors
|
||||
neutral: ${JSON.stringify(FIGMA_COLORS.neutral, null, 4).replace(/"/g, '"')},
|
||||
|
||||
// Status Colors
|
||||
success: ${JSON.stringify(FIGMA_COLORS.success, null, 4).replace(/"/g, '"')},
|
||||
|
||||
error: ${JSON.stringify(FIGMA_COLORS.error, null, 4).replace(/"/g, '"')},
|
||||
|
||||
warning: ${JSON.stringify(FIGMA_COLORS.warning, null, 4).replace(/"/g, '"')},
|
||||
|
||||
info: ${JSON.stringify(FIGMA_COLORS.info, null, 4).replace(/"/g, '"')},
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
sans: ["Vazirmatn", "Inter", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
mono: ["ui-monospace", "SFMono-Regular", "Consolas", "monospace"],
|
||||
},
|
||||
|
||||
fontSize: {
|
||||
xs: ["0.75rem", { lineHeight: "1rem" }],
|
||||
sm: ["0.875rem", { lineHeight: "1.25rem" }],
|
||||
base: ["1rem", { lineHeight: "1.5rem" }],
|
||||
lg: ["1.125rem", { lineHeight: "1.75rem" }],
|
||||
xl: ["1.25rem", { lineHeight: "1.75rem" }],
|
||||
"2xl": ["1.5rem", { lineHeight: "2rem" }],
|
||||
"3xl": ["1.875rem", { lineHeight: "2.25rem" }],
|
||||
"4xl": ["2.25rem", { lineHeight: "2.5rem" }],
|
||||
"5xl": ["3rem", { lineHeight: "1" }],
|
||||
"6xl": ["3.75rem", { lineHeight: "1" }],
|
||||
},
|
||||
|
||||
fontWeight: {
|
||||
thin: "100",
|
||||
extralight: "200",
|
||||
light: "300",
|
||||
normal: "400",
|
||||
medium: "500",
|
||||
semibold: "600",
|
||||
bold: "700",
|
||||
extrabold: "800",
|
||||
black: "900",
|
||||
},
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
px: "1px",
|
||||
0: "0px",
|
||||
0.5: "0.125rem",
|
||||
1: "0.25rem",
|
||||
1.5: "0.375rem",
|
||||
2: "0.5rem",
|
||||
2.5: "0.625rem",
|
||||
3: "0.75rem",
|
||||
3.5: "0.875rem",
|
||||
4: "1rem",
|
||||
5: "1.25rem",
|
||||
6: "1.5rem",
|
||||
7: "1.75rem",
|
||||
8: "2rem",
|
||||
9: "2.25rem",
|
||||
10: "2.5rem",
|
||||
11: "2.75rem",
|
||||
12: "3rem",
|
||||
14: "3.5rem",
|
||||
16: "4rem",
|
||||
20: "5rem",
|
||||
24: "6rem",
|
||||
28: "7rem",
|
||||
32: "8rem",
|
||||
36: "9rem",
|
||||
40: "10rem",
|
||||
44: "11rem",
|
||||
48: "12rem",
|
||||
52: "13rem",
|
||||
56: "14rem",
|
||||
60: "15rem",
|
||||
64: "16rem",
|
||||
72: "18rem",
|
||||
80: "20rem",
|
||||
96: "24rem",
|
||||
};
|
||||
|
||||
export const borderRadius = {
|
||||
none: "0px",
|
||||
sm: "0.125rem",
|
||||
DEFAULT: "0.25rem",
|
||||
md: "0.375rem",
|
||||
lg: "0.5rem",
|
||||
xl: "0.75rem",
|
||||
"2xl": "1rem",
|
||||
"3xl": "1.5rem",
|
||||
full: "9999px",
|
||||
};
|
||||
|
||||
export const shadows = {
|
||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
DEFAULT: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
|
||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||
xl: "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)",
|
||||
"2xl": "0 25px 50px -12px rgb(0 0 0 / 0.25)",
|
||||
inner: "inset 0 2px 4px 0 rgb(0 0 0 / 0.05)",
|
||||
none: "0 0 #0000",
|
||||
};
|
||||
|
||||
// Theme variants
|
||||
export const themes = {
|
||||
light: ${JSON.stringify(SEMANTIC_COLORS.light, null, 4).replace(/"/g, '"')},
|
||||
dark: ${JSON.stringify(SEMANTIC_COLORS.dark, null, 4).replace(/"/g, '"')},
|
||||
};
|
||||
`;
|
||||
|
||||
fs.writeFileSync(tokensPath, designTokensContent);
|
||||
console.log("✅ Updated design-tokens.ts with new colors");
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution function
|
||||
*/
|
||||
function updateColors() {
|
||||
console.log("🎨 Updating colors from Figma design...\n");
|
||||
|
||||
try {
|
||||
updateAppCSS();
|
||||
updateDesignTokens();
|
||||
|
||||
console.log("\n✨ Color update completed successfully!");
|
||||
console.log("\nUpdated files:");
|
||||
console.log("- app/app.css");
|
||||
console.log("- app/lib/design-tokens.ts");
|
||||
console.log("\nNext steps:");
|
||||
console.log("1. Review the changes");
|
||||
console.log("2. Test the application");
|
||||
console.log("3. Verify colors match Figma design");
|
||||
console.log("4. Update any hardcoded colors in components");
|
||||
} catch (error) {
|
||||
console.error("❌ Error updating colors:", error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script if called directly
|
||||
if (require.main === module) {
|
||||
updateColors();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FIGMA_COLORS,
|
||||
SEMANTIC_COLORS,
|
||||
updateColors,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user