latest code

This commit is contained in:
2026-09-08 10:50:13 +05:30
commit 86a06d819f
374 changed files with 98701 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
VITE_API_BASE_URL=https://docqube.maskantech.in/api
VITE_PRODUCT_HUB_API_URL=https://product-hub.maskantech.in/api/v1
VITE_GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
VITE_DOCUSEAL_URL=https://docuseal.com
VITE_DOCUSEAL_SCRIPT_URL=https://cdn.docuseal.com/js/form.js
VITE_PDF_EDITOR_DOCKER_URL=https://pdf-dev.maskantech.in/
VITE_LANDING_PAGE_URL=http://localhost:3000
+13
View File
@@ -0,0 +1,13 @@
VITE_API_BASE_URL=http://localhost:20001/api
VITE_PRODUCT_HUB_API_URL=http://localhost:5551/api/v1
VITE_DOCUSEAL_SCRIPT_URL=https://cdn.docuseal.com/js/form.js
VITE_DOCUSEAL_URL=https://docuseal.com
VITE_PDF_EDITOR_DOCKER_URL=http://localhost:5174/
VITE_LANDING_PAGE_URL=http://localhost:3000
# VITE_GOOGLE_CLIENT_ID=997635516025-ns8fnfv9uoci8j8rj3icleg6c9tmaud1.apps.googleusercontent.com
# VITE_GOOGLE_APP_ID=997635516025
# VITE_GOOGLE_API_KEY=AIzaSyAwBNWlMT_RCtKCTQFgHEgqVzz5R_new84
VITE_GOOGLE_CLIENT_ID=167747878572-850t5vbj5scovtkbtdapkh56pibh2ppa.apps.googleusercontent.com
VITE_GOOGLE_APP_ID=167747878572
VITE_GOOGLE_API_KEY=AIzaSyB6FtM7xidLQ02kN7t5HutZQ0uYagRVtsE
VITE_SAAS_API_URL=http://localhost:8000
+7
View File
@@ -0,0 +1,7 @@
VITE_API_BASE_URL=https://api.docqube.com/api
VITE_GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
VITE_PRODUCT_HUB_API_URL=https://product-hub.maskantech.in/api/v1
VITE_DOCUSEAL_URL=https://docuseal.com
VITE_DOCUSEAL_SCRIPT_URL=https://cdn.docuseal.com/js/form.js
VITE_PDF_EDITOR_DOCKER_URL=https://jiefqdyc.docqube.com/
VITE_LANDING_PAGE_URL=https://docqube.com
+7
View File
@@ -0,0 +1,7 @@
VITE_API_BASE_URL=https://docqubeapi-test.maskantech.in/api
VITE_GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
VITE_PRODUCT_HUB_API_URL=https://product-hub.maskantech.in/api/v1
VITE_DOCUSEAL_URL=https://docuseal.com
VITE_DOCUSEAL_SCRIPT_URL=https://cdn.docuseal.com/js/form.js
VITE_PDF_EDITOR_DOCKER_URL=https://pdf-dev.maskantech.in/
VITE_SAAS_API_URL=https://saas-test-api.maskantech.in
+6
View File
@@ -0,0 +1,6 @@
VITE_API_BASE_URL=https://docqube.maskantech.in/api
VITE_GOOGLE_CLIENT_ID=989963514121-nh9ibk3e4u1dh6jp4ql17tue7f9suk38.apps.googleusercontent.com
VITE_PRODUCT_HUB_API_URL=https://product-hub.maskantech.in/api/v1
VITE_DOCUSEAL_URL=https://docuseal.com
VITE_DOCUSEAL_SCRIPT_URL=https://cdn.docuseal.com/js/form.js
VITE_LANDING_PAGE_URL=https://docqube.maskantech.in
+30
View File
@@ -0,0 +1,30 @@
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.bolt
# Debug tools
public/debug-*.html
.gitea\wokflows
.github\workflows
+918
View File
@@ -0,0 +1,918 @@
# DocuStream Frontend
<div align="center">
**Professional Document Conversion & Collaboration Platform**
[![React](https://img.shields.io/badge/React-18.3-blue.svg)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.5-blue.svg)](https://www.typescriptlang.org/)
[![Vite](https://img.shields.io/badge/Vite-5.4-purple.svg)](https://vitejs.dev/)
[![TailwindCSS](https://img.shields.io/badge/Tailwind-3.4-cyan.svg)](https://tailwindcss.com/)
[Live Demo](#) | [Documentation](#) | [Report Bug](#) | [Request Feature](#)
</div>
---
## 📋 Table of Contents
- [Overview](#-overview)
- [Key Features](#-key-features)
- [Architecture](#-architecture)
- [Use Cases](#-use-cases)
- [Tech Stack](#-tech-stack)
- [Getting Started](#-getting-started)
- [Project Structure](#-project-structure)
- [API Integration](#-api-integration)
- [Component Documentation](#-component-documentation)
- [Deployment](#-deployment)
- [Contributing](#-contributing)
- [License](#-license)
---
## 🎯 Overview
**DocuStream Frontend** is a modern, responsive web application built with React and TypeScript that provides a seamless interface for converting PDF documents to various formats (Markdown, XML, HTML, JSON). It features real-time collaboration, version control, and intelligent document editing capabilities.
### What Makes DocuStream Special?
- 🚀 **Lightning Fast**: Built with Vite for instant hot-reload and optimized builds
- 🎨 **Beautiful UI**: Modern, responsive design with TailwindCSS
- 📝 **Smart Editing**: AI-powered document conversion with manual editing capabilities
- 👥 **Collaborative**: Real-time sharing and version history
- 🔒 **Secure**: Role-based access control with authentication
- 📱 **Responsive**: Works perfectly on desktop, tablet, and mobile
---
## ✨ Key Features
### 🔄 Document Conversion
- **Multi-Format Support**: Convert PDFs to Markdown, XML, HTML, and JSON
- **AI-Powered Processing**: Uses 6 specialized AI models for accurate conversion
- **Real-time Preview**: See conversion results instantly
- **Manual Editing**: Fine-tune converted documents with built-in editors
### 📊 Project Management
- **Dashboard**: Centralized view of all your conversion projects
- **Project Tracking**: Monitor conversion status and history
- **Search & Filter**: Quickly find projects by name, date, or format
- **Bulk Operations**: Convert multiple documents at once
### 🤝 Collaboration Features
- **Project Sharing**: Share projects with team members via email or link
- **Permission Control**: Set viewer or editor permissions
- **Version History**: Track all changes with automatic versioning
- **Version Comparison**: View differences between versions
- **Rollback**: Restore previous versions easily
### ✍️ Advanced Editing
- **Markdown Editor**:
- Live preview with syntax highlighting
- Mathematical equations support (KaTeX)
- Table editing with visual interface
- Code block support with syntax highlighting
- **XML Editor**:
- Syntax highlighting and validation
- Tree view for nested structures
- Auto-formatting and beautification
- Error detection and suggestions
- **Visual Editor**: WYSIWYG editing experience
### 📤 Export Options
- **Multiple Formats**: Export to Markdown, XML, HTML, JSON, DOCX, PDF
- **Custom Styling**: Apply custom templates and themes
- **Batch Export**: Export multiple projects simultaneously
- **Download Options**: Single file or ZIP archive
### 🎨 User Experience
- **Dark/Light Mode**: Automatic theme switching
- **Responsive Design**: Works on all devices
- **Keyboard Shortcuts**: Speed up your workflow
- **Drag & Drop**: Easy file uploads
- **Progress Tracking**: Real-time conversion progress
---
## 🏗️ Architecture
### System Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ DocuStream Frontend │
│ (React + TypeScript + Vite) │
└───────────────────┬─────────────────────────────────────────┘
┌───────────┴───────────┐
│ │
▼ ▼
┌───────────────┐ ┌──────────────────┐
│ Presentation │ │ State Management│
│ Layer │ │ (Context API) │
├───────────────┤ ├──────────────────┤
│ - Pages │ │ - AuthContext │
│ - Components │ │ - ProjectContext │
│ - Layouts │ │ - ThemeContext │
└───────────────┘ └──────────────────┘
┌─────────────────────────────────────┐
│ Business Logic Layer │
├─────────────────────────────────────┤
│ - API Services │
│ - Data Transformation │
│ - Validation Logic │
│ - File Processing │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Backend API Layer │
├─────────────────────────────────────┤
│ - PDF Processing API │
│ - Authentication API │
│ - Storage API │
│ - Sharing & Versioning API │
└─────────────────────────────────────┘
```
### Component Architecture
```
App (Root)
├── Router (React Router)
│ │
│ ├── Public Routes
│ │ ├── HomePage
│ │ ├── AboutPage
│ │ ├── LoginPage
│ │ ├── RegisterPage
│ │ └── SubscriptionPage
│ │
│ └── Protected Routes (RequireAuth)
│ ├── Dashboard
│ │ ├── ProjectList
│ │ ├── ProjectCard
│ │ ├── SearchBar
│ │ └── FilterOptions
│ │
│ └── ConversionWorkflow
│ ├── FileUpload
│ ├── DocumentPreview
│ ├── SimpleMarkdownEditor
│ ├── VisualMarkdownEditor
│ ├── XMLEditor
│ ├── ExportOptions
│ ├── VersionHistory
│ └── ProjectSharing
├── Shared Components
│ ├── Navbar
│ ├── Footer
│ ├── MarkdownPreview
│ └── ScrollAnimated
└── Context Providers
├── AuthProvider
├── ProjectProvider
└── ThemeProvider
```
### Data Flow Architecture
```
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ User │────▶│ View │────▶│ Action │────▶│ API │
│ Interact │ │(Component)│ │ (Event) │ │ Service │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
▲ │
│ ▼
│ ┌──────────┐
│ │ Backend │
│ │ API │
│ └──────────┘
│ │
│ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Update │◀────│ State │◀────│ Response │
│ View │ │ Update │ │ Data │
└──────────┘ └──────────┘ └──────────┘
```
### State Management
```
AuthContext
├── currentUser: User | null
├── isAuthenticated: boolean
├── login(credentials)
├── logout()
└── register(userData)
ProjectContext
├── projects: Project[]
├── currentProject: Project | null
├── createProject(data)
├── updateProject(id, data)
├── deleteProject(id)
└── shareProject(id, shareData)
ThemeContext
├── theme: 'light' | 'dark'
└── toggleTheme()
```
---
## 💼 Use Cases
### 1. **Academic Research Publishing**
**Scenario**: A researcher needs to convert research papers from PDF to XML format for journal submission.
**Flow**:
1. Upload research paper PDF
2. AI processes and converts to XML
3. Review and edit using XML Editor
4. Validate against journal schema
5. Export in required format
6. Share with co-authors for review
**Benefits**:
- Saves hours of manual formatting
- Ensures compliance with journal standards
- Collaborative review process
---
### 2. **Technical Documentation Team**
**Scenario**: A software company needs to convert legacy PDF documentation to Markdown for their GitHub wiki.
**Flow**:
1. Bulk upload multiple PDF documents
2. Batch convert to Markdown
3. Team members edit and refine content
4. Track changes with version history
5. Export as individual files or archive
6. Publish to documentation platform
**Benefits**:
- Streamlines documentation migration
- Version control for collaborative editing
- Consistent formatting across documents
---
### 3. **Legal Document Processing**
**Scenario**: A law firm needs to digitize and edit old legal contracts.
**Flow**:
1. Upload scanned contract PDFs
2. OCR and convert to editable format
3. Use visual editor for amendments
4. Share with clients for review
5. Track versions for audit trail
6. Export to PDF or DOCX
**Benefits**:
- Makes legacy documents searchable
- Maintains legal audit trail
- Secure client collaboration
---
### 4. **Educational Content Creation**
**Scenario**: Teachers converting textbook chapters to interactive online content.
**Flow**:
1. Upload textbook PDF chapters
2. Convert to Markdown
3. Add mathematical equations with KaTeX
4. Create interactive tables and diagrams
5. Preview in web format
6. Export for LMS integration
**Benefits**:
- Creates accessible educational content
- Supports mathematical notation
- Easy LMS integration
---
### 5. **Business Report Conversion**
**Scenario**: Corporate team converting quarterly reports for web publishing.
**Flow**:
1. Upload business report PDF
2. Convert to HTML format
3. Customize styling and branding
4. Add interactive charts
5. Share with stakeholders
6. Publish to corporate website
**Benefits**:
- Professional web-ready reports
- Brand consistency
- Easy stakeholder sharing
---
### 6. **Medical Records Digitization**
**Scenario**: Healthcare provider digitizing patient records.
**Flow**:
1. Upload patient record PDFs
2. Convert to structured XML
3. Validate against HL7 standards
4. Secure sharing with authorized personnel
5. Version tracking for compliance
6. Integration with EHR systems
**Benefits**:
- HIPAA-compliant workflow
- Structured data for EHR
- Audit trail for compliance
---
## 🛠️ Tech Stack
### Core Technologies
| Technology | Version | Purpose |
|------------|---------|---------|
| **React** | 18.3 | UI Framework |
| **TypeScript** | 5.5 | Type Safety |
| **Vite** | 5.4 | Build Tool & Dev Server |
| **React Router** | 7.8 | Client-side Routing |
### UI & Styling
| Technology | Purpose |
|------------|---------|
| **TailwindCSS** | Utility-first CSS Framework |
| **Lucide React** | Icon Library |
| **Custom CSS** | Additional Styling |
### Document Processing
| Technology | Purpose |
|------------|---------|
| **Marked** | Markdown Parser |
| **KaTeX** | Mathematical Equations |
| **React-KaTeX** | React Integration for KaTeX |
### Development Tools
| Tool | Purpose |
|------|---------|
| **ESLint** | Code Linting |
| **PostCSS** | CSS Processing |
| **Autoprefixer** | CSS Vendor Prefixes |
---
## 🚀 Getting Started
### Prerequisites
Before you begin, ensure you have the following installed:
- **Node.js** (v18+ recommended)
- **npm** or **yarn** or **pnpm**
- **Git**
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/MaskanTechnologies/pdf-xml-frontend.git
cd pdf-xml-frontend
```
2. **Install dependencies**
```bash
npm install
# or
yarn install
# or
pnpm install
```
3. **Configure environment variables**
Create a `.env` file in the root directory:
```env
VITE_API_BASE_URL=http://localhost:8000
VITE_APP_NAME=DocuStream
VITE_APP_VERSION=1.0.0
```
4. **Start development server**
```bash
npm run dev
```
The application will be available at `http://localhost:5173`
### Development Workflow
```bash
# Start dev server with hot reload
npm run dev
# Build for production
npm run build
# Preview production build
npm run preview
# Run linting
npm run lint
```
---
## 📁 Project Structure
```
frontend/
├── public/ # Static assets
│ └── favicon.ico
├── src/
│ ├── components/ # Reusable UI components
│ │ ├── DocumentPreview.tsx # PDF preview component
│ │ ├── ExportOptions.tsx # Export functionality
│ │ ├── FileUpload.tsx # Drag & drop upload
│ │ ├── Footer.tsx # App footer
│ │ ├── MarkdownPreview.tsx # Live Markdown preview
│ │ ├── Navbar.tsx # Navigation bar
│ │ ├── ProjectSharing.tsx # Sharing modal
│ │ ├── RequireAuth.tsx # Auth guard
│ │ ├── ScrollAnimated.tsx # Scroll animations
│ │ ├── SimpleMarkdownEditor.tsx # Markdown editor
│ │ ├── VersionHistory.tsx # Version control UI
│ │ ├── VisualMarkdownEditor.tsx # WYSIWYG editor
│ │ └── XMLEditor.tsx # XML editing interface
│ │
│ ├── pages/ # Page components (routes)
│ │ ├── AboutPage.tsx # About/features page
│ │ ├── ConversionWorkflow.tsx # Main conversion UI
│ │ ├── Dashboard.tsx # User dashboard
│ │ ├── HomePage.tsx # Landing page
│ │ ├── LoginPage.tsx # Login form
│ │ ├── RegisterPage.tsx # Registration form
│ │ └── SubscriptionPage.tsx # Pricing plans
│ │
│ ├── context/ # React Context providers
│ │ ├── AuthContext.tsx # Authentication state
│ │ └── ProjectContext.tsx # Project management
│ │
│ ├── hooks/ # Custom React hooks
│ │ └── useAuth.ts # Auth hook
│ │
│ ├── utils/ # Utility functions
│ │ ├── api.ts # API client
│ │ ├── formatters.ts # Data formatting
│ │ └── projectTracking.ts # Project utilities
│ │
│ ├── styles/ # Global styles
│ │ └── globals.css
│ │
│ ├── App.tsx # Root component
│ ├── main.tsx # Application entry
│ ├── index.css # Global CSS
│ └── vite-env.d.ts # Vite types
├── .gitignore # Git ignore rules
├── eslint.config.js # ESLint configuration
├── index.html # HTML entry point
├── package.json # Dependencies
├── postcss.config.js # PostCSS config
├── tailwind.config.js # Tailwind configuration
├── tsconfig.json # TypeScript config
├── tsconfig.app.json # App-specific TS config
├── tsconfig.node.json # Node-specific TS config
├── vite.config.ts # Vite configuration
└── README.md # This file
```
---
## 🔌 API Integration
### Backend Connection
The frontend connects to the DocuStream backend API for document processing.
**Base URL**: `http://localhost:8000` (configurable via `.env`)
### Key API Endpoints
#### Authentication
```typescript
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/user
```
#### Document Conversion
```typescript
POST /api/convert # Single file conversion
POST /api/convert/batch # Batch conversion
GET /api/convert/status/:id # Check conversion status
```
#### Project Management
```typescript
GET /api/projects # Get all projects
POST /api/projects # Create project
GET /api/projects/:id # Get project details
PUT /api/projects/:id # Update project
DELETE /api/projects/:id # Delete project
```
#### Sharing & Collaboration
```typescript
POST /api/projects/:id/share # Share project
GET /api/projects/:id/access # Check access
POST /api/projects/:id/revoke # Revoke access
```
#### Version Control
```typescript
GET /api/projects/:id/versions # Get version history
POST /api/projects/:id/versions # Create new version
GET /api/projects/:id/versions/:vid # Get specific version
POST /api/projects/:id/versions/:vid/restore # Restore version
```
### API Client Configuration
```typescript
// src/utils/api.ts
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;
```
---
## 📚 Component Documentation
### Core Components
#### 1. **ConversionWorkflow**
Main conversion interface with step-by-step workflow.
**Props**: None (uses route params)
**Features**:
- File upload with drag & drop
- Format selection
- Real-time conversion progress
- Multi-tab editing interface
- Export options
- Version control
- Sharing capabilities
**Usage**:
```tsx
<Route path="/workflow/:projectId" element={<ConversionWorkflow />} />
```
---
#### 2. **Dashboard**
User dashboard for managing all projects.
**Features**:
- Project list with cards
- Search and filter
- Sort by date, name, format
- Quick actions (view, edit, delete)
- Project statistics
**State Management**:
```typescript
interface DashboardState {
projects: Project[];
searchQuery: string;
filterFormat: string;
sortBy: 'date' | 'name';
}
```
---
#### 3. **SimpleMarkdownEditor**
Advanced Markdown editing component.
**Props**:
```typescript
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
readOnly?: boolean;
}
```
**Features**:
- Syntax highlighting
- Live preview
- KaTeX math support
- Table editing
- Code blocks
- Image insertion
- Keyboard shortcuts
---
#### 4. **XMLEditor**
Specialized XML editing interface.
**Props**:
```typescript
interface XMLEditorProps {
value: string;
onChange: (value: string) => void;
schema?: string;
}
```
**Features**:
- Syntax highlighting
- Tree view
- Validation
- Auto-formatting
- Error detection
- Schema validation
---
#### 5. **VersionHistory**
Version control interface.
**Props**:
```typescript
interface VersionHistoryProps {
projectId: string;
onRestore: (versionId: string) => void;
}
```
**Features**:
- Timeline view
- Version comparison
- Diff viewer
- Restore capability
- Version metadata
---
#### 6. **ProjectSharing**
Collaboration and sharing interface.
**Props**:
```typescript
interface ProjectSharingProps {
projectId: string;
isOpen: boolean;
onClose: () => void;
}
```
**Features**:
- Share via email
- Share via link
- Permission control (view/edit)
- Manage existing shares
- Revoke access
---
## 🎨 Styling Guide
### Tailwind Configuration
The project uses a custom Tailwind configuration with brand colors:
```javascript
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
},
// ... more colors
},
},
},
};
```
### Design Tokens
```css
/* Primary Colors */
--primary: #0ea5e9;
--primary-dark: #0369a1;
/* Secondary Colors */
--secondary: #64748b;
/* Status Colors */
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
/* Typography */
--font-sans: 'Inter', sans-serif;
--font-mono: 'Fira Code', monospace;
```
---
## 🚢 Deployment
### Build for Production
```bash
# Build the application
npm run build
# Output will be in the 'dist' directory
```
### Deployment Options
#### Option 1: Vercel
```bash
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
```
#### Option 2: Netlify
```bash
# Build command
npm run build
# Publish directory
dist
```
#### Option 3: Docker
```dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["npm", "run", "preview"]
```
### Environment Variables for Production
```env
VITE_API_BASE_URL=https://api.docustream.com
VITE_APP_NAME=DocuStream
VITE_APP_VERSION=1.0.0
VITE_ENABLE_ANALYTICS=true
```
---
## 🧪 Testing
### Unit Tests
```bash
npm run test
```
### E2E Tests
```bash
npm run test:e2e
```
### Coverage
```bash
npm run test:coverage
```
---
## 🤝 Contributing
We welcome contributions! Please follow these steps:
1. **Fork the repository**
2. **Create a feature branch**
```bash
git checkout -b feature/amazing-feature
```
3. **Commit your changes**
```bash
git commit -m 'Add amazing feature'
```
4. **Push to the branch**
```bash
git push origin feature/amazing-feature
```
5. **Open a Pull Request**
### Coding Standards
- Use TypeScript for all new components
- Follow React best practices
- Write meaningful commit messages
- Add comments for complex logic
- Update documentation for new features
---
## 📝 License
This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details.
---
## 👥 Team
**Maskan Technologies**
- Organization: [Maskan Technologies](https://github.com/MaskanTechnologies)
- Frontend Repository: [pdf-xml-frontend](https://github.com/MaskanTechnologies/pdf-xml-frontend)
- Backend Repository: [pdf-xml-backend](https://github.com/MaskanTechnologies/pdf_xml_bakcend)
---
## 📞 Support
For issues, questions, or contributions:
- **GitHub Issues**: [Create an issue](https://github.com/MaskanTechnologies/pdf-xml-frontend/issues)
- **Email**: support@maskantechnologies.com
- **Documentation**: [Full Documentation](#)
---
## 🗺️ Roadmap
### Q1 2025
- [ ] Mobile app (React Native)
- [ ] Offline mode support
- [ ] Advanced AI suggestions
- [ ] Plugin system
### Q2 2025
- [ ] Real-time collaboration
- [ ] Voice-to-text integration
- [ ] Advanced analytics
- [ ] API webhooks
---
## 🙏 Acknowledgments
- **React Team** - For the amazing framework
- **Vite Team** - For the blazing fast build tool
- **TailwindCSS** - For the utility-first CSS framework
- **Lucide** - For the beautiful icons
- **Open Source Community** - For inspiration and support
---
<div align="center">
**Made with ❤️ by Maskan Technologies**
[⬆ Back to Top](#docustream-frontend)
</div>
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/FavIcon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DocQube - Document Conversion Platform</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600&family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>
+9008
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -0,0 +1,109 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"local": "vite --host --mode localdev --port 5173",
"dev": "vite --mode development",
"test": "vite --mode test",
"uat": "vite --mode uat",
"prod": "vite --mode production",
"build": "tsc && vite build --mode production",
"build:local": "tsc && vite build --mode localdev",
"build:dev": "tsc && vite build --mode development",
"build:test": "tsc && vite build --mode test",
"build:uat": "tsc && vite build --mode uat",
"build:prod": "tsc && vite build --mode production",
"lint": "eslint .",
"preview": "vite preview",
"types:api": "openapi-typescript ../docqube_backend/openapi.json -o src/types/api/schema.d.ts",
"types:api:check": "node scripts/check-api-types.mjs",
"typecheck": "tsc -p tsconfig.app.json --noEmit",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@react-oauth/google": "^0.13.4",
"@tiptap/extension-color": "^3.20.0",
"@tiptap/extension-highlight": "^3.20.0",
"@tiptap/extension-image": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-subscript": "^3.20.0",
"@tiptap/extension-superscript": "^3.20.0",
"@tiptap/extension-table": "^3.20.0",
"@tiptap/extension-table-cell": "^3.20.0",
"@tiptap/extension-table-header": "^3.20.0",
"@tiptap/extension-table-row": "^3.20.0",
"@tiptap/extension-task-item": "^3.20.0",
"@tiptap/extension-task-list": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/extension-text-style": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@types/dompurify": "^3.0.5",
"@types/marked": "^5.0.2",
"axios": "^1.7.9",
"axios-retry": "^4.5.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"docx-preview": "^0.3.7",
"dompurify": "^3.3.3",
"gradflow": "^0.1.0",
"html2canvas": "^1.4.1",
"i18next": "^25.10.10",
"i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1",
"jszip": "^3.10.1",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.344.0",
"mammoth": "^1.11.0",
"marked": "^16.4.2",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.4",
"react-joyride": "^3.0.2",
"react-router-dom": "^7.8.0",
"recharts": "^3.8.0",
"tailwind-merge": "^3.6.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yup": "^1.7.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.4",
"@types/node": "^25.3.3",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "4.3.4",
"@vitest/coverage-v8": "^2.1.9",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"jsdom": "^25.0.1",
"openapi-typescript": "^7.13.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "5.4.21",
"vitest": "^2.1.9"
},
"overrides": {
"esbuild": "^0.25.1",
"tar": "^7.5.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg width="1032" height="1022" viewBox="0 0 1032 1022" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M465.481 15.201C493.311 -2.71187 528.744 -4.89651 558.823 9.1951C572.589 15.2917 585.639 22.2175 595.038 26.9724C664.339 61.3855 732.706 89.3169 797.657 130.994C942.256 223.784 873.89 329.361 751.269 399.059C653.057 454.886 550.66 480.553 435.772 455.637C346.251 438.863 263.61 392.028 182.536 353.617L110.557 319.462C77.2742 303.04 74.5135 256.228 105.98 236.802C115.53 230.94 125.087 225.089 134.663 219.261L258.845 143.038L400.641 55.9988C412.363 48.7972 432.142 35.84 452.18 23.7625L465.481 15.201ZM569.171 138.706C567.29 138.028 559.932 138.446 557.596 138.606C533.658 145.323 465.291 194.228 438.304 208.503C419.092 218.665 346.231 262.661 336.082 274.782C331.205 295.798 393.972 316.258 408.947 319.887C478.181 329.493 532.086 297.132 587.341 256.913C648.557 212.355 635.158 162.337 569.171 138.706Z" fill="url(#paint0_linear_631_741)"/>
<path d="M83.2947 371.889C142.099 365.625 196.814 397.246 245.141 423.974C272.278 438.982 303.423 454.608 329.466 470.661C408.198 519.205 468.372 596.187 487.126 683.6C497.224 731.908 495.336 782.155 495.241 831.233C495.115 896.414 504.222 1004.17 414.302 1020.37C352.429 1028.11 286.118 984.549 234.633 956.982C208.101 942.77 178.058 926.879 153.105 911.062C78.2833 863.638 25.0031 789.416 8.67749 705.621C-0.656831 657.712 -0.252272 621.627 0.177487 572.263C0.644271 518.534 -3.80754 450.443 28.3171 402.649C40.0343 385.213 62.1466 375.384 83.2947 371.889ZM233.848 559.371C229.641 561.522 227.41 562.281 224.841 566.198C223.074 573.97 223.823 644.659 223.82 655.365L223.833 745.166C223.85 777.694 214.119 830.292 263.752 831.187C267.208 829.161 271.947 826.425 272.188 822.446C273.742 796.844 273.123 765.795 273.121 740.279L273.095 646.325C273.074 615.065 282.208 560.527 233.848 559.371Z" fill="url(#paint1_linear_631_741)"/>
<path d="M941.503 353.712C990.676 347.165 1021.13 380.196 1028.05 422.961C1031.01 441.247 1036.47 481.783 1021.71 496.207C1000.3 517.134 962.517 530.974 936.33 546.877C913.133 558.951 888.358 573.872 865.033 585.188C814.037 609.945 839.476 555.532 822.518 542.065C808.6 531.011 779.413 551.901 770.922 564.858C756.173 587.376 759.098 613.09 759.126 639.041L759.26 711.708C759.267 746.387 757.741 782.447 760.731 816.965C760.979 819.837 764.461 824.761 767.442 825.868C781.346 830.966 796.713 820.894 807.145 813.52C841.937 788.925 820.746 750.366 837.315 719.504C843.144 708.636 855.364 702.494 866.448 696.637C899.764 679.018 932.763 660.403 966.005 642.622C980.78 635.074 1029.04 600.544 1031.14 634.938C1035.57 707.094 1015.51 768.607 969.455 825.88C947.847 851.495 923.004 874.536 895.476 894.493C863.006 918.031 822.2 938.635 785.018 958.033C725.673 988.758 616.271 1062.63 560.148 989.678C529.713 950.112 537.052 868.232 535.804 821.578C533.072 719.516 538.817 632.463 607.511 548.593C627.614 524.11 650.813 502.014 676.596 482.791C712.924 455.833 754.975 435.77 794.798 413.839C841.989 387.852 885.975 360.527 941.503 353.712Z" fill="url(#paint2_linear_631_741)"/>
<defs>
<linearGradient id="paint0_linear_631_741" x1="481.51" y1="0" x2="481.51" y2="465.172" gradientUnits="userSpaceOnUse">
<stop stop-color="#4949E6"/>
<stop offset="1" stop-color="#2A61EB"/>
</linearGradient>
<linearGradient id="paint1_linear_631_741" x1="247.723" y1="371.086" x2="247.723" y2="1021.28" gradientUnits="userSpaceOnUse">
<stop stop-color="#4949E6"/>
<stop offset="1" stop-color="#2A61EB"/>
</linearGradient>
<linearGradient id="paint2_linear_631_741" x1="783.511" y1="352.882" x2="783.511" y2="1020.85" gradientUnits="userSpaceOnUse">
<stop stop-color="#4949E6"/>
<stop offset="1" stop-color="#2A61EB"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+39
View File
@@ -0,0 +1,39 @@
/**
* B3.2 — fail when the generated API types are stale.
*
* Regenerates into a temporary file and compares. If this fails, the backend
* contract changed and `npm run types:api` has not been run — which is exactly
* the moment a field rename would otherwise reach a user at runtime.
*/
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const COMMITTED = 'src/types/api/schema.d.ts';
const SOURCE = '../docqube_backend/openapi.json';
const dir = mkdtempSync(join(tmpdir(), 'docqube-types-'));
const fresh = join(dir, 'schema.d.ts');
try {
execFileSync('npx', ['openapi-typescript', SOURCE, '-o', fresh], {
stdio: 'pipe',
shell: process.platform === 'win32',
});
const a = readFileSync(COMMITTED, 'utf8').replace(/\r\n/g, '\n');
const b = readFileSync(fresh, 'utf8').replace(/\r\n/g, '\n');
if (a !== b) {
console.error(
'API types are stale.\n' +
`${COMMITTED} does not match what ${SOURCE} generates.\n` +
'Run: npm run types:api — then commit the diff.',
);
process.exit(1);
}
console.log('API types are current.');
} finally {
rmSync(dir, { recursive: true, force: true });
}
Binary file not shown.
+251
View File
@@ -0,0 +1,251 @@
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Toaster, useToasterStore, toast } from 'react-hot-toast';
import { Suspense, useEffect } from 'react';
import Footer from '@/components/layout/Footer';
import LoginPage from '@/features/auth/components/LoginForm';
import ForgotPasswordPage from '@/features/auth/components/ForgotPasswordForm';
import ResetPasswordPage from '@/features/auth/components/ResetPasswordForm';
import PrivacyPolicy from '@/features/public/PrivacyPolicy';
import TermsOfService from '@/features/public/TermsOfService';
import RequireAuth from '@/components/auth/RequireAuth';
import PublicSharePage from '@/features/share/PublicSharePage';
import PublicRoute from '@/components/auth/PublicRoute';
import DashboardLayout from '@/components/layout/DashboardLayout';
import { DashboardProvider } from '@/features/dashboard/DashboardContext';
import {
DashboardMainView,
MyDocumentsView,
FavoritesView,
RecentFilesView,
SharedByMeView,
SharedWithMeView,
SharedProjectsView,
TrashView,
ConversionView,
StorageManager,
SettingsView,
ChatBotView,
} from '@/features/components/components';
import HistoryView from '@/features/HistoryView';
import TrackEnvelopesView from '@/features/TrackEnvelopesView';
import SignatureVerificationPage from '@/features/signatureVerification/SignatureVerificationPage';
import SigningPage from '@/features/signing/SigningPage';
import QuarantineView from '@/features/security/QuarantineView';
import { UploadProvider } from '@/context/UploadContext';
import ConversionWorkflow from '@/features/conversion/ConversionWorkflow';
import UpgradePlan from '@/features/subscription/SubscriptionPage';
const RouteFallback = () => (
<div className="flex min-h-[60vh] items-center justify-center" role="status" aria-live="polite">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-transparent" />
<span className="sr-only">Loading</span>
</div>
);
// Scroll to top component
const ScrollToTop = () => {
const { pathname } = useLocation();
useEffect(() => {
// Scroll instantly to the top
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
// Fallback for some browsers/layouts
document.documentElement.scrollTo({ top: 0, left: 0, behavior: 'instant' });
document.body.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}, [pathname]);
return null;
};
// Admin Components
import AdminLayout from '@/features/superAdmin/layout/AdminLayout';
import AdminDashboard from '@/features/superAdmin/dashboard/AdminDashboard';
import RoleManagement from '@/features/superAdmin/role/components/RoleManagement';
import UserManagement from '@/features/superAdmin/users/UserManagement';
import LogsView from '@/features/superAdmin/logs/LogsView';
import SystemSettingsPage from '@/features/superAdmin/settings/SystemSettingsPage';
import Profile from '@/features/superAdmin/profile/Profile';
import TenantManagement from '@/features/superAdmin/tenant/TenantManagement';
import RequireAccess from '@/components/auth/RequireAccess';
import OrganisationView from '@/features/OrganisationView';
import PlanManagement from '@/features/superAdmin/billing/PlanManagement';
import { CONVERSION_PAGE_ACCESS_CODES } from '@/features/conversion/toolAccess';
import DriveFileEditorPage from '@/features/editor/DriveFileEditorPage';
function AppContent() {
const hideFooter = true;
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
{}
<main className="flex-grow">
<Suspense fallback={<RouteFallback />}>
<Routes>
{}
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/signin" element={<Navigate to="/login" replace />} />
{}
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
<Route path="/terms" element={<TermsOfService />} />
<Route path="/s/:token" element={<PublicSharePage />} />
<Route path="/sign/:slug" element={<SigningPage />} />
<Route path="/verification/signature" element={<SignatureVerificationPage />} />
{}
<Route path="/convert" element={<RequireAuth><ConversionWorkflow /></RequireAuth>} />
<Route path="/convert/:projectId" element={<RequireAuth><ConversionWorkflow /></RequireAuth>} />
<Route path="/workflow/:projectId" element={<RequireAuth><ConversionWorkflow /></RequireAuth>} />
<Route path="/subscription" element={<RequireAuth><UpgradePlan /></RequireAuth>} />
<Route path="/tenant" element={<RequireAuth><TenantManagement /></RequireAuth>} />
{}
<Route
path="/admin"
element={
<RequireAccess anyOf={['superadmin.main.view', 'admin.user.read', 'admin.role.read']}>
<AdminLayout />
</RequireAccess>
}
>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="tenants" element={<RequireAccess accessCode="superadmin.tenant.read"><TenantManagement /></RequireAccess>} />
<Route path="plans" element={<RequireAccess accessCode="superadmin.tenant.read"><PlanManagement /></RequireAccess>} />
<Route path="users" element={<RequireAccess anyOf={['admin.user.read', 'superadmin.main.view']}><UserManagement /></RequireAccess>} />
<Route path="roles" element={<RequireAccess anyOf={['superadmin.role.read', 'admin.role.read']}><RoleManagement /></RequireAccess>} />
{/* <Route path="organisation" element={<RequireAccess anyOf={['superadmin.role.read', 'admin.role.read']}><OrganisationView /></RequireAccess>} /> */}
<Route
path="settings"
element={
<RequireAccess anyOf={['superadmin.config.read', 'superadmin.config.update', 'superadmin.main.view']}>
<SystemSettingsPage />
</RequireAccess>
}
/>
<Route path="profile" element={<Profile />} />
</Route>
{/* Full Screen Editor Route */}
<Route
path="/drive/file/:id"
element={
<RequireAuth>
<RequireAccess accessCode="document.read">
<DriveFileEditorPage />
</RequireAccess>
</RequireAuth>
}
/>
{/* Dashboard Layout — all sidebar views as root-level routes */}
<Route
element={
<RequireAuth>
<UploadProvider>
<DashboardProvider>
<DashboardLayout />
</DashboardProvider>
</UploadProvider>
</RequireAuth>
}
>
<Route path="/dashboard" element={<DashboardMainView />} />
<Route path="/documents" element={<RequireAccess accessCode="document.read"><MyDocumentsView /></RequireAccess>} />
<Route
path="/new-conversion"
element={
<RequireAccess anyOf={CONVERSION_PAGE_ACCESS_CODES}>
<ConversionView />
</RequireAccess>
}
/>
<Route path="/recent" element={<RequireAccess accessCode="document.read"><RecentFilesView /></RequireAccess>} />
<Route path="/favorites" element={<RequireAccess accessCode="document.read"><FavoritesView /></RequireAccess>} />
<Route path="/history" element={<RequireAccess accessCode="document.conversion"><HistoryView /></RequireAccess>} />
<Route path="/envelopes" element={<RequireAccess accessCode="document.sign"><TrackEnvelopesView /></RequireAccess>} />
<Route
path="/ai-assistant"
element={
<RequireAccess anyOf={['chatbot', 'chatbot.read', 'chatbot.write']}>
<ChatBotView />
</RequireAccess>
}
/>
<Route path="/shared-projects" element={<RequireAccess accessCode="document.conversion"><SharedProjectsView /></RequireAccess>} />
<Route path="/shared-with-me" element={<RequireAccess anyOf={['document.conversion', 'document.read']}><SharedWithMeView /></RequireAccess>} />
<Route path="/shared-by-me" element={<RequireAccess anyOf={['document.conversion', 'document.read']}><SharedByMeView /></RequireAccess>} />
<Route path="/trash" element={<RequireAccess accessCode="document.read"><TrashView /></RequireAccess>} />
<Route path="/settings" element={<SettingsView />} />
<Route path="/storage" element={<StorageManager />} />
<Route path="/users" element={<RequireAccess anyOf={['admin.user.read', 'superadmin.main.view']}><UserManagement /></RequireAccess>} />
<Route path="/roles" element={<RequireAccess anyOf={['superadmin.role.read', 'admin.role.read']}><RoleManagement /></RequireAccess>} />
<Route path="/organisation" element={<RequireAccess anyOf={['admin.org.read', 'superadmin.main.view']}><OrganisationView /></RequireAccess>} />
<Route path="/logs" element={<RequireAccess anyOf={['admin.logs.read', 'superadmin.main.view']}><LogsView mode="admin" /></RequireAccess>} />
<Route path="/quarantine" element={<RequireAccess anyOf={['admin.storage.read', 'admin.security.read', 'admin.user.read', 'superadmin.main.view']}><QuarantineView /></RequireAccess>} />
<Route path="/activities" element={<LogsView mode="user" />} />
</Route>
</Routes>
</Suspense>
</main>
{!hideFooter && <Footer />}
</div>
);
}
const ToastLimiter = () => {
const { toasts } = useToasterStore();
useEffect(() => {
toasts
.filter((t) => t.visible)
.filter((_, i) => i >= 1)
.forEach((t) => toast.dismiss(t.id));
}, [toasts]);
return null;
};
function App() {
return (
<>
<Router>
<ScrollToTop />
<AppContent />
</Router>
<ToastLimiter />
<Toaster
position="bottom-right"
containerStyle={{ zIndex: 99999 }}
toastOptions={{
duration: 3000,
style: {
background: '#363636',
color: '#fff',
},
success: {
iconTheme: {
primary: '#10B981',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#EF4444',
secondary: '#fff',
},
},
}}
/>
</>
);
}
export default App;
+1230
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { GoogleOAuthProvider } from '@react-oauth/google';
import App from './App';
import '@/styles/globals.css';
import { AuthProvider } from '@/features/auth/context';
import { ToastProvider } from '@/components/ui/ToastProvider';
import { CacheProvider } from '@/context/CacheContext';
import { ThemeProvider } from '@/context/ThemeContext';
import { setupFetchInterceptor } from '@/utils/fetchInterceptor';
setupFetchInterceptor();
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
createRoot(document.getElementById('root')!).render(
<StrictMode>
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<AuthProvider>
<ToastProvider>
<CacheProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</CacheProvider>
</ToastProvider>
</AuthProvider>
</GoogleOAuthProvider>
</StrictMode>
);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg">
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da"/>
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47"/>
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#ea4335"/>
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d"/>
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc"/>
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#ffba00"/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

+76
View File
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { CustomLoader } from "@/components/custom/CustomLoader";
import Loader from "@/components/ui/Loader";
describe("Loader (ui)", () => {
it("always shows text, defaulting to 'Loading...'", () => {
const { container } = render(<Loader />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(container.firstChild).toHaveClass("py-12");
});
it("uses the caller's text when given", () => {
render(<Loader text="Fetching files" />);
expect(screen.getByText("Fetching files")).toBeInTheDocument();
});
it.each([
["sm", "h-6", "text-xs"],
["md", "h-8", "text-sm"],
["lg", "h-12", "text-base"],
] as const)("size %s sets %s on the spinner", (size, spinnerClass, textClass) => {
const { container } = render(<Loader size={size} text="x" />);
expect(container.querySelector(`.${spinnerClass}`)).not.toBeNull();
expect(screen.getByText("x")).toHaveClass(textClass);
});
});
describe("CustomLoader (custom)", () => {
it("renders NO label unless one is given", () => {
const { container } = render(<CustomLoader />);
expect(container.textContent).toBe("");
});
it("renders the label when given", () => {
render(<CustomLoader label="Uploading" />);
expect(screen.getByText("Uploading")).toBeInTheDocument();
});
it("marks itself busy for assistive technology", () => {
const { container } = render(<CustomLoader />);
expect(container.firstChild).toHaveAttribute("aria-busy", "true");
expect(container.firstChild).toHaveAttribute("aria-live", "polite");
});
it.each([
["sm", 20],
["md", 32],
["lg", 48],
["xl", 64],
] as const)("size %s is %spx", (size, px) => {
const { container } = render(<CustomLoader size={size} />);
const spinner = container.querySelector<HTMLElement>("[style*='width']");
expect(spinner?.style.width).toBe(`${px}px`);
});
it("lays out inline when asked", () => {
const { container } = render(<CustomLoader inline label="x" />);
expect(container.firstChild).toHaveClass("flex-row");
});
it("passes className through", () => {
const { container } = render(<CustomLoader className="mt-8" />);
expect(container.firstChild).toHaveClass("mt-8");
});
});
describe("the two disagree, which is why the swap is not mechanical", () => {
it("differs on whether text appears by default", () => {
const { container: ui } = render(<Loader />);
const { container: custom } = render(<CustomLoader />);
expect(ui.textContent).toBe("Loading...");
expect(custom.textContent).toBe("");
});
});
+26
View File
@@ -0,0 +1,26 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/features/auth/context';
const PublicRoute: React.FC<{ children: React.ReactElement }> = ({ children }) => {
const { isAuthenticated, isLoading, user } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (isAuthenticated) {
if (user?.accesses?.includes('superadmin.main.view')) {
return <Navigate to="/admin/dashboard" replace />;
}
return <Navigate to="/dashboard" replace />;
}
return children;
};
export default PublicRoute;
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/features/auth/context';
import { hasAccess, hasAnyAccess, hasAllAccess, hasRole } from '@/utils/permissions';
import { Loader2 } from 'lucide-react';
interface RequireAccessProps {
children: React.ReactNode;
accessCode?: string;
anyOf?: string[];
allOf?: string[];
role?: string;
redirectTo?: string;
}
const RequireAccess: React.FC<RequireAccessProps> = ({
children,
accessCode,
anyOf,
allOf,
role,
redirectTo = '/dashboard'
}) => {
const { user, isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-gray-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!isAuthenticated || !user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
let isAuthorized = true;
if (accessCode && !hasAccess(user.accesses, accessCode)) {
isAuthorized = false;
}
if (isAuthorized && anyOf && anyOf.length > 0 && !hasAnyAccess(user.accesses, anyOf)) {
isAuthorized = false;
}
if (isAuthorized && allOf && allOf.length > 0 && !hasAllAccess(user.accesses, allOf)) {
isAuthorized = false;
}
if (isAuthorized && role && !hasRole(user, role)) {
isAuthorized = false;
}
if (!isAuthorized) {
return <Navigate to={redirectTo} replace />;
}
return <>{children}</>;
};
export default RequireAccess;
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/features/auth/context';
const RequireAuth: React.FC<{ children: React.ReactElement }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
export default RequireAuth;
@@ -0,0 +1,154 @@
import React, { useState, useEffect, useRef } from 'react';
import axiosInstance from '@/services/axios';
import { ChevronDown, X } from 'lucide-react';
interface Contact {
id: string;
name: string;
email: string;
}
interface ContactsAutocompleteProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
excludeEmails?: string[];
allowMultiple?: boolean;
}
export const ContactsAutocomplete: React.FC<ContactsAutocompleteProps> = ({
value,
onChange,
placeholder = "Email address",
className = "",
excludeEmails = [],
allowMultiple = false
}) => {
const [contacts, setContacts] = useState<Contact[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const fetchContacts = async () => {
try {
const { data } = await axiosInstance.get('/tenant/contacts');
setContacts(data);
} catch (error) {
console.error('Failed to load contacts for autocomplete', error);
}
};
fetchContacts();
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setShowSuggestions(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [wrapperRef]);
const searchTerm = allowMultiple ? (value.split(',').pop()?.trim() || '') : value;
const filteredContacts = contacts.filter(
contact => {
return !searchTerm ||
contact.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
contact.name.toLowerCase().includes(searchTerm.toLowerCase());
}
);
return (
<div className="relative flex-1 w-full" ref={wrapperRef}>
<div className="relative w-full">
<input
ref={inputRef}
type={allowMultiple ? "text" : "email"}
className={`w-full rounded-lg bg-white text-gray-900 border border-gray-300 py-3 text-sm px-3 pr-10 outline-none transition-all duration-200 hover:border-[#2563EB] focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed ${className}`}
placeholder={placeholder}
value={value}
onChange={(e) => {
onChange(e.target.value);
setShowSuggestions(true);
}}
onFocus={(e) => {
setShowSuggestions(true);
if (!allowMultiple) e.target.select();
}}
onClick={() => setShowSuggestions(true)}
/>
{!allowMultiple && value ? (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 cursor-pointer hover:text-gray-600"
onClick={(e) => {
e.stopPropagation();
onChange('');
setShowSuggestions(true);
}}
>
<X size={16} />
</span>
) : (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
setShowSuggestions(!showSuggestions);
}}
>
<ChevronDown size={16} />
</span>
)}
</div>
{showSuggestions && filteredContacts.length > 0 && (
<ul className="absolute z-50 w-full bg-white border border-gray-200 mt-1 rounded-md shadow-lg max-h-60 overflow-auto">
{filteredContacts.map(contact => {
const isDisabled = excludeEmails.includes(contact.email.toLowerCase()) ||
(allowMultiple && value.toLowerCase().includes(contact.email.toLowerCase()));
return (
<li
key={contact.id}
className={`px-4 py-2 flex flex-col ${isDisabled ? 'opacity-50 cursor-not-allowed bg-gray-50' : 'hover:bg-gray-100 cursor-pointer'}`}
onClick={() => {
if (isDisabled) return;
if (allowMultiple) {
const parts = value.split(',');
parts.pop(); // remove current search term
const newValue = parts.length > 0 ? parts.join(',') + ', ' + contact.email + ', ' : contact.email + ', ';
onChange(newValue);
// Auto-focus and scroll to the end so user can continue typing
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.scrollLeft = inputRef.current.scrollWidth;
const len = newValue.length;
inputRef.current.setSelectionRange(len, len);
}
}, 0);
} else {
onChange(contact.email);
}
setShowSuggestions(false);
}}
>
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-900">{contact.name}</span>
{isDisabled && <span className="text-[10px] text-gray-500 font-medium bg-gray-200 px-2 py-0.5 rounded-full">Added</span>}
</div>
<span className="text-xs text-gray-500">{contact.email}</span>
</li>
);
})}
</ul>
)}
</div>
);
};
+70
View File
@@ -0,0 +1,70 @@
import CustomLoader from "./CustomLoader";
type ButtonVariant = "primary" | "secondary" | "outlined" | "text" | "link" | "ghost";
type ButtonSize = "sm" | "md" | "lg";
interface CustomButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
loading?: boolean;
children: React.ReactNode;
}
const CustomButton: React.FC<CustomButtonProps> = ({
variant = "primary",
size = "md",
leftIcon,
rightIcon,
loading = false,
disabled,
className = "",
children,
...props
}) => {
const baseClasses =
"inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2";
const variantClasses = {
primary:
"bg-[#2563EB] text-white hover:bg-[#1D4ED8] focus:ring-[#2563EB] disabled:bg-gray-300 disabled:text-gray-500",
secondary:
"bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500 disabled:bg-gray-300 disabled:text-gray-500",
outlined:
"border-2 border-[#2563EB] text-[#2563EB] bg-transparent hover:bg-blue-50 focus:ring-[#2563EB] disabled:border-gray-300 disabled:text-gray-400 disabled:hover:bg-transparent",
text: "text-[#2563EB] bg-transparent hover:bg-blue-50 focus:ring-[#2563EB] disabled:text-gray-400 disabled:hover:bg-transparent",
ghost: "text-gray-700 bg-transparent hover:bg-gray-100 focus:ring-gray-300 disabled:text-gray-400 disabled:hover:bg-transparent",
link: "text-[#2563EB] bg-transparent hover:underline p-0 h-auto focus:ring-transparent disabled:text-gray-400",
};
const sizeClasses = {
sm: "px-3 py-1.5 text-sm gap-1.5",
md: "px-4 py-2 text-sm gap-2",
lg: "px-6 py-3 text-base gap-2",
};
const isDisabled = disabled || loading;
const loaderVariant = variant === "outlined" || variant === "text" || variant === "ghost" ? "primary" : "white";
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${variant === "link" ? "" : sizeClasses[size]
} ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} ${className}`}
disabled={isDisabled}
{...props}
>
{loading ? (
<CustomLoader size="sm" variant={loaderVariant} />
) : leftIcon ? (
<span className="flex items-center justify-center">{leftIcon}</span>
) : null}
{children}
{rightIcon ? <span className="flex items-center justify-center">{rightIcon}</span> : null}
</button>
);
};
export default CustomButton;
+58
View File
@@ -0,0 +1,58 @@
import React, { forwardRef } from "react";
import { Check } from "lucide-react";
interface CustomCheckBoxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
}
const CustomCheckBox = forwardRef<HTMLInputElement, CustomCheckBoxProps>(
({ label, className = "", disabled, ...props }, ref) => {
return (
<label
className={`
inline-flex items-center gap-2.5 cursor-pointer
${disabled ? "cursor-default" : ""}
${className}
`}
>
<div className="relative flex items-center">
<input
ref={ref}
type="checkbox"
className="peer sr-only"
disabled={disabled}
{...props}
/>
<div
className={`
w-5 h-5 border rounded
transition-all duration-200
flex items-center justify-center
border-gray-300 bg-white
peer-checked:bg-[#2563EB] peer-checked:border-[#2563EB]
peer-checked:[&_svg]:opacity-100
peer-hover:border-[#2563EB]
peer-focus:ring-2 peer-focus:ring-[#2563EB]/20
peer-disabled:bg-gray-50 peer-disabled:border-gray-300
peer-disabled:peer-checked:bg-blue-100 peer-disabled:peer-checked:border-blue-200
`}
>
<Check
size={14}
className={`opacity-0 transition-opacity duration-200 ${disabled ? 'text-blue-600' : 'text-white'}`}
strokeWidth={3}
/>
</div>
</div>
{label && (
<span className={`text-sm font-medium select-none ${disabled ? 'text-gray-600' : 'text-gray-700'}`}>
{label}
</span>
)}
</label>
);
}
);
export default CustomCheckBox;
@@ -0,0 +1,73 @@
import React from "react";
import CustomModal from "./CustomModal";
import CustomButton from "./CustomButton";
import { AlertTriangle } from "lucide-react";
interface ConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: "danger" | "primary" | "warning";
isLoading?: boolean;
}
const ConfirmationModal: React.FC<ConfirmationModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
description,
confirmText = "Confirm",
cancelText = "Cancel",
variant = "danger",
isLoading = false,
}) => {
return (
<CustomModal
isOpen={isOpen}
onClose={onClose}
title={title}
size="sm"
showCloseButton={false}
>
<div className="flex flex-col items-center text-center">
<div className={`p-3 rounded-full mb-4 ${variant === 'danger' ? 'bg-[#2563EB]/10 text-[#2563EB]' :
variant === 'warning' ? 'bg-amber-50 text-amber-600' :
'bg-blue-50 text-blue-600'
}`}>
<AlertTriangle size={32} />
</div>
<p className="text-gray-500 mb-6 font-medium">
{description}
</p>
<div className="flex gap-3 w-full">
<CustomButton
variant="outlined"
onClick={onClose}
disabled={isLoading}
className="flex-1"
>
{cancelText}
</CustomButton>
<CustomButton
variant={variant === 'danger' ? 'primary' : 'primary'}
onClick={onConfirm}
loading={isLoading}
disabled={isLoading}
className="flex-1"
>
{confirmText}
</CustomButton>
</div>
</div>
</CustomModal>
);
};
export default ConfirmationModal;
+96
View File
@@ -0,0 +1,96 @@
import React, { forwardRef, useId } from "react";
import { ChevronDown } from "lucide-react";
interface Option {
label: string;
value: string | number;
}
interface CustomDropdownProps
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "size"> {
label?: string;
options: Option[];
leftIcon?: React.ReactNode;
placeholder?: string;
size?: "sm" | "md";
}
const CustomDropdown = forwardRef<HTMLSelectElement, CustomDropdownProps>(
(
{
label,
options,
leftIcon,
placeholder,
disabled,
className = "",
size = "md",
...props
},
ref
) => {
const sizeClasses = size === "sm" ? "py-1.5 text-sm border-2 min-h-[36px]" : "py-3 text-sm";
const generatedId = useId();
const selectId = props.id ?? generatedId;
return (
<div className="w-full flex flex-col gap-1.5">
{label && (
<label htmlFor={selectId} className="text-sm font-semibold text-gray-900">
{label}
{props.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="relative w-full">
{leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
{leftIcon}
</span>
)}
<select
ref={ref}
id={selectId}
disabled={disabled}
className={`
w-full rounded-lg
bg-white text-gray-900
border border-gray-300
${sizeClasses}
px-3
outline-none
transition-all duration-200
appearance-none
hover:border-[#2563EB]
focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
${leftIcon ? "pl-10" : ""}
pr-10
${className}
`}
{...props}
>
{placeholder && (
<option value="" disabled hidden className="bg-white text-gray-900">
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value} className="bg-white text-gray-900">
{option.label}
</option>
))}
</select>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-(--text-secondary) pointer-events-none">
<ChevronDown size={16} />
</span>
</div>
</div>
);
}
);
export default CustomDropdown;
@@ -0,0 +1,195 @@
import React, { useRef, useState, useEffect } from "react";
import { Upload, X, File } from "lucide-react";
interface CustomFileUploaderProps {
label?: string;
required?: boolean;
multiple?: boolean;
maxFiles?: number;
accept?: string;
value?: File[];
onChange?: (files: File[]) => void;
disabled?: boolean;
className?: string;
}
const CustomFileUploader: React.FC<CustomFileUploaderProps> = ({
label,
required,
multiple = false,
maxFiles,
accept,
value = [],
onChange,
disabled,
className = "",
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [previews, setPreviews] = useState<{ [key: string]: string }>({});
useEffect(() => {
return () => {
Object.values(previews).forEach((url) => URL.revokeObjectURL(url));
};
}, [previews]);
useEffect(() => {
const newPreviews: { [key: string]: string } = {};
value.forEach((file) => {
if (file.type.startsWith("image/") && !previews[file.name]) {
newPreviews[file.name] = URL.createObjectURL(file);
}
});
if (Object.keys(newPreviews).length > 0) {
setPreviews((prev) => ({ ...prev, ...newPreviews }));
}
}, [value, previews]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const files = Array.from(e.target.files || []);
handleFiles(files);
};
const handleFiles = (newFiles: File[]) => {
let updatedFiles = multiple ? [...value, ...newFiles] : newFiles;
if (maxFiles && updatedFiles.length > maxFiles) {
updatedFiles = updatedFiles.slice(0, maxFiles);
}
onChange?.(updatedFiles);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!disabled) setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const files = Array.from(e.dataTransfer.files);
handleFiles(files);
};
const removeFile = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
if (disabled) return;
const fileToRemove = value[index];
const newFiles = value.filter((_, i) => i !== index);
onChange?.(newFiles);
if (previews[fileToRemove.name]) {
URL.revokeObjectURL(previews[fileToRemove.name]);
setPreviews((prev) => {
const newPrev = { ...prev };
delete newPrev[fileToRemove.name];
return newPrev;
});
}
};
return (
<div className={`w-full flex flex-col gap-1.5 ${className}`}>
{label && (
<label className="text-sm font-semibold text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div
className={`
relative w-full rounded-lg border-2 border-dashed
flex flex-col items-center justify-center p-6
transition-all duration-200
${isDragging
? "border-[#2563EB] bg-[#2563EB]/5"
: "border-gray-300 bg-gray-50 hover:bg-[#2563EB]/5 hover:border-[#2563EB]/50"
}
${disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !disabled && inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
multiple={multiple}
accept={accept}
className="hidden"
onChange={handleFileChange}
disabled={disabled}
/>
<div className="flex flex-col items-center gap-2 text-gray-500">
<Upload size={24} />
<span className="text-sm font-medium">
{isDragging ? "Drop files here" : "Click or drag to upload"}
</span>
{maxFiles && (
<span className="text-xs text-gray-400">
Max {maxFiles} file{maxFiles > 1 ? "s" : ""}
</span>
)}
</div>
</div>
{
value.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{value.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg"
>
<div className="flex items-center gap-3 overflow-hidden">
<div className="w-10 h-10 shrink-0 rounded-lg bg-gray-100 flex items-center justify-center overflow-hidden border border-gray-200">
{file.type.startsWith("image/") && previews[file.name] ? (
<img
src={previews[file.name]}
alt={file.name}
className="w-full h-full object-cover"
/>
) : (
<File size={20} className="text-gray-500" />
)}
</div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-medium text-gray-700 truncate">
{file.name}
</span>
<span className="text-xs text-gray-500">
{(file.size / 1024).toFixed(1)} KB
</span>
</div>
</div>
{!disabled && (
<button
onClick={(e) => removeFile(e, index)}
className="p-1 text-gray-400 hover:text-[#2563EB] transition-colors rounded-full hover:bg-gray-100"
>
<X size={18} />
</button>
)}
</div>
))}
</div>
)
}
</div >
);
};
export default CustomFileUploader;
+132
View File
@@ -0,0 +1,132 @@
import React, { useState, forwardRef } from "react";
import { Phone, Eye, EyeOff } from "lucide-react";
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "month";
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
type?: InputType;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
maxLength?: number;
phonePrefix?: string;
error?: string;
containerClassName?: string;
}
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
(
{
label,
type = "text",
leftIcon,
rightIcon,
maxLength,
phonePrefix,
disabled,
className = "",
containerClassName = "",
error,
onInput,
...props
},
ref
) => {
const isPassword = type === "password";
const isPhone = phonePrefix !== undefined;
const [showPassword, setShowPassword] = useState(false);
const inputType = isPassword && showPassword ? "text" : type;
const handleInput = (e: React.InputEvent<HTMLInputElement>) => {
if (maxLength && (type === "number" || type === "tel")) {
const target = e.target as HTMLInputElement;
if (target.value.length > maxLength) {
target.value = target.value.slice(0, maxLength);
}
}
onInput?.(e);
};
return (
<div className={`w-full flex flex-col gap-1.5 ${containerClassName}`}>
{label && (
<label htmlFor={props.id} className="block text-sm font-medium text-gray-900">
{label}
{props.required && <span className="ml-1 text-red-500">*</span>}
</label>
)}
<div
className={`relative w-full ${isPhone
? "flex overflow-hidden rounded-lg border border-gray-300 bg-white"
: ""
}`}
>
{isPhone && (
<div className="flex items-center gap-2 border-r border-gray-300 bg-gray-50 px-3 text-sm font-semibold text-gray-900">
<Phone size={16} className="text-gray-500" />
{phonePrefix}
</div>
)}
{!isPhone && leftIcon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">
{leftIcon}
</span>
)}
<input
ref={ref}
type={inputType}
maxLength={
type === "number" || type === "tel" ? undefined : maxLength
}
onInput={handleInput}
disabled={disabled}
className={`
${isPhone
? "w-full px-3 py-3 text-sm text-gray-900 bg-white outline-none placeholder:text-gray-500"
: `
w-full rounded-lg
bg-white text-gray-900 text-sm
border ${error ? "border-red-500" : "border-gray-300"}
px-3 py-3
outline-none
transition-all duration-200
hover:border-[#2563EB]
focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed disabled:border-gray-300
placeholder:text-gray-500
${leftIcon ? "pl-10" : ""}
${rightIcon || isPassword ? "pr-10" : ""}
`
}
${className}
`}
{...props}
/>
{!isPhone && isPassword ? (
<span
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-slate-400 select-none hover:text-slate-600"
>
{showPassword ? <Eye size={20} /> : <EyeOff size={20} />}
</span>
) : (
!isPhone &&
rightIcon && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400">
{rightIcon}
</span>
)
)}
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
);
}
);
export default CustomInput;
+66
View File
@@ -0,0 +1,66 @@
import type { ComponentProps } from "react";
import SpinnerArc from "../ui/SpinnerArc";
type LoaderProps = {
label?: string;
size?: "sm" | "md" | "lg" | "xl";
variant?: "primary" | "white" | "gray" | "red";
inline?: boolean;
className?: string;
} & ComponentProps<"div">;
const variantColorMap = {
primary: "text-[#2563EB]",
white: "text-white",
gray: "text-gray-500",
red: "text-[#2563EB]",
};
export function CustomLoader({
label,
size = "md",
variant = "primary",
inline = false,
className,
...rest
}: LoaderProps) {
const containerClass = [
"flex flex-col items-center justify-center gap-3",
inline ? "flex-row" : "",
className,
]
.filter(Boolean)
.join(" ");
const colorClass = variantColorMap[variant];
const pixelSize = {
sm: 20,
md: 32,
lg: 48,
xl: 64,
}[size];
return (
<div
className={containerClass}
aria-live="polite"
aria-busy="true"
{...rest}
>
<div className={`relative ${colorClass}`} style={{ width: pixelSize, height: pixelSize }}>
{}
<SpinnerArc className="animate-spin absolute inset-0" />
</div>
{label && (
<span className={`text-sm font-bold tracking-wide ${variant === 'white' ? 'text-white' : 'text-[#2563EB]'}`}>
{label}
</span>
)}
</div>
);
}
export default CustomLoader;
+153
View File
@@ -0,0 +1,153 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
interface CustomModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
title?: string;
description?: string;
footer?: React.ReactNode;
size?: ModalSize;
showCloseButton?: boolean;
allowBackdropClose?: boolean;
className?: string;
contentClassName?: string;
overlayClassName?: string;
}
const SIZE_CLASS_MAP: Record<ModalSize, string> = {
sm: "max-w-md",
md: "max-w-2xl",
lg: "max-w-4xl",
xl: "max-w-6xl",
full: "max-w-none",
};
const CustomModal: React.FC<CustomModalProps> = ({
isOpen,
onClose,
children,
title,
description,
footer,
size = "lg",
showCloseButton = true,
allowBackdropClose = false,
className = "",
contentClassName = "",
overlayClassName = "",
}) => {
const { t } = useTranslation("common");
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (isOpen) {
document.addEventListener("keydown", handleEsc);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEsc);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const widthClasses =
size === "full"
? "w-screen h-screen"
: `w-full ${SIZE_CLASS_MAP[size]} mx-4`;
const roundingClass = size === "full" ? "rounded-none" : "rounded-2xl";
const heightClass = size === "full" ? "h-full" : "max-h-[90vh]";
return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
{}
<div
className={`absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity ${overlayClassName}`}
onClick={() => allowBackdropClose && onClose()}
/>
{}
<div
role="dialog"
aria-modal="true"
className={`relative z-10 flex flex-col overflow-hidden bg-white text-gray-900 shadow-2xl
${roundingClass} ${widthClasses} ${className}
${heightClass}
animate-in fade-in zoom-in-95 duration-200`}
>
{}
{(title || showCloseButton) && (
<div
className="relative border-b px-6 py-4 flex-shrink-0"
style={showCloseButton ? { paddingInlineEnd: "4.5rem" } : undefined}
>
{title && (
<h2 className="text-lg font-semibold tracking-tight text-gray-900">
{title}
</h2>
)}
{description && (
<p className="mt-1 text-sm text-gray-500">
{description}
</p>
)}
{showCloseButton && (
<button
type="button"
onClick={onClose}
aria-label={t("actions.close")}
className="absolute top-4 flex h-9 w-9 items-center justify-center rounded-full
bg-gray-200 text-gray-800 transition shadow-sm border border-gray-300
hover:bg-gray-300 hover:text-black hover:shadow-md"
style={{ insetInlineEnd: "1rem" }}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.225 6.225a.75.75 0 011.06 0L12 10.94l4.715-4.715a.75.75 0 111.06 1.06L13.06 12l4.715 4.715a.75.75 0 11-1.06 1.06L12 13.06l-4.715 4.715a.75.75 0 11-1.06-1.06L10.94 12 6.225 7.285a.75.75 0 010-1.06z"
fill="currentColor"
/>
</svg>
</button>
)}
</div>
)}
{}
<div
className={`flex-1 overflow-y-auto min-h-0 px-6 py-5 no-scrollbar ${contentClassName}`}
>
{children}
</div>
{}
{footer && (
<div className="flex items-center justify-end gap-3 border-t bg-gray-50/50 px-6 py-4 flex-shrink-0">
{footer}
</div>
)}
</div>
</div>
);
};
export default CustomModal;
+56
View File
@@ -0,0 +1,56 @@
import React from "react";
export type StatusVariant =
| "success"
| "error"
| "warning"
| "info"
| "neutral";
interface CustomStatusProps {
status: string;
variant?: StatusVariant;
className?: string;
}
const getVariantFromStatus = (status: string): StatusVariant => {
const lower = status.toLowerCase();
if (["inactive", "failed", "rejected", "error", "deleted", "banned"].some(s => lower.includes(s))) return "error";
if (["active", "success", "completed", "paid", "delivered", "ready to ship"].some(s => lower.includes(s))) return "success";
if (["pending", "processing", "draft", "warning", "hold", "ready"].some(s => lower.includes(s))) return "warning";
if (["info", "shipped", "in_transit"].some(s => lower.includes(s))) return "info";
return "neutral";
};
const CustomStatus: React.FC<CustomStatusProps> = ({
status,
variant,
className = ""
}) => {
const finalVariant = variant || getVariantFromStatus(status);
const styles = {
success: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800",
error: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800",
warning: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800",
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-blue-200 dark:border-blue-800",
neutral: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-200 dark:border-gray-700",
};
return (
<span
className={`
inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border
${styles[finalVariant]}
${className}
`}
>
{status}
</span>
);
};
export default CustomStatus;
+49
View File
@@ -0,0 +1,49 @@
import React, { forwardRef } from "react";
interface CustomSwitchProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
label?: string;
}
const CustomSwitch = forwardRef<HTMLInputElement, CustomSwitchProps>(
({ label, className = "", disabled, ...props }, ref) => {
return (
<label
className={`
inline-flex items-center gap-3 cursor-pointer
${disabled ? "cursor-not-allowed opacity-60" : ""}
${className}
`}
>
<div className="relative inline-flex items-center">
<input
ref={ref}
type="checkbox"
className="peer sr-only"
disabled={disabled}
{...props}
/>
<div
className={`
w-11 h-6 bg-gray-200 rounded-full
peer-focus:ring-2 peer-focus:ring-[#2563EB]/20
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-gray-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all
peer-checked:bg-[#2563EB]
peer-disabled:bg-gray-100
`}
/>
</div>
{label && (
<span className="text-sm font-medium text-gray-700 select-none">
{label}
</span>
)}
</label>
);
}
);
export default CustomSwitch;
+701
View File
@@ -0,0 +1,701 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import CustomButton from "./CustomButton";
import CustomInput from "./CustomInput";
import Skeleton from "./Skeleton";
import { Upload, ChevronLeft, ChevronRight, GripVertical } from "lucide-react";
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
export function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}
export type Primitive =
| string
| number
| boolean
| null
| undefined
| Date
| Record<string, unknown>;
export type ColumnDef<T> = {
key: keyof T | string;
header: React.ReactNode;
exportHeader?: string;
render?: (row: T) => React.ReactNode;
searchable?: boolean;
cellClassName?: string;
noExport?: boolean;
renderExport?: (row: T) => string | number | boolean;
};
export type DataTableProps<T> = {
data: T[];
columns: Array<ColumnDef<T>>;
defaultPageSize?: number;
pageSizeOptions?: number[];
exportFileName?: string;
className?: string;
getRowId?: (row: T, index: number) => string | number;
filterControls?: React.ReactNode;
enableSearchDropdown?: boolean;
buildSuggestionLabel?: (row: T) => string;
onSuggestionSelect?: (row: T) => void;
maxSuggestions?: number;
highlightClassName?: string;
manualPagination?: boolean;
manualFiltering?: boolean;
totalRows?: number;
page?: number;
pageSize?: number;
search?: string;
onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
onSearchChange?: (search: string) => void;
searchPlaceholder?: string;
searchInputRef?: React.RefObject<HTMLInputElement>;
maxHeight?: string;
isLoading?: boolean;
disableScroll?: boolean;
bodyWrapperClassName?: string;
tableClassName?: string;
hideExport?: boolean;
expandedRowId?: string | number | null;
renderExpandedRow?: (row: T) => React.ReactNode;
enableDragAndDrop?: boolean;
onReorder?: (newData: T[]) => void;
};
// --- Sortable Row Component ---
function SortableTableRow<T>({
id,
row,
columns,
getCellValue,
isHighlighted,
highlightClassName,
renderExpandedRow,
expandedRowId
}: {
id: string | number;
row: T;
columns: Array<ColumnDef<T>>;
getCellValue: (row: T, key: string | keyof T) => unknown;
isHighlighted: boolean;
highlightClassName?: string;
renderExpandedRow?: (row: T) => React.ReactNode;
expandedRowId?: string | number | null;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 1 : 0,
opacity: isDragging ? 0.8 : 1,
};
return (
<React.Fragment>
<tr
ref={setNodeRef}
style={style}
className={cn(
"bg-white transition-colors group",
isHighlighted && highlightClassName,
isDragging ? "shadow-md bg-gray-50" : "hover:bg-gray-50"
)}
>
<td className="px-3 py-4 w-10 text-gray-400">
<button
type="button"
className="p-1 cursor-grab active:cursor-grabbing hover:bg-gray-100 rounded text-gray-400 hover:text-gray-600 focus:outline-none"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
</td>
{columns.map((c, ci) => {
const content = c.render
? c.render(row)
: (getCellValue(row, c.key) as React.ReactNode);
return (
<td
key={String(c.key) + ci}
className={cn(
"whitespace-nowrap px-6 py-4 text-gray-900",
c.cellClassName
)}
>
{content ?? ""}
</td>
);
})}
</tr>
{renderExpandedRow && expandedRowId === id && (
<tr className="bg-gray-50/50">
<td colSpan={columns.length + 1} className="p-0 border-b border-gray-100">
{renderExpandedRow(row)}
</td>
</tr>
)}
</React.Fragment>
);
}
// -----------------------------
function toCSVValue(v: unknown) {
if (v == null) return "";
const text =
v instanceof Date
? v.toISOString()
: typeof v === "object"
? JSON.stringify(v)
: String(v);
const needsWrap = /[",\n]/.test(text);
return needsWrap ? `"${text.replace(/"/g, '""')}"` : text;
}
function downloadCSV(fileName: string, rows: string[]) {
const blob = new Blob([rows.join("\n")], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
export function DataTable<T>(
props: DataTableProps<T>
) {
const { t, i18n } = useTranslation("common");
const {
data,
columns,
defaultPageSize = 10,
pageSizeOptions = [5, 10, 20, 50],
exportFileName = "export.csv",
className,
getRowId,
filterControls,
enableSearchDropdown = false,
buildSuggestionLabel,
onSuggestionSelect,
maxSuggestions = 8,
highlightClassName = "ring-2 ring-blue-400",
manualPagination = false,
manualFiltering = false,
totalRows,
page: controlledPage,
pageSize: controlledPageSize,
search: controlledSearch,
onPageChange,
onPageSizeChange,
onSearchChange,
searchInputRef,
maxHeight = "70vh",
isLoading,
disableScroll = false,
bodyWrapperClassName,
tableClassName,
hideExport = true,
expandedRowId,
renderExpandedRow,
enableDragAndDrop = false,
onReorder,
} = props;
const [internalSearch, setInternalSearch] = React.useState("");
const [internalPageSize, setInternalPageSize] = React.useState(defaultPageSize);
const [internalPage, setInternalPage] = React.useState(1);
const search = manualFiltering && controlledSearch !== undefined ? controlledSearch : internalSearch;
const pageSize = manualPagination && controlledPageSize !== undefined ? controlledPageSize : internalPageSize;
const page = manualPagination && controlledPage !== undefined ? controlledPage : internalPage;
React.useEffect(() => {
if (!manualPagination) {
setInternalPage(1);
}
}, [internalSearch, internalPageSize, manualPagination]);
const searchableColumns = React.useMemo(
() => columns.filter((c) => c.searchable !== false),
[columns]
);
const getCellValue = React.useCallback(
(row: T, key: ColumnDef<T>["key"]) =>
(row as Record<string, unknown>)[String(key)],
[]
);
const normalizedText = (v: unknown) => {
if (v instanceof Date) return v.toISOString();
if (v == null) return "";
return String(v);
};
const filtered = React.useMemo(() => {
if (manualFiltering) return data;
const query = search.trim().toLowerCase();
return data.filter((row) => {
if (!query) return true;
return searchableColumns.some((c) => {
const text = normalizedText(getCellValue(row, c.key));
return text.toLowerCase().includes(query);
});
});
}, [data, getCellValue, search, searchableColumns, manualFiltering]);
const total = manualPagination && totalRows !== undefined ? totalRows : filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const clampedPage = Math.min(page, totalPages);
const start = (clampedPage - 1) * pageSize;
const pageRows = manualPagination ? data : filtered.slice(start, start + pageSize);
const exportCurrentView = () => {
const exportColumns = columns.filter((c) => !c.noExport);
const header = exportColumns
.map((c) =>
toCSVValue(
c.exportHeader ??
(typeof c.header === "string" ? c.header : "")
)
)
.join(",");
const lines = pageRows.map((row) =>
exportColumns
.map((c) => {
if (c.renderExport) return toCSVValue(c.renderExport(row));
const value = c.render ? c.render(row) : getCellValue(row, c.key);
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
return toCSVValue(value);
return toCSVValue(getCellValue(row, c.key));
})
.join(",")
);
downloadCSV(exportFileName, [header, ...lines]);
};
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [showSuggestions, setShowSuggestions] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(-1);
const [highlightedId, setHighlightedId] = React.useState<string | number | null>(null);
const isRtl = i18n.dir() === "rtl";
const defaultLabelBuilder = React.useCallback(
(row: T) => {
const parts = searchableColumns
.map((c) => normalizedText(getCellValue(row, c.key)))
.filter(Boolean);
return parts.join(" • ");
},
[getCellValue, searchableColumns]
);
const suggestionLabel = buildSuggestionLabel ?? defaultLabelBuilder;
const suggestions = React.useMemo(() => {
if (!enableSearchDropdown) return [];
const q = search.trim().toLowerCase();
if (!q) return [];
const scored = data
.map((row, idx) => {
const label = suggestionLabel(row);
const lower = label.toLowerCase();
if (!lower.includes(q)) return null;
const starts = lower.startsWith(q) ? 0 : 1;
return { row, idx, label, score: starts };
})
.filter(Boolean) as Array<{
row: T;
idx: number;
label: string;
score: number;
}>;
scored.sort((a, b) => a.score - b.score || a.label.localeCompare(b.label));
return scored.slice(0, maxSuggestions);
}, [enableSearchDropdown, search, data, maxSuggestions, suggestionLabel]);
React.useEffect(() => {
if (!enableSearchDropdown) return;
const onDocClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setShowSuggestions(false);
setActiveIndex(-1);
}
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [enableSearchDropdown]);
const jumpToRow = React.useCallback(
(absoluteIndex: number, row: T) => {
const targetPage = Math.max(1, Math.ceil((absoluteIndex + 1) / pageSize));
if (manualPagination && onPageChange) {
onPageChange(targetPage);
} else {
setInternalPage(targetPage);
}
const id = getRowId?.(row, absoluteIndex) ?? String(absoluteIndex);
setHighlightedId(id);
window.setTimeout(() => setHighlightedId(null), 1600);
},
[getRowId, pageSize, manualPagination, onPageChange]
);
const selectSuggestion = React.useCallback(
(s: { row: T; idx: number; label: string }) => {
if (manualFiltering && onSearchChange) {
onSearchChange(s.label);
} else {
setInternalSearch(s.label);
}
setShowSuggestions(false);
setActiveIndex(-1);
jumpToRow(s.idx, s.row);
onSuggestionSelect?.(s.row);
},
[jumpToRow, onSuggestionSelect, manualFiltering, onSearchChange]
);
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!enableSearchDropdown || !showSuggestions) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && activeIndex >= 0) {
e.preventDefault();
const chosen = suggestions[activeIndex];
selectSuggestion({ row: chosen.row, idx: chosen.idx, label: chosen.label });
} else if (e.key === "Escape") {
setShowSuggestions(false);
setActiveIndex(-1);
}
};
return (
<div
className={cn(
"rounded-lg border border-gray-200 bg-white shadow-md",
className
)}
ref={containerRef}
>
<div className="flex items-center justify-between p-4 bg-white border-b border-gray-200 rounded-t-lg">
<div className="relative w-full max-w-sm">
<CustomInput
label=""
value={search}
onChange={(e) => {
const newValue = e.target.value;
if (manualFiltering && onSearchChange) {
onSearchChange(newValue);
} else {
setInternalSearch(newValue);
}
if (enableSearchDropdown) setShowSuggestions(true);
}}
onKeyDown={onSearchKeyDown}
onFocus={() => {
if (enableSearchDropdown && search.trim()) setShowSuggestions(true);
}}
placeholder={t("dataTable.searchPlaceholder")}
className="mb-0 text-gray-900"
ref={searchInputRef}
/>
{enableSearchDropdown &&
showSuggestions &&
suggestions.length > 0 && (
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
<ul className="max-h-72 overflow-auto py-1 text-sm">
{suggestions.map((s, i) => (
<li
key={(getRowId?.(s.row, s.idx) ?? s.label) as React.Key}
className={cn(
"cursor-pointer px-3 py-2 hover:bg-gray-50",
i === activeIndex && "bg-gray-50"
)}
onMouseEnter={() => setActiveIndex(i)}
onMouseLeave={() => setActiveIndex(-1)}
onClick={() =>
selectSuggestion({
row: s.row,
idx: s.idx,
label: s.label,
})
}
>
<div className="truncate text-gray-900">{s.label}</div>
</li>
))}
</ul>
</div>
)}
</div>
<div className="flex items-center gap-2">
{filterControls}
{!hideExport && (
<CustomButton
variant="outlined"
size="sm"
onClick={exportCurrentView}
leftIcon={<Upload size={16} />}
>
{t("dataTable.export")}
</CustomButton>
)}
</div>
</div>
<div
className={cn(
"max-w-full relative",
disableScroll
? "overflow-visible"
: "overflow-x-auto overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-gray-400",
bodyWrapperClassName
)}
style={disableScroll ? undefined : { maxHeight }}
>
<DndContext
sensors={useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)}
collisionDetection={closestCenter}
onDragEnd={(event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id && onReorder) {
const oldIndex = data.findIndex((item) => (getRowId?.(item, 0) ?? (item as any).id) === active.id);
const newIndex = data.findIndex((item) => (getRowId?.(item, 0) ?? (item as any).id) === over?.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newData = arrayMove(data, oldIndex, newIndex);
onReorder(newData);
}
}
}}
>
<table className={cn(disableScroll ? "w-full text-sm table-auto" : "min-w-max w-full text-sm", tableClassName)}>
<thead className={cn("bg-gray-50 shadow-sm", !disableScroll && "sticky top-0 z-10")}>
<tr className="text-start text-xs uppercase tracking-wider text-gray-500">
{enableDragAndDrop && (
<th className="w-10 px-3 py-3 bg-gray-50"></th>
)}
{columns.map((c, idx) => (
<th
key={String(c.key) + idx}
className={cn(
"whitespace-nowrap px-6 py-3 font-medium bg-gray-50 text-gray-500 text-start",
c.cellClassName
)}
>
{c.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 text-gray-900">
<SortableContext
items={pageRows.map((row, i) => getRowId?.(row, start + i) ?? `${start + i}`)}
strategy={verticalListSortingStrategy}
>
{isLoading ? (
Array.from({ length: Math.min(pageSize || defaultPageSize || 6, 8) }).map((_, rIdx) => (
<tr key={`table-skel-row-${rIdx}`} className="bg-white animate-fadeIn">
{enableDragAndDrop && <td className="w-10 px-3 py-4"></td>}
{columns.map((c, cIdx) => (
<td
key={`table-skel-cell-${rIdx}-${cIdx}`}
className={cn(
"whitespace-nowrap px-6 py-4",
c.cellClassName
)}
>
<Skeleton
className={cn(
"h-4 rounded",
cIdx === 0
? "w-36 sm:w-48"
: cIdx === columns.length - 1
? "w-16 ml-auto"
: cIdx % 2 === 0
? "w-24"
: "w-20"
)}
/>
</td>
))}
</tr>
))
) : pageRows.length > 0 ? (
pageRows.map((row, i) => {
const absoluteIndex = start + i;
const id = getRowId?.(row, absoluteIndex) ?? `${absoluteIndex}`;
const isHighlighted = highlightedId === id;
if (enableDragAndDrop) {
return (
<SortableTableRow
key={id}
id={id}
row={row}
columns={columns}
getCellValue={getCellValue}
isHighlighted={isHighlighted}
highlightClassName={highlightClassName}
renderExpandedRow={renderExpandedRow}
expandedRowId={expandedRowId}
/>
);
}
return (
<React.Fragment key={id}>
<tr
className={cn(
"bg-white hover:bg-gray-50 transition-colors",
isHighlighted && highlightClassName
)}
>
{columns.map((c, ci) => {
const content = c.render
? c.render(row)
: (getCellValue(row, c.key) as React.ReactNode);
return (
<td
key={String(c.key) + ci}
className={cn(
"whitespace-nowrap px-6 py-4 text-gray-900",
c.cellClassName
)}
>
{content ?? ""}
</td>
);
})}
</tr>
{renderExpandedRow && expandedRowId === id && (
<tr key={`${id}-expanded`} className="bg-gray-50/50">
<td colSpan={columns.length} className="p-0 border-b border-gray-100">
{renderExpandedRow(row)}
</td>
</tr>
)}
</React.Fragment>
);
})
) : (
<tr>
<td
colSpan={columns.length + (enableDragAndDrop ? 1 : 0)}
className="px-6 py-12 text-center text-gray-500"
>
{t("dataTable.empty")}
</td>
</tr>
)}
</SortableContext>
</tbody>
</table>
</DndContext>
</div>
<div className="flex items-center justify-between p-4 bg-white border-t border-gray-200 rounded-b-lg">
<div className="text-sm text-gray-500">
{t("dataTable.showing", {
start: total === 0 ? 0 : start + 1,
end: start + pageRows.length,
total,
})}
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{t("dataTable.rowsPerPage")}</span>
<select
value={pageSize}
onChange={(e) => {
const newSize = Number(e.target.value);
if (manualPagination && onPageSizeChange) {
onPageSizeChange(newSize);
} else {
setInternalPageSize(newSize);
}
}}
className="rounded-md border border-gray-200 text-sm py-1.5 px-2 focus:outline-none focus:ring-2 focus:ring-blue-600 bg-white text-gray-900"
>
{pageSizeOptions.map((ps) => (
<option key={ps} value={ps}>
{ps}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const newPage = Math.max(1, page - 1);
if (manualPagination && onPageChange) {
onPageChange(newPage);
} else {
setInternalPage((p: number) => Math.max(1, p - 1));
}
}}
disabled={clampedPage === 1}
className="flex items-center justify-center h-9 w-9 text-sm border border-gray-200 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-gray-900"
>
{isRtl ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
<div className="text-sm text-gray-500">
{t("dataTable.pageOf", {
page: clampedPage,
totalPages,
})}
</div>
<button
onClick={() => {
const newPage = Math.min(totalPages, page + 1);
if (manualPagination && onPageChange) {
onPageChange(newPage);
} else {
setInternalPage((p: number) => Math.min(totalPages, p + 1));
}
}}
disabled={clampedPage === totalPages}
className="flex items-center justify-center h-9 w-9 text-sm border border-gray-200 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-gray-900"
>
{isRtl ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
</button>
</div>
</div>
</div>
</div>
);
}
export default DataTable;
+42
View File
@@ -0,0 +1,42 @@
import React from "react";
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
variant?: "text" | "circular" | "rectangular" | "rounded";
width?: string | number;
height?: string | number;
}
export const Skeleton: React.FC<SkeletonProps> = ({
className = "",
variant = "rectangular",
width,
height,
style,
...props
}) => {
const baseStyles = "bg-gray-200/90 dark:bg-gray-700/60 animate-pulse motion-reduce:animate-none";
const variantStyles = {
text: "rounded",
circular: "rounded-full",
rectangular: "rounded-md",
rounded: "rounded-xl",
};
const combinedStyle = {
width,
height,
...style,
};
return (
<div
className={`${baseStyles} ${variantStyles[variant] || "rounded-md"} ${className}`}
style={combinedStyle}
aria-hidden="true"
{...props}
/>
);
};
export default Skeleton;
+28
View File
@@ -0,0 +1,28 @@
import CustomInput from "./CustomInput";
import CustomDropdown from "./CustomDropdown";
export * from './ContactsAutocomplete';
import CustomSearchableDropdown from "./CustomSearchableDropdown";
import CustomMultiSelect from "./CustomMultiSelect";
import CustomFileUploader from "./CustomFileUploader";
import CustomCheckBox from "./CustomCheckBox";
import CustomTable from "./CustomTable";
import CustomButton from "./CustomButton";
import CustomSwitch from "./CustomSwitch";
import CustomModal from "./CustomModal";
import CustomConfirmationModal from "./CustomConfirmationModal";
import CustomLoader from "./CustomLoader";
import CustomStatus from "./CustomStatus";
export {
CustomInput,
CustomDropdown,
CustomFileUploader,
CustomCheckBox,
CustomTable,
CustomButton,
CustomSwitch,
CustomModal,
CustomConfirmationModal,
CustomLoader,
CustomStatus,
};
+169
View File
@@ -0,0 +1,169 @@
import React, { Suspense, lazy, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useDashboard } from '../../features/dashboard/DashboardContext';
import DashboardSidebar from '@/features/DashboardSidebar';
import { SearchProvider } from '../../features/components/layout/SearchContext';
import AppHeader from '../../features/components/layout/AppHeader';
import { ConfirmDialog } from '@/components/ui';
const ProjectSharing = lazy(() => import('../../features/sharing/components/ProjectSharing'));
const VersionHistory = lazy(() => import('../../features/versioning/VersionHistory'));
const PreviewModal = lazy(() => import('@/features/PreviewModal'));
const SharedProjectsModal = lazy(() => import('@/features/SharedProjectsModal'));
const VersionConflictModal = lazy(
() => import('@/features/components/components/VersionConflictModal'),
);
const FileDetailsPanel = lazy(() =>
import('@/features/FileDetailsPanel/FileDetailsPanel').then((m) => ({
default: m.FileDetailsPanel,
})),
);
import { useAuth } from '../../features/auth/context';
import TermsAcceptanceModal from '../../features/auth/components/TermsAcceptanceModal';
const DashboardLayout: React.FC = () => {
const location = useLocation();
const { user } = useAuth();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const {
handleLogout,
previewModalOpen, previewContent, previewLoading, previewProjectName,
setPreviewModalOpen, setPreviewContent, setPreviewProjectName,
shareModalOpen, selectedProjectId, selectedProjectName, setShareModalOpen,
fetchProjects,
sharedProjectsModalOpen, setSharedProjectsModalOpen,
sharedProjects,
versionHistoryOpen, selectedVersionProjectId, setVersionHistoryOpen, setSelectedVersionProjectId,
deleteConfirm, setDeleteConfirm, handleDeleteProject, handleDeleteFile,
conflictModalOpen, conflictData, setConflictModalOpen, setConflictData, handleConflictDecision,
getStatusColor,
fileDetails, setFileDetails,
} = useDashboard();
const openProject = (projectId: string) => {
window.location.href = `/convert/${projectId}`;
};
return (
<SearchProvider>
<div className="flex h-screen bg-gray-50">
{}
<DashboardSidebar
currentView={location.pathname as any}
setCurrentView={() => { }}
driveView="my"
setDriveView={() => { }}
isMobileSidebarOpen={isMobileSidebarOpen}
setIsMobileSidebarOpen={setIsMobileSidebarOpen}
user={user}
onLogout={handleLogout}
/>
{}
<div className="flex-1 flex flex-col h-screen overflow-hidden">
{location.pathname !== '/ai-assistant' && (
<AppHeader onMenuClick={() => setIsMobileSidebarOpen(!isMobileSidebarOpen)} />
)}
<div className="flex-1 overflow-y-auto relative">
{}
<Outlet />
</div>
</div>
{}
{}
<Suspense fallback={null}>
{shareModalOpen && selectedProjectId && (
<ProjectSharing
projectId={selectedProjectId}
projectName={selectedProjectName}
isOpen={shareModalOpen}
onClose={() => setShareModalOpen(false)}
onShareSuccess={() => fetchProjects()}
/>
)}
<SharedProjectsModal
isOpen={sharedProjectsModalOpen}
onClose={() => setSharedProjectsModalOpen(false)}
sharedProjects={sharedProjects as any}
onOpenProject={openProject}
onViewActivity={(id) => console.log('View activity for project:', id)}
getStatusColor={getStatusColor}
/>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => setDeleteConfirm({ isOpen: false, type: 'project', id: '', name: '' })}
onConfirm={() => {
if (deleteConfirm.type === 'project') {
handleDeleteProject(String(deleteConfirm.id));
} else {
handleDeleteFile(Number(deleteConfirm.id));
}
}}
title={`Delete ${deleteConfirm.type === 'project' ? 'Project' : 'File'}?`}
message={`Are you sure you want to delete "${deleteConfirm.name}"? This action cannot be undone.`}
confirmText="Delete"
cancelText="Cancel"
type="danger"
/>
<VersionHistory
projectId={selectedVersionProjectId}
isOpen={versionHistoryOpen}
onClose={() => {
setVersionHistoryOpen(false);
setSelectedVersionProjectId(null);
fetchProjects();
}}
/>
<PreviewModal
isOpen={previewModalOpen}
onClose={() => {
setPreviewModalOpen(false);
setPreviewContent('');
setPreviewProjectName('');
}}
projectName={previewProjectName}
content={previewContent}
isLoading={previewLoading}
/>
<VersionConflictModal
isOpen={conflictModalOpen}
onClose={() => {
setConflictModalOpen(false);
setConflictData(null);
}}
filename={conflictData?.existingProjectName || ''}
onDecision={handleConflictDecision}
/>
{}
<FileDetailsPanel
isOpen={fileDetails.isOpen}
onClose={() => setFileDetails({ isOpen: false })}
fileId={fileDetails.fileId}
fileName={fileDetails.fileName}
fileType={fileDetails.fileType}
createdAt={fileDetails.createdAt}
updatedAt={fileDetails.updatedAt}
size={fileDetails.size}
owner={fileDetails.owner}
isDriveFile={fileDetails.isDriveFile}
/>
</Suspense>
{}
<TermsAcceptanceModal />
</div>
</SearchProvider>
);
};
export default DashboardLayout;
+96
View File
@@ -0,0 +1,96 @@
import React from 'react';
import { Github, Twitter, Globe } from 'lucide-react';
import logo from '@/assets/update_image/Frame9.png';
const Footer: React.FC = () => {
return (
<footer className="bg-white border-t border-gray-100 pt-8 sm:pt-12 lg:pt-16 pb-6 sm:pb-8">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 md:grid-cols-6 gap-6 sm:gap-8 mb-8 sm:mb-12">
{}
<div className="col-span-2">
<div className="flex items-center gap-2 mb-3 sm:mb-4">
<img
src={logo}
alt="DocQube"
className="h-7 sm:h-8 w-auto"
/>
</div>
<p className="text-gray-500 text-xs sm:text-sm mb-4 sm:mb-6 max-w-sm leading-relaxed">
AI-powered document conversion made simple, fast, and secure.
</p>
<div className="flex space-x-3 sm:space-x-4">
<a href="#" className="text-gray-400 hover:text-gray-600 transition-colors">
<Twitter className="h-4 w-4 sm:h-5 sm:w-5" />
</a>
<a href="#" className="text-gray-400 hover:text-gray-600 transition-colors">
<Github className="h-4 w-4 sm:h-5 sm:w-5" />
</a>
<a href="#" className="text-gray-400 hover:text-gray-600 transition-colors">
<Globe className="h-4 w-4 sm:h-5 sm:w-5" />
</a>
</div>
</div>
{}
<div>
<h3 className="font-semibold text-gray-900 mb-3 sm:mb-4 text-sm sm:text-base">Product</h3>
<ul className="space-y-2 sm:space-y-3">
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Features</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Pricing</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">API</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Integrations</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Updates</a></li>
</ul>
</div>
{}
<div>
<h3 className="font-semibold text-gray-900 mb-3 sm:mb-4 text-sm sm:text-base">Company</h3>
<ul className="space-y-2 sm:space-y-3">
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">About</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Blog</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Careers</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Press</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Partners</a></li>
</ul>
</div>
{}
<div>
<h3 className="font-semibold text-gray-900 mb-3 sm:mb-4 text-sm sm:text-base">Resources</h3>
<ul className="space-y-2 sm:space-y-3">
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Documentation</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Help Center</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Community</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Contact</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Status</a></li>
</ul>
</div>
{}
<div>
<h3 className="font-semibold text-gray-900 mb-3 sm:mb-4 text-sm sm:text-base">Legal</h3>
<ul className="space-y-2 sm:space-y-3">
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Privacy</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Terms</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Security</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Cookies</a></li>
<li><a href="#" className="text-gray-500 hover:text-gray-900 text-xs sm:text-sm transition-colors">Licenses</a></li>
</ul>
</div>
</div>
{}
<div className="border-t border-gray-100 pt-6 sm:pt-8 flex flex-col items-center">
<div className="w-full h-[1px] bg-gradient-to-r from-transparent via-blue-500 to-transparent mb-6 sm:mb-8"></div>
<p className="text-gray-400 text-xs sm:text-sm">
&copy; {new Date().getFullYear()} DocQube. All rights reserved.
</p>
</div>
</div>
</footer>
);
};
export default Footer;
+220
View File
@@ -0,0 +1,220 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Menu, X } from 'lucide-react';
import { useAuth } from '@/features/auth/context';
import { useToast } from '@/components/ui/ToastProvider';
import { hasAnyAccess } from '@/utils/permissions';
import { ConfirmDialog } from '@/components/ui';
import logoImage from '@/assets/update_image/Frame9.png';
const Navbar: React.FC = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { isAuthenticated, logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const handleLogout = async () => {
await logout();
toast.success('Logged out successfully. See you soon!');
navigate('/', { state: { loggingOut: true } });
};
const handleScrollTo = (id: string) => {
if (location.pathname !== '/') {
navigate('/');
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 300);
} else {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
};
const isActive = (path: string) => location.pathname === path;
return (
<nav className="bg-white border-b border-gray-100 sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{}
<Link to="/" className="flex items-center gap-2 flex-shrink-0">
<img
src={logoImage}
alt="DocuFlow Logo"
className="h-8 sm:h-9 lg:h-10 w-auto"
/>
</Link>
{}
<div className="hidden md:flex items-center justify-center flex-1 space-x-8">
{!isAuthenticated && (
<>
<Link
to="/"
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
Home
</Link>
<button
onClick={() => handleScrollTo('features')}
className="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
Features
</button>
<button
onClick={() => handleScrollTo('pricing')}
className="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
Pricing
</button>
<button
onClick={() => handleScrollTo('faq')}
className="text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
FAQs
</button>
</>
)}
{}
</div>
{}
<div className="hidden md:flex items-center space-x-4 flex-shrink-0">
{isAuthenticated ? (
<>
{hasAnyAccess(useAuth().user?.accesses, ['superadmin.main.view', 'admin.user.read', 'admin.role.read']) && (
<Link
to="/admin/dashboard"
className="text-sm font-medium text-primary hover:text-primary/80 transition-colors flex items-center gap-1 bg-primary/10 px-3 py-1.5 rounded-lg"
>
Admin Center
</Link>
)}
<Link
to="/dashboard"
className="bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Dashboard
</Link>
</>
) : (
<>
<Link
to="/login"
className="bg-blue-600 text-white hover:bg-blue-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Login
</Link>
</>
)}
</div>
{}
{!isActive('/dashboard') && !isActive('/settings') && !isActive('/convert') && !location.pathname.startsWith('/convert/') && !location.pathname.includes('/workflow/') && (
<div className="md:hidden">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="p-1.5 rounded-lg text-gray-700 hover:bg-gray-50 transition-all duration-200"
>
{isMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
)}
</div>
{}
{isMenuOpen && !isActive('/dashboard') && !isActive('/settings') && !isActive('/convert') && !location.pathname.startsWith('/convert/') && !location.pathname.includes('/workflow/') && (
<div className="md:hidden pb-4 border-t border-gray-100">
<div className="flex flex-col space-y-1 mt-4">
{!isAuthenticated && (
<>
<Link
to="/"
className="px-4 py-2 text-left text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-lg"
onClick={() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
setIsMenuOpen(false);
}}
>
Home
</Link>
<button
onClick={() => {
handleScrollTo('features');
setIsMenuOpen(false);
}}
className="px-4 py-2 text-left text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-lg"
>
Features
</button>
<button
onClick={() => {
handleScrollTo('pricing');
setIsMenuOpen(false);
}}
className="px-4 py-2 text-left text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-lg"
>
Pricing
</button>
<button
onClick={() => {
handleScrollTo('faq');
setIsMenuOpen(false);
}}
className="px-4 py-2 text-left text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-lg"
>
FAQs
</button>
</>
)}
{}
{isAuthenticated ? (
<>
<Link
to="/dashboard"
className="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-lg"
onClick={() => setIsMenuOpen(false)}
>
Dashboard
</Link>
<button
onClick={() => {
setShowLogoutConfirm(true);
setIsMenuOpen(false);
}}
className="text-left px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-50 rounded-lg"
>
Logout
</button>
</>
) : (
<div className="flex flex-col space-y-2 pt-4 border-t border-gray-100">
<Link
to="/login"
className="mx-4 py-2 text-sm font-medium bg-blue-600 text-white hover:bg-blue-700 rounded-lg text-center"
onClick={() => setIsMenuOpen(false)}
>
Login
</Link>
</div>
)}
</div>
</div>
)}
</div>
</nav>
);
};
export default Navbar;
+60
View File
@@ -0,0 +1,60 @@
import React from 'react';
import { X, AlertTriangle } from 'lucide-react';
import { Button, IconButton } from '@/components/ui';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmLabel?: string;
isDestructive?: boolean;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Confirm',
isDestructive = false,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
{isDestructive && <AlertTriangle className="w-5 h-5 text-red-500" />}
{title}
</h3>
<IconButton icon={X} onClick={onClose} size="sm" />
</div>
<div className="p-6">
<p className="text-gray-600">{message}</p>
</div>
<div className="flex justify-end gap-3 p-4 bg-gray-50 border-t border-gray-100">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
variant={isDestructive ? 'danger' : 'primary'}
onClick={() => {
onConfirm();
onClose();
}}
>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;
+75
View File
@@ -0,0 +1,75 @@
import React, { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { Button, Input, IconButton } from '@/components/ui';
interface InputDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (value: string) => void;
title: string;
label: string;
initialValue?: string;
placeholder?: string;
confirmLabel?: string;
}
const InputDialog: React.FC<InputDialogProps> = ({
isOpen,
onClose,
onSubmit,
title,
label,
initialValue = '',
placeholder = '',
confirmLabel = 'OK'
}) => {
const [value, setValue] = useState(initialValue);
useEffect(() => {
if (isOpen) {
setValue(initialValue);
}
}, [isOpen, initialValue]);
if (!isOpen) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(value);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<IconButton icon={X} onClick={onClose} size="sm" />
</div>
<form onSubmit={handleSubmit} className="p-6">
<div className="mb-6">
<Input
label={label}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={placeholder}
autoFocus
/>
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={!value.trim()}>
{confirmLabel}
</Button>
</div>
</form>
</div>
</div>
);
};
export default InputDialog;
+41
View File
@@ -0,0 +1,41 @@
import React, { ReactNode } from 'react';
import { useScrollAnimation } from '@/hooks/useScrollAnimation';
interface ScrollAnimatedProps {
children: ReactNode;
animation?: 'fade' | 'slide-left' | 'slide-right' | 'scale' | 'zoom';
delay?: number;
className?: string;
threshold?: number;
}
const ScrollAnimated: React.FC<ScrollAnimatedProps> = ({
children,
animation = 'fade',
delay = 0,
className = '',
threshold = 0.1
}) => {
const { elementRef, isVisible } = useScrollAnimation({ threshold, triggerOnce: true });
const animationClass = {
fade: 'scroll-fade-in',
'slide-left': 'scroll-slide-left',
'slide-right': 'scroll-slide-right',
scale: 'scroll-scale-in',
zoom: 'scroll-zoom-in'
}[animation];
const delayClass = delay > 0 ? `scroll-stagger-${Math.min(Math.ceil(delay / 100), 6)}` : '';
return (
<div
ref={elementRef}
className={`${animationClass} ${delayClass} ${isVisible ? 'visible' : ''} ${className}`}
>
{children}
</div>
);
};
export default ScrollAnimated;
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from 'lucide-react';
export type AlertType = 'success' | 'error' | 'warning' | 'info';
interface AlertProps {
type: AlertType;
title?: string;
message: string;
onClose?: () => void;
dismissible?: boolean;
className?: string;
}
const Alert: React.FC<AlertProps> = React.memo(({
type,
title,
message,
onClose,
dismissible = false,
className = ''
}) => {
const config = {
success: {
bgColor: 'bg-green-50',
textColor: 'text-green-800',
borderColor: 'border-green-200',
icon: CheckCircle,
iconColor: 'text-green-500',
titleColor: 'text-green-900'
},
error: {
bgColor: 'bg-red-50',
textColor: 'text-red-800',
borderColor: 'border-red-200',
icon: AlertCircle,
iconColor: 'text-red-500',
titleColor: 'text-red-900'
},
warning: {
bgColor: 'bg-yellow-50',
textColor: 'text-yellow-800',
borderColor: 'border-yellow-200',
icon: AlertTriangle,
iconColor: 'text-yellow-500',
titleColor: 'text-yellow-900'
},
info: {
bgColor: 'bg-blue-50',
textColor: 'text-blue-800',
borderColor: 'border-blue-200',
icon: Info,
iconColor: 'text-blue-500',
titleColor: 'text-blue-900'
}
};
const { bgColor, textColor, borderColor, icon: Icon, iconColor, titleColor } = config[type];
return (
<div className={`${bgColor} border ${borderColor} rounded-lg p-4 ${className}`}>
<div className="flex items-start gap-3">
<Icon className={`w-5 h-5 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1">
{title && (
<h4 className={`font-semibold mb-1 ${titleColor}`}>{title}</h4>
)}
<p className={`text-sm ${textColor}`}>{message}</p>
</div>
{dismissible && onClose && (
<button
onClick={onClose}
className={`${textColor} hover:opacity-70 transition-opacity flex-shrink-0`}
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
);
});
export default Alert;
+104
View File
@@ -0,0 +1,104 @@
import React from 'react';
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
interface AvatarProps {
name?: string;
src?: string;
size?: AvatarSize;
className?: string;
status?: 'online' | 'offline' | 'away' | 'busy';
}
const Avatar: React.FC<AvatarProps> = ({
name,
src,
size = 'md',
className = '',
status,
}) => {
const sizes: Record<AvatarSize, string> = {
xs: 'w-6 h-6 text-xs',
sm: 'w-8 h-8 text-sm',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-16 h-16 text-lg',
};
const statusSizes: Record<AvatarSize, string> = {
xs: 'w-1.5 h-1.5',
sm: 'w-2 h-2',
md: 'w-2.5 h-2.5',
lg: 'w-3 h-3',
xl: 'w-4 h-4',
};
const statusColors = {
online: 'bg-green-500',
offline: 'bg-gray-400',
away: 'bg-yellow-500',
busy: 'bg-red-500',
};
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
const getColorFromName = (name: string) => {
const colors = [
'from-blue-400 to-blue-600',
'from-purple-400 to-purple-600',
'from-pink-400 to-pink-600',
'from-indigo-400 to-indigo-600',
'from-teal-400 to-teal-600',
'from-orange-400 to-orange-600',
'from-green-400 to-green-600',
'from-cyan-400 to-cyan-600',
];
const index = name.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % colors.length;
return colors[index];
};
return (
<div className={`relative inline-flex ${className}`}>
{src ? (
<img
src={src}
alt={name || 'Avatar'}
className={`
${sizes[size]}
rounded-full object-cover
`}
/>
) : (
<div
className={`
${sizes[size]}
rounded-full flex items-center justify-center
bg-gradient-to-br ${name ? getColorFromName(name) : 'from-gray-400 to-gray-600'}
text-white font-medium
`}
>
{name ? getInitials(name) : '?'}
</div>
)}
{status && (
<span
className={`
absolute bottom-0 right-0
${statusSizes[size]}
${statusColors[status]}
rounded-full border-2 border-white
`}
/>
)}
</div>
);
};
export default Avatar;
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
export type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
export type BadgeSize = 'sm' | 'md' | 'lg';
interface BadgeProps {
variant?: BadgeVariant;
size?: BadgeSize;
children: React.ReactNode;
className?: string;
dot?: boolean;
}
const Badge: React.FC<BadgeProps> = React.memo(({
variant = 'default',
size = 'md',
children,
className = '',
dot = false,
}) => {
const variants: Record<BadgeVariant, string> = {
default: 'bg-gray-100 text-gray-700',
primary: 'bg-blue-100 text-blue-700',
success: 'bg-green-100 text-green-700',
warning: 'bg-yellow-100 text-yellow-700',
danger: 'bg-red-100 text-red-700',
info: 'bg-indigo-100 text-indigo-700',
};
const dotColors: Record<BadgeVariant, string> = {
default: 'bg-gray-500',
primary: 'bg-blue-500',
success: 'bg-green-500',
warning: 'bg-yellow-500',
danger: 'bg-red-500',
info: 'bg-indigo-500',
};
const sizes: Record<BadgeSize, string> = {
sm: 'px-1.5 py-0.5 text-[10px]',
md: 'px-2 py-0.5 text-xs',
lg: 'px-2.5 py-1 text-sm',
};
return (
<span
className={`
inline-flex items-center gap-1 font-medium rounded-full
${variants[variant]}
${sizes[size]}
${className}
`}
>
{dot && (
<span className={`w-1.5 h-1.5 rounded-full ${dotColors[variant]}`} />
)}
{children}
</span>
);
});
export default Badge;
+78
View File
@@ -0,0 +1,78 @@
import React from 'react';
import { Loader2, LucideIcon } from 'lucide-react';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
leftIcon?: LucideIcon;
rightIcon?: LucideIcon;
fullWidth?: boolean;
children: React.ReactNode;
}
const Button: React.FC<ButtonProps> = React.memo(({
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon: LeftIcon,
rightIcon: RightIcon,
fullWidth = false,
children,
className = '',
disabled,
...props
}) => {
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
outline: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 focus:ring-blue-500',
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
success: 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500',
};
const sizes: Record<ButtonSize, string> = {
xs: 'px-2 py-1 text-xs gap-1',
sm: 'px-3 py-1.5 text-xs gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-5 py-2.5 text-base gap-2',
};
const iconSizes: Record<ButtonSize, string> = {
xs: 'h-3 w-3',
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
return (
<button
className={`
${baseStyles}
${variants[variant]}
${sizes[size]}
${fullWidth ? 'w-full' : ''}
${className}
`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<Loader2 className={`${iconSizes[size]} animate-spin`} />
) : LeftIcon ? (
<LeftIcon className={iconSizes[size]} />
) : null}
{children}
{!isLoading && RightIcon && <RightIcon className={iconSizes[size]} />}
</button>
);
});
export { Button };
export default Button;
+109
View File
@@ -0,0 +1,109 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
padding?: 'none' | 'sm' | 'md' | 'lg';
shadow?: 'none' | 'sm' | 'md' | 'lg';
hover?: boolean;
onClick?: () => void;
}
interface CardHeaderProps {
children: React.ReactNode;
className?: string;
}
interface CardTitleProps {
children: React.ReactNode;
className?: string;
}
interface CardDescriptionProps {
children: React.ReactNode;
className?: string;
}
interface CardContentProps {
children: React.ReactNode;
className?: string;
}
interface CardFooterProps {
children: React.ReactNode;
className?: string;
}
const CardComponent: React.FC<CardProps> = ({
children,
className = '',
padding = 'md',
shadow = 'sm',
hover = false,
onClick,
}) => {
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
};
const shadows = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
};
return (
<div
className={`
bg-white rounded-lg border border-gray-200
${paddings[padding]}
${shadows[shadow]}
${hover ? 'hover:shadow-md hover:border-gray-300 transition-all cursor-pointer' : ''}
${className}
`}
onClick={onClick}
>
{children}
</div>
);
};
const Card = React.memo(CardComponent) as unknown as typeof CardComponent & {
Header: React.FC<CardHeaderProps>;
Title: React.FC<CardTitleProps>;
Description: React.FC<CardDescriptionProps>;
Content: React.FC<CardContentProps>;
Footer: React.FC<CardFooterProps>;
};
const CardHeader = React.memo(({ children, className = '' }: CardHeaderProps) => (
<div className={`mb-4 ${className}`}>{children}</div>
));
const CardTitle = React.memo(({ children, className = '' }: CardTitleProps) => (
<h3 className={`text-lg font-semibold text-gray-900 ${className}`}>{children}</h3>
));
const CardDescription = React.memo(({ children, className = '' }: CardDescriptionProps) => (
<p className={`text-sm text-gray-500 mt-1 ${className}`}>{children}</p>
));
const CardContent = React.memo(({ children, className = '' }: CardContentProps) => (
<div className={className}>{children}</div>
));
const CardFooter = React.memo(({ children, className = '' }: CardFooterProps) => (
<div className={`mt-4 pt-4 border-t border-gray-100 ${className}`}>{children}</div>
));
Card.Header = CardHeader;
Card.Title = CardTitle;
Card.Description = CardDescription;
Card.Content = CardContent;
Card.Footer = CardFooter;
export default Card;
+120
View File
@@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { AlertTriangle } from 'lucide-react';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
type?: 'danger' | 'warning' | 'info';
isLoading?: boolean;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
type = 'danger',
isLoading = false,
}) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!isOpen || !mounted) return null;
const colors = {
danger: {
icon: 'text-rose-600 bg-rose-50',
button: 'bg-rose-600 hover:bg-rose-700 ',
focus: 'focus:ring-rose-500',
},
warning: {
icon: 'text-amber-600 bg-amber-50',
button: 'bg-amber-600 hover:bg-amber-700 ',
focus: 'focus:ring-amber-500',
},
info: {
icon: 'text-blue-600 bg-blue-50',
button: 'bg-blue-600 hover:bg-blue-700 shadow-blue-100',
focus: 'focus:ring-blue-500',
},
};
const theme = colors[type];
return createPortal(
<div className="fixed inset-0 z-[10000] overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div className="flex items-center justify-center min-h-screen p-4 text-center sm:block sm:p-0">
<div
className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity"
aria-hidden="true"
onClick={!isLoading ? onClose : undefined}
></div>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div className="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-2xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md w-full scale-100 animate-in zoom-in-95 duration-200">
<div className="bg-white p-6 sm:p-6">
<div className="sm:flex sm:items-start gap-6">
<div className={`mx-auto flex-shrink-0 flex items-center justify-center h-14 w-14 rounded-2xl sm:mx-0 ${theme.icon} shadow-sm`}>
<AlertTriangle className="h-7 w-7" aria-hidden="true" />
</div>
<div className="mt-4 text-center sm:mt-0 sm:text-left flex-1">
<h3 className="text-xl font-bold text-slate-900 tracking-tight" id="modal-title">
{title}
</h3>
<div className="mt-3">
<p className="text-sm text-slate-500 leading-relaxed font-medium">
{message}
</p>
</div>
</div>
</div>
<div className="mt-10 flex flex-col sm:flex-row-reverse gap-3">
<button
type="button"
className={`inline-flex items-center justify-center px-6 py-2.5 rounded-xl text-sm font-bold text-white shadow-md transition-all duration-300 hover:-translate-y-0.5 active:scale-95 focus:outline-none focus:ring-2 focus:ring-offset-2 ${theme.button} ${theme.focus} ${isLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<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"></path>
</svg>
Processing...
</>
) : confirmText}
</button>
<button
type="button"
className="inline-flex items-center justify-center px-6 py-2.5 rounded-xl text-sm font-bold text-slate-600 bg-slate-50 hover:bg-slate-100 transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-200 active:scale-95"
onClick={onClose}
disabled={isLoading}
>
{cancelText}
</button>
</div>
</div>
</div>
</div>
</div>,
document.body
);
};
export default ConfirmDialog;
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from './index';
export interface Column<T> {
header: string;
accessor: keyof T | ((item: T) => React.ReactNode);
className?: string;
headerClassName?: string;
align?: 'left' | 'center' | 'right';
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
isLoading?: boolean;
emptyMessage?: string;
onRowClick?: (item: T) => void;
rowClassName?: string;
}
const DataTable = <T extends { id: string | number }>({
columns,
data,
isLoading = false,
emptyMessage = 'No data available',
onRowClick,
rowClassName = '',
}: DataTableProps<T>) => {
const { t } = useTranslation('common');
return (
<div className="w-full overflow-hidden rounded-xl border border-gray-100 bg-white">
<div className="overflow-x-auto">
<table className="dashboard-data-table w-full table-fixed border-collapse">
<thead>
<tr className="bg-gray-50/80 border-b border-gray-100">
{columns.map((column, index) => (
<th
key={index}
data-align={column.align || 'left'}
className={`py-4 px-4 text-left text-[11px] font-extrabold text-gray-700 uppercase tracking-wider ${column.headerClassName || ''
} ${column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left'}`}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{isLoading ? (
<tr>
<td colSpan={columns.length} className="py-12">
<div className="flex flex-col items-center justify-center">
<Loader text={t('common.loading')} />
</div>
</td>
</tr>
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length} className="py-16 text-center">
<p className="text-sm text-gray-400 font-medium">{emptyMessage}</p>
</td>
</tr>
) : (
data.map((item) => (
<tr
key={item.id}
onClick={() => onRowClick?.(item)}
className={`group hover:bg-gray-50/50 transition-colors ${onRowClick ? 'cursor-pointer' : ''
} ${rowClassName}`}
>
{columns.map((column, index) => {
const content =
typeof column.accessor === 'function'
? column.accessor(item)
: (item[column.accessor] as React.ReactNode);
return (
<td
key={index}
data-align={column.align || 'left'}
className={`py-5 px-4 text-sm text-gray-700 whitespace-nowrap ${column.className || ''
} ${column.align === 'center'
? 'text-center'
: column.align === 'right'
? 'text-right'
: 'text-left'
}`}
>
{content}
</td>
);
})}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
};
export default DataTable;
+103
View File
@@ -0,0 +1,103 @@
// @ts-nocheck
import React from 'react';
import { ChevronDown, LucideIcon } from 'lucide-react';
export type DropdownSize = 'sm' | 'md' | 'lg';
interface DropdownItem {
label: string;
value: string;
icon?: LucideIcon;
disabled?: boolean;
danger?: boolean;
divider?: boolean;
}
interface DropdownProps {
trigger: React.ReactNode;
items: DropdownItem[];
onSelect: (value: string) => void;
isOpen: boolean;
onToggle: () => void;
align?: 'left' | 'right';
size?: DropdownSize;
className?: string;
}
const Dropdown: React.FC<DropdownProps> = ({
trigger,
items,
onSelect,
isOpen,
onToggle,
align = 'left',
size = 'md',
className = '',
}) => {
const sizes: Record<DropdownSize, string> = {
sm: 'py-1 text-xs',
md: 'py-1.5 text-sm',
lg: 'py-2 text-base',
};
const iconSizes: Record<DropdownSize, string> = {
sm: 'h-3 w-3',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
const handleSelect = (item: DropdownItem) => {
if (item.disabled) return;
onSelect(item.value);
onToggle();
};
return (
<div className={`relative ${className}`}>
<div onClick={onToggle}>
{trigger}
</div>
{isOpen && (
<div
className={`
absolute z-20 mt-1 w-48 bg-white rounded-lg shadow-lg border border-gray-200
${align === 'right' ? 'right-0' : 'left-0'}
`}
>
<div className="py-1">
{items.map((item, index) => {
if (item.divider) {
return <div key={index} className="border-t border-gray-100 my-1" />;
}
const Icon = item.icon;
return (
<button
key={item.value}
onClick={() => handleSelect(item)}
disabled={item.disabled}
className={`
w-full flex items-center gap-2 px-3 ${sizes[size]}
${item.disabled
? 'text-gray-400 cursor-not-allowed'
: item.danger
? 'text-red-600 hover:bg-red-50'
: 'text-gray-700 hover:bg-gray-50'
}
transition-colors
`}
>
{Icon && <Icon className={iconSizes[size]} />}
<span>{item.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
};
export default Dropdown;
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon,
title,
description,
action,
className = '',
}) => {
return (
<div className={`flex flex-col items-center justify-center py-12 px-4 text-center ${className}`}>
{icon && (
<div className="text-gray-300 mb-4">
{icon}
</div>
)}
<h3 className="text-lg font-medium text-gray-900 mb-1">{title}</h3>
{description && (
<p className="text-sm text-gray-500 max-w-sm mb-4">{description}</p>
)}
{action && (
<div className="mt-2">
{action}
</div>
)}
</div>
);
};
export default EmptyState;
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: LucideIcon;
size?: 'xs' | 'sm' | 'md' | 'lg';
variant?: 'default' | 'ghost' | 'outline';
tooltip?: string;
}
const IconButton: React.FC<IconButtonProps> = ({
icon: Icon,
size = 'md',
variant = 'default',
tooltip,
className = '',
...props
}) => {
const sizes = {
xs: 'p-1',
sm: 'p-1.5',
md: 'p-2',
lg: 'p-2.5',
};
const iconSizes = {
xs: 'h-3 w-3',
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
const variants = {
default: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100',
ghost: 'text-gray-400 hover:text-gray-600',
outline: 'text-gray-500 hover:text-gray-700 border border-gray-200 hover:bg-gray-50',
};
return (
<button
className={`
rounded-lg transition-colors
disabled:opacity-50 disabled:cursor-not-allowed
${sizes[size]}
${variants[variant]}
${className}
`}
title={tooltip}
{...props}
>
<Icon className={iconSizes[size]} />
</button>
);
};
export default IconButton;
+98
View File
@@ -0,0 +1,98 @@
import React, { forwardRef } from 'react';
import { LucideIcon } from 'lucide-react';
export type InputSize = 'sm' | 'md' | 'lg';
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
error?: string;
hint?: string;
leftIcon?: LucideIcon;
rightIcon?: LucideIcon;
inputSize?: InputSize;
fullWidth?: boolean;
'aria-label'?: string;
'aria-describedby'?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(({
label,
error,
hint,
leftIcon: LeftIcon,
rightIcon: RightIcon,
inputSize = 'md',
fullWidth = true,
className = '',
id,
...props
}, ref) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
const sizes: Record<InputSize, string> = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-3 py-2 text-sm',
lg: 'px-4 py-2.5 text-base',
};
const iconSizes: Record<InputSize, string> = {
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
};
const paddingLeft = LeftIcon ? 'pl-9' : '';
const paddingRight = RightIcon ? 'pr-9' : '';
return (
<div className={fullWidth ? 'w-full' : ''}>
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
)}
<div className="relative">
{LeftIcon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
<LeftIcon className={iconSizes[inputSize]} />
</div>
)}
<input
ref={ref}
id={inputId}
aria-label={props['aria-label'] || label}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : props['aria-describedby']}
className={`
block border rounded-lg transition-colors
bg-white text-gray-900 placeholder-gray-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed
${sizes[inputSize]}
${paddingLeft}
${paddingRight}
${error ? 'border-red-300 focus:ring-red-500' : 'border-gray-300'}
${fullWidth ? 'w-full' : ''}
${className}
`}
{...props}
/>
{RightIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">
<RightIcon className={iconSizes[inputSize]} />
</div>
)}
</div>
{error && (
<p id={`${inputId}-error`} className="mt-1 text-xs text-red-600">{error}</p>
)}
{hint && !error && (
<p className="mt-1 text-xs text-gray-500">{hint}</p>
)}
</div>
);
});
Input.displayName = 'Input';
export default Input;
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import SpinnerArc from './SpinnerArc';
interface LoaderProps {
text?: string;
size?: 'sm' | 'md' | 'lg';
}
const Loader: React.FC<LoaderProps> = ({ text = 'Loading...', size = 'md' }) => {
const sizeClasses = {
sm: 'h-6 w-6',
md: 'h-8 w-8',
lg: 'h-12 w-12'
};
const textSizes = {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base'
};
return (
<div className="flex flex-col items-center justify-center py-12 gap-3">
<div className={`${sizeClasses[size]} animate-spin`}>
<SpinnerArc className="w-full h-full text-blue-600" />
</div>
<span className={`${textSizes[size]} text-gray-600 font-medium`}>{text}</span>
</div>
);
};
export default Loader;
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
import { X } from 'lucide-react';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
size?: ModalSize;
title?: string;
description?: string;
showCloseButton?: boolean;
closeOnOverlayClick?: boolean;
}
interface ModalHeaderProps {
children: React.ReactNode;
className?: string;
}
interface ModalBodyProps {
children: React.ReactNode;
className?: string;
}
interface ModalFooterProps {
children: React.ReactNode;
className?: string;
}
const Modal: React.FC<ModalProps> & {
Header: React.FC<ModalHeaderProps>;
Body: React.FC<ModalBodyProps>;
Footer: React.FC<ModalFooterProps>;
} = ({
isOpen,
onClose,
children,
size = 'md',
title,
description,
showCloseButton = true,
closeOnOverlayClick = true,
}) => {
if (!isOpen) return null;
const sizes: Record<ModalSize, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
full: 'max-w-4xl',
};
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget && closeOnOverlayClick) {
onClose();
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50"
onClick={handleOverlayClick}
>
<div
className={`
bg-white rounded-xl shadow-xl w-full
${sizes[size]}
animate-fade-in
max-h-[90vh] flex flex-col overflow-hidden my-auto
`}
>
{(title || showCloseButton) && (
<div className="flex items-start justify-between p-4 border-b border-gray-100">
<div>
{title && <h2 className="text-lg font-semibold text-gray-900">{title}</h2>}
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
</div>
{showCloseButton && (
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
)}
</div>
)}
{children}
</div>
</div>
);
};
const ModalHeader: React.FC<ModalHeaderProps> = ({ children, className = '' }) => (
<div className={`p-4 border-b border-gray-100 ${className}`}>{children}</div>
);
const ModalBody: React.FC<ModalBodyProps> = ({ children, className = '' }) => (
<div className={`p-4 ${className}`}>{children}</div>
);
const ModalFooter: React.FC<ModalFooterProps> = ({ children, className = '' }) => (
<div className={`p-4 border-t border-gray-100 flex items-center justify-end gap-2 ${className}`}>
{children}
</div>
);
Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
export default Modal;
+122
View File
@@ -0,0 +1,122 @@
import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from './Button';
export interface PaginationProps {
currentPage: number;
totalItems: number;
pageSize?: number;
onPageChange: (page: number) => void;
className?: string;
itemLabel?: string;
}
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalItems,
pageSize = 10,
onPageChange,
className = '',
itemLabel = 'items',
}) => {
const totalPages = Math.ceil(totalItems / pageSize);
if (totalItems <= pageSize && totalPages <= 1) {
return null;
}
const startItem = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
// Calculate page range with ellipsis
const getPageNumbers = () => {
const pages: (number | string)[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
if (currentPage > 3) {
pages.push('...');
}
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) {
pages.push('...');
}
pages.push(totalPages);
}
return pages;
};
return (
<div className={`flex flex-col sm:flex-row items-center justify-between gap-3 pt-4 mt-4 border-t border-gray-100 text-sm text-gray-500 ${className}`}>
<div>
Showing <span className="font-medium text-gray-800">{startItem}</span> to{' '}
<span className="font-medium text-gray-800">{endItem}</span> of{' '}
<span className="font-medium text-gray-800">{totalItems}</span> {itemLabel}
</div>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={currentPage <= 1}
onClick={() => onPageChange(currentPage - 1)}
className="h-8 px-2.5"
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
<span className="hidden sm:inline ml-1">Previous</span>
</Button>
<div className="flex items-center gap-1">
{getPageNumbers().map((page, idx) => {
if (typeof page === 'string') {
return (
<span key={`ellipsis-${idx}`} className="px-2 text-gray-400">
...
</span>
);
}
const isCurrent = page === currentPage;
return (
<button
key={page}
onClick={() => onPageChange(page)}
className={`h-8 min-w-[32px] px-2 rounded-lg text-xs font-medium transition-colors ${
isCurrent
? 'bg-blue-600 text-white font-semibold shadow-sm'
: 'text-gray-700 hover:bg-gray-100 border border-gray-200 bg-white'
}`}
>
{page}
</button>
);
})}
</div>
<Button
variant="outline"
size="sm"
disabled={currentPage >= totalPages}
onClick={() => onPageChange(currentPage + 1)}
className="h-8 px-2.5"
aria-label="Next page"
>
<span className="hidden sm:inline mr-1">Next</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
);
};
export default Pagination;
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
import { Loader2 } from 'lucide-react';
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
interface SpinnerProps {
size?: SpinnerSize;
className?: string;
color?: string;
label?: string;
}
const Spinner: React.FC<SpinnerProps> = ({
size = 'md',
className = '',
color = 'text-blue-600',
label,
}) => {
const sizes: Record<SpinnerSize, string> = {
xs: 'h-3 w-3',
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
xl: 'h-12 w-12',
};
return (
<div className={`inline-flex flex-col items-center gap-2 ${className}`}>
<Loader2 className={`${sizes[size]} ${color} animate-spin`} />
{label && <span className="text-sm text-gray-500">{label}</span>}
</div>
);
};
export default Spinner;
+32
View File
@@ -0,0 +1,32 @@
type SpinnerArcProps = {
className?: string;
};
export function SpinnerArc({ className = "" }: SpinnerArcProps) {
return (
<svg
className={className}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
focusable="false"
>
<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>
);
}
export default SpinnerArc;
+75
View File
@@ -0,0 +1,75 @@
import React, { useEffect } from 'react';
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
interface ToastProps {
type: ToastType;
message: string;
onClose: () => void;
duration?: number;
autoClose?: boolean;
}
const Toast: React.FC<ToastProps> = ({
type,
message,
onClose,
duration = 5000,
autoClose = true
}) => {
useEffect(() => {
if (autoClose) {
const timer = setTimeout(onClose, duration);
return () => clearTimeout(timer);
}
}, [autoClose, duration, onClose]);
const config = {
success: {
bgColor: 'bg-green-50',
textColor: 'text-green-800',
borderColor: 'border-green-200',
icon: CheckCircle,
iconColor: 'text-green-500'
},
error: {
bgColor: 'bg-red-50',
textColor: 'text-red-800',
borderColor: 'border-red-200',
icon: AlertCircle,
iconColor: 'text-red-500'
},
warning: {
bgColor: 'bg-yellow-50',
textColor: 'text-yellow-800',
borderColor: 'border-yellow-200',
icon: AlertTriangle,
iconColor: 'text-yellow-500'
},
info: {
bgColor: 'bg-blue-50',
textColor: 'text-blue-800',
borderColor: 'border-blue-200',
icon: Info,
iconColor: 'text-blue-500'
}
};
const { bgColor, textColor, borderColor, icon: Icon, iconColor } = config[type];
return (
<div className={`${bgColor} ${textColor} border ${borderColor} rounded-lg p-4 shadow-lg flex items-start gap-3 min-w-[300px] max-w-md animate-slide-in`}>
<Icon className={`w-5 h-5 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1 text-sm">{message}</div>
<button
onClick={onClose}
className={`${textColor} hover:opacity-70 transition-opacity flex-shrink-0`}
>
<X className="w-4 h-4" />
</button>
</div>
);
};
export default Toast;
+75
View File
@@ -0,0 +1,75 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import Toast, { ToastType } from './Toast';
interface ToastContextType {
showToast: (type: ToastType, message: string, duration?: number) => void;
success: (message: string, duration?: number) => void;
error: (message: string, duration?: number) => void;
warning: (message: string, duration?: number) => void;
info: (message: string, duration?: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
interface ToastItem {
id: string;
type: ToastType;
message: string;
duration?: number;
}
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback((type: ToastType, message: string, duration?: number) => {
const id = `${Date.now()}-${Math.random()}`;
setToasts([{ id, type, message, duration }]);
}, []);
const success = useCallback((message: string, duration?: number) => {
showToast('success', message, duration);
}, [showToast]);
const error = useCallback((message: string, duration?: number) => {
showToast('error', message, duration);
}, [showToast]);
const warning = useCallback((message: string, duration?: number) => {
showToast('warning', message, duration);
}, [showToast]);
const info = useCallback((message: string, duration?: number) => {
showToast('info', message, duration);
}, [showToast]);
return (
<ToastContext.Provider value={{ showToast, success, error, warning, info }}>
{children}
<div className="fixed top-4 right-4 z-[9999] space-y-2">
{toasts.map((toast) => (
<Toast
key={toast.id}
type={toast.type}
message={toast.message}
duration={toast.duration}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = (): ToastContextType => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export default ToastProvider;
+48
View File
@@ -0,0 +1,48 @@
export { default as Button } from './Button';
export type { ButtonVariant, ButtonSize } from './Button';
export { default as Input } from './Input';
export type { InputSize } from './Input';
export { default as Badge } from './Badge';
export type { BadgeVariant, BadgeSize } from './Badge';
export { default as Card } from './Card';
export { default as Modal } from './Modal';
export type { ModalSize } from './Modal';
export { default as Avatar } from './Avatar';
export type { AvatarSize } from './Avatar';
export { default as Spinner } from './Spinner';
export type { SpinnerSize } from './Spinner';
export { default as IconButton } from './IconButton';
export { default as Loader } from './Loader';
export { default as Dropdown } from './Dropdown';
export type { DropdownSize } from './Dropdown';
export { default as EmptyState } from './EmptyState';
export { default as Skeleton } from '../custom/Skeleton';
export type { SkeletonProps } from '../custom/Skeleton';
export { default as Alert } from './Alert';
export type { AlertType } from './Alert';
export { default as Toast } from './Toast';
export type { ToastType } from './Toast';
export { ToastProvider, useToast } from './ToastProvider';
export { default as ConfirmDialog } from './ConfirmDialog';
export { default as DataTable } from './DataTable';
export type { Column } from './DataTable';
export { default as Pagination } from './Pagination';
export type { PaginationProps } from './Pagination';
export { StripeLikeGradientShader } from './stripe-like-gradient-shader';
@@ -0,0 +1,44 @@
import type { ReactNode } from "react";
import { GradFlow } from "gradflow";
import { cn } from "@/lib/utils";
type StripeLikeGradientShaderProps = {
children?: ReactNode;
className?: string;
};
export function StripeLikeGradientShader({
children,
className,
}: StripeLikeGradientShaderProps) {
return (
<div className={cn("relative h-full w-full overflow-hidden", className)}>
<div className="absolute inset-0 z-0 opacity-[0.68]">
<GradFlow
config={{
color1: { r: 255, g: 255, b: 255 },
color2: { r: 225, g: 235, b: 255 },
color3: { r: 47, g: 94, b: 255 },
speed: 0.5,
scale: 0.92,
type: "stripe",
noise: 0.008,
}}
/>
</div>
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(circle_at_76%_12%,rgba(255,255,255,0.22),transparent_22%),radial-gradient(circle_at_22%_70%,rgba(190,198,255,0.12),transparent_28%),linear-gradient(180deg,rgba(255,255,255,0.14)_0%,rgba(255,255,255,0.07)_30%,rgba(12,35,120,0.035)_100%)]" />
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_at_top_right,rgba(255,255,255,0.1),transparent_26%),linear-gradient(160deg,rgba(10,34,119,0.012)_0%,rgba(10,34,119,0.035)_58%,rgba(7,22,88,0.08)_100%)]" />
<div
className="pointer-events-none absolute inset-0 z-[1] opacity-[0.03] mix-blend-soft-light"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180' viewBox='0 0 180 180'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23noise)'/%3E%3C/svg%3E\")",
}}
/>
<div className="relative z-10 h-full w-full">{children}</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
const rawApiUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:20001/api';
const API_BASE_URL = (typeof window !== 'undefined' && window.location.protocol === 'https:' && rawApiUrl.startsWith('http://'))
? '/api'
: rawApiUrl;
export const getApiUrl = (endpoint: string): string => {
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
if (API_BASE_URL.startsWith('/')) {
if (cleanEndpoint.startsWith(API_BASE_URL)) {
return cleanEndpoint;
}
}
return `${API_BASE_URL}${cleanEndpoint}`;
};
export const getApiBaseUrl = (): string => {
return API_BASE_URL;
};
export const isValidApiUrl = (url: string): boolean => {
if (!url) return false;
try {
const urlObj = new URL(url, window.location.origin);
const baseUrlObj = new URL(API_BASE_URL, window.location.origin);
return urlObj.origin === baseUrlObj.origin && urlObj.pathname.startsWith(baseUrlObj.pathname);
} catch (e) {
return false;
}
};
export const getImageUrl = (sessionId: string, filename: string): string => {
return `${API_BASE_URL}/serve-image/${sessionId}/${filename}`;
};
export const API_ENDPOINTS = {
LOGIN: '/auth/login',
REGISTER: '/auth/register',
MY_PROJECTS: '/me/projects',
PROJECT_DETAIL: (id: number | string) => `/me/projects/${id}`,
PROJECT_VERSIONS: (id: number | string) => `/me/projects/${id}/versions`,
PROJECT_PREVIEW: (id: number | string) => `/me/preview/${id}`,
SHARED_WITH_ME: '/sharing/shared-with-me',
PROJECT_SHARES: (projectId: number | string) => `/sharing/project/${projectId}/shares`,
SHARE: '/sharing/share',
SHARE_DETAIL: (shareId: number) => `/sharing/share/${shareId}`,
PROCESS_DOCUMENT: '/1-process-document/',
CONVERT_TO_XML: '/2-convert-to-xml/',
AUTO_STRUCTURE: '/auto-structure/',
EXPORT_HTML: '/export-html/',
EXPORT_DOCX: '/export-docx/',
ME: '/me',
UPGRADE: '/me/upgrade',
} as const;
export default {
getApiUrl,
getApiBaseUrl,
getImageUrl,
API_ENDPOINTS,
};
+66
View File
@@ -0,0 +1,66 @@
export const APP_ROUTES = {
HOME: '/',
LOGIN: '/login',
REGISTER: '/register',
DASHBOARD: '/dashboard',
DRIVE: '/drive',
CONVERSION: '/conversion',
SETTINGS: '/settings',
SUBSCRIPTION: '/subscription',
} as const;
export const FILE_TYPES = {
PDF: 'application/pdf',
DOCX: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
MD: 'text/markdown',
XML: 'application/xml',
HTML: 'text/html',
} as const;
export const MAX_FILE_SIZE = 50 * 1024 * 1024;
export const SUPPORTED_FORMATS = [
'pdf',
'docx',
'doc',
'txt',
'html',
'md',
'xml',
] as const;
export const STORAGE_KEYS = {
THEME: 'theme',
CACHE: 'cache',
} as const;
export const API_ENDPOINTS = {
AUTH: {
LOGIN: '/api/auth/login',
REGISTER: '/api/auth/register',
LOGOUT: '/api/auth/logout',
VERIFY: '/api/auth/verify',
GOOGLE_LOGIN: '/api/auth/google',
},
PROJECTS: {
LIST: '/api/projects',
CREATE: '/api/projects',
GET: (id: string) => `/api/projects/${id}`,
UPDATE: (id: string) => `/api/projects/${id}`,
DELETE: (id: string) => `/api/projects/${id}`,
},
FILES: {
UPLOAD: '/api/files/upload',
DOWNLOAD: (id: string) => `/api/files/${id}/download`,
DELETE: (id: string) => `/api/files/${id}`,
},
} as const;
export default {
APP_ROUTES,
FILE_TYPES,
MAX_FILE_SIZE,
SUPPORTED_FORMATS,
STORAGE_KEYS,
API_ENDPOINTS,
};
+22
View File
@@ -0,0 +1,22 @@
import { getApiBaseUrl } from './api';
export const ENV = {
get API_BASE_URL() {
return getApiBaseUrl();
},
API_TIMEOUT: 300000,
APP_NAME: 'DocQube',
APP_VERSION: '1.0.0',
FEATURES: {
ENABLE_CHATBOT: true,
ENABLE_ANALYTICS: false,
ENABLE_SUBSCRIPTION: true,
},
isDevelopment: import.meta.env.DEV,
isProduction: import.meta.env.PROD,
} as const;
export default ENV;
+117
View File
@@ -0,0 +1,117 @@
import React, { createContext, useContext, useState, useCallback, useMemo, useRef, ReactNode } from 'react';
import { DRIVE_FAVORITE_CHANGED_EVENT } from '@/services/drive';
interface CacheContextType {
get: (key: string) => any;
set: (key: string, value: any, ttl?: number) => void;
invalidate: (key: string) => void;
invalidatePattern: (pattern: string) => void;
clear: () => void;
}
interface CacheEntry {
value: any;
expiry: number;
}
const CacheContext = createContext<CacheContextType | undefined>(undefined);
export const CacheProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [cache, setCache] = useState<Map<string, CacheEntry>>(new Map());
const cacheRef = useRef(cache);
cacheRef.current = cache;
const get = useCallback((key: string) => {
const entry = cacheRef.current.get(key);
if (!entry) return null;
if (Date.now() > entry.expiry) {
setCache(prev => {
const newCache = new Map(prev);
newCache.delete(key);
return newCache;
});
return null;
}
return entry.value;
}, []);
const set = useCallback((key: string, value: any, ttl: number = Infinity) => {
setCache(prev => {
const newCache = new Map(prev);
newCache.set(key, {
value,
expiry: ttl === Infinity ? Infinity : Date.now() + ttl
});
return newCache;
});
}, []);
const invalidate = useCallback((key: string) => {
setCache(prev => {
const newCache = new Map(prev);
newCache.delete(key);
return newCache;
});
}, []);
const invalidatePattern = useCallback((pattern: string) => {
setCache(prev => {
const newCache = new Map(prev);
const regex = new RegExp(pattern.replace('*', '.*'));
for (const key of newCache.keys()) {
if (regex.test(key)) {
newCache.delete(key);
}
}
return newCache;
});
}, []);
const clear = useCallback(() => {
setCache(new Map());
}, []);
React.useEffect(() => {
const handleLogout = () => {
clear();
};
window.addEventListener('auth:logout', handleLogout);
return () => window.removeEventListener('auth:logout', handleLogout);
}, [clear]);
React.useEffect(() => {
const handleFavoriteChanged = () => {
invalidatePattern('mydocs:*');
invalidate('favorites:starred');
invalidate('dashboard:main');
invalidate('recent:combined');
invalidate('shared:with_me');
};
window.addEventListener(DRIVE_FAVORITE_CHANGED_EVENT, handleFavoriteChanged);
return () => window.removeEventListener(DRIVE_FAVORITE_CHANGED_EVENT, handleFavoriteChanged);
}, [invalidate, invalidatePattern]);
const value = useMemo(() => ({ get, set, invalidate, invalidatePattern, clear }), [get, set, invalidate, invalidatePattern, clear]);
return (
<CacheContext.Provider value={value}>
{children}
</CacheContext.Provider>
);
};
export const useCache = (): CacheContextType => {
const context = useContext(CacheContext);
if (!context) {
throw new Error('useCache must be used within a CacheProvider');
}
return context;
};
+57
View File
@@ -0,0 +1,57 @@
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback, useMemo } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme');
return (savedTheme === 'dark' || savedTheme === 'light') ? savedTheme as Theme : 'light';
});
useEffect(() => {
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = useCallback(() => {
setThemeState(prev => prev === 'light' ? 'dark' : 'light');
}, []);
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme);
}, []);
const value = useMemo(() => ({
theme,
toggleTheme,
setTheme
}), [theme, toggleTheme, setTheme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
+229
View File
@@ -0,0 +1,229 @@
import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { listChildren, preScanFile, uploadFile } from '@/services/drive';
import { ConfirmDialog } from '@/components/ui';
import { useToast } from '@/components/ui/ToastProvider';
import { tenantApi } from '@/services/tenant';
export interface UploadFileState {
file: File;
progress: number;
status: 'pending' | 'scanning' | 'ready' | 'uploading' | 'compressing' | 'processing' | 'completed' | 'error';
error?: string;
virusName?: string;
wasCompressed?: boolean;
isSignedPdf?: boolean;
}
export const MAX_FILE_SIZE = 30 * 1024 * 1024;
export const MAX_FILE_COUNT = 10;
export const isBlockedType = (file: File) => {
const type = file.type.toLowerCase();
const ext = file.name.split('.').pop()?.toLowerCase() || '';
if (type.startsWith('video/') || type.startsWith('audio/')) return true;
const blockedExts = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'mp3', 'wav', 'ogg', 'aac', 'flac', 'ppt', 'pptx'];
if (blockedExts.includes(ext)) return true;
return false;
};
export const sanitizeFilename = (name: string) => {
if (!name) return "file";
let namePart = name;
let extPart = "";
if (name.includes(".")) {
const lastDot = name.lastIndexOf(".");
namePart = name.substring(0, lastDot);
extPart = name.substring(lastDot);
}
const sanitized = namePart.replace(/[^\w.-]/g, "_").replace(/_+/g, "_").replace(/^[_.]+|[_.]+$/g, "");
const finalName = sanitized || "file";
return extPart ? `${finalName}${extPart}` : finalName;
};
interface UploadContextType {
uploads: UploadFileState[];
startUploads: (files: File[], folderId: number, onComplete?: () => void) => void;
clearUploads: () => void;
dismissUpload: (fileName: string) => void;
isUploading: boolean;
}
const UploadContext = createContext<UploadContextType | undefined>(undefined);
export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [uploads, setUploads] = useState<UploadFileState[]>([]);
const [disablePdfCompression, setDisablePdfCompression] = useState(false);
const { error: showToastError, info: showToastInfo } = useToast();
useEffect(() => {
tenantApi.getMyTenantDetails()
.then(data => setDisablePdfCompression(data?.disable_pdf_compression || false))
.catch(console.error);
}, []);
const updateItemStatus = useCallback((file: File, updates: Partial<UploadFileState>) => {
setUploads(prev => prev.map(u => u.file === file ? { ...u, ...updates } : u));
}, []);
const [duplicateConfirm, setDuplicateConfirm] = useState<{
item: UploadFileState;
folderId: number;
onConfirm: () => void;
onCancel: () => void;
} | null>(null);
const processUpload = async (item: UploadFileState, folderId: number, onComplete?: () => void) => {
const updateStatus = (updates: Partial<UploadFileState>) => {
updateItemStatus(item.file, updates);
};
try {
if (isBlockedType(item.file)) {
updateStatus({ status: 'error', error: 'Video and audio files are not allowed' });
return;
}
if (item.file.size > MAX_FILE_SIZE) {
updateStatus({ status: 'error', error: 'File size exceeds 30MB limit' });
return;
}
updateStatus({ status: 'scanning' });
const { files: existingFiles } = await listChildren(folderId);
const sanitizedInputName = sanitizeFilename(item.file.name);
const duplicate = existingFiles.find((f: any) => sanitizeFilename(f.name) === sanitizedInputName);
let targetFileId: number | undefined;
if (duplicate) {
const proceed = await new Promise<boolean>((resolve) => {
setDuplicateConfirm({
item,
folderId,
onConfirm: () => {
setDuplicateConfirm(null);
resolve(true);
},
onCancel: () => {
setDuplicateConfirm(null);
resolve(false);
}
});
});
if (!proceed) {
setUploads(prev => prev.filter(u => u.file.name !== item.file.name));
showToastInfo(`Upload of "${item.file.name}" was cancelled — file not uploaded.`);
return;
}
targetFileId = duplicate.id;
}
updateStatus({ status: 'uploading', progress: 0 });
try {
const uploadRes: any = await uploadFile(folderId, item.file, targetFileId, (progressEvent: any) => {
if (progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
if (percentCompleted >= 100) {
updateStatus({
status: (item.file.type === 'application/pdf' && !disablePdfCompression) ? 'compressing' : 'processing',
progress: 99
});
} else {
updateStatus({ status: 'uploading', progress: percentCompleted });
}
}
});
updateStatus({
status: 'completed',
progress: 100,
wasCompressed: uploadRes?.was_compressed,
isSignedPdf: uploadRes?.is_signed_pdf
});
if (onComplete) onComplete();
} catch (err: any) {
console.error('Upload failed for', item.file.name, err);
const isVirus = err?.message?.toLowerCase()?.includes('virus') || err?.message?.toLowerCase()?.includes('malware');
updateStatus({
status: 'error',
error: err.message || 'Upload failed',
virusName: isVirus ? (err.virusName || 'Threat detected') : undefined
});
}
} catch (err: any) {
console.error('Upload error:', err);
updateStatus({
status: 'error',
error: err.message || 'Upload failed',
virusName: err.virusName
});
}
};
const startUploads = useCallback((newFiles: File[], folderId: number, onComplete?: () => void) => {
const validatedItems: UploadFileState[] = [];
const pendingUploads: UploadFileState[] = [];
newFiles.forEach(file => {
const item: UploadFileState = { file, progress: 0, status: 'pending' };
if (isBlockedType(file)) {
item.status = 'error';
item.error = 'Video and audio files are not allowed';
} else if (file.size > MAX_FILE_SIZE) {
item.status = 'error';
item.error = 'File size exceeds 30MB limit';
} else {
pendingUploads.push(item);
}
validatedItems.push(item);
});
if (newFiles.length > MAX_FILE_COUNT) {
showToastError(`You can only upload a maximum of ${MAX_FILE_COUNT} files at a time.`);
return;
}
setUploads(prev => [...validatedItems, ...prev]);
pendingUploads.forEach(item => processUpload(item, folderId, onComplete));
}, [disablePdfCompression, showToastError]);
const clearUploads = useCallback(() => {
setUploads([]);
}, []);
const dismissUpload = useCallback((fileName: string) => {
setUploads(prev => prev.filter(u => u.file.name !== fileName));
}, []);
const isUploading = uploads.some(u =>
u.status === 'uploading' ||
u.status === 'scanning' ||
u.status === 'compressing' ||
u.status === 'processing' ||
u.status === 'pending'
);
return (
<UploadContext.Provider value={{ uploads, startUploads, clearUploads, dismissUpload, isUploading }}>
{children}
<ConfirmDialog
isOpen={!!duplicateConfirm}
title="Duplicate File Detected"
message={`A file named "${duplicateConfirm?.item.file.name}" already exists in this folder. Continuing will create a new version — the existing version history is preserved. Do you want to continue?`}
confirmText="Upload as new version"
cancelText="Cancel"
type="info"
onConfirm={() => duplicateConfirm?.onConfirm()}
onClose={() => duplicateConfirm?.onCancel()}
/>
</UploadContext.Provider>
);
};
export const useUpload = () => {
const context = useContext(UploadContext);
if (context === undefined) {
throw new Error('useUpload must be used within an UploadProvider');
}
return context;
};
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
export { default } from './ChatBotView';
@@ -0,0 +1,272 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
FileText,
Globe,
Code,
Image as ImageIcon,
Cloud,
Sparkles,
CheckCircle,
Clock
} from 'lucide-react';
import { listProjects, Project } from '@/features/project/api';
import { listRecent, DriveFile } from '@/services/drive';
import FileUpload from '@/features/conversion/components/FileUpload';
import { useDashboard } from '@/features/dashboard/DashboardContext';
import { useAuth } from '@/features/auth/context';
import {
type ConversionToolId,
CONVERSION_PAGE_ACCESS_CODES,
hasConversionToolAccess,
} from '@/features/conversion/toolAccess';
import { hasAnyAccess } from '@/utils/permissions';
import { Skeleton } from '@/components/ui';
const ConversionView: React.FC = () => {
const { t, i18n } = useTranslation(['conversion', 'common']);
const { user } = useAuth();
const {
isUploading,
setIsUploading,
handleFileUpload: onFileUpload,
uploadProgress,
setUploadProgress,
setConversionType,
} = useDashboard();
const [selectedConversion, setSelectedConversion] = useState<ConversionToolId | null>(null);
const [uploadAreaHighlighted, setUploadAreaHighlighted] = useState(false);
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [recentFiles, setRecentFiles] = useState<DriveFile[]>([]);
const [countsLoading, setCountsLoading] = useState(true);
useEffect(() => {
const fetchCounts = async () => {
try {
const [projects, recentData] = await Promise.all([
listProjects(),
listRecent(50)
]);
setAllProjects(projects);
setRecentFiles(recentData.files || []);
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setCountsLoading(false);
}
};
fetchCounts();
}, []);
const totalCount = allProjects.length + recentFiles.length;
const readyCount = allProjects.filter(p => p.currentStep === 'export').length + recentFiles.length;
const convertingCount = allProjects.filter(p => p.currentStep !== 'export').length;
const conversionTools: Array<{
id: ConversionToolId;
icon: typeof FileText;
outputFormat: string;
bgColor: string;
iconColor: string;
acceptFormats: string;
}> = [
{ id: 'pdf-word', icon: FileText, outputFormat: 'DOCX', bgColor: 'bg-red-50', iconColor: 'text-red-500', acceptFormats: '.pdf' },
{ id: 'pdf-html', icon: Globe, outputFormat: 'HTML', bgColor: 'bg-blue-50', iconColor: 'text-blue-500', acceptFormats: '.pdf' },
{ id: 'pdf-xml', icon: Code, outputFormat: 'XML', bgColor: 'bg-purple-50', iconColor: 'text-purple-500', acceptFormats: '.pdf' },
{ id: 'word-pdf', icon: FileText, outputFormat: 'PDF', bgColor: 'bg-orange-50', iconColor: 'text-orange-500', acceptFormats: '.doc,.docx' },
{ id: 'jpg-word', icon: ImageIcon, outputFormat: 'DOCX', bgColor: 'bg-teal-50', iconColor: 'text-teal-500', acceptFormats: '.jpg,.jpeg,.png' },
];
const visibleConversionTools = conversionTools.filter((tool) =>
hasConversionToolAccess(user?.accesses, tool.id),
);
const canUseConversion = hasAnyAccess(user?.accesses, CONVERSION_PAGE_ACCESS_CODES);
const selectedTool = visibleConversionTools.find((tool) => tool.id === selectedConversion);
useEffect(() => {
if (selectedConversion && !visibleConversionTools.some((tool) => tool.id === selectedConversion)) {
setSelectedConversion(null);
setUploadAreaHighlighted(false);
}
}, [selectedConversion, visibleConversionTools]);
const handleToolClick = (toolId: ConversionToolId) => {
setSelectedConversion(toolId);
setUploadAreaHighlighted(true);
const tool = visibleConversionTools.find((item) => item.id === toolId);
if (tool) {
setConversionType(tool.outputFormat);
}
setTimeout(() => {
const uploadArea = document.getElementById('upload-area-conversion');
if (uploadArea) {
uploadArea.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
};
return (
<div className="flex-1 p-3 sm:p-4">
{}
<div className="mb-4">
<h1 className="text-xl font-semibold text-gray-900">{t('conversion:header.welcome', { name: user?.name || t('common:nav.userFallback') })}</h1>
<p className="text-gray-500 text-xs">{t('conversion:header.title')}</p>
</div>
{}
<div className="bg-white rounded-lg border border-gray-200 p-3 mb-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-0">
<div className="grid grid-cols-3 sm:flex sm:items-center gap-2 sm:gap-6 w-full sm:w-auto">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div className="min-w-0">
{countsLoading ? (
<Skeleton className="h-4 w-8 rounded my-0.5" />
) : (
<p className="text-sm font-medium text-gray-900 truncate">{totalCount}</p>
)}
<p className="text-[10px] text-gray-500 truncate">{t('conversion:stats.totalFiles')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
<CheckCircle className="h-4 w-4 text-green-600" />
</div>
<div className="min-w-0">
{countsLoading ? (
<Skeleton className="h-4 w-8 rounded my-0.5" />
) : (
<p className="text-sm font-medium text-gray-900 truncate">{readyCount}</p>
)}
<p className="text-[10px] text-gray-500 truncate">{t('conversion:stats.ready')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-orange-50 flex items-center justify-center flex-shrink-0">
<Clock className="h-4 w-4 text-orange-600" />
</div>
<div className="min-w-0">
{countsLoading ? (
<Skeleton className="h-4 w-8 rounded my-0.5" />
) : (
<p className="text-sm font-medium text-gray-900 truncate">{convertingCount}</p>
)}
<p className="text-[10px] text-gray-500 truncate">{t('conversion:stats.converting')}</p>
</div>
</div>
</div>
<p className={`text-xs text-gray-400 self-end sm:self-auto pt-2 sm:pt-0 border-t sm:border-t-0 border-gray-100 w-full sm:w-auto ${i18n.dir() === 'rtl' ? 'text-left' : 'text-right'}`}>{t('conversion:stats.last7Days')}</p>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-3 sm:p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-1">{t('conversion:section.title')}</h2>
<p className="text-xs text-gray-500 mb-4 sm:mb-6">{t('conversion:section.description')}</p>
{!canUseConversion ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
{t('conversion:permission.denied')}
</div>
) : (
<>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 mb-6">
{visibleConversionTools.map((tool) => {
const Icon = tool.icon;
const isActive = selectedConversion === tool.id;
return (
<button
key={tool.id}
onClick={() => handleToolClick(tool.id)}
className={`flex items-center gap-2.5 px-3 py-3 rounded-lg border transition-all text-left relative overflow-hidden group ${isActive
? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500'
: 'border-gray-100 hover:border-blue-300 hover:shadow-sm'
}`}
>
<div className={`w-9 h-9 sm:w-10 sm:h-10 rounded-md ${tool.bgColor} flex items-center justify-center flex-shrink-0 shadow-sm group-hover:scale-110 transition-transform`}>
<Icon className={`h-5 w-5 ${tool.iconColor}`} />
</div>
<div className="flex flex-col">
<span className={`text-xs sm:text-sm font-medium leading-tight ${isActive ? 'text-blue-700' : 'text-gray-700'}`}>
{t(`conversion:tools.${tool.id}.label`)}
</span>
</div>
</button>
);
})}
</div>
<div
id="upload-area-conversion"
className={`relative border-2 border-dashed rounded-xl p-8 sm:p-12 text-center transition-all duration-300 cursor-pointer ${uploadAreaHighlighted && selectedTool
? 'border-blue-500 bg-blue-50 shadow-lg scale-[1.02] animate-pulse-border'
: 'border-blue-200 hover:border-blue-400 bg-blue-50/30'
}`}
onClick={() => setUploadAreaHighlighted(false)}
>
{selectedTool && (
<div className={`absolute top-4 px-3 py-1.5 rounded-full bg-blue-600 text-white text-xs font-semibold flex items-center gap-2 shadow-md ${i18n.dir() === 'rtl' ? 'left-4' : 'right-4'}`}>
<Sparkles className="h-3.5 w-3.5" />
{t('conversion:upload.selectedBadge', { tool: t(`conversion:tools.${selectedTool.id}.label`) })}
</div>
)}
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-blue-100 flex items-center justify-center shadow-inner">
<Cloud className="h-7 w-7 text-blue-500" />
</div>
<h3 className="text-base font-bold text-gray-900 mb-1">
{selectedTool
? t('conversion:upload.dropSelectedFile', { format: t(`conversion:tools.${selectedTool.id}.sourceFormat`) })
: t('conversion:upload.dropDefaultTitle')
}
</h3>
<p className="text-sm text-gray-500 mb-2">
{selectedTool
? t('conversion:upload.acceptSelectedFiles', { formats: selectedTool.acceptFormats })
: t('conversion:upload.dropDefaultHint')
}
</p>
{!selectedTool && (
<p className="text-xs text-gray-400">{t('conversion:upload.supportedFormats')}</p>
)}
<FileUpload
onFileUpload={onFileUpload}
onUploadStateChange={setIsUploading}
externalUploading={isUploading}
externalProgress={uploadProgress}
onProgressChange={setUploadProgress}
acceptedFormats={selectedTool?.acceptFormats}
outputFormat={selectedTool?.outputFormat}
/>
</div>
</>
)}
{}
<style>{`
@keyframes pulse-border {
0%, 100% {
border-color: rgb(59, 130, 246);
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
50% {
border-color: rgb(37, 99, 235);
box-shadow: 0 0 0 10px rgba(59, 130, 246, 0);
}
}
.animate-pulse-border {
animation: pulse-border 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
`}</style>
</div>
</div>
);
};
export default ConversionView;
@@ -0,0 +1 @@
export { default } from './ConversionView';
@@ -0,0 +1,579 @@
// @ts-nocheck
import React, { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Cloud,
FileText,
File,
Globe,
Code,
Image as ImageIcon,
BookOpen
} from 'lucide-react';
import { Joyride } from 'react-joyride';
import { useDashboardTour } from './useDashboardTour';
import TourTooltip from './TourTooltip';
import { useDashboard } from '@/features/dashboard/DashboardContext';
import { useAuth } from '@/features/auth/context';
import FileUpload from '@/features/conversion/components/FileUpload';
import { UploadModal } from '@/features/components/upload/UploadModal';
import { FileActionMenu } from '@/features/components/action-menu';
import { DriveFile, listRecent, getStreamUrl, getDownloadUrl, starFile, trashFile, getRoot } from '@/services/drive';
import { documentCache } from '@/utils/documentCache';
import { getStorageUsage, StorageStat } from '@/services/storage';
import { isValidApiUrl } from '@/config/api';
import StorageOverview from '@/features/StorageManager/StorageOverview';
import { useHeaderSearch } from '@/features/components/layout/SearchContext';
import { useCache } from '@/context/CacheContext';
import { DataTable, Column } from '@/components/ui';
import DriveFileEditor from '@/features/editor/DriveFileEditor';
import { saveFileContent } from '@/services/drive';
import dashboardAPI from '@/features/dashboard/api';
import type { AdminDashboardMetrics } from '@/features/dashboard/types';
import AdminMetricsSection from '@/features/dashboard/AdminMetricsSection';
import {
type ConversionToolId,
CONVERSION_PAGE_ACCESS_CODES,
hasConversionToolAccess
} from '@/features/conversion/toolAccess';
import { hasAccess, hasAnyAccess } from '@/utils/permissions';
const DashboardMainView: React.FC = () => {
const navigate = useNavigate();
const { t, i18n } = useTranslation(['dashboard', 'common']);
const { user } = useAuth();
const {
projects: contextProjects,
isUploading,
setIsUploading,
uploadProgress,
setUploadProgress,
handleFileUpload: onFileUpload,
setConversionType,
conversionType,
} = useDashboard();
const { setPlaceholder, setIsGlobalSearchActive } = useHeaderSearch();
const cache = useCache();
const [recentFiles, setRecentFiles] = useState<DriveFile[]>([]);
const [loadingRecent, setLoadingRecent] = useState(true);
const [storageStats, setStorageStats] = useState<StorageStat | null>(null);
const [adminMetrics, setAdminMetrics] = useState<AdminDashboardMetrics | null>(null);
const [isLoadingAdminMetrics, setIsLoadingAdminMetrics] = useState(false);
const [selectedConversion, setSelectedConversion] = useState<ConversionToolId | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [rootFolderId, setRootFolderId] = useState<number>(0);
const [previewFile, setPreviewFile] = useState<{ id: number; name: string; effective_role?: any } | null>(null);
const canSeeAdminMetrics = hasAnyAccess(user?.accesses, ['admin.user.read']);
const isCurrentUserSuperadmin = !!user?.accesses?.some((access) => access.startsWith('superadmin.'));
const isTenantAdmin = !!user?.tenant_id && !isCurrentUserSuperadmin;
const canViewConversionJobs = hasAnyAccess(user?.accesses, CONVERSION_PAGE_ACCESS_CODES);
const canViewSignaturesTrend = hasAccess(user?.accesses, 'document.sign');
useEffect(() => {
setPlaceholder(t('common:search.placeholder'));
setIsGlobalSearchActive(true);
}, [setPlaceholder, setIsGlobalSearchActive, t]);
const DASHBOARD_CACHE_KEY = 'dashboard:main';
const DASHBOARD_CACHE_TTL = 60_000; // 60 seconds
const { run, key, steps, handleJoyrideCallback, startTour } = useDashboardTour();
const fetchData = async (forceRefresh = false) => {
if (!forceRefresh) {
const cached = cache.get(DASHBOARD_CACHE_KEY);
if (cached) {
setStorageStats(cached.storageStats);
setRecentFiles(cached.recentFiles);
setLoadingRecent(false);
return;
}
}
try {
setLoadingRecent(true);
const [storageRes, recentRes] = await Promise.all([
getStorageUsage(),
listRecent(5)
]);
const stats = storageRes.usage;
const files = recentRes.files || [];
setStorageStats(stats);
setRecentFiles(files);
cache.set(DASHBOARD_CACHE_KEY, { storageStats: stats, recentFiles: files }, DASHBOARD_CACHE_TTL);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
setLoadingRecent(false);
}
};
const handleOpenUploadModal = async () => {
if (!rootFolderId) {
try {
const root = await getRoot();
if (root?.id) setRootFolderId(root.id);
} catch (err) {
console.error('Failed to resolve root folder for upload:', err);
}
}
setShowUploadModal(true);
};
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
let isActive = true;
const loadAdminMetrics = async (forceRefresh = false) => {
if (!canSeeAdminMetrics) {
setAdminMetrics(null);
return;
}
const metricsCacheKey = 'dashboard:admin_metrics';
if (!forceRefresh) {
const cached = cache.get(metricsCacheKey);
if (cached) {
setAdminMetrics(cached);
return;
}
}
try {
setIsLoadingAdminMetrics(true);
const metrics = await dashboardAPI.getAdminDashboardMetrics();
if (isActive) {
setAdminMetrics(metrics);
cache.set(metricsCacheKey, metrics, DASHBOARD_CACHE_TTL);
}
} catch (error) {
console.error('Failed to load admin dashboard metrics:', error);
} finally {
if (isActive) {
setIsLoadingAdminMetrics(false);
}
}
};
loadAdminMetrics();
return () => {
isActive = false;
};
}, [canSeeAdminMetrics, cache]);
const handleFileFavorite = async (targetFile: DriveFile) => {
const willStar = !targetFile.is_starred;
try {
await starFile(targetFile.id, willStar);
setRecentFiles(prev => {
const next = prev.map(f => f.id === targetFile.id ? { ...f, is_starred: willStar } : f);
cache.set(DASHBOARD_CACHE_KEY, { storageStats, recentFiles: next }, DASHBOARD_CACHE_TTL);
return next;
});
cache.invalidate('favorites:starred');
toast.success(willStar ? t('dashboard:messages.favoriteAdded') : t('dashboard:messages.favoriteRemoved'));
} catch (error) {
toast.error(t('dashboard:messages.favoriteUpdateFailed'));
}
};
const handleDeleteRecentFile = async (targetFile: DriveFile) => {
try {
await trashFile(targetFile.id);
documentCache.invalidate(targetFile.id);
setRecentFiles(prev => {
const next = prev.filter(f => f.id !== targetFile.id);
cache.set(DASHBOARD_CACHE_KEY, { storageStats, recentFiles: next }, DASHBOARD_CACHE_TTL);
return next;
});
cache.invalidatePattern('mydocs:*');
cache.invalidatePattern('trash:items*');
cache.invalidate('recent:combined');
cache.invalidate('storage');
toast.success(t('dashboard:messages.fileDeleted') || 'File moved to trash');
} catch (error) {
toast.error(t('dashboard:messages.fileDeleteFailed') || 'Failed to delete file');
}
};
const getFileIcon = (fileName: string) => {
const ext = fileName.split('.').pop()?.toLowerCase();
if (ext === 'pdf') return <div className="w-8 h-8 rounded bg-red-50 flex items-center justify-center"><FileText className="h-5 w-5 text-red-500" /></div>;
if (['doc', 'docx'].includes(ext || '')) return <div className="w-8 h-8 rounded bg-blue-50 flex items-center justify-center"><FileText className="h-5 w-5 text-blue-500" /></div>;
return <div className="w-8 h-8 rounded bg-gray-50 flex items-center justify-center"><File className="h-5 w-5 text-gray-400" /></div>;
};
const formatSize = (bytes?: number) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const formatDisplayDate = (value?: string | null) => {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat(i18n.language, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date);
};
const recentColumns: Column<DriveFile>[] = [
{
header: t('dashboard:recentFiles.columns.fileName'),
accessor: (file) => (
<button
onClick={() => setPreviewFile({ id: file.id, name: file.name, effective_role: (file as any)?.effective_role })}
className="flex w-full min-w-0 items-center gap-3 overflow-hidden text-left hover:text-blue-600"
title={file.name}
>
{getFileIcon(file.name)}
<span className="block min-w-0 truncate font-bold text-gray-900">{file.name}</span>
</button>
),
className: 'w-[42%] max-w-0',
headerClassName: 'text-gray-700',
},
{
header: t('dashboard:recentFiles.columns.lastModified'),
accessor: (file) => {
return <span className="block whitespace-nowrap text-gray-500 font-medium">{formatDisplayDate(file.updated_at || file.created_at)}</span>;
},
className: 'w-[22%]',
},
{
header: t('dashboard:recentFiles.columns.size'),
accessor: (file) => <span className="rtl-ltr-value text-gray-900 font-bold">{formatSize(file.size)}</span>,
className: 'w-[20%]',
align: 'right',
headerClassName: 'text-gray-700',
},
{
header: t('dashboard:recentFiles.columns.actions'),
accessor: (file) => (
<div className="flex justify-start whitespace-nowrap">
<FileActionMenu
file={{ id: file.id.toString(), name: file.name, size: formatSize(file.size) }}
context="recent"
isFavorite={file.is_starred}
onPreview={() => {
const url = getStreamUrl(file.id);
if (isValidApiUrl(url)) window.open(url, '_blank');
}}
onDownload={async () => {
const url = getDownloadUrl(file.id);
if (!isValidApiUrl(url)) return;
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${localStorage.getItem('access_token') || ''}` },
});
if (!response.ok) throw new Error();
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
} catch {
toast.error(t('dashboard:messages.downloadFailed'));
}
}}
onFavorite={() => handleFileFavorite(file)}
onDelete={() => handleDeleteRecentFile(file)}
onRefresh={() => fetchData(true)}
/>
</div>
),
className: 'w-[12%]',
align: 'left',
headerClassName: 'text-gray-700',
}
];
const conversionTools: Array<{
id: ConversionToolId;
icon: any;
label: string;
outputFormat: string;
bgColor: string;
iconColor: string;
acceptFormats: string;
}> = [
{ id: 'pdf-word', icon: FileText, label: t('dashboard:conversion.tools.pdfWord'), outputFormat: 'DOCX', bgColor: 'bg-red-50', iconColor: 'text-red-500', acceptFormats: '.pdf' },
{ id: 'pdf-html', icon: Globe, label: t('dashboard:conversion.tools.pdfHtml'), outputFormat: 'HTML', bgColor: 'bg-blue-50', iconColor: 'text-blue-500', acceptFormats: '.pdf' },
{ id: 'word-pdf', icon: FileText, label: t('dashboard:conversion.tools.wordPdf'), outputFormat: 'PDF', bgColor: 'bg-orange-50', iconColor: 'text-orange-500', acceptFormats: '.doc,.docx' },
{ id: 'pdf-xml', icon: Code, label: t('dashboard:conversion.tools.pdfXml'), outputFormat: 'XML', bgColor: 'bg-purple-50', iconColor: 'text-purple-500', acceptFormats: '.pdf' },
{ id: 'jpg-word', icon: ImageIcon, label: t('dashboard:conversion.tools.jpgWord'), outputFormat: 'DOCX', bgColor: 'bg-teal-50', iconColor: 'text-teal-500', acceptFormats: '.jpg,.jpeg,.png' },
];
const visibleConversionTools = conversionTools.filter((tool) =>
hasConversionToolAccess(user?.accesses, tool.id),
);
const canUseConversion = hasAnyAccess(user?.accesses, CONVERSION_PAGE_ACCESS_CODES);
const handleToolClick = (toolId: ConversionToolId) => {
setSelectedConversion(toolId);
const tool = visibleConversionTools.find(t => t.id === toolId);
if (tool) {
setConversionType(tool.outputFormat);
}
};
const convertingProjects = contextProjects.filter(
(p: any) => p.status === 'processing' || p.currentStep !== 'export'
);
const conversionColumns: Column<any>[] = [
{
header: t('dashboard:conversion.columns.fileName'),
accessor: (p) => (
<div className="flex min-w-0 items-center gap-3" title={p.name}>
{getFileIcon(p.name)}
<span className="block min-w-0 truncate font-bold text-gray-900">{p.name}</span>
</div>
),
className: 'w-[40%]',
headerClassName: 'text-gray-700',
},
{
header: t('dashboard:conversion.columns.conversion'),
accessor: (p) => <span className="text-gray-500 font-medium">{p.file_type}</span>,
className: 'w-[30%]',
headerClassName: 'text-gray-700',
},
{
header: t('dashboard:conversion.columns.progress'),
accessor: (p) => (
<div className="w-full bg-gray-100 rounded-full h-1.5 max-w-[150px]">
<div
className="bg-blue-600 h-1.5 rounded-full transition-all duration-500"
style={{ width: `${p.progress || 0}%` }}
/>
</div>
),
className: 'w-[30%]',
headerClassName: 'text-gray-700',
}
];
const JoyrideComponent = Joyride as any;
return (
<div className="p-6 bg-[#f9fafb] min-h-full">
{run && (Joyride as any) && (
<JoyrideComponent
key={key}
steps={steps}
run={run}
continuous={true}
onEvent={handleJoyrideCallback}
tooltipComponent={(props: any) => <TourTooltip {...props} tourKey="dashboard" />}
options={{
showProgress: true,
skipBeacon: true,
buttons: ['back', 'skip', 'primary'],
overlayClickAction: false,
dismissKeyAction: false,
zIndex: 10000,
overlayColor: 'rgba(15, 23, 42, 0.55)',
} as any}
/>
)}
{}
<div className="mb-6 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">{t('dashboard:header.welcome', { name: user?.name || t('common:nav.userFallback') })}</h1>
<p className="text-gray-400 text-sm font-medium">{t('dashboard:header.title')}</p>
</div>
<button
onClick={startTour}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg text-sm font-bold text-gray-700 hover:bg-gray-50 hover:text-blue-600 transition-colors shadow-sm"
>
<BookOpen className="w-4 h-4" />
{t('dashboard:header.viewTutorial')}
</button>
</div>
{canSeeAdminMetrics && (isLoadingAdminMetrics || adminMetrics) ? (
<AdminMetricsSection
metrics={adminMetrics}
isLoading={isLoadingAdminMetrics}
canViewConversionJobs={canViewConversionJobs}
canViewSignaturesTrend={canViewSignaturesTrend}
/>
) : null}
{}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{}
<div className="lg:col-span-3">
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-sm space-y-8">
{}
<div id="tour-upload-zone">
<h2 className="text-sm font-bold text-gray-900 mb-4">{t('dashboard:upload.title')}</h2>
<div className="relative border-2 border-dashed border-blue-100 rounded-xl p-8 bg-blue-50/40 group hover:border-blue-300 transition-colors">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<h3 className="text-sm font-bold text-gray-900 mb-1">{t('dashboard:upload.heading')}</h3>
<p className="text-xs text-gray-400 font-medium">{t('dashboard:upload.acceptedTypes')}</p>
</div>
<div className="flex-shrink-0">
<button
onClick={handleOpenUploadModal}
className="bg-[#2563eb] text-white px-6 py-2.5 rounded-lg text-sm font-bold hover:bg-blue-700 transition-colors shadow-sm"
>
{t('dashboard:upload.chooseFiles')}
</button>
</div>
</div>
<button
onClick={handleOpenUploadModal}
className="absolute inset-0 opacity-0 cursor-pointer"
aria-label={t('dashboard:upload.openUploadModal')}
/>
</div>
</div>
{}
<div id="tour-recent-files" className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-bold text-gray-900">{t('dashboard:recentFiles.title')}</h2>
<button
onClick={() => navigate('/recent')}
className="dashboard-view-more rounded-md px-2 py-1 text-xs font-bold text-blue-600 hover:text-blue-700"
>
{t('dashboard:recentFiles.viewMore')}
</button>
</div>
<DataTable
columns={recentColumns}
data={recentFiles}
isLoading={loadingRecent}
emptyMessage={t('dashboard:recentFiles.empty')}
/>
</div>
</div>
</div>
{}
<div id="tour-storage-overview" className="lg:col-span-1">
<StorageOverview
usedBytes={storageStats?.total_bytes || 0}
quotaBytes={storageStats?.quota_bytes || 1073741824}
breakdown={storageStats?.breakdown}
userStats={storageStats?.user_stats}
alerts={storageStats?.alerts}
/>
</div>
</div>
{}
{canUseConversion && (
<div className="mt-8 bg-white rounded-2xl border border-gray-100 p-6 shadow-sm space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="space-y-4">
<h2 className="text-sm font-bold text-gray-900">{t('dashboard:conversion.title')}</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{visibleConversionTools.map((tool) => (
<button
key={tool.id}
onClick={() => handleToolClick(tool.id)}
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${selectedConversion === tool.id
? 'border-blue-500 bg-blue-50/50 shadow-sm'
: 'border-gray-100 bg-white hover:border-blue-200 hover:shadow-sm'
}`}
>
<div className={`w-10 h-10 rounded-lg ${tool.bgColor} flex items-center justify-center flex-shrink-0`}>
<tool.icon className={`h-5 w-5 ${tool.iconColor}`} />
</div>
<span className="text-xs font-bold text-gray-700">{tool.label}</span>
</button>
))}
</div>
{}
<div className="relative border-2 border-dashed border-blue-100 rounded-xl p-6 bg-blue-50/40 group hover:border-blue-300 transition-colors flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 rounded-full bg-blue-600 flex items-center justify-center mb-4 transition-all">
<Cloud className="h-6 w-6 text-white" />
</div>
<h3 className="text-sm font-bold text-gray-900">{t('dashboard:conversion.uploadForConversion')}</h3>
<FileUpload
onFileUpload={onFileUpload}
onUploadStateChange={setIsUploading}
externalUploading={isUploading}
externalProgress={uploadProgress}
onProgressChange={setUploadProgress}
outputFormat={conversionType}
/>
</div>
</div>
</div>
{}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<h2 className="text-sm font-bold text-gray-900">{t('dashboard:conversion.inProgressTitle')}</h2>
<p className="text-[10px] text-gray-400 font-medium">{t('dashboard:conversion.inProgressSubtitle')}</p>
</div>
<button
onClick={() => navigate('/history')}
className="text-xs font-bold text-blue-600 hover:text-blue-700"
>
{t('dashboard:conversion.viewAll')}
</button>
</div>
<DataTable
columns={conversionColumns}
data={convertingProjects}
emptyMessage={t('dashboard:conversion.empty')}
/>
</div>
</div>
)}
<UploadModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
folderId={rootFolderId || 0}
onUploadComplete={() => {
fetchData(true);
setShowUploadModal(false);
}}
/>
<DriveFileEditor
isOpen={!!previewFile}
onClose={() => setPreviewFile(null)}
fileId={previewFile?.id ?? null}
fileName={previewFile?.name ?? ''}
userRole={previewFile?.effective_role ?? null}
onSave={async (content) => {
if (previewFile) {
await saveFileContent(previewFile.id, content);
cache.invalidate('recent:combined');
}
}}
/>
</div>
);
};
export default DashboardMainView;
@@ -0,0 +1,43 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
interface StatCardProps {
icon: LucideIcon;
title: string;
value: React.ReactNode;
subLabel: string;
badge?: React.ReactNode;
iconBgColor?: string;
iconColor?: string;
}
const StatCard: React.FC<StatCardProps> = ({
icon: Icon,
title,
value,
subLabel,
badge,
iconBgColor = 'bg-blue-50',
iconColor = 'text-blue-500',
}) => {
return (
<div className="bg-white rounded-xl border border-gray-100 p-4 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className={`w-10 h-10 rounded-lg ${iconBgColor} flex items-center justify-center`}>
<Icon className={`h-5 w-5 ${iconColor}`} />
</div>
<div className="flex flex-col items-end">
<span className="text-sm font-semibold text-gray-900">{title}</span>
{badge && <div className="mt-1">{badge}</div>}
</div>
</div>
<div className="mt-4">
<h3 className="text-2xl font-bold text-gray-900 leading-tight">{value}</h3>
<p className="text-xs text-gray-400 mt-1 font-medium">{subLabel}</p>
</div>
</div>
);
};
export default StatCard;
@@ -0,0 +1,217 @@
import React from 'react';
import { X, ChevronLeft, ChevronRight, CheckCircle2, BookOpen } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/features/auth/context';
interface TourTooltipProps {
continuous: boolean;
index: number;
size: number;
step: {
content: React.ReactNode;
title?: React.ReactNode;
};
backProps: React.HTMLAttributes<HTMLButtonElement>;
closeProps: React.HTMLAttributes<HTMLButtonElement>;
primaryProps: React.HTMLAttributes<HTMLButtonElement>;
skipProps: React.HTMLAttributes<HTMLButtonElement>;
tooltipProps: React.HTMLAttributes<HTMLDivElement>;
tourKey?: 'dashboard' | 'docs';
}
const TourTooltip: React.FC<TourTooltipProps> = ({
continuous,
index,
size,
step,
backProps,
primaryProps,
skipProps,
tooltipProps,
tourKey = 'dashboard',
}) => {
const { completeTutorial } = useAuth();
const { t } = useTranslation('dashboard');
const isLastStep = index === size - 1;
const progress = ((index + 1) / size) * 100;
return (
<div
{...tooltipProps}
style={{
maxWidth: 360,
borderRadius: 16,
background: '#fff',
boxShadow: '0 20px 60px rgba(37, 99, 235, 0.15), 0 4px 20px rgba(0,0,0,0.08)',
overflow: 'hidden',
fontFamily: 'Inter, system-ui, sans-serif',
border: '1px solid rgba(37, 99, 235, 0.1)',
}}
>
{}
<div style={{
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
padding: '16px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'rgba(255,255,255,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<BookOpen size={16} color="#fff" />
</div>
<div>
<div style={{ color: 'rgba(255,255,255,0.75)', fontSize: 11, fontWeight: 500, letterSpacing: '0.05em', textTransform: 'uppercase' }}>{t('tour.label')}</div>
<div style={{ color: '#fff', fontSize: 13, fontWeight: 700 }}>
{step.title || t('tour.stepTitle', { current: index + 1, total: size })}
</div>
</div>
</div>
<div style={{
background: 'rgba(255,255,255,0.2)',
borderRadius: 20,
padding: '3px 10px',
color: '#fff',
fontSize: 12,
fontWeight: 600,
}}>
{index + 1}/{size}
</div>
</div>
{}
<div style={{ height: 3, background: '#e8f0fe' }}>
<div style={{
height: '100%',
width: `${progress}%`,
background: 'linear-gradient(90deg, #2563eb, #60a5fa)',
borderRadius: '0 2px 2px 0',
transition: 'width 0.4s ease',
}} />
</div>
{}
<div style={{ padding: '20px 20px 16px' }}>
<p style={{
color: '#374151',
fontSize: 14,
lineHeight: 1.6,
margin: 0,
fontWeight: 450,
}}>
{step.content}
</p>
</div>
{}
<div style={{
padding: '12px 20px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderTop: '1px solid #f1f5f9',
gap: 8,
}}>
{}
<button
{...skipProps}
style={{
background: 'transparent',
border: 'none',
color: '#9ca3af',
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
padding: '6px 8px',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
gap: 4,
transition: 'color 0.2s',
}}
onMouseEnter={e => (e.currentTarget.style.color = '#ef4444')}
onMouseLeave={e => (e.currentTarget.style.color = '#9ca3af')}
>
<X size={13} />
{t('tour.exit')}
</button>
{}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{index > 0 && (
<button
{...backProps}
style={{
background: '#f8fafc',
border: '1px solid #e2e8f0',
color: '#475569',
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
padding: '7px 14px',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
gap: 4,
transition: 'all 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = '#f1f5f9'; e.currentTarget.style.borderColor = '#cbd5e1'; }}
onMouseLeave={e => { e.currentTarget.style.background = '#f8fafc'; e.currentTarget.style.borderColor = '#e2e8f0'; }}
>
<ChevronLeft size={14} /> {t('tour.back')}
</button>
)}
{continuous && (
<button
{...primaryProps}
onClick={(e) => {
if (isLastStep) {
completeTutorial(tourKey);
}
primaryProps.onClick?.(e);
}}
style={{
background: isLastStep
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
border: 'none',
color: '#fff',
fontSize: 13,
fontWeight: 700,
cursor: 'pointer',
padding: '7px 16px',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
gap: 5,
boxShadow: isLastStep
? '0 4px 12px rgba(5, 150, 105, 0.3)'
: '0 4px 12px rgba(37, 99, 235, 0.3)',
transition: 'all 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.opacity = '0.9'; e.currentTarget.style.transform = 'translateY(-1px)'; }}
onMouseLeave={e => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'translateY(0)'; }}
>
{isLastStep ? (
<><CheckCircle2 size={14} /> {t('tour.finish')}</>
) : (
<>{t('tour.next')} <ChevronRight size={14} /></>
)}
</button>
)}
</div>
</div>
</div>
);
};
export default TourTooltip;
+1
View File
@@ -0,0 +1 @@
export { default } from './DashboardMainView';

Some files were not shown because too many files have changed in this diff Show More