Init
This commit is contained in:
32
.cursor/rules/api-routes.mdc
Normal file
32
.cursor/rules/api-routes.mdc
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
globs: app/api/**/*.ts
|
||||
---
|
||||
|
||||
# API Routes Rules
|
||||
|
||||
1. Routes MUST only use services for data access
|
||||
2. Routes MUST handle input validation
|
||||
3. Routes MUST return typed responses
|
||||
4. Routes MUST use proper error handling
|
||||
|
||||
Example of correct API route:
|
||||
|
||||
```typescript
|
||||
import { MyService } from "@/services/my-service";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const service = new MyService(pool);
|
||||
const data = await service.getData();
|
||||
return Response.json(data);
|
||||
} catch (error) {
|
||||
return Response.error();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Direct database queries
|
||||
- Business logic implementation
|
||||
- Untyped responses
|
||||
167
.cursor/rules/business-logic-separation.mdc
Normal file
167
.cursor/rules/business-logic-separation.mdc
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
description: Enforce business logic separation between frontend and backend
|
||||
---
|
||||
|
||||
# Business Logic Separation Rules
|
||||
|
||||
## Core Principle: NO Business Logic in Frontend
|
||||
|
||||
All business logic, data processing, and domain rules MUST be implemented in the backend services layer. The frontend is purely for presentation and user interaction.
|
||||
|
||||
## ✅ ALLOWED in Frontend ([src/components/](mdc:src/components/), [src/hooks/](mdc:src/hooks/), [src/clients/](mdc:src/clients/))
|
||||
|
||||
### Components
|
||||
- UI rendering and presentation logic
|
||||
- Form validation (UI-level only, not business rules)
|
||||
- User interaction handling (clicks, inputs, navigation)
|
||||
- Visual state management (loading, errors, UI states)
|
||||
- Data formatting for display purposes only
|
||||
|
||||
### Hooks
|
||||
- React state management
|
||||
- API call orchestration (using clients)
|
||||
- UI-specific logic (modals, forms, animations)
|
||||
- Data fetching and caching coordination
|
||||
|
||||
### Clients
|
||||
- HTTP requests to API routes
|
||||
- Request/response transformation (serialization only)
|
||||
- Error handling and retry logic
|
||||
- Authentication token management
|
||||
|
||||
## ❌ FORBIDDEN in Frontend
|
||||
|
||||
### Business Rules
|
||||
```typescript
|
||||
// ❌ BAD: Business logic in component
|
||||
const TaskCard = ({ task }) => {
|
||||
const canEdit = task.status === 'open' && task.assignee === currentUser.id;
|
||||
const priority = task.dueDate < new Date() ? 'high' : 'normal';
|
||||
// This is business logic!
|
||||
}
|
||||
|
||||
// ✅ GOOD: Get computed values from backend
|
||||
const TaskCard = ({ task }) => {
|
||||
const { canEdit, priority } = task; // Computed by backend service
|
||||
}
|
||||
```
|
||||
|
||||
### Data Processing
|
||||
```typescript
|
||||
// ❌ BAD: Data transformation in frontend
|
||||
const processJiraTasks = (tasks) => {
|
||||
return tasks.map(task => ({
|
||||
...task,
|
||||
normalizedStatus: mapJiraStatus(task.status),
|
||||
estimatedHours: calculateEstimate(task.storyPoints)
|
||||
}));
|
||||
}
|
||||
|
||||
// ✅ GOOD: Data already processed by backend service
|
||||
const { processedTasks } = await tasksClient.getTasks();
|
||||
```
|
||||
|
||||
### Domain Logic
|
||||
```typescript
|
||||
// ❌ BAD: Domain calculations in frontend
|
||||
const calculateTeamVelocity = (sprints) => {
|
||||
// Complex business calculation
|
||||
}
|
||||
|
||||
// ✅ GOOD: Domain logic in service
|
||||
// This belongs in services/team-analytics.ts
|
||||
```
|
||||
|
||||
## ✅ REQUIRED in Backend ([src/services/](mdc:src/services/), [src/app/api/](mdc:src/app/api/))
|
||||
|
||||
### Services Layer
|
||||
- All business rules and domain logic
|
||||
- Data validation and processing
|
||||
- Integration with external APIs (Jira, macOS Reminders)
|
||||
- Complex calculations and algorithms
|
||||
- Data aggregation and analytics
|
||||
- Permission and authorization logic
|
||||
|
||||
### API Routes
|
||||
- Input validation and sanitization
|
||||
- Service orchestration
|
||||
- Response formatting
|
||||
- Error handling and logging
|
||||
- Authentication and authorization
|
||||
|
||||
## Implementation Pattern
|
||||
|
||||
### ✅ Correct Flow
|
||||
```
|
||||
User Action → Component → Client → API Route → Service → Database
|
||||
↑ ↓
|
||||
Pure UI Logic Business Logic
|
||||
```
|
||||
|
||||
### ❌ Incorrect Flow
|
||||
```
|
||||
User Action → Component with Business Logic → Database
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Task Status Management
|
||||
```typescript
|
||||
// ❌ BAD: In component
|
||||
const updateTaskStatus = (taskId, newStatus) => {
|
||||
if (newStatus === 'done' && !task.hasAllSubtasks) {
|
||||
throw new Error('Cannot complete task with pending subtasks');
|
||||
}
|
||||
// Business rule in frontend!
|
||||
}
|
||||
|
||||
// ✅ GOOD: In services/task-processor.ts
|
||||
export const updateTaskStatus = async (taskId: string, newStatus: TaskStatus) => {
|
||||
const task = await getTask(taskId);
|
||||
|
||||
// Business rules in service
|
||||
if (newStatus === 'done' && !await hasAllSubtasksCompleted(taskId)) {
|
||||
throw new BusinessError('Cannot complete task with pending subtasks');
|
||||
}
|
||||
|
||||
return await updateTask(taskId, { status: newStatus });
|
||||
}
|
||||
```
|
||||
|
||||
### Team Analytics
|
||||
```typescript
|
||||
// ❌ BAD: In component
|
||||
const TeamDashboard = () => {
|
||||
const calculateBurndown = (tasks) => {
|
||||
// Complex business calculation in component
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GOOD: In services/team-analytics.ts
|
||||
export const getTeamBurndown = async (teamId: string, sprintId: string) => {
|
||||
// All calculation logic in service
|
||||
const tasks = await getSprintTasks(sprintId);
|
||||
return calculateBurndownMetrics(tasks);
|
||||
}
|
||||
```
|
||||
|
||||
## Enforcement
|
||||
|
||||
When reviewing code:
|
||||
1. **Components**: Should only contain JSX, event handlers, and UI state
|
||||
2. **Hooks**: Should only orchestrate API calls and manage React state
|
||||
3. **Clients**: Should only make HTTP requests and handle responses
|
||||
4. **Services**: Should contain ALL business logic and data processing
|
||||
5. **API Routes**: Should validate input and call appropriate services
|
||||
|
||||
## Red Flags
|
||||
|
||||
Watch for these patterns that indicate business logic in frontend:
|
||||
- Complex calculations in components/hooks
|
||||
- Business rule validation in forms
|
||||
- Data transformation beyond display formatting
|
||||
- Domain-specific constants and rules
|
||||
- Integration logic with external systems
|
||||
|
||||
Remember: **The frontend is a thin presentation layer. All intelligence lives in the backend.**
|
||||
31
.cursor/rules/clients.mdc
Normal file
31
.cursor/rules/clients.mdc
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
globs: clients/**/*.ts
|
||||
---
|
||||
|
||||
# HTTP Clients Rules
|
||||
|
||||
1. All HTTP calls MUST be in clients/domains/
|
||||
2. Each domain MUST have its own client
|
||||
3. Clients MUST use the base HTTP client
|
||||
4. Clients MUST type their responses
|
||||
|
||||
Example of correct client:
|
||||
|
||||
```typescript
|
||||
import { HttpClient } from "@/clients/base/http-client";
|
||||
import { MyData } from "@/lib/types";
|
||||
|
||||
export class MyClient {
|
||||
constructor(private httpClient: HttpClient) {}
|
||||
|
||||
async getData(): Promise<MyData[]> {
|
||||
return this.httpClient.get("/api/data");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Direct fetch calls
|
||||
- Business logic in clients
|
||||
- Untyped responses
|
||||
28
.cursor/rules/components.mdc
Normal file
28
.cursor/rules/components.mdc
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
globs: src/components/**/*.tsx
|
||||
---
|
||||
|
||||
# Components Rules
|
||||
|
||||
1. UI components MUST be in src/components/ui/
|
||||
2. Feature components MUST be in their feature folder
|
||||
3. Components MUST use clients for data fetching
|
||||
4. Components MUST be properly typed
|
||||
|
||||
Example of correct component:
|
||||
|
||||
```typescript
|
||||
import { useMyClient } from '@/hooks/use-my-client';
|
||||
|
||||
export const MyComponent = () => {
|
||||
const { data } = useMyClient();
|
||||
return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Direct service usage
|
||||
- Direct database queries
|
||||
- Direct fetch calls
|
||||
- Untyped props
|
||||
167
.cursor/rules/css-variables-theme.mdc
Normal file
167
.cursor/rules/css-variables-theme.mdc
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
description: CSS Variables theme system best practices
|
||||
---
|
||||
|
||||
# CSS Variables Theme System
|
||||
|
||||
## Core Principle: Pure CSS Variables for Theming
|
||||
|
||||
This project uses **CSS Variables exclusively** for theming. No Tailwind `dark:` classes or conditional CSS classes.
|
||||
|
||||
## ✅ Architecture Pattern
|
||||
|
||||
### CSS Structure
|
||||
```css
|
||||
:root {
|
||||
/* Light theme (default values) */
|
||||
--background: #f1f5f9;
|
||||
--foreground: #0f172a;
|
||||
--primary: #0891b2;
|
||||
--success: #059669;
|
||||
--destructive: #dc2626;
|
||||
--accent: #d97706;
|
||||
--purple: #8b5cf6;
|
||||
--yellow: #eab308;
|
||||
--green: #059669;
|
||||
--blue: #2563eb;
|
||||
--gray: #6b7280;
|
||||
--gray-light: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme (override values) */
|
||||
--background: #1e293b;
|
||||
--foreground: #f1f5f9;
|
||||
--primary: #06b6d4;
|
||||
--success: #10b981;
|
||||
--destructive: #ef4444;
|
||||
--accent: #f59e0b;
|
||||
--purple: #8b5cf6;
|
||||
--yellow: #eab308;
|
||||
--green: #10b981;
|
||||
--blue: #3b82f6;
|
||||
--gray: #9ca3af;
|
||||
--gray-light: #374151;
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Application
|
||||
- **Single source of truth**: [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) applies theme class to `document.documentElement`
|
||||
- **No duplication**: Theme is applied only once, not in multiple places
|
||||
- **SSR safe**: Initial theme from server-side preferences
|
||||
|
||||
## ✅ Component Usage Patterns
|
||||
|
||||
### Correct: Using CSS Variables
|
||||
```tsx
|
||||
// ✅ GOOD: CSS Variables in className
|
||||
<div className="bg-[var(--card)] text-[var(--foreground)] border-[var(--border)]">
|
||||
|
||||
// ✅ GOOD: CSS Variables in style prop
|
||||
<div style={{ color: 'var(--primary)', backgroundColor: 'var(--card)' }}>
|
||||
|
||||
// ✅ GOOD: CSS Variables with color-mix for transparency
|
||||
<div style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--primary) 10%, transparent)',
|
||||
borderColor: 'color-mix(in srgb, var(--primary) 20%, var(--border))'
|
||||
}}>
|
||||
```
|
||||
|
||||
### ❌ Forbidden: Tailwind Dark Mode Classes
|
||||
```tsx
|
||||
// ❌ BAD: Tailwind dark: classes
|
||||
<div className="bg-white dark:bg-gray-800 text-black dark:text-white">
|
||||
|
||||
// ❌ BAD: Conditional classes
|
||||
<div className={theme === 'dark' ? 'bg-gray-800' : 'bg-white'}>
|
||||
|
||||
// ❌ BAD: Hardcoded colors
|
||||
<div className="bg-red-500 text-blue-600">
|
||||
```
|
||||
|
||||
## ✅ Color System
|
||||
|
||||
### Semantic Color Tokens
|
||||
- `--background`: Main background color
|
||||
- `--foreground`: Main text color
|
||||
- `--card`: Card/panel background
|
||||
- `--card-hover`: Card hover state
|
||||
- `--card-column`: Column background (darker than cards)
|
||||
- `--border`: Border color
|
||||
- `--input`: Input field background
|
||||
- `--primary`: Primary brand color
|
||||
- `--primary-foreground`: Text on primary background
|
||||
- `--muted`: Muted text color
|
||||
- `--muted-foreground`: Secondary text color
|
||||
- `--accent`: Accent color (orange/amber)
|
||||
- `--destructive`: Error/danger color (red)
|
||||
- `--success`: Success color (green)
|
||||
- `--purple`: Purple accent
|
||||
- `--yellow`: Yellow accent
|
||||
- `--green`: Green accent
|
||||
- `--blue`: Blue accent
|
||||
- `--gray`: Gray color
|
||||
- `--gray-light`: Light gray background
|
||||
|
||||
### Color Mixing Patterns
|
||||
```css
|
||||
/* Background with transparency */
|
||||
background-color: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
|
||||
/* Border with transparency */
|
||||
border-color: color-mix(in srgb, var(--primary) 20%, var(--border));
|
||||
|
||||
/* Text with opacity */
|
||||
color: color-mix(in srgb, var(--destructive) 80%, transparent);
|
||||
```
|
||||
|
||||
## ✅ Theme Context Usage
|
||||
|
||||
### ThemeProvider Setup
|
||||
```tsx
|
||||
// In layout.tsx
|
||||
<ThemeProvider initialTheme={initialPreferences.viewPreferences.theme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
### Component Usage
|
||||
```tsx
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
function MyComponent() {
|
||||
const { theme, toggleTheme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button onClick={toggleTheme}>
|
||||
Switch to {theme === 'dark' ? 'light' : 'dark'} theme
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Future Extensibility
|
||||
|
||||
This system is designed to support:
|
||||
- **Custom color themes**: Easy to add new color variables
|
||||
- **User preferences**: Colors can be dynamically changed
|
||||
- **Theme presets**: Multiple predefined themes
|
||||
- **Accessibility**: High contrast modes
|
||||
|
||||
## 🚨 Anti-patterns to Avoid
|
||||
|
||||
1. **Don't mix approaches**: Never use both CSS variables and Tailwind dark: classes
|
||||
2. **Don't duplicate theme application**: Theme should be applied only in ThemeContext
|
||||
3. **Don't hardcode colors**: Always use semantic color tokens
|
||||
4. **Don't use conditional classes**: Use CSS variables instead
|
||||
5. **Don't forget transparency**: Use `color-mix()` for semi-transparent colors
|
||||
|
||||
## 📁 Key Files
|
||||
|
||||
- [globals.css](mdc:src/app/globals.css) - CSS Variables definitions
|
||||
- [ThemeContext.tsx](mdc:src/contexts/ThemeContext.tsx) - Theme management
|
||||
- [UserPreferencesContext.tsx](mdc:src/contexts/UserPreferencesContext.tsx) - Preferences sync
|
||||
- [layout.tsx](mdc:src/app/layout.tsx) - Theme provider setup
|
||||
|
||||
Remember: **CSS Variables are the single source of truth for theming. Keep it pure and consistent.**
|
||||
30
.cursor/rules/project-structure.mdc
Normal file
30
.cursor/rules/project-structure.mdc
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Project Structure Rules
|
||||
|
||||
1. Backend:
|
||||
- [src/services/](mdc:src/services/) - ALL database access
|
||||
- [src/app/api/](mdc:src/app/api/) - API routes using services
|
||||
|
||||
2. Frontend:
|
||||
- [src/clients/](mdc:src/clients/) - HTTP clients
|
||||
- [src/components/](mdc:src/components/) - React components (organized by domain)
|
||||
- [src/hooks/](mdc:src/hooks/) - React hooks
|
||||
|
||||
3. Shared:
|
||||
- [src/lib/](mdc:src/lib/) - Types and utilities
|
||||
- [scripts/](mdc:scripts/) - Utility scripts
|
||||
|
||||
Key Files:
|
||||
|
||||
- [src/services/database.ts](mdc:src/services/database.ts) - Database pool
|
||||
- [src/clients/base/http-client.ts](mdc:src/clients/base/http-client.ts) - Base HTTP client
|
||||
- [src/lib/types.ts](mdc:src/lib/types.ts) - Shared types
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Database access outside src/services/
|
||||
- HTTP calls outside src/clients/
|
||||
- Business logic in src/components/
|
||||
113
.cursor/rules/server-actions.mdc
Normal file
113
.cursor/rules/server-actions.mdc
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
description: Guide for when to use Server Actions vs API Routes in Next.js App Router
|
||||
---
|
||||
|
||||
# Server Actions vs API Routes - Decision Guide
|
||||
|
||||
## ✅ USE SERVER ACTIONS for:
|
||||
|
||||
### Quick Actions & Mutations
|
||||
- **TaskCard actions**: `updateTaskStatus()`, `updateTaskTitle()`, `deleteTask()`
|
||||
- **Daily checkboxes**: `toggleCheckbox()`, `addCheckbox()`, `updateCheckbox()`
|
||||
- **User preferences**: `updateTheme()`, `updateViewPreferences()`, `updateFilters()`
|
||||
- **Simple CRUD**: `createTag()`, `updateTag()`, `deleteTag()`
|
||||
|
||||
### Characteristics of Server Action candidates:
|
||||
- Simple, frequent mutations
|
||||
- No complex business logic
|
||||
- Used in interactive components (forms, buttons, toggles)
|
||||
- Need immediate UI feedback with `useTransition`
|
||||
- Benefit from automatic cache revalidation
|
||||
|
||||
## ❌ KEEP API ROUTES for:
|
||||
|
||||
### Complex Endpoints
|
||||
- **Initial data fetching**: `GET /api/tasks` with complex filters
|
||||
- **External integrations**: `POST /api/jira/sync` with complex logic
|
||||
- **Analytics & reports**: Complex data aggregation
|
||||
- **Public API**: Endpoints that might be called from mobile/external
|
||||
|
||||
### Characteristics that require API Routes:
|
||||
- Complex business logic or data processing
|
||||
- Multiple service orchestration
|
||||
- Need for HTTP monitoring/logging
|
||||
- External consumption (mobile apps, webhooks)
|
||||
- Real-time features (WebSockets, SSE)
|
||||
- File uploads or special content types
|
||||
|
||||
## 🔄 Implementation Pattern
|
||||
|
||||
### Server Actions Structure
|
||||
```typescript
|
||||
// actions/tasks.ts
|
||||
'use server'
|
||||
|
||||
import { tasksService } from '@/services/tasks';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
export async function updateTaskStatus(taskId: string, status: TaskStatus) {
|
||||
try {
|
||||
const task = await tasksService.updateTask(taskId, { status });
|
||||
revalidatePath('/'); // Auto cache invalidation
|
||||
return { success: true, data: task };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component Usage with useTransition
|
||||
```typescript
|
||||
// components/TaskCard.tsx
|
||||
'use client';
|
||||
|
||||
import { updateTaskStatus } from '@/actions/tasks';
|
||||
import { useTransition } from 'react';
|
||||
|
||||
export function TaskCard({ task }) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleStatusChange = (status) => {
|
||||
startTransition(async () => {
|
||||
const result = await updateTaskStatus(task.id, status);
|
||||
if (!result.success) {
|
||||
// Handle error
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={isPending ? 'opacity-50' : ''}>
|
||||
{/* UI with loading state */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🏗️ Migration Strategy
|
||||
|
||||
When migrating from API Routes to Server Actions:
|
||||
|
||||
1. **Create server action** in `actions/` directory
|
||||
2. **Update component** to use server action directly
|
||||
3. **Remove API route** (PATCH, POST, DELETE for mutations)
|
||||
4. **Simplify client** (remove mutation methods, keep GET only)
|
||||
5. **Update hooks** to use server actions instead of HTTP calls
|
||||
|
||||
## 🎯 Benefits of Server Actions
|
||||
|
||||
- **🚀 Performance**: No HTTP serialization overhead
|
||||
- **🔄 Cache intelligence**: Automatic revalidation with `revalidatePath()`
|
||||
- **📦 Bundle reduction**: Less client-side HTTP code
|
||||
- **⚡ UX**: Native loading states with `useTransition`
|
||||
- **🎯 Simplicity**: Direct service calls, less boilerplate
|
||||
|
||||
## 🚨 Anti-patterns to Avoid
|
||||
|
||||
- Don't use server actions for complex data fetching
|
||||
- Don't use server actions for endpoints that need HTTP monitoring
|
||||
- Don't use server actions for public API endpoints
|
||||
- Don't mix server actions with client-side state management for the same data
|
||||
|
||||
Remember: Server Actions are for **direct mutations**, API Routes are for **complex operations** and **public interfaces**.
|
||||
42
.cursor/rules/services.mdc
Normal file
42
.cursor/rules/services.mdc
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
globs: src/services/*.ts
|
||||
---
|
||||
|
||||
# Services Rules
|
||||
|
||||
1. Services MUST contain ALL PostgreSQL queries
|
||||
2. Services are the ONLY layer allowed to communicate with the database
|
||||
3. Each service MUST:
|
||||
- Use the pool from [src/services/database.ts](mdc:src/services/database.ts)
|
||||
- Implement proper transaction management
|
||||
- Handle errors and logging
|
||||
- Validate data before insertion
|
||||
- Have a clear interface
|
||||
|
||||
Example of correct service implementation:
|
||||
|
||||
```typescript
|
||||
export class MyService {
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async myMethod(): Promise<Result> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
// ... queries
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
❌ FORBIDDEN:
|
||||
|
||||
- Direct database queries outside src/services
|
||||
- Raw SQL in API routes
|
||||
- Database logic in components
|
||||
54
.cursor/rules/todo-tracking.mdc
Normal file
54
.cursor/rules/todo-tracking.mdc
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
description: Automatic TODO tracking and task completion management
|
||||
---
|
||||
|
||||
# TODO Task Tracking Rules
|
||||
|
||||
## Automatic Task Completion
|
||||
|
||||
Whenever you complete a task or implement a feature mentioned in [TODO.md](mdc:TODO.md), you MUST:
|
||||
|
||||
1. **Immediately update the TODO.md** by changing `- [ ]` to `- [x]` for the completed task
|
||||
2. **Use the exact task description** from the TODO - don't modify the text
|
||||
3. **Update related sub-tasks** if completing a parent task affects them
|
||||
4. **Add completion timestamp** in a comment if the task was significant
|
||||
|
||||
## Task Completion Examples
|
||||
|
||||
### ✅ Correct completion marking:
|
||||
```markdown
|
||||
- [x] Initialiser Next.js avec TypeScript
|
||||
- [x] Configurer ESLint, Prettier
|
||||
- [x] Setup structure de dossiers selon les règles du workspace
|
||||
```
|
||||
|
||||
### ✅ With timestamp for major milestones:
|
||||
```markdown
|
||||
- [x] Créer `services/database.ts` - Pool de connexion DB <!-- Completed 2025-01-15 -->
|
||||
```
|
||||
|
||||
## When to Update TODO.md
|
||||
|
||||
Update the TODO immediately after:
|
||||
- Creating/modifying files mentioned in tasks
|
||||
- Implementing features described in tasks
|
||||
- Completing configuration steps
|
||||
- Finishing any work item listed in the TODO
|
||||
|
||||
## Task Dependencies
|
||||
|
||||
When completing tasks, consider:
|
||||
- **Parent tasks**: Mark parent complete only when ALL sub-tasks are done
|
||||
- **Blocking tasks**: Some tasks may unblock others - mention this in updates
|
||||
- **Phase completion**: Note when entire phases are completed
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
Always maintain visibility of:
|
||||
- Current phase progress
|
||||
- Next logical task to tackle
|
||||
- Any blockers or issues encountered
|
||||
- Completed vs remaining work ratio
|
||||
|
||||
This ensures the TODO.md remains an accurate reflection of project progress and helps maintain momentum.
|
||||
4
.eslintrc.json
Normal file
4
.eslintrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
|
||||
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
.pnpm-store
|
||||
node_modules/.pnpm
|
||||
.pnpm-debug.log*
|
||||
# Note: pnpm-lock.yaml should be committed
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
.pnpm-store
|
||||
|
||||
31
README.md
Normal file
31
README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# People Randomizr
|
||||
|
||||
Application Next.js pour extraire aléatoirement un certain nombre de personnes à partir d'un fichier CSV.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Affichage de toutes les personnes du CSV
|
||||
- Extraction aléatoire d'un nombre configurable de personnes
|
||||
- Affichage des résultats avec nom, description et type
|
||||
|
||||
## Structure
|
||||
|
||||
- `app/page.tsx` - Page principale avec l'interface utilisateur
|
||||
- `app/api/people/route.ts` - API route pour charger les données du CSV
|
||||
- `lib/csv-parser.ts` - Parser pour le fichier CSV
|
||||
- `group-dev-ad.csv` - Fichier source des données
|
||||
|
||||
18
app/api/people/route.ts
Normal file
18
app/api/people/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { parseCSV } from '@/lib/csv-parser';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const filePath = join(process.cwd(), 'group-dev-ad.csv');
|
||||
const people = await parseCSV(filePath);
|
||||
return NextResponse.json(people);
|
||||
} catch (error) {
|
||||
console.error('Error parsing CSV:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to parse CSV file' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
83
app/globals.css
Normal file
83
app/globals.css
Normal file
@@ -0,0 +1,83 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.glow-cyan {
|
||||
box-shadow: 0 0 20px rgba(6, 182, 212, 0.5), 0 0 40px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.glow-purple {
|
||||
box-shadow: 0 0 20px rgba(168, 85, 247, 0.5), 0 0 40px rgba(168, 85, 247, 0.3);
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 via-purple-400 to-pink-400;
|
||||
}
|
||||
|
||||
.border-gradient {
|
||||
border-image: linear-gradient(135deg, rgba(6, 182, 212, 0.5), rgba(168, 85, 247, 0.5)) 1;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(6, 182, 212, 0.5), rgba(168, 85, 247, 0.5));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(6, 182, 212, 0.7), rgba(168, 85, 247, 0.7));
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(30px, -50px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
}
|
||||
|
||||
20
app/layout.tsx
Normal file
20
app/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "People Randomizr",
|
||||
description: "Extract random people from your team",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
333
app/page.tsx
Normal file
333
app/page.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { parseCSV, Person } from '@/lib/csv-parser';
|
||||
|
||||
export default function Home() {
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCount, setSelectedCount] = useState<number>(5);
|
||||
const [selectedPostes, setSelectedPostes] = useState<Set<string>>(new Set());
|
||||
const [randomPeople, setRandomPeople] = useState<Person[]>([]);
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchPoste, setSearchPoste] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
try {
|
||||
const response = await fetch('/api/people');
|
||||
const data = await response.json();
|
||||
setPeople(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Compter le nombre de personnes par poste
|
||||
const posteCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
people.forEach(person => {
|
||||
if (person.description) {
|
||||
counts[person.description] = (counts[person.description] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [people]);
|
||||
|
||||
// Extraire tous les postes uniques triés par nombre décroissant
|
||||
const allPostes = useMemo(() => {
|
||||
const postes = new Set<string>();
|
||||
people.forEach(person => {
|
||||
if (person.description) {
|
||||
postes.add(person.description);
|
||||
}
|
||||
});
|
||||
return Array.from(postes).sort((a, b) => {
|
||||
const countA = posteCounts[a] || 0;
|
||||
const countB = posteCounts[b] || 0;
|
||||
// Tri décroissant par nombre, puis alphabétique si égal
|
||||
if (countA !== countB) {
|
||||
return countB - countA;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [people, posteCounts]);
|
||||
|
||||
// Filtrer les postes selon la recherche
|
||||
const filteredPostes = useMemo(() => {
|
||||
if (!searchPoste.trim()) {
|
||||
return allPostes;
|
||||
}
|
||||
const searchLower = searchPoste.toLowerCase();
|
||||
return allPostes.filter(poste =>
|
||||
poste.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [allPostes, searchPoste]);
|
||||
|
||||
// Filtrer les personnes selon les postes sélectionnés
|
||||
const filteredPeople = useMemo(() => {
|
||||
if (selectedPostes.size === 0) {
|
||||
return people;
|
||||
}
|
||||
return people.filter(person =>
|
||||
person.description && selectedPostes.has(person.description)
|
||||
);
|
||||
}, [people, selectedPostes]);
|
||||
|
||||
const handlePosteToggle = (poste: string) => {
|
||||
const newSelected = new Set(selectedPostes);
|
||||
if (newSelected.has(poste)) {
|
||||
newSelected.delete(poste);
|
||||
} else {
|
||||
newSelected.add(poste);
|
||||
}
|
||||
setSelectedPostes(newSelected);
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedPostes.size === allPostes.length) {
|
||||
setSelectedPostes(new Set());
|
||||
} else {
|
||||
setSelectedPostes(new Set(allPostes));
|
||||
}
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const handleRandomize = () => {
|
||||
if (selectedCount <= 0 || selectedCount > filteredPeople.length) {
|
||||
alert(`Veuillez entrer un nombre entre 1 et ${filteredPeople.length}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const shuffled = [...filteredPeople].sort(() => Math.random() - 0.5);
|
||||
const selected = shuffled.slice(0, selectedCount);
|
||||
setRandomPeople(selected);
|
||||
setShowResults(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen py-8 px-4 relative overflow-hidden">
|
||||
{/* Effets de fond animés */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||||
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto relative z-10">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-6xl font-black mb-4 text-gradient tracking-tight">
|
||||
People Randomizr
|
||||
</h1>
|
||||
<p className="text-cyan-300 text-lg font-light">Sélection intelligente • Extraction aléatoire</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-cyan-500/20">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
|
||||
Filtres par poste
|
||||
</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="px-4 py-2 glass rounded-lg border border-cyan-500/30">
|
||||
<span className="text-sm text-cyan-300">
|
||||
<span className="font-bold text-cyan-400 text-lg">{selectedPostes.size}</span>
|
||||
<span className="text-purple-300"> / </span>
|
||||
<span className="text-white">{allPostes.length}</span>
|
||||
<span className="text-gray-400 ml-2">sélectionné{selectedPostes.size > 1 ? 's' : ''}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className="px-5 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-cyan-600 to-purple-600 rounded-lg hover:from-cyan-500 hover:to-purple-500 transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 border border-cyan-400/30"
|
||||
>
|
||||
{selectedPostes.size === allPostes.length ? 'Tout désélectionner' : 'Tout sélectionner'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barre de recherche */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="🔍 Rechercher un poste..."
|
||||
value={searchPoste}
|
||||
onChange={(e) => setSearchPoste(e.target.value)}
|
||||
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white placeholder-gray-400 border border-cyan-500/20 transition-all duration-300"
|
||||
/>
|
||||
{searchPoste && (
|
||||
<button
|
||||
onClick={() => setSearchPoste('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges de filtres */}
|
||||
<div className="max-h-64 overflow-y-auto p-3 mb-6 custom-scrollbar">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{filteredPostes.length === 0 ? (
|
||||
<div className="w-full text-center py-8">
|
||||
<p className="text-gray-400 text-sm">Aucun poste trouvé</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredPostes.map((poste) => {
|
||||
const isSelected = selectedPostes.has(poste);
|
||||
const count = posteCounts[poste] || 0;
|
||||
return (
|
||||
<button
|
||||
key={poste}
|
||||
onClick={() => handlePosteToggle(poste)}
|
||||
className={`
|
||||
px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-300
|
||||
flex items-center gap-2 border-2 relative overflow-hidden group
|
||||
${isSelected
|
||||
? 'bg-gradient-to-r from-cyan-600 to-purple-600 text-white border-cyan-400 shadow-lg glow-cyan transform scale-105'
|
||||
: 'glass text-gray-300 border-cyan-500/20 hover:border-cyan-400/50 hover:bg-cyan-500/10 hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 animate-pulse"></div>
|
||||
)}
|
||||
<span className="relative z-10">{poste}</span>
|
||||
<span className={`
|
||||
px-2.5 py-1 rounded-lg text-xs font-bold relative z-10
|
||||
${isSelected
|
||||
? 'bg-white/20 text-white backdrop-blur-sm'
|
||||
: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
|
||||
}
|
||||
`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-6 items-end">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="count" className="block text-sm font-semibold text-cyan-300 mb-3">
|
||||
Nombre de personnes à extraire
|
||||
</label>
|
||||
<input
|
||||
id="count"
|
||||
type="number"
|
||||
min="1"
|
||||
max={filteredPeople.length}
|
||||
value={selectedCount}
|
||||
onChange={(e) => setSelectedCount(parseInt(e.target.value) || 1)}
|
||||
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white bg-white/5 border border-cyan-500/20 transition-all duration-300"
|
||||
/>
|
||||
<p className="text-sm text-gray-400 mt-2 font-medium">
|
||||
{selectedPostes.size === 0 ? (
|
||||
<>📊 Total disponible: <span className="text-cyan-400 font-bold">{people.length}</span> personnes</>
|
||||
) : (
|
||||
<>🎯 Total filtré: <span className="text-cyan-400 font-bold">{filteredPeople.length}</span> personne{filteredPeople.length > 1 ? 's' : ''} <span className="text-gray-500">(sur {people.length})</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRandomize}
|
||||
disabled={filteredPeople.length === 0}
|
||||
className="px-8 py-4 bg-gradient-to-r from-cyan-600 via-purple-600 to-pink-600 text-white font-bold rounded-xl hover:from-cyan-500 hover:via-purple-500 hover:to-pink-500 focus:outline-none transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-cyan-600 disabled:hover:via-purple-600 disabled:hover:to-pink-600 border border-cyan-400/30 relative overflow-hidden group"
|
||||
>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
<span>🎲</span>
|
||||
<span>Extraire aléatoirement</span>
|
||||
</span>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showResults && randomPeople.length > 0 && (
|
||||
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-purple-500/20">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-1 h-8 bg-gradient-to-b from-cyan-400 to-purple-400 rounded-full"></div>
|
||||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
|
||||
Résultats sélectionnés
|
||||
</h2>
|
||||
<span className="px-3 py-1 glass rounded-lg text-cyan-300 font-semibold border border-cyan-500/30">
|
||||
{randomPeople.length} personne{randomPeople.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{randomPeople.map((person, index) => (
|
||||
<div
|
||||
key={`${person.nom}-${index}`}
|
||||
className="glass rounded-xl p-5 hover:bg-white/10 border border-cyan-500/20 hover:border-cyan-400/50 transition-all duration-300 hover:shadow-lg hover:shadow-cyan-500/20 group"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<h3 className="font-bold text-lg text-white mb-2 group-hover:text-cyan-300 transition-colors">
|
||||
{person.nom}
|
||||
</h3>
|
||||
{person.description && (
|
||||
<p className="text-sm text-gray-300 mb-3">{person.description}</p>
|
||||
)}
|
||||
<span className="inline-block px-3 py-1 text-xs rounded-lg bg-gradient-to-r from-cyan-600/30 to-purple-600/30 text-cyan-300 border border-cyan-500/30 font-semibold">
|
||||
{person.type}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="glass-strong rounded-2xl p-8 shadow-2xl border border-purple-500/20">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-1 h-8 bg-gradient-to-b from-purple-400 to-pink-400 rounded-full"></div>
|
||||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">
|
||||
{selectedPostes.size === 0
|
||||
? `Toutes les personnes`
|
||||
: `Personnes filtrées`
|
||||
}
|
||||
</h2>
|
||||
<span className="px-3 py-1 glass rounded-lg text-purple-300 font-semibold border border-purple-500/30">
|
||||
{filteredPeople.length} / {people.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-96 overflow-y-auto custom-scrollbar p-2">
|
||||
{filteredPeople.map((person, index) => (
|
||||
<div
|
||||
key={`${person.nom}-${index}`}
|
||||
className={`glass rounded-xl p-4 hover:bg-white/10 border transition-all duration-300 hover:shadow-lg ${
|
||||
selectedPostes.size > 0 && selectedPostes.has(person.description)
|
||||
? 'border-cyan-400/50 bg-cyan-500/10 hover:border-cyan-400 hover:shadow-cyan-500/20'
|
||||
: 'border-purple-500/20 hover:border-purple-400/50'
|
||||
}`}
|
||||
>
|
||||
<h3 className="font-semibold text-white mb-1">{person.nom}</h3>
|
||||
{person.description && (
|
||||
<p className="text-sm text-gray-400 mt-1">{person.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
73
lib/csv-parser.ts
Normal file
73
lib/csv-parser.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface Person {
|
||||
nom: string;
|
||||
description: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export async function parseCSV(filePath: string): Promise<Person[]> {
|
||||
const fs = await import('fs');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Skip header line
|
||||
const dataLines = lines.slice(1);
|
||||
|
||||
const people: Person[] = [];
|
||||
|
||||
for (const line of dataLines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
// Parse CSV line (handling quoted fields)
|
||||
const fields = parseCSVLine(line);
|
||||
|
||||
if (fields.length >= 7) {
|
||||
const nom = fields[4]?.trim() || '';
|
||||
const description = fields[5]?.trim() || '';
|
||||
const type = fields[6]?.trim() || '';
|
||||
|
||||
// Skip empty names and service accounts
|
||||
if (nom && !nom.startsWith('svc.') && !nom.startsWith('!') && nom !== 'datascience') {
|
||||
people.push({
|
||||
nom,
|
||||
description,
|
||||
type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return people;
|
||||
}
|
||||
|
||||
function parseCSVLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let currentField = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
// Escaped quote
|
||||
currentField += '"';
|
||||
i++;
|
||||
} else {
|
||||
// Toggle quote state
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
// Field separator
|
||||
fields.push(currentField);
|
||||
currentField = '';
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add last field
|
||||
fields.push(currentField);
|
||||
|
||||
return fields.map(field => field.trim());
|
||||
}
|
||||
|
||||
5
next.config.js
Normal file
5
next.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "people-randomizr",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"next": "^14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^14"
|
||||
}
|
||||
}
|
||||
3724
pnpm-lock.yaml
generated
Normal file
3724
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
15
tailwind.config.ts
Normal file
15
tailwind.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user